Docs
Dashboard
Examples / Data for AIBeta

Site to RAG chunks

Turn a site (or one page) into chunks ready for an embedding pipeline or a vector store: boilerplate removed, each page cut at its headings and then into chunks within a token budget, every chunk with its source address, its heading path and a ready-to-embed text that starts with that path.

When to use it: You are feeding a site into an embedding pipeline or a vector store.

Usescrawl (Markdown)
Needsany plan with crawls; no model
Siteyour docs; the default is the Playwright docs; the runner uses a stand-in docs site
Outputoutput/chunks.jsonl (one chunk per line), output/result.json
LanguagesNode and Python

Boilerplate across pages: a line that 60% or more of the pages repeat (navigation, footers, cookie notes) is removed everywhere, which a one-page cleaner cannot tell from content. Headings are never removed. Chunking: a section's paragraphs stay together while they fit the budget; a paragraph that is too long alone is cut at sentence ends. Tokens are estimated at 4 characters each; use your embedding model's tokenizer if you need exact counts.

Each line of chunks.jsonl: {id, url, title, headings, text, tokens, embedText}. Embed embedText (it carries the page and heading context) and store url with it to cite the source.

The code

The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.

/** * Site to RAG chunks: crawl a site (or one page) and turn it into chunks ready for an embedding pipeline: boilerplate * found across pages (navigation, footers, cookie notes: lines most pages repeat) removed, each page cut at its * headings, then into chunks within a token budget, each with its heading path and a ready-to-embed text. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; SITE_URL, MAX_PAGES, MAX_TOKENS) * * No model is used. The crawl respects robots.txt. Writes output/chunks.jsonl and output/result.json. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const start = process.env.SITE_URL ?? "https://playwright.dev/docs/intro";const maxPages = Number(process.env.MAX_PAGES ?? 10);const maxTokens = Number(process.env.MAX_TOKENS ?? 300);const bx = new Boxline();const tokens = (s: string) => Math.ceil(s.length / 4); // a rough count (about 4 characters per token in English)const isHeading = (line: string) => /^#{1,6}\s/.test(line);/** Navigation, not content: a block of 3 or more links where links make up 70% or more of the visible text. */const isLinkList = (block: string) => {  const links = [...block.matchAll(/!?\[([^\]]*)\]\([^)]*\)/g)];  if (links.length < 3) return false;  const visible = block.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_#>`|-]/g, "").replace(/\s+/g, "").length;  const linked = links.reduce((n, m) => n + m[1]!.replace(/\s+/g, "").length, 0);  return visible === 0 || linked / visible >= 0.7;};// 1. The pages, as Markdown (same host, breadth-first).const job = await bx.crawl.start({ url: start, maxPages, maxDepth: 2, format: "markdown" });const done = await bx.crawl.wait(job.id, { pollMs: 1500, timeoutMs: 10 * 60_000 });const pages = done.data.filter((p) => p.content && !p.error && (p.status ?? 0) < 400);console.log(`Crawled ${start}: ${pages.length} pages to chunk (${done.pagesFailed} failed, ${done.skippedByRobots} skipped by robots.txt)`);// 2. Boilerplate: a line (not a heading) that most pages repeat.const seenOn = new Map<string, number>();for (const p of pages) for (const line of new Set(p.content!.split("\n").map((l) => l.trim()).filter((l) => l.length > 3 && !isHeading(l)))) seenOn.set(line, (seenOn.get(line) ?? 0) + 1);const boilerplate = new Set(pages.length >= 3 ? [...seenOn].filter(([, n]) => n >= Math.max(2, Math.ceil(pages.length * 0.6))).map(([l]) => l) : []);console.log(`${boilerplate.size} boilerplate lines removed${boilerplate.size ? `, e.g. "${[...boilerplate][0]!.slice(0, 60)}"` : ""}`);// 3. Sections at headings, then chunks within the budget (paragraphs kept whole when they fit, else cut at sentences).type Chunk = { id: string; url: string; title: string; headings: string[]; text: string; tokens: number; embedText: string };const chunks: Chunk[] = [];for (const p of pages) {  const url = p.finalUrl ?? p.url;  const title = p.title ?? url;  const slug = new URL(url).pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "index";  const stack: string[] = [];  let paragraphs: string[] = [];  let n = 0;  const flush = () => {    let buf: string[] = [];    const emit = () => {      const text = buf.join("\n\n").trim();      if (!text) return;      const headings = [...stack];      chunks.push({ id: `${slug}-${++n}`, url, title, headings, text, tokens: tokens(text), embedText: `${[title, ...headings].join(" > ")}\n\n${text}` });      buf = [];    };    for (const para of paragraphs) {      const pieces = tokens(para) <= maxTokens ? [para] : (para.match(/[^.!?]+[.!?]+(\s|$)|[^.!?]+$/g) ?? [para]).map((s) => s.trim());      for (const piece of pieces) {        if (tokens([...buf, piece].join("\n\n")) > maxTokens) emit();        buf.push(piece.length / 4 > maxTokens ? piece.slice(0, maxTokens * 4) : piece);      }    }    emit();    paragraphs = [];  };  // The page starts at its title when it has one (what comes before is the site's header and breadcrumbs).  const blocks = p.content!.split(/\n\s*\n/);  const titleAt = blocks.findIndex((b) => /^#\s/.test(b.trim()));  for (const block of titleAt > 0 ? blocks.slice(titleAt) : blocks) {    const lines = block.split("\n").filter((l) => !boilerplate.has(l.trim()));    if (!lines.join("").trim() || isLinkList(lines.join("\n"))) continue;    if (isHeading(lines[0]!.trim())) {      flush();      const level = lines[0]!.trim().match(/^#+/)![0].length;      stack.splice(level - 1);      stack[level - 1] = lines[0]!.trim().replace(/^#+\s*/, "");      for (let i = 0; i < stack.length; i++) stack[i] ??= ""; // a skipped level stays empty, then is left out      const rest = lines.slice(1).join("\n").trim();      if (rest) paragraphs.push(rest);    } else paragraphs.push(lines.join("\n").trim());  }  flush();}for (const c of chunks) c.headings = c.headings.filter(Boolean);const largest = Math.max(0, ...chunks.map((c) => c.tokens));console.log(`${chunks.length} chunks of at most ${maxTokens} tokens (largest ${largest}) from ${pages.length} pages`);for (const c of chunks.slice(0, 3)) console.log(`  ${c.id}  [${c.headings.join(" > ")}]  ${c.text.slice(0, 80).replace(/\n/g, " ")}…`);mkdirSync(out, { recursive: true });writeFileSync(join(out, "chunks.jsonl"), chunks.map((c) => JSON.stringify(c)).join("\n") + "\n");writeFileSync(join(out, "result.json"), JSON.stringify({ start, crawlId: job.id, pages: pages.map((p) => p.finalUrl ?? p.url), maxTokens, chunks: chunks.length, largest, boilerplate: [...boilerplate] }, null, 2));

Inputs

Environment variables the example reads:

  • SITE_URL, MAX_PAGES (default 10; 1 for one page), MAX_TOKENS (default 300).

Run it

Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/site-to-rag-chunks. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.

# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=…   # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts

Then check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.

What its check proves

check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.

Every chunk is within the budget, has a unique id, and its embedText ends with its text. On the runner's stand-in docs site: the navigation and footer every page repeats are in no chunk, each page's own fact is in a chunk whose heading path is [the page's title, its section], and the long advanced guide is cut into several chunks.

Source

github.com/Boxline-dev/examples/site-to-rag-chunks on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.

These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.