17 KiB
model
| model |
|---|
| openai/gpt-5.4 |
HTMLTextSplitter Implementation Plan
Overview
Build a new HTMLTextSplitter that extends LangChain's TextSplitter and splits HTML by traversing the parsed DOM tree instead of slicing raw HTML strings.
This version is intentionally narrow:
- size metric uses cheerio text extraction only
- separators are CSS selectors
neverBreakWithinis respected unlessmaxChunkSizeforces a deeper split- no
chunkOverlapsupport - output is text chunks, not reconstructed partial HTML
Confirmed Decisions
- Use a traversal-based algorithm, not source offsets.
- Only split when needed for size control.
- Separator matches are soft structural hints, not unconditional split points.
- If a separator subtree is too large, recurse into that subtree.
neverBreakWithinwins unless a node exceedsmaxChunkSize; then recurse as a last resort.- Ignore separator matches inside protected subtrees during normal traversal.
- V1 emits text chunks, not valid partial HTML.
- Traverse both element nodes and text nodes.
- Normalize output after boundary-aware joining so block-ish tags create readable spacing without adding synthetic spaces between inline tags.
- When a direct child matches a separator during decomposition, it stays with the following content.
partIndexandtotalPartsare tracked per input document.chunkOverlapis not supported.- Scope is the splitter itself, with ingest integration called out as a follow-up.
Goals
- Extend
TextSplitterfrom@langchain/textsplitters. - Accept HTML input and parse with
cheerio. - Use CSS selectors for
separatorsandneverBreakWithin. - Use
chunkSizeas a soft target andmaxChunkSizeas a hard limit. - Produce chunks whose content is text derived from the traversed HTML.
- Recurse only when needed; avoid unnecessary fragmentation.
Non-Goals
- Preserving exact HTML markup in chunk output.
- Supporting
chunkOverlap. - Building a full
setup/ingest.tsmigration in this plan. - Reproducing browser-accurate
innerTextbehavior.
Public Interface
import { type Document } from "@langchain/core/documents";
import { TextSplitter, type TextSplitterParams } from "@langchain/textsplitters";
export interface HTMLTextSplitterParams extends TextSplitterParams {
/**
* CSS selectors that act as preferred structural split points.
* They are only used when a subtree needs to be broken down.
*/
separators?: string[];
/**
* CSS selectors for nodes that should stay atomic unless maxChunkSize forces recursion.
*/
neverBreakWithin?: string[];
/**
* Absolute hard limit for chunk text length.
* Defaults to Infinity if omitted.
*/
maxChunkSize?: number;
}
export class HTMLTextSplitter extends TextSplitter {
constructor(fields?: Partial<HTMLTextSplitterParams>);
/**
* Splits HTML input into normalized text chunks.
*
* Important: this splitter does not preserve HTML markup in output.
* It uses parsed HTML structure for traversal and sizing, but emits text only.
*/
splitText(text: string): Promise<string[]>;
/**
* Splits HTML documents into text-only child documents.
*
* Important: child document `pageContent` contains normalized text, not HTML.
* Parent metadata is preserved and per-document part metadata is added.
*/
splitDocuments(documents: Document[]): Promise<Document[]>;
}
Output Semantics
splitText()accepts HTML and returnsstring[]text chunks.- Each chunk is text content extracted from a DOM subtree or subtree fragment.
splitDocuments()preserves parent metadata and adds per-documentpartIndexandtotalParts.- Final chunk text is normalized after DOM-aware joining; block-ish tag boundaries may add spacing, while inline tag boundaries do not add synthetic spaces.
Size Semantics
chunkSizeis the target size.maxChunkSizeis the hard cap when provided.- The size function is based on the same normalized text content used for output segments.
- Segment text is derived from cheerio text extraction, joined with DOM-aware boundaries, then whitespace-normalized before measurement.
- No separate text-length implementation should be maintained outside that semantic.
- If
maxChunkSizeis omitted, treat it asInfinity.
Core Algorithm
Mental Model
Treat each DOM node as a unit that can either:
- fit as-is
- stay atomic because it is protected and still within
maxChunkSize - be recursively decomposed into children because it is too large
Separators only matter when a node must be broken down. They influence recursion order; they do not force a split on their own.
Traversal Strategy
- Parse the HTML with cheerio.
- Select a traversal root:
- use
bodychildren if a body exists - otherwise use root children
- use
- Walk top-level nodes in document order.
- Convert each node into one or more text segments using recursive decomposition.
- Merge resulting segments into final chunks while respecting
chunkSizeandmaxChunkSize.
Recursive Decomposition Rules
For a node N:
- Compute
nodeTextusing the sharedgetNodeText()helper so both element nodes and text nodes follow the same trimming semantics. - If
nodeTextis empty, skip it. - If
nodeText.length <= chunkSize, emit it as one segment. - If
NmatchesneverBreakWithinandnodeText.length <= maxChunkSize, emit it as one segment. - Otherwise, recurse into children.
- If
Nhas no element/text children that can further subdivide the content, force-split the node text withRecursiveCharacterTextSplitter.
Separator-Aware Recursion
When recursing into a node with child elements:
- Partition children into traversal groups.
- Prefer group boundaries at direct child elements matching
separators. - A separator child starts a new group and stays with the following sibling content rather than the preceding content.
- If no separator child exists, recurse in plain DOM order.
- Ignore separator matches inside any subtree currently treated as protected.
Internal Types
interface Segment {
text: string;
isProtected: boolean;
sourceKind: "node" | "forced-split";
}
V1 should keep internal types local to the implementation file unless reuse appears during build.
Detailed Behavior
1. Protected Nodes
If a node matches neverBreakWithin, keep it atomic while its text length is <= maxChunkSize. Recurse into it only if it exceeds maxChunkSize.
If maxChunkSize is Infinity, protected nodes are never forced open by hard-cap rules.
2. Forced Splitting
When recursion reaches a node whose text still exceeds maxChunkSize, and there is no useful child structure left, use RecursiveCharacterTextSplitter on the extracted text.
Use it with:
chunkSize: this.maxChunkSizechunkOverlap: 0
This is the final fallback that guarantees the hard limit.
If maxChunkSize is Infinity, this fallback is not used for hard-cap enforcement.
3. Merging
After recursion produces a flat list of text segments:
- Start a new chunk.
- Append the next segment if the combined length stays within
chunkSize. - If appending would exceed
chunkSize, flush the current chunk and start a new one. - If any individual segment is already larger than
chunkSizebut not larger thanmaxChunkSize, allow that segment to become its own chunk. - Join merged segment text using DOM-aware boundaries and whitespace normalization.
- No overlap is applied.
When maxChunkSize is Infinity, a single segment may grow beyond chunkSize if it cannot be decomposed further without violating other rules.
Pseudocode
async splitText(html: string): Promise<string[]> {
const $ = cheerio.load(html);
const roots = $("body").length > 0 ? $("body").contents().toArray() : $.root().contents().toArray();
const segments: Segment[] = [];
for (const node of roots) {
segments.push(...(await this.collectSegments($, node, false)));
}
return this.mergeSegments(segments);
}
private async collectSegments(
$: cheerio.CheerioAPI,
node: cheerio.AnyNode,
inProtectedAncestor: boolean,
): Promise<Segment[]> {
const text = this.getNodeText($, node);
if (!text) return [];
const isProtected = !inProtectedAncestor && this.matchesAny($, node, this.neverBreakWithin);
if (text.length <= this.chunkSize) {
return [{ text, isProtected, sourceKind: "node" }];
}
if (isProtected && text.length <= this.maxChunkSize) {
return [{ text, isProtected, sourceKind: "node" }];
}
const childNodes = this.getChildNodesForRecursion(node);
if (childNodes.length === 0) {
return this.forceSplitText(text);
}
const childGroups = this.groupChildrenForDecomposition($, childNodes, isProtected || inProtectedAncestor);
const segments: Segment[] = [];
for (const group of childGroups) {
for (const child of group) {
segments.push(...(await this.collectSegments($, child, isProtected || inProtectedAncestor)));
}
}
return segments.length > 0 ? segments : this.forceSplitText(text);
}
Utils Pseudocode
/**
* Returns true when a tag node matches any configured CSS selector.
*
* Needed so separator and protected-node checks share the same selector logic
* and non-tag nodes are ignored safely.
*/
private matchesAny(
$: cheerio.CheerioAPI,
node: cheerio.AnyNode,
selectors: string[],
): boolean {
if (node.type !== "tag") {
return false;
}
for (const selector of selectors) {
if ($(node).is(selector)) {
return true;
}
}
return false;
}
/**
* Extracts the canonical text value for a node.
*
* Needed so sizing and emitted output use the same trimmed text semantics for
* both element nodes and raw text nodes.
*/
private getNodeText($: cheerio.CheerioAPI, node: cheerio.AnyNode): string {
if (node.type === "text") {
return (node.data ?? "").trim();
}
return $(node).text().trim();
}
/**
* Returns the child nodes that are meaningful recursion units.
*
* Needed to recurse through DOM structure without carrying empty whitespace-only
* text nodes or unsupported node types through the algorithm.
*/
private getChildNodesForRecursion(node: cheerio.AnyNode): cheerio.AnyNode[] {
if (!("children" in node) || !Array.isArray(node.children)) {
return [];
}
return node.children.filter((child) => {
if (child.type === "text") {
return (child.data ?? "").trim().length > 0;
}
return child.type === "tag";
});
}
/**
* Groups child nodes so separator nodes start a new group.
*
* Needed because separators represent preferred section boundaries. Grouping
* makes "separator plus following content" explicit during recursion.
*/
private groupChildrenForDecomposition(
$: cheerio.CheerioAPI,
childNodes: cheerio.AnyNode[],
inProtectedTree: boolean,
): cheerio.AnyNode[][] {
if (inProtectedTree || this.separators.length === 0) {
return [childNodes];
}
const groups: cheerio.AnyNode[][] = [];
let currentGroup: cheerio.AnyNode[] = [];
const flush = () => {
if (currentGroup.length > 0) {
groups.push(currentGroup);
currentGroup = [];
}
};
for (const child of childNodes) {
if (this.matchesAny($, child, this.separators)) {
flush();
}
currentGroup.push(child);
}
flush();
return groups;
}
/**
* Applies the final plain-text fallback when a node cannot be decomposed further.
*
* Needed to enforce a finite hard cap with `RecursiveCharacterTextSplitter`
* once DOM structure is exhausted.
*/
private async forceSplitText(text: string): Promise<Segment[]> {
if (!Number.isFinite(this.maxChunkSize)) {
return [{ text: text.trim(), isProtected: false, sourceKind: "forced-split" }];
}
const fallback = new RecursiveCharacterTextSplitter({
chunkSize: this.maxChunkSize,
chunkOverlap: 0,
});
const chunks = await fallback.splitText(text);
return chunks
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => ({
text: chunk,
isProtected: false,
sourceKind: "forced-split" as const,
}));
}
/**
* Merges flat segments into final chunks using `chunkSize` as a soft target.
*
* Needed to keep chunk assembly separate from DOM traversal and to normalize the
* final text output by joining segment text with single spaces.
*/
private mergeSegments(segments: Segment[]): string[] {
const chunks: string[] = [];
let currentParts: string[] = [];
let currentLength = 0;
const flush = () => {
if (currentParts.length === 0) {
return;
}
chunks.push(currentParts.join(" "));
currentParts = [];
currentLength = 0;
};
for (const segment of segments) {
const nextLength = currentLength === 0
? segment.text.length
: currentLength + 1 + segment.text.length;
if (currentParts.length > 0 && nextLength > this.chunkSize) {
flush();
}
currentParts.push(segment.text);
currentLength = currentLength === 0
? segment.text.length
: currentLength + 1 + segment.text.length;
}
flush();
return chunks;
}
Implementation Plan
Phase 1: Class Skeleton
- Create
textsplitters/html_text_splitter.ts. - Define
HTMLTextSplitterParams. - Extend
TextSplitter. - Reject unsupported overlap in the constructor:
- if
chunkOverlap !== 0, throw a clear error
- if
- Default
maxChunkSizetoInfinitywhen omitted.
Phase 2: Tree Traversal Helpers
matchesAny()for selector matching.getNodeText()using cheerio text extraction and trimming semantics shared with output.getChildNodesForRecursion()to expose both text and element children in DOM order.groupChildrenForDecomposition()to build explicit child groups, with separator nodes grouped with following content.
Phase 3: Recursive Collection
- Implement
collectSegments(). - Preserve document order.
- Skip empty/whitespace-only results.
Phase 4: Final Fallback
- Implement
forceSplitText()withRecursiveCharacterTextSplitter. - Use
maxChunkSizeas the chunk size when it is finite. - Use
chunkOverlap: 0. - Trim and drop empty results.
Phase 5: Merge And Document Support
- Merge segments into final chunks using
chunkSizeas the soft target. - Implement
splitDocuments()by mapping each input document throughsplitText(). - Preserve original metadata and add per-document
partIndex/totalParts.
Edge Cases
Empty HTML
Return [].
Plain Text Input
Cheerio will still parse it; return one or more text chunks based on size.
Deeply Nested Protected Content
Keep it atomic unless it exceeds maxChunkSize; then recurse into its children and only force-split as a last resort.
If maxChunkSize is Infinity, it remains atomic unless some ancestor decomposition requires visiting its children.
Separator Inside Protected Content
Ignore the separator during normal traversal.
Large Text Node With No Useful Child Structure
Use text fallback splitting.
Consecutive Small Nodes
Merge them up to chunkSize.
A Single Segment Between chunkSize And maxChunkSize
Allow it as a standalone chunk.
Testing Strategy
Use Bun's built-in test runner and add automated tests under test/textsplitters/.
Minimum Verification
bunx tsc --noEmit- automated splitter tests under
test/textsplitters/ bun run setup/ingest.tson a small fixture or targeted spec file if practical
Behavior Cases To Verify
- Returns one chunk when content is under
chunkSize. - Ignores HTML tag length when using cheerio text extraction.
- Does not split at separators unless size pressure requires it.
- Keeps protected nodes intact up to
maxChunkSize. - Recurses into protected nodes larger than
maxChunkSize. - Uses fallback splitting for oversized leaf text.
- Preserves metadata in
splitDocuments(). - Throws when
chunkOverlap !== 0. - Leaves hard-cap fallback disabled when
maxChunkSizeis omitted.
The implementation should also update package.json with a real bun test command so these tests can run in CI and locally.
Example Important Cases
// separator is a soft hint, not an unconditional boundary
const splitter = new HTMLTextSplitter({
chunkSize: 100,
maxChunkSize: 100,
separators: ["h2"],
});
await splitter.splitText("<div><h2>A</h2><p>short body</p></div>");
// expected: one chunk because no size pressure exists
// protected node stays atomic until maxChunkSize is exceeded
const splitter = new HTMLTextSplitter({
chunkSize: 20,
maxChunkSize: 80,
neverBreakWithin: ["pre"],
});
await splitter.splitText("<pre>...60 chars...</pre>");
// expected: one chunk
Repository Integration Notes
The eventual migration in setup/ingest.ts should account for these changes:
- output will already be text chunks, so downstream
cheerio.load(chunk.pageContent).text()cleanup may become redundant keepSeparatoris removed from the new designchunkOverlapmust remain0- current warnings around chunk sizes should be rechecked after migration
Success Criteria
- No raw HTML offset slicing is used anywhere.
- The splitter traverses DOM structure recursively.
- Separators act as soft decomposition hints only.
- Protected nodes remain atomic unless
maxChunkSizeis exceeded. - No output chunk exceeds
maxChunkSizewhen a finite hard cap is configured. - No overlap behavior is implemented or implied.
splitDocuments()preserves metadata and returns chunked documents.- The implementation can be verified in this repo without assuming a missing test framework.
Open Questions
No remaining open questions in the current plan.