/** * Adaptive Article Chunker for KB Embeddings * * Strategy 5 (Recursive/Hierarchical) as the framework: * - Strategy 1 (Heading-Based) for structured docs * - Strategy 3 (Heading + Size Guards) for oversized sections * - Strategy 4 (Paragraph Grouping) for unstructured prose * - Strategy 2 (Fixed Window) deliberately excluded * * Hard rules: * - Never split a code block or table * - Never split mid-paragraph * - Merge tiny chunks (<100 tokens) into neighbor * - Prefix every chunk with context */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface Chunk { /** The text content to embed */ content: string /** Section path for heading-based chunks, e.g. "Fields > data_type reference" */ sectionPath: string | null /** 0-based index within the article */ chunkIndex: number /** Total chunks for this article */ chunkTotal: number } export interface ChunkerOptions { /** Article title — used as prefix context for every chunk */ articleTitle: string /** Max tokens per chunk before sub-splitting. Default 800. */ maxTokens?: number /** Min tokens per chunk before merging with neighbor. Default 100. */ minTokens?: number /** Token count below which the entire article is a single chunk. Default 600. */ singleChunkThreshold?: number } // --------------------------------------------------------------------------- // Token estimation // --------------------------------------------------------------------------- /** * Rough token estimate: ~4 chars per token for English text. * Good enough for chunking decisions — actual tokenization happens at OpenAI. */ export function estimateTokens(text: string): number { return Math.ceil(text.length / 4) } // --------------------------------------------------------------------------- // HTML → plain text // --------------------------------------------------------------------------- /** * Strip HTML tags to plain text, preserving structure via newlines. * Headings, paragraphs, divs get newlines. Inline tags are stripped. */ export function htmlToPlainText(html: string): string { let text = html // Normalize line breaks text = text.replace(/\r\n?/g, '\n') // Block-level elements → newlines text = text.replace(/<\/(p|div|li|tr|blockquote)>/gi, '\n') text = text.replace(//gi, '\n') text = text.replace(/<\/(h[1-6])>/gi, '\n') // Strip remaining tags text = text.replace(/<[^>]+>/g, '') // Decode common entities text = text.replace(/&/g, '&') text = text.replace(/</g, '<') text = text.replace(/>/g, '>') text = text.replace(/"/g, '"') text = text.replace(/'/g, "'") text = text.replace(/ /g, ' ') // Collapse excessive newlines text = text.replace(/\n{3,}/g, '\n\n') return text.trim() } // --------------------------------------------------------------------------- // Detect content format // --------------------------------------------------------------------------- type ContentFormat = 'markdown' | 'html' function detectFormat(content: string): ContentFormat { // If it has HTML block tags, treat as HTML if (/<(p|div|h[1-6]|ul|ol|table|pre)\b/i.test(content)) return 'html' return 'markdown' } // --------------------------------------------------------------------------- // Heading extraction // --------------------------------------------------------------------------- interface Section { /** Heading level: 1-6 for explicit headings, 0 for preamble */ level: number /** Heading text (empty for preamble) */ heading: string /** Body content below this heading (not including sub-sections) */ body: string } /** * Parse markdown into a flat list of sections by heading. * Each section includes its heading text and the body up to the next heading. * * @param splitLevel - Only split on headings at this level or shallower. * Deeper headings (e.g. ### when splitLevel=2) are kept as body content. * Default 6 = split on all headings. */ function parseMarkdownSections(content: string, splitLevel: number = 6): Section[] { const lines = content.split('\n') const sections: Section[] = [] let currentLevel = 0 let currentHeading = '' let bodyLines: string[] = [] for (const line of lines) { const headingMatch = line.match(/^(#{1,6})\s+(.+)$/) if (headingMatch && headingMatch[1].length <= splitLevel) { // Flush previous section if (bodyLines.length > 0 || currentHeading) { sections.push({ level: currentLevel, heading: currentHeading, body: bodyLines.join('\n').trim() }) } currentLevel = headingMatch[1].length currentHeading = headingMatch[2] bodyLines = [] } else { bodyLines.push(line) } } // Flush final section if (bodyLines.length > 0 || currentHeading) { sections.push({ level: currentLevel, heading: currentHeading, body: bodyLines.join('\n').trim() }) } return sections } /** * Parse HTML into sections by h2/h3/h4 tags. * Converts to plain text first, then extracts heading structure. */ function parseHtmlSections(html: string, splitLevel: number = 6): Section[] { // Insert markdown-style headings before stripping, so we can parse them let marked = html.replace(/]*>(.*?)<\/h\1>/gi, (_, level, text) => { const hashes = '#'.repeat(parseInt(level)) // Strip any HTML inside the heading text const cleanText = text.replace(/<[^>]+>/g, '').trim() return `\n${hashes} ${cleanText}\n` }) // Convert the rest to plain text marked = htmlToPlainText(marked) return parseMarkdownSections(marked, splitLevel) } // --------------------------------------------------------------------------- // Atomic block detection // --------------------------------------------------------------------------- /** * Check if a line is the start of a fenced code block. */ function isCodeFenceStart(line: string): boolean { return /^```/.test(line.trim()) } /** * Check if a line is inside a markdown table (starts with |). */ function isTableRow(line: string): boolean { return /^\|/.test(line.trim()) } // --------------------------------------------------------------------------- // Paragraph-based splitting (Strategy 4) // --------------------------------------------------------------------------- /** * Split text into paragraphs, keeping code blocks and tables as atomic units. * Returns an array of paragraph strings. */ function splitIntoParagraphs(text: string): string[] { const lines = text.split('\n') const paragraphs: string[] = [] let current: string[] = [] let inCodeBlock = false for (let i = 0; i < lines.length; i++) { const line = lines[i] // Track code fence boundaries if (isCodeFenceStart(line)) { if (inCodeBlock) { // End of code block — include closing fence, flush as atomic unit current.push(line) paragraphs.push(current.join('\n')) current = [] inCodeBlock = false continue } else { // Start of code block — flush what we have, start atomic unit if (current.length > 0 && current.some(l => l.trim())) { paragraphs.push(current.join('\n').trim()) } current = [line] inCodeBlock = true continue } } if (inCodeBlock) { current.push(line) continue } // Table rows are grouped together as atomic if (isTableRow(line)) { // If previous content wasn't a table, flush it if (current.length > 0 && !isTableRow(current[current.length - 1])) { if (current.some(l => l.trim())) { paragraphs.push(current.join('\n').trim()) } current = [] } current.push(line) continue } // If we were in a table and hit a non-table line, flush the table if (current.length > 0 && isTableRow(current[current.length - 1]) && !isTableRow(line)) { paragraphs.push(current.join('\n').trim()) current = [] } // Empty line = paragraph boundary if (line.trim() === '') { if (current.length > 0 && current.some(l => l.trim())) { paragraphs.push(current.join('\n').trim()) current = [] } continue } current.push(line) } // Flush remaining if (inCodeBlock && current.length > 0) { // Unclosed code block — flush as-is paragraphs.push(current.join('\n').trim()) } else if (current.length > 0 && current.some(l => l.trim())) { paragraphs.push(current.join('\n').trim()) } return paragraphs.filter(p => p.length > 0) } /** * Group paragraphs into chunks of approximately maxTokens. * Never splits a paragraph — it's the atomic unit. */ function groupParagraphs(paragraphs: string[], maxTokens: number): string[] { const chunks: string[] = [] let current: string[] = [] let currentTokens = 0 for (const para of paragraphs) { const paraTokens = estimateTokens(para) // If a single paragraph exceeds max, it goes as its own chunk (atomic — don't break it) if (paraTokens > maxTokens && current.length === 0) { chunks.push(para) continue } // Would adding this paragraph exceed the limit? if (currentTokens + paraTokens > maxTokens && current.length > 0) { chunks.push(current.join('\n\n')) current = [para] currentTokens = paraTokens } else { current.push(para) currentTokens += paraTokens } } if (current.length > 0) { chunks.push(current.join('\n\n')) } return chunks } // --------------------------------------------------------------------------- // Section-based splitting (Strategy 1 + 3) // --------------------------------------------------------------------------- /** * Build section path from nested heading context. * E.g. "fields" → "Fields" or "fields > data_type reference" → "Fields > data_type reference" */ function buildSectionPath(headingStack: string[]): string { return headingStack.filter(Boolean).join(' > ') } /** * Recursively split sections that are too large. * Strategy 3: sub-split on sub-headings if available, else paragraph split (Strategy 4). */ function splitOversizedSection( body: string, currentLevel: number, maxTokens: number ): string[] { // Try to find sub-headings at the next level const subHeadingPattern = new RegExp(`^${'#'.repeat(currentLevel + 1)}\\s+`, 'm') if (subHeadingPattern.test(body)) { // Has sub-headings — split on them (recurse Strategy 1) const subSections = parseMarkdownSections(body).filter(s => s.level > currentLevel || s.level === 0 ) // Re-parse keeping proper section boundaries const subLines = body.split('\n') const subChunks: string[] = [] let currentChunk: string[] = [] for (const line of subLines) { const subMatch = line.match(new RegExp(`^(#{${currentLevel + 1},6})\\s+(.+)$`)) if (subMatch && currentChunk.length > 0) { const chunkText = currentChunk.join('\n').trim() if (chunkText) { if (estimateTokens(chunkText) > maxTokens) { // Still too big — go deeper subChunks.push(...splitOversizedSection(chunkText, currentLevel + 1, maxTokens)) } else { subChunks.push(chunkText) } } currentChunk = [line] } else { currentChunk.push(line) } } if (currentChunk.length > 0) { const chunkText = currentChunk.join('\n').trim() if (chunkText) { if (estimateTokens(chunkText) > maxTokens) { subChunks.push(...splitOversizedSection(chunkText, currentLevel + 1, maxTokens)) } else { subChunks.push(chunkText) } } } return subChunks } // No sub-headings — fall back to paragraph grouping (Strategy 4) const paragraphs = splitIntoParagraphs(body) return groupParagraphs(paragraphs, maxTokens) } // --------------------------------------------------------------------------- // Merge tiny chunks // --------------------------------------------------------------------------- /** * Merge chunks that are below the minimum token threshold into their neighbor. * Prefers merging with the next chunk; if last, merges with previous. */ function mergeTinyChunks(chunks: string[], minTokens: number): string[] { if (chunks.length <= 1) return chunks const result: string[] = [] for (let i = 0; i < chunks.length; i++) { const tokens = estimateTokens(chunks[i]) if (tokens < minTokens && result.length > 0) { // Merge with previous result[result.length - 1] += '\n\n' + chunks[i] } else if (tokens < minTokens && i < chunks.length - 1) { // Merge with next chunks[i + 1] = chunks[i] + '\n\n' + chunks[i + 1] } else { result.push(chunks[i]) } } return result } // --------------------------------------------------------------------------- // Main chunker // --------------------------------------------------------------------------- /** * Chunk an article for embedding using the adaptive recursive strategy. * * Decision tree: * 1. ≤ singleChunkThreshold tokens → single chunk, no splitting * 2. Has headings → heading-based split (Strategy 1) * - Oversized sections → sub-split (Strategy 3 → recurse or Strategy 4) * 3. No headings → paragraph grouping (Strategy 4) * 4. Merge tiny chunks * 5. Prefix every chunk with context */ export function chunkArticle(content: string, options: ChunkerOptions): Chunk[] { const { articleTitle, maxTokens = 800, minTokens = 100, singleChunkThreshold = 600, } = options if (!content || content.trim().length === 0) { return [{ content: articleTitle, sectionPath: null, chunkIndex: 0, chunkTotal: 1, }] } // Detect format and normalize to plain text for token counting / splitting const format = detectFormat(content) let plainContent: string let sections: Section[] // Primary split at ## level — ### and deeper stay as body content // Sub-splitting (Strategy 3) will split on ### when a section is too large const primarySplitLevel = 2 if (format === 'html') { sections = parseHtmlSections(content, primarySplitLevel) plainContent = htmlToPlainText(content) } else { sections = parseMarkdownSections(content, primarySplitLevel) plainContent = content } const totalTokens = estimateTokens(plainContent) // ── Step 1: Small article → single chunk ────────────────────────── if (totalTokens <= singleChunkThreshold) { const prefix = articleTitle return [{ content: `${prefix}\n\n${plainContent}`, sectionPath: null, chunkIndex: 0, chunkTotal: 1, }] } // ── Step 2/3: Check for headings ────────────────────────────────── // h1 is treated as preamble/title — only ## and deeper count as section headings const hasHeadings = sections.some(s => s.level >= 2) let rawChunks: { text: string; sectionPath: string | null }[] if (hasHeadings) { // Strategy 1: heading-based split rawChunks = [] // Track headings by level for proper nesting: level → heading text const headingByLevel = new Map() for (const section of sections) { if ((section.level === 0 && !section.heading) || section.level === 1) { // Preamble or h1 title — treat as intro context, not a section to chunk if (section.body.trim()) { rawChunks.push({ text: section.body, sectionPath: null, }) } continue } // Clear this level and all deeper levels, then set current heading for (const lvl of headingByLevel.keys()) { if (lvl >= section.level) headingByLevel.delete(lvl) } headingByLevel.set(section.level, section.heading) // Build path from sorted levels: ## Parent > ### Child const sortedLevels = [...headingByLevel.keys()].sort((a, b) => a - b) const sectionPath = sortedLevels.map(l => headingByLevel.get(l)!).join(' > ') const fullSection = section.heading + '\n' + section.body const sectionTokens = estimateTokens(fullSection) if (sectionTokens > maxTokens) { // Strategy 3: sub-split oversized section const subChunks = splitOversizedSection(fullSection, section.level, maxTokens) for (let i = 0; i < subChunks.length; i++) { rawChunks.push({ text: subChunks[i], sectionPath: subChunks.length > 1 ? `${sectionPath} (${i + 1}/${subChunks.length})` : sectionPath, }) } } else { rawChunks.push({ text: fullSection, sectionPath, }) } } } else { // Strategy 4: paragraph grouping for unstructured content const paragraphs = splitIntoParagraphs(plainContent) const grouped = groupParagraphs(paragraphs, maxTokens) rawChunks = grouped.map(text => ({ text, sectionPath: null, })) } // ── Step 4: Merge tiny chunks ───────────────────────────────────── // We need to merge while preserving sectionPath, so we work at the rawChunks level const mergedChunks: { text: string; sectionPath: string | null }[] = [] for (let i = 0; i < rawChunks.length; i++) { const tokens = estimateTokens(rawChunks[i].text) if (tokens < minTokens && mergedChunks.length > 0) { // Merge with previous chunk const prev = mergedChunks[mergedChunks.length - 1] prev.text += '\n\n' + rawChunks[i].text // Keep the previous section path (the primary one) } else if (tokens < minTokens && i < rawChunks.length - 1) { // Merge with next chunk rawChunks[i + 1].text = rawChunks[i].text + '\n\n' + rawChunks[i + 1].text // Next chunk keeps its section path } else { mergedChunks.push({ ...rawChunks[i] }) } } // ── Step 5: Prefix and build final chunks ───────────────────────── const total = mergedChunks.length return mergedChunks.map((chunk, index) => { let prefix: string if (chunk.sectionPath) { prefix = `${articleTitle} > ${chunk.sectionPath}` } else if (total > 1) { prefix = `${articleTitle} (chunk ${index + 1} of ${total})` } else { prefix = articleTitle } return { content: `${prefix}\n\n${chunk.text}`, sectionPath: chunk.sectionPath, chunkIndex: index, chunkTotal: total, } }) }