Document Ingestion Pipelines
A document ingestion pipeline extracts, cleans, chunks, embeds, upserts, and deletes. Version it so a parser bug can be rebuilt.
- rag
- ingestion
- pipelines
If you cannot rebuild the index from source files, you do not have a document ingestion pipeline. You have a snowflake: a laptop script, a half-updated namespace, and a chatbot that still quotes the PDF you deleted. Ingestion is extract, clean, chunk, embed, upsert, and delete, with a version number on the code that did it.
Parser bugs happen. The pipeline’s job is to make “run it again” a boring command, not a folk ritual.
Key takeaways
- Source files are the source of truth. The index is a projection you can throw away.
- Identity is
sourceIdplus a content hash, not “whatever row we inserted on Tuesday.”- Delete is a first-class stage. A removed page must not linger as a high-ranking chunk.
- Observe extract failures as product metrics. Silent empty extracts become confident nonsense.
- Queue for large or bursty corpora. Batch APIs for embeddings. Chat APIs for chat.
A document ingestion pipeline has six stages
| Stage | Input | Output | Typical failure |
|---|---|---|---|
| Extract | File bytes or MDX | Text + structure (headings, tables) | Scanned PDF, wrong encoding |
| Clean | Raw text | Body without nav, cookie banners, repeated footers | Left the sidebar in, retrieve “Buy now” |
| Chunk | Clean text | Sections with metadata | Timer split through a table |
| Embed | Chunk text | Vectors from one model | Mixed models in one index |
| Upsert | Vectors + metadata | Index rows keyed by chunk id | Append-only duplicates |
| Delete | Source id or hash set | Tombstones / removed rows | Orphans rank forever |
This is not chunking strategies and not RAG on PDFs. Those pages pick splitters and parsers. Here you wire the job so those choices are replayable.
Content hashes as identity
Hash the bytes you actually embed (chunk text plus the metadata you send to the model). If the hash matches, skip the embedding call. If it changed, replace the rows for that source. If the source vanished, delete.
import { createHash } from 'node:crypto';
import { embedMany } from 'ai';
type Source = { sourceId: string; chunks: { heading: string; text: string }[] };
type Row = {
id: string;
sourceId: string;
heading: string;
text: string;
hash: string;
pipeline: string;
embedding: number[];
};
export const PIPELINE = 'docs-mdx-v3';
function hashChunk(sourceId: string, heading: string, text: string) {
return createHash('sha256')
.update(PIPELINE)
.update(sourceId)
.update(heading)
.update(text)
.digest('hex');
}
export async function ingestSource(
source: Source,
index: {
fetchBySource(id: string): Promise<Row[]>;
upsert(rows: Row[]): Promise<void>;
deleteIds(ids: string[]): Promise<void>;
},
) {
const existing = await index.fetchBySource(source.sourceId);
const byHash = new Map(existing.map((row) => [row.hash, row]));
const next: Row[] = [];
const toEmbed: { heading: string; text: string; hash: string }[] = [];
for (const [i, chunk] of source.chunks.entries()) {
const hash = hashChunk(source.sourceId, chunk.heading, chunk.text);
const reuse = byHash.get(hash);
if (reuse) {
next.push({ ...reuse, id: `${source.sourceId}:${i}` });
} else {
toEmbed.push({ ...chunk, hash });
next.push({
id: `${source.sourceId}:${i}`,
sourceId: source.sourceId,
heading: chunk.heading,
text: chunk.text,
hash,
pipeline: PIPELINE,
embedding: [],
});
}
}
if (toEmbed.length > 0) {
const { embeddings } = await embedMany({
model: 'openai/text-embedding-3-small',
values: toEmbed.map((c) => `${c.heading}\n${c.text}`),
});
let j = 0;
for (const row of next) {
if (row.embedding.length === 0) {
row.embedding = embeddings[j++];
}
}
}
const nextIds = new Set(next.map((r) => r.id));
const stale = existing.map((r) => r.id).filter((id) => !nextIds.has(id));
if (stale.length) await index.deleteIds(stale);
await index.upsert(next);
}
Idempotent upserts mean a crash mid-file is safe. Re-run the source. Do not “insert again” without delete, or cosine search will return two near-identical chunks and waste the prompt.
PIPELINE is in the hash on purpose. When you change the cleaner, every chunk is new. That is a rebuild, not a surprise.
Delete is part of ingest
A job that only writes will rot. Sources of deletes:
- File removed from the repo
draft: trueflipped on- ACL says the tenant must not see it
- Parser now returns empty (treat as failure, not as “empty doc”)
export async function reconcile(
liveSourceIds: Set<string>,
listed: { sourceId: string }[],
deleteBySource: (id: string) => Promise<void>,
) {
for (const row of listed) {
if (!liveSourceIds.has(row.sourceId)) {
await deleteBySource(row.sourceId);
}
}
}
If your vector store has no delete, you do not have a store you can operate. Soft-delete via deletedAt metadata and a filter is acceptable only if every query includes the filter. Forgetting the filter is how “deleted” docs still answer. Vector index updates covers freshness. Ingestion is what produces the events.
Queue vs batch
| Work | Mechanism |
|---|---|
| One MDX commit | Synchronous CI job, or a queue with one source per task |
| 10k PDFs | Queue. Isolate parser crashes per file |
| Embedding those 10k | Provider batch APIs, not the chat endpoint |
| User-facing Q&A | Streaming chat, never this pipeline |
A parser that throws must fail the file, record the error, and continue the corpus. One bad PDF is not a reason to skip the rest, and a swallowed exception is not a reason to index empty strings.
type IngestEvent =
| { type: 'ok'; sourceId: string; chunks: number; ms: number }
| { type: 'extract_failed'; sourceId: string; reason: string }
| { type: 'empty_extract'; sourceId: string };
export function record(e: IngestEvent) {
// metrics + log. empty_extract should page you after a threshold.
console.info(JSON.stringify({ pipeline: PIPELINE, ...e }));
}
Watch extract_failed and empty_extract like you watch 5xx. Retrieval quality is downstream of this counter.
Version the pipeline
Store pipeline: 'docs-mdx-v3' on every row. When v4 ships (better HTML strip, new chunker), you can:
- Build v4 into a new namespace or table.
- Flip the query alias.
- Drop v3 after a soak.
Or rebuild in place if downtime is fine. What you must not do is mix v3 and v4 vectors in one unfiltered namespace. Different chunk boundaries plus the same source id is duplicate meaning.
Python loaders (LangChain document loaders) are fine for extract. The ops contract is language-agnostic: hash, upsert, delete, version, observe. TypeScript above is the same contract you can run next to a Next.js app.
Footguns
- Indexing the built site HTML without stripping chrome. You will retrieve the global nav.
- Embedding with model A, querying with model B. Similarity is undefined.
- No
sourceId. You cannot delete. - Transforming text after hashing. Then the hash never matches and you re-embed forever.
- Running ingest inside the chat request. First-token latency becomes “we crawled Notion.”
Interview answer (out loud)
“A document ingestion pipeline is a replayable projection: extract, clean, chunk, embed, upsert, delete. I key rows by source id and a hash of the pipeline plus text so re-runs are idempotent. Deletes are part of the job. I version the pipeline so a parser fix can rebuild. Failures are metrics, not silent empty chunks. Embeddings go through batch or a queue, not the chat path.”
Further reading (primary sources)
Related on this site
Related guides
- RAG on PDFs and HTMLPDFs are layout, not text. HTML is a tree. If your parser dumps reading-order garbage, retrieval will confidently fetch the footer.
- Building a Docs RAG ChatbotBuild a docs chatbot on MDX: ingest pages, embed, retrieve, then stream a cited answer. The shape you can ship on a content site.
- Chunking Strategies for RAGChunking decides what the retriever can return. Size, overlap, headings, and why splitting on a 500-character timer wrecks tables and code.
- Citations and GroundingGrounding means the answer is entailed by retrieved text. Citations are the UI. How to require quotes, and how models still hallucinate a source.
- Embeddings and Similarity SearchNearest-neighbor search over embeddings is fast guesswork. Distance metrics, top-k, and why the nearest chunk can still be the wrong chunk.