src / chunk.ts
src / chunk.ts
// Markdown-aware chunking: split by headings first, then pack paragraphs into
// size-bounded windows with overlap. Each chunk carries its heading path so the
// embedding (and the citation shown to the user) keeps its structural context.
export interface Chunk {
text: string; // raw chunk text (without the heading-path prefix)
headingPath: string[]; // e.g. ["Résumé", "Détails"]
}
export interface ChunkOptions {
chunkSizeChars: number;
chunkOverlapChars: number;
}
interface Section {
headingPath: string[];
body: string;
}
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
/** Split markdown into sections keyed by their heading path. */
function splitIntoSections(markdown: string): Section[] {
const lines = markdown.split(/\r?\n/);
const stack: string[] = []; // stack[level-1] = title
let current: Section = { headingPath: [], body: "" };
const sections: Section[] = [];
let inFence = false;
const flush = () => {
if (current.body.trim().length > 0) sections.push(current);
};
for (const line of lines) {
if (/^\s*```/.test(line)) inFence = !inFence;
const m = inFence ? null : line.match(HEADING_RE);
if (m) {
flush();
const level = m[1].length;
const title = m[2].trim();
stack.length = Math.min(stack.length, level - 1);
stack[level - 1] = title;
current = { headingPath: stack.slice(0, level), body: "" };
} else {
current.body += line + "\n";
}
}
flush();
return sections;
}
/** Greedily pack paragraphs into windows of ~size chars, with char overlap. */
function packWithOverlap(text: string, size: number, overlap: number): string[] {
const trimmed = text.trim();
if (trimmed.length <= size) return trimmed.length ? [trimmed] : [];
const paragraphs = trimmed.split(/\n{2,}/);
const out: string[] = [];
let buf = "";
const pushBuf = () => {
const t = buf.trim();
if (t) out.push(t);
};
for (const para of paragraphs) {
// A single oversized paragraph is hard-split on character windows.
if (para.length > size) {
pushBuf();
buf = "";
for (let i = 0; i < para.length; i += Math.max(1, size - overlap)) {
out.push(para.slice(i, i + size));
}
continue;
}
if (buf.length + para.length + 2 > size && buf.length > 0) {
pushBuf();
// start the next buffer with a tail overlap of the previous one
buf = overlap > 0 ? buf.slice(Math.max(0, buf.length - overlap)) + "\n\n" : "";
}
buf += (buf.length ? "\n\n" : "") + para;
}
pushBuf();
return out;
}
export function chunkMarkdown(markdown: string, opts: ChunkOptions): Chunk[] {
const size = Math.max(200, opts.chunkSizeChars);
const overlap = Math.max(0, Math.min(opts.chunkOverlapChars, Math.floor(size / 2)));
const sections = splitIntoSections(markdown);
const chunks: Chunk[] = [];
for (const section of sections) {
for (const piece of packWithOverlap(section.body, size, overlap)) {
chunks.push({ text: piece, headingPath: section.headingPath });
}
}
return chunks;
}
/** Text actually handed to the embedding model: heading path + chunk body. */
export function embeddingTextFor(pageTitle: string, chunk: Chunk): string {
const crumbs = [pageTitle, ...chunk.headingPath].filter(Boolean).join(" > ");
return crumbs ? `${crumbs}\n\n${chunk.text}` : chunk.text;
}
// Markdown-aware chunking: split by headings first, then pack paragraphs into
// size-bounded windows with overlap. Each chunk carries its heading path so the
// embedding (and the citation shown to the user) keeps its structural context.
export interface Chunk {
text: string; // raw chunk text (without the heading-path prefix)
headingPath: string[]; // e.g. ["Résumé", "Détails"]
}
export interface ChunkOptions {
chunkSizeChars: number;
chunkOverlapChars: number;
}
interface Section {
headingPath: string[];
body: string;
}
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
/** Split markdown into sections keyed by their heading path. */
function splitIntoSections(markdown: string): Section[] {
const lines = markdown.split(/\r?\n/);
const stack: string[] = []; // stack[level-1] = title
let current: Section = { headingPath: [], body: "" };
const sections: Section[] = [];
let inFence = false;
const flush = () => {
if (current.body.trim().length > 0) sections.push(current);
};
for (const line of lines) {
if (/^\s*```/.test(line)) inFence = !inFence;
const m = inFence ? null : line.match(HEADING_RE);
if (m) {
flush();
const level = m[1].length;
const title = m[2].trim();
stack.length = Math.min(stack.length, level - 1);
stack[level - 1] = title;
current = { headingPath: stack.slice(0, level), body: "" };
} else {
current.body += line + "\n";
}
}
flush();
return sections;
}
/** Greedily pack paragraphs into windows of ~size chars, with char overlap. */
function packWithOverlap(text: string, size: number, overlap: number): string[] {
const trimmed = text.trim();
if (trimmed.length <= size) return trimmed.length ? [trimmed] : [];
const paragraphs = trimmed.split(/\n{2,}/);
const out: string[] = [];
let buf = "";
const pushBuf = () => {
const t = buf.trim();
if (t) out.push(t);
};
for (const para of paragraphs) {
// A single oversized paragraph is hard-split on character windows.
if (para.length > size) {
pushBuf();
buf = "";
for (let i = 0; i < para.length; i += Math.max(1, size - overlap)) {
out.push(para.slice(i, i + size));
}
continue;
}
if (buf.length + para.length + 2 > size && buf.length > 0) {
pushBuf();
// start the next buffer with a tail overlap of the previous one
buf = overlap > 0 ? buf.slice(Math.max(0, buf.length - overlap)) + "\n\n" : "";
}
buf += (buf.length ? "\n\n" : "") + para;
}
pushBuf();
return out;
}
export function chunkMarkdown(markdown: string, opts: ChunkOptions): Chunk[] {
const size = Math.max(200, opts.chunkSizeChars);
const overlap = Math.max(0, Math.min(opts.chunkOverlapChars, Math.floor(size / 2)));
const sections = splitIntoSections(markdown);
const chunks: Chunk[] = [];
for (const section of sections) {
for (const piece of packWithOverlap(section.body, size, overlap)) {
chunks.push({ text: piece, headingPath: section.headingPath });
}
}
return chunks;
}
/** Text actually handed to the embedding model: heading path + chunk body. */
export function embeddingTextFor(pageTitle: string, chunk: Chunk): string {
const crumbs = [pageTitle, ...chunk.headingPath].filter(Boolean).join(" > ");
return crumbs ? `${crumbs}\n\n${chunk.text}` : chunk.text;
}