Indexing a corpus
agentfootprint/rag — loaders, splitters and the indexing chart. A folder of documents becomes a searchable index, and the run explains itself.
You have a folder of documents and you want an agent to answer from it. Before 8.10.0 the library gave you the second half of that —
defineRAGretrieves,sqliteVectorStorekeeps — and left you to write the loaders, the splitters and the re-index logic yourself. This is the first half.
The three steps, and the one call over them
import { indexFolder } from 'agentfootprint/rag';
import { sqliteVectorStore } from 'agentfootprint/memory';
import { staticEmbedder } from 'agentfootprint/providers';
const report = await indexFolder('./docs', {
to: sqliteVectorStore({ file: './corpus.db' }),
embedder: staticEmbedder(),
});
// { discovered: 3, loaded: 3, chunks: 14, embedded: 14, skipped: 0, removed: 0, … }indexFolder is sugar. The pieces are public and compose in the obvious order:
import { loadDocuments, splitDocuments, byHeading, indexCorpus } from 'agentfootprint/rag';
const { documents, failed } = await loadDocuments({ dir: './docs' });
const chunks = splitDocuments(documents, byHeading());
// …or hand the whole thing to the chart, which does both and records what it did:
const report = await indexCorpus({ source: { dir: './docs' }, store, embedder });Where the index lands
store is any vector-ranking MemoryStore, and the indexers are identical
across all of them — swapping one for another changes durability and operations,
not a line of the call:
import { sqliteVectorStore, pgVectorStore, s3VectorsStore } from 'agentfootprint/memory';
sqliteVectorStore({ file: './corpus.db' }); // one machine, one file
pgVectorStore({ client: pool }); // beside your own data
s3VectorsStore({ bucket: 'my-corpus', index: 'docs' }); // nothing to runThe last one is the interesting change of shape: an index in S3 Vectors can be added to from a cron job at 14:00, and the agent sees it on the next turn. An index in a corpus bundle changes when you redeploy.
A store that declares it cannot serve vectors back is refused before anything
is embedded, and therefore before anything is billed — including a store that
ranks TEXT on its own server (ranksBy: 'server-text'), whose own console does
its ingestion. See ranking mode.
defineRAG is not on this door, deliberately. The retriever is run-time wiring — registered on an agent, running every turn — so it stays on the main barrel beside defineTool. This door is the other half: the part that runs once, before any agent exists. Index time touches the filesystem and reaches for a PDF dependency; run time must never resolve either.
Loaders
| loader | extensions | dependency |
|---|---|---|
textLoader | .txt .log .csv .json .yaml … | none |
markdownLoader | .md .markdown .mdx | none |
htmlLoader | .html .htm | none |
pdfLoader | .pdf | unpdf, lazily loaded |
mockLoader | whatever you say | none |
loadDocuments routes by extension. A loader you pass is consulted before the built-ins, so overriding one is putting yours in front rather than editing a registry:
await loadDocuments({ dir: './docs' }, { loaders: [myConfluenceLoader()] });Markdown keeps its markup. Stripping # would throw away the one splitting signal that is not a heuristic — the author already told us where the sections are — and rewriting text would break the offsets every citation depends on.
HTML is a tag stripper, not a parser. Good enough for documentation pages and exported articles; not good enough for a single-page app, where you will get navigation labels. Run a real extractor and feed the result in through { text, uri }.
PDF needs one optional peer. unpdf was picked by measurement — 2.5 MB and zero transitive dependencies, against 86 MB and a native binary for the nearest alternative. It returns text per page, which is why a PDF citation can name a page you can turn to. Missing, it refuses with an install line rather than skipping the file.
Splitters
| splitter | cuts on | use when |
|---|---|---|
byHeading() | Markdown # lines | the document declares its own sections — not a heuristic |
byParagraph() | blank lines | prose with no headings |
fixedWithOverlap() | character count | no structure at all (transcripts, OCR) |
wholeDocument() | nothing | documents that are already one idea |
Defaults are 1000 characters with 150 of overlap, and they are chosen by constraint rather than taste. localEmbedder's default model silently truncates at 512 wordpiece tokens — measured directly: at 508 tokens an appended tail still moves the vector, at 596 it does not, and nothing says so. 1,000 characters is about 250 tokens, comfortably inside that cliff and inside the length the model was trained at.
The floor — minChars (8.20.0)
Short chunks retrieve too well, not too badly. Similarity is a density measure: a heading plus one preamble sentence concentrates its topic's vocabulary with none of its substance. Measured in a production corpus, a 180-character heading-and-preamble chunk outranked the 1,032-character body of its own section — and the model, handed a passage that promises findings and contains none, fabricated a plausible file path to fill the gap.
So byHeading and byParagraph enforce a floor. A section (or packed paragraph chunk) whose own text is under minChars — default min(250, maxChars / 4) — merges forward into the next chunk under its own heading: the preamble sentence survives, leading the chunk it introduces, and nothing is ever dropped. The last chunk has no next, so a trailing short merges backward. Two rules are unconditional, even at minChars: 0: a heading with no body is never emitted alone (a coordinate, not a passage), and a document that is nothing but headings yields no chunks at all. fixedWithOverlap is exempt by design — its chunks are uniformly sized by request — and wholeDocument is one chunk per document by definition.
Re-indexing an existing corpus after upgrading will produce different chunks. That is the fix, not a regression: the old default could ship the exact chunk shape that drove a fabricated citation.
The ceiling — maxChunkChars and maxInputChars (9.1.0)
Two numbers bound one chunk, and they are set in different places. maxChars on the splitter decides how big a chunk is cut. maxChunkChars on indexCorpus / indexFolder / indexDocuments decides how much of it the embedder actually reads. At the shipped defaults they compose safely: 1,000 characters cut, at least 2,000 read.
The trap opens when you raise the splitter's ceiling. byHeading({ maxChars: 2500 }) against a 2,000-character reading ceiling produces chunks that are stored whole as the passage and indexed by their opening. Retrieval then cannot find wording that is plainly visible in the <source> block the model is shown, and nothing throws. Measured in a production corpus: 6 of 26 chunks, against an embedder that would have read every one of them in full.
So the reading ceiling comes from the embedder, which is the only object that knows it:
| what the embedder declares | what an indexer uses |
|---|---|
localEmbedder() → maxInputChars: 2000 | 2,000 — the measured 512-token cliff |
openaiEmbedder() / bedrockEmbedder() → 32000 | 32,000 — their documented 8k-token window at 4 chars/token |
staticEmbedder() / mockEmbedder() → 1000000 | no context window at all; nothing is ever clipped |
| nothing (a hand-written embedder) | 2,000 — the old default, unchanged |
An explicit maxChunkChars on the call always wins over the embedder's number: dense text (code, tables, CJK) tokenises tighter than 4 characters a token, and you are allowed to know that about your own corpus.
Chunks past whichever ceiling is in effect are recorded, and — since 9.1.0 — said out loud once per run, because a number nobody reads is not visibility:
report.truncated; // [{ id: 'handbook.pdf#3', chars: 4200 }]
report.truncatedCount; // 1 ← the number you can assert onindexCorpus: 6 of 26 chunk(s) are longer than the 2000-character input ceiling in effect
('local:Xenova/all-MiniLM-L6-v2:q8's declared maxInputChars), so they were embedded CLIPPED.
The vector represents only the opening of each one, while the FULL text is stored and served
as the passage — so retrieval cannot find wording that is plainly visible in the block the
model is shown. …
Fix: re-split smaller (lower the splitter's maxChars), or raise maxChunkChars if … really
reads more than 2000 characters.Writing your own Embedder? Declare maxInputChars on it. It is optional — absent, an indexer behaves exactly as it did before the field existed — but the alternative is that your ceiling is discovered by someone reading a retrieval result that is missing a paragraph they can see.
The invariant every splitter holds
doc.text.slice(chunk.charStart, chunk.charEnd) === chunk.textsplitDocuments checks it rather than trusting it, and refuses a splitter that breaks it. A chunk that cannot be located in its own document produces citations that point at the wrong words — and a citation nobody can check is worse than no citation, because it looks checked.
indexCorpus — the chart
Indexing is a footprintjs chart, not a loop:
discover → load → split → plan (DECIDER)
→ take-window → embed (FAN-OUT + retry) → tally → more? ⟲
→ remove → reportThe commit log IS the indexing report. A for loop answers no questions after it finishes; the chart answers "why is this chunk here, why was that one skipped, what did this run cost, which document went missing" months later, from its own log, without the caller having saved anything.
- plan is a real decider because "skip this chunk" is a decision with evidence: same content hash and same embedder fingerprint. Its branches —
full-index,incremental,nothing-to-do— are named in the trace. - embed fans out one branch per batch with a declarative
retry, so a rate-limited attempt appears in the record instead of vanishing inside a hand-rolled loop. It runs a window at a time and loops: the fan-out ceiling truncates rather than queues, so a single fan-out over 200 batches with a ceiling of 4 would embed 4 and report success. embeddedis counted from what the batches actually wrote, never from what the plan intended.
A failing batch fails the run. A half-indexed corpus keeps answering and quietly cannot see what did not land, which reads as "the model does not know that" rather than as a failure.
Incremental re-index
Run it again and nothing happens, which is the point:
run 1 (first index) discovered 3 · loaded 3 · chunks 8 · embedded 8 · skipped 0 · removed 0
run 2 (no changes) discovered 3 · loaded 3 · chunks 8 · embedded 0 · skipped 8 · removed 0
run 3 (edit + delete) discovered 2 · loaded 2 · chunks 5 · embedded 1 · skipped 4 · removed 3A chunk is reused when its content hash and the embedder fingerprint both match. Change the text and only that chunk is re-embedded; change the embedder and everything is, because the two vector spaces are not comparable. Delete a document and its chunks are removed — an index that still answers from a file you deleted is worse than one that cannot answer.
An empty walk never prunes. A typo in a path must not delete a corpus.
From the command line
npx agentfootprint-index ./docs --to ./corpus.db
npx agentfootprint-index ./docs --to ./corpus.db --embedder local --split paragraph --chars 800
npx agentfootprint-index ./docs --to ./corpus.db --dry-run --json--embedder is static (default, no key, no network), local, openai, or mock. --dry-run reports against a throwaway in-memory index without touching the file.
The worked example
const index = () => indexFolder(docs, { to: store, embedder, embedderId: embedder.id });// RUN 1 — everything is new.const first = await index();lines.push(summarize('run 1 (first index) ', first));// RUN 2 — nothing changed. Nothing is embedded again.const second = await index();lines.push(summarize('run 2 (no changes) ', second));// RUN 3 — edit one document, delete another.const policy = join(docs, 'refund-policy.md');writeFileSync( policy, readFileSync(policy, 'utf8').replace('within 3 business days', 'within 5 business days'),);unlinkSync(join(docs, 'pricing.md'));const third = await index();lines.push(summarize('run 3 (edit + delete) ', third));Three real documents ship with it — two Markdown and a two-page PDF — and the agent's answer comes back with the passage, the document, the page and the score:
why this passage
✓ refund-policy.md#1 0.87 refund-policy.md, Refund timing
✓ refund-policy.md#3 0.86 refund-policy.md, Partial refunds
✗ security-overview.pdf#0 0.82 security-overview.pdf, p1 over-max-entriesReference
Everything on agentfootprint/rag.
Pipeline
| Name | What it is |
|---|---|
loadDocuments(source, options?) | Read a DocumentSource into documents. Returns LoadDocumentsResult — { documents, failed, discovered }. LoadDocumentsOptions carries loaders and maxBytes. |
splitDocuments(docs, splitter, options?) | Cut documents into Chunks and verify each one's offsets. SplitDocumentsOptions has verifyOffsets. |
indexCorpus(config) | Run the indexing chart. IndexCorpusConfig takes source, store, embedder, and the optional corpus, splitter, loaders, embedderId, batchSize, maxConcurrentBatches, attempts, removeMissing, maxChunkChars (defaults to the embedder's own maxInputChars). Refuses a store that declares supportsVectorSearch: false, or ranksBy: 'server-text' — see the store table. |
buildIndexChart(config) | The same chart, unrun — mount it, attach recorders, or run it under your own executor. |
indexFolder(dir, options) | Sugar over indexCorpus. IndexFolderOptions renames store to to and adds include / recursive. |
sha256(input) | The content hash the incremental skip compares. Distinct from the trace's FNV-1a: this one decides whether something is re-embedded. |
Value objects
| Name | What it is |
|---|---|
DocumentSource | The union: { dir, include?, recursive? } · { files } · { text, uri }. The modes exclude. |
DocumentInput | What a loader receives — { uri, bytes, mtimeMs? }. |
LoadedDocument | One document read — { uri, text, pages?, contentHash, bytes, mtimeMs?, loader }. |
LoadedDocumentDraft | What a loader returns — { text, pages? }. The bookkeeping is added around it. |
Chunk | { id, docUri, index, text, charStart, charEnd, page?, heading?, contentHash }. |
SplitPiece | One cut, before it is given an id and a hash. |
IndexReport | { discovered, loaded, chunks, embedded, skipped, removed, failed, truncated, truncatedCount, embedderFingerprint, splitter, elapsedMs }. |
FailedDocument / TruncatedChunk | A document that could not be read; a chunk past the embedder's ceiling. |
Ports and adapters
| Name | What it is |
|---|---|
DocumentLoader | The port — { name, extensions, load(input) }. |
DEFAULT_LOADERS | The routing table used when you pass none. |
textLoader() · markdownLoader() · htmlLoader() · pdfLoader(options?) · mockLoader(options?) | The shipped adapters. PdfLoaderOptions takes a pre-imported backend for bundled apps; MockLoaderOptions takes extensions / textFor / pagesFor. |
MissingPdfSupportError | Raised when a PDF is met and unpdf is not installed. |
stripTags(html) | The HTML loader's own pass, exported because a custom markup loader may want it. |
Splitter | The strategy — { name, split(doc) }. |
byHeading(options?) · byParagraph(options?) · fixedWithOverlap(options?) · wholeDocument() | The shipped strategies, with ByHeadingOptions / ByParagraphOptions / FixedWithOverlapOptions. |
DEFAULT_MAX_CHARS · DEFAULT_OVERLAP_CHARS · DEFAULT_MIN_CHARS | 1000, 150 and 250 — the measured defaults (the floor scales as min(250, maxChars / 4)). |
exportCorpus(store, identity?) · importCorpus(store, bundle, identity?) | The corpus as a build artifact (8.20.0): a plain-JSON CorpusBundle out of any listable store, and back into any writable vector-capable one. |
staticVectorStore(bundle, embedder?) | Read-only MemoryStore over a bundle — canonical in agentfootprint/memory, re-exported here. Refuses a mismatched embedder at load. |
CorpusBundle · CorpusBundleEntry · CORPUS_BUNDLE_FORMAT | The bundle shape: entries of { id, text, vector, metadata } under a format marker (CORPUS_BUNDLE_FORMAT versions the FORMAT, not the library). |
EmbedderFingerprint | The { id?, dimensions } slice staticVectorStore checks at load — pass your embedder itself; it satisfies the shape. |
assertCorpusBundle(bundle, caller) | The shared bundle validator — a truncated or hand-edited file fails the same way at every door. |
bundleEntryToMemoryEntry(entry, bundle) | How a bundle entry becomes a served MemoryEntry — passage on value.content, provenance under value.metadata — shared by staticVectorStore and importCorpus so both render identically. |
Deliberately not here
- Re-ranking and MMR are retrieval-side. They belong behind
RetrievalStrategyonagentfootprint/memory— a re-ranker is a different rule for choosing among candidates, not a different way of building an index. - File watching.
indexCorpusis an explicit call: run it at boot, from a cron, or from the CLI. Magic that re-indexes behind you is magic that re-bills you.
Next steps
- RAG —
defineRAG, and reading back why a passage was retrieved - Memory store adapters —
sqliteVectorStore,pgVectorStore,s3VectorsStore, and what each store'ssearch()ranks
