RAG corpus curation: the active librarian who decides what goes in, what goes out and what gets signed
Contents
This is the curation layer inside stage 1 (Data) of the six-stage LLMOps pipeline. It complements the other Data posts: dataset versioning for the four versionable artefacts, PostgreSQL + Qdrant ingestion in microservices for the outbox + CDC pattern, and RAG over Kafka and a datalake for streaming transport. This post is not about moving data: it is about what to do with data before you let a model read it.
TL;DR
A RAG system serving mediocre answers is rarely the retriever’s fault, nor the model’s. The root cause is usually the corpus: three nearly identical versions of the same PDF that make top-k return the same thing three times, an old manual nobody removed that contradicts the current one, a free-text field with customer numbers that the model quotes verbatim, a scanned PDF with dirty OCR that the chunker cut in the middle of a sentence. None of that is fixed by changing the model, the embedder, the reranker or the prompt. It is fixed by curating the corpus. This post takes apart the five operational layers of curation (schema-validated ingest, three-level deduplication, PII anonymisation measured with precision/recall, anti-contamination against the golden eval set, chunk→trace lineage), the minimum maths needed to avoid fooling yourself, the 2026 stack (Presidio, Unstructured, Argilla, LangChain text splitters, OpenLineage, Marquez, Great Expectations), the seven traps that reduce the stage to theatre, and the on-premise hardware to sustain all of it without sending anything sensitive to external APIs.
The analogy: the active librarian
A serious librarian does not accept books by the kilo. When someone offers a new volume:
- They look at the spine, the ISBN and the stamp: is it legible? Is it catalogued correctly? Does it belong to a recognised collection? Without valid metadata, it does not get in. This is the schema check.
- They check whether they already hold a copy: is it exactly the same book? Is it a later edition of the same one? Is it a translated version of something already on the shelves? If so, they decide explicitly what to do (replace, archive the old one, withdraw both from lending). This is dedup at its three levels.
- They flag what is restricted: if the book contains identifiable personal data, some pages cannot be lent as they are, so they must be redacted, anonymised or moved to the reserved section. This is PII anonymisation.
- They verify it is not this year’s final exam book: if it is, it stays out of the public collection until the syllabus changes, because if students can consult it the exam stops measuring what it is meant to measure. This is anti-contamination against the golden eval set.
- They write it into the register: this book, this edition, this provenance, this date, this person who approved the entry. This is lineage.
If the book passes all five, it joins the collection. If it fails any of them, it goes to an auditable quarantine shelf with the reason for rejection. The difference between a good collection and a mediocre one is not size: it is how much discipline you apply across the five layers, every day, to every new book that arrives.
A RAG corpus is exactly that. The only differences are scale (thousands or millions of documents per month) and the fact that the “readers” are LLMs that cannot tell a duplicate from a reinforced truth, nor a PII value from a synthetic example, nor a contaminated fragment from an authentic one.
The four data artefacts and where the RAG corpus fits
Before going down to the five layers it is worth being clear about which corpus we are curating. The Data stage of the pipeline manages four distinct artefacts, each with its own discipline. The data versioning post lists them; here I reorder them from a curation perspective:
| Artefact | Who consumes it | Dominant curation |
|---|---|---|
| Training dataset | Tune (fine-tuning the model or an adapter) | aggressive dedup + quality filters + label balancing |
| RAG corpus | Deploy (retrieval at request time) | the 5 layers in this post |
| Golden eval set | Eval (promotion gates) | strict hold-out + stratification + maintenance driven by incidents |
| Retrain enriched dataset | Retrain (closing the loop) | production feedback + human triage |
The RAG corpus is the most volatile of the four and the one most exposed to the end user: every answer the system serves literally contains fragments of it. A duplicate in the training dataset degrades learning but ends up buried in the weights; a duplicate in the RAG corpus shows up in today’s answer and tomorrow’s. That justifies the extra discipline that follows.
Layer 1 — Schema-validated ingest
Every piece entering the corpus has to arrive with structured metadata validated against a schema. This is not bureaucracy: it is the only way to make the following layers (dedup, PII, lineage) work without friction.
The canonical pattern is to define a schema in JSON Schema or Pydantic that every document must satisfy:
class CorpusDocument(BaseModel):
source_system: str # e.g. "confluence", "salesforce", "manual_pdf"
source_id: str # unique ID in the source system
version: str # document version (semver or date)
language: str # ISO 639-1: "es", "en"
title: str
body: str
captured_at: datetime
captured_by: str # pipeline or human
sensitivity: Literal["public", "internal", "restricted"]
schema_version: str # version of the schema itself, not of the document
Any piece that fails this contract is rejected at ingest and never reaches the following layers. Validation is done with Great Expectations (declarative suites), Pandera (more pythonic, integrates with pandas) or Soda (oriented towards continuous data quality). The choice is a matter of style; what matters is:
- Validation suites live in code and are versioned with the pipeline, not in a notebook off to one side.
- Rejection generates an auditable event (quarantine) with the specific schema failure as its reason, not a log line lost in stdout.
- The schema itself is versioned. When it changes, previous documents are reprocessed or explicit backward compatibility is maintained.
The RAG over Kafka post covers the Schema Registry pattern (Confluent Schema Registry or Apicurio) that materialises this in streaming: every message on the topic is validated against the registered schema before propagating downstream. For batch or pull, Great Expectations is the equivalent.
Common trap: leaving the body field free with no further validation. You have to tighten it up with minimum/maximum length (a PDF that yields 12 characters after extraction is almost certainly broken), valid encoding (UTF-8 with no control characters), proportion of alphanumeric characters (dirty OCR returns a soup of symbols). These are simple rules that filter 80% of the noise with no need for AI.
Layer 2 — Three-level deduplication
The most expensive and most silent mistake in a RAG corpus is the duplicate. A document that appears three times in the corpus makes retrieval’s top-k return it three times, wasting two slots and reinforcing a single source. The LLM reads it as if three independent sources agreed, when in fact it is the same thing repeated.
Deduplication is done at three levels, in this order by cost:
Level A — Exact dedup (SHA-256 hash)
Compute the hash of the normalised content (trim, lower-case where applicable, remove redundant whitespace) and compare it against an index of already ingested hashes. On a match, discard or replace. Cost: \(O(1)\) per document. It catches literal duplicates (the same PDF uploaded twice, two byte-for-byte copies of the same HTML).
Level B — Near-duplicate (MinHash + LSH)
Nearly identical documents with minor differences (a different header, an updated date, one version in Castilian and another in Galician with minimal changes). The canonical algorithm is MinHash with Locality-Sensitive Hashing (LSH), which approximates Jaccard similarity over shingles of k tokens. For n documents, comparing all against all is \(O(n^2)\), which is unworkable for large corpora. LSH cuts the cost to \(O(n)\) most-likely buckets.
A typical threshold is Jaccard ≥ 0.80 over shingles of 5 tokens. The standard libraries are datasketch (Python, MIT) or dedup (Python, MIT). A numerical example: for 1 M short documents (300 tokens each), datasketch.MinHashLSH with 128 permutations and threshold 0.8 takes about 2 GB of RAM and processes the full corpus in around 30 minutes on a modern CPU. The fraction of duplicates found in a real enterprise corpus is usually between 5% and 25%; removing them cuts storage and improves retrieval quality at the same time.
Level C — Semantic dedup (cosine over embeddings)
Documents saying the same thing in different words, such as paraphrases, translations or rewritten versions, are not caught by MinHash. This is where semantic similarity comes in: compute the embedding of each document and compare the cosine between pairs.
The problem is quadratic cost: for n documents, computing every pair is \(O(n^2)\). For n = 1 M and 768-dimensional embeddings (a typical model such as BAAI/bge-base-en-v1.5), that is 5×10^11 dot products, which is unworkable. The solution is the same idea as LSH but over dense vectors: HNSW (Hierarchical Navigable Small World) or IVF (Inverted File) to build an approximate search index. For each new document you run a k-NN query against the index and examine only the k nearest neighbours.
A sensible threshold for treating something as a semantic duplicate: cosine ≥ 0.95. Below 0.95 the documents are related but distinct; above it, they are almost always the same information rewritten. The exact threshold is calibrated by observing precision/recall over a human-annotated sample; 100 pairs confirmed by a reviewer is reasonable for setting it.
A numerical example: with qdrant or pgvector as the HNSW index and k=10 neighbours per query, deduplicating 1 M documents against the existing corpus takes on the order of 2-4 hours on an RTX 4090 (including embedding computation). If the embedder is self-hosted with vLLM, the cost per token is negligible against compute time.
Policy on what to do with a duplicate
Detection is not enough, you have to decide. Three common policies, in order of complexity:
- Drop: discard the more recent one, keep the older one. Simple, with no extra lineage.
- Replace: discard the old one, index the new one. More volatility but it reflects the update.
- Merge with provenance: mark the new one as a “shadow” of the old one, keep both in lineage but index only one. Better for regulated audit.
The policy has to be explicit and applied uniformly, not an ad-hoc decision per document.
Layer 3 — PII anonymisation with measured precision/recall
This layer is the one that most easily turns into theatre. The typical mistake: install Presidio, run it over the corpus, assume the output is clean. Without measuring the detector’s precision and recall against an annotated golden set, you know nothing.
A PII detector can fail in two ways:
- False negative (low recall): it does not detect a national ID written as “12345678-A” because your model was trained on the
12345678Aformat without a hyphen. The RAG serves personal data unredacted. - False positive (low precision): it redacts a number from a configuration manual thinking it is a phone number. The RAG loses useful information.
Both are problems; regulation (GDPR, ENS, NIS2) penalises the first, and user experience degrades with the second. The acceptable ratio depends on the domain. In medical data, practically zero false negatives is non-negotiable; in internal technical documentation you can tolerate more recall in exchange for less precision.
The standard metric is F1 over an annotated golden set:
\[ \text{precision} = \frac{TP}{TP + FP}, \quad \text{recall} = \frac{TP}{TP + FN}, \quad F_1 = 2 \cdot \frac{\text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}} \]To build the PII golden set, annotate around 200 documents by hand with every entity marked (national ID, IBAN, email, phone, address, personal name). Then run the detector and compute the metrics per category, not just aggregated, because a global F1 of 0.90 can hide a recall of 0.55 on IBANs.
2026 stack for this layer:
- Microsoft Presidio (MIT, Microsoft): the most complete OSS option. Configurable detectors, recognises around 50 entities by default, extensible with your own regex patterns or with fine-tuned NER models.
- spaCy NER (MIT, Explosion AI): a base for custom detectors; useful when Presidio does not cover a domain entity.
- Llama Guard 4 (Llama Community License, Meta): a safety classifier that also detects PII in one pass, an option when you already have a GPU for inference and prefer a single pass.
- DataFog (Apache 2.0): a more recent alternative, specialised in streaming pipelines.
Recommended hybrid pattern: Presidio for rule-based + regex detection (fast, deterministic) → Llama Guard as a second pass over what Presidio did not flag (an ensemble that raises recall without killing throughput). This is measured and reported as aggregate and per-category F1 on every release of the detector.
Common fallacy: trusting that a detector with F1 0.95 “is very good”. If you have 1 M documents and each contains on average 1 PII entity, F1 0.95 means 50,000 mishandled entities (between false positives and negatives). With sensitive data you have to design so that false negatives go to human quarantine, not to the public corpus.
Layer 4 — Anti-contamination against the golden eval set
If the RAG corpus contains fragments of the golden eval set, Eval metrics measure memorisation. The model returns the exact answer because it has it literally in its context, not because it generalised anything. Deploy then promotes models that shine in the exam and fail in production.
This layer is the easiest to implement and the easiest to forget:
- The golden eval set has its own versioned hash.
- Before indexing any new document into the RAG corpus, run a token-by-token overlap check (or by shingles, similar to MinHash) against the golden set.
- If overlap exceeds a threshold (typically ≥ 30% of 5-token n-grams), the document is not indexed. It stays in quarantine flagged as “contamination risk vs golden_v12”.
- A human reviews the rejections. Sometimes they are false positives (a short quotation, a boilerplate sentence). Sometimes they are real contamination that a supplier introduced without realising.
The deeper reason: the RAG corpus and the golden set are enemies by design. The golden set measures how well the system generalises to questions it has not seen. If those questions are in the RAG, the system “sees” them on every query. The metric stops measuring generalisation.
This check is computationally trivial, a hash join over n-grams. The difficulty is keeping it up: every time the golden set changes (monthly or quarterly), the full corpus has to be re-validated against the new golden set. Without that discipline, contamination sneaks in through the back door when someone updates the golden set with real cases that the RAG was already serving.
Layer 5 — End-to-end lineage: from document to trace
The last layer is the one that closes the auditable chain. Every chunk indexed in the vector store carries metadata that lets you answer the forensic question:
“The system generated this answer on 14 March at 16:23. Which exact document did the quoted fragment come from? When did that document enter the corpus? Which version of the embedder processed it? Who approved its ingest?”
Without lineage, that question is unanswerable. With lineage done properly, it is four queries.
The canonical pattern:
- Every indexed chunk carries in its metadata:
source_system,source_id,document_version,chunk_index,embedder_version,dataset_hash,ingested_at,ingested_by,schema_version. - Every RAG answer in production emits a trace span that includes the retrieved
chunk_idvalues. - The central tracing system (Langfuse, Phoenix or OpenLLMetry) joins
chunk_id→ chunk metadata → document metadata → corpusdataset_hash→ embedder version → and so on.
The tools that standardise this glue are OpenLineage (Apache 2.0, LF AI & Data) and Marquez (Apache 2.0, its server implementation). They define a lineage event schema that interoperates between systems; an ingest job emits an event “produced corpus_v12.3 from source X with embedder bge-base-v1.5”; a retrieval job emits “consumed corpus_v12.3 with query Q produced response R”. The graph is reconstructed automatically.
This layer is the only way to pass real audits under regulations such as the EU AI Act, GDPR or ENS, where traceability of which data went into which answer is a requirement, not an option. Without it, the answer “we do not know which document this came from” is not acceptable, and it is the default answer if lineage is not built from day one.
The maths that matter
Beyond dedup thresholds and PII F1 scores, there are three mathematical pieces that any serious team ends up using.
Chunk size vs retrieval quality. Chunk size affects retrieval quality in a non-monotonic way: chunks that are too small fragment ideas (retrieval returns a piece without context), and chunks that are too large dilute the signal (the embedding mixes several topics and similarity drops). The empirical sweet spot for technical text in 2026 is between 256 and 768 tokens per chunk, with 15-25% overlap between contiguous chunks to preserve continuity.
Numerically, for a corpus of 1 M documents with a mean length of 2,000 tokens, chunking at 512 tokens with overlap 100 gives \(\frac{2000}{512 - 100} \approx 5\) chunks per document, a total of roughly 5 M indexed chunks. With 768-dimensional embeddings in float32, that occupies \(5 \cdot 10^6 \cdot 768 \cdot 4 \approx 15\) GB of vector memory, manageable in any modern vector store.
PII golden set coverage. To know whether the annotated PII golden set is representative enough, compute the proportion of categories covered: if your golden set of 200 documents has 5 IBAN examples and production has 12,000 IBANs per day, the measured F1 on IBANs is statistical noise. Rule of thumb: a minimum of 30 examples per category for per-category metrics to mean anything.
Cost of re-embedding when rotating the model. Changing the embedder invalidates the whole index. For a corpus of 5 M chunks with a model such as BAAI/bge-base-en-v1.5 (768 dim, around 110 M parameters) served in vLLM on 1× H100, throughput is on the order of 8,000-15,000 chunks/second. Full re-embedding: about 5-10 minutes. For a larger embedder (bge-large, 1024 dim, around 335 M parameters), a factor of 3× worse, around 15-30 minutes. The bottleneck is usually vector store I/O, not GPU compute. The dual-index pattern, keeping the old index serving while the new one is built and doing an atomic swap at the end, avoids downtime and allows rollback.
Applied to typical on-premise hardware
For an on-premise deployment that keeps all curation in-house without sending data to external APIs:
- RTX 4090 (24 GB): covers layer 1 (the schema check with Great Expectations is CPU-bound), layer 2 levels A and B (hash + MinHash are CPU-bound), and layer 2 level C semantic dedup with a
bge-base-class embedder (8-15k chunks/s, enough for corpora of up to 5-10 M chunks in hours). For Presidio in NER mode (layer 3) it runs comfortably. It is the sensible GPU for the whole curation pipeline on a mid-size corpus. - Generic 4×H100 SXM configuration (320 GB total, NVLink): only needed if the corpus exceeds around 50 M chunks or if you want frequent re-embeddings with large models (
bge-large,e5-mistral). In practice, two GPUs serve the embedder at TP=2 with throughput above 50k chunks/s, and the other two go to the PII judge (Llama Guard 4) or to serving the main inference model. Capacity for corpora of hundreds of millions of chunks.
The stubborn arithmetic: with a 4090, corpus curation is an overnight job; with 4×H100, it is minutes. The decision depends on corpus size and on how often you rotate the embedder or the PII rules.
The seven traps that kill this stage
Trap 1 — No validated schema at ingest. Malformed documents reach the chunker, the chunker slices them into nonsense, garbage embeddings enter the index. The RAG answer quotes incoherent text and nobody knows why.
Trap 2 — Dedup only at exact hash level. The corpus fills up with paraphrases and translations of the same document. Retrieval’s top-k returns the same source 3 times. The LLM reads it as three confirmations.
Trap 3 — PII detector without precision/recall measurement. Everyone assumes Presidio “works”. IBANs in non-standard formats slip through. The RAG serves personal data.
Trap 4 — Golden eval set contaminated with the corpus. Eval metrics measure memorisation. Promotions approve models that fail in real production.
Trap 5 — No lineage down to the chunk. The question “where did this quotation come from?” has no answer. Regulatory audit fails. Incidents cannot be investigated.
Trap 6 — Maintenance as a one-off event. The corpus is curated once when the system is initialised, and afterwards everyone assumes it is fine. Six months later the documents are out of date, the new PII rules are not applied retrospectively, dedup has not been re-run after adding new sources. The corpus degrades silently.
Trap 7 — Quarantine without human review. Rejected documents go into a table nobody looks at. False positives pile up, real contamination cases go uninvestigated, the team’s trust in curation erodes and the pressure to “relax the thresholds” begins.
All seven are operational, not technical. Corpus curation does not break because of an algorithm bug: it breaks because discipline slackens. It is the exact equivalent of the kind of degradation that kills Eval suites, and in both cases the symptom is the same: the metrics improve or hold steady while the real experience gets worse.
What we have not covered (upcoming posts)
- Vector store versioning proper: an embedding index is not versioned like a raw dataset because it depends on the embedding model. Changing the embedder rewrites the whole index. It is a different animal with its own patterns (index branching, selective reembedding, recall-aware ANN parameters).
- Streaming corpus updates with CDC: when the corpus has to be updated in near-real time from an OLTP system. The Postgres + Qdrant ingestion post covers the mechanics; still pending is the pattern for selectively invalidating chunks that depend on deleted rows.
- Multi-tenant corpus isolation: how to set up a shared corpus versus one with per-tenant namespaces, with ACLs over individual chunks. Especially relevant for multi-customer RAG under data sovereignty constraints.
- Federated corpus: corpora distributed across silos that the system queries without centralising the content. An emerging pattern for companies with several sites and cross-border restrictions.
- Reranking-aware curation: how curation discipline changes when there is a reranker (Cohere Rerank, ColBERTv2, BGE-Reranker) reordering the top-k after retrieval. Some duplicates you would tolerate without a reranker become intolerable when the reranker pushes them up the ranking.
See also
End-to-end document ingestion: from PDF to indexed chunk — how curation materialises in the ingestion pipeline, from PDF to indexed chunk.
The six-stage LLMOps pipeline — Stage 1 (Data) and why curation is the most underrated sub-task in the whole chain.
Embeddings in 2026: the three families, the model zoo and the decision that matters — which cartographer processes the chunks curated here to turn them into searchable vectors; embedder selection criteria (bge-m3 / Snowflake Arctic / Jina / Nomic / ColBERT) and storage cost.
Ontologies and knowledge graphs in LLMOps — the Linnaean nomenclature without which curation is left with ad-hoc categories; how the chunks in this post are typed against a TBox (FIBO / SNOMED / ENS / schema.org), validated with SHACL and enriched with metadata queryable in SPARQL.
Anatomy of an LLM request in production, May 2026 — the forensic tour crosses the corpus and the retrieved chunks; here are the criteria any chunk had to pass to be in production.
Data versioning: DVC, lakeFS and the reproducible golden dataset challenge — the four data artefacts and why they are versioned separately. The RAG corpus is one of the four.
PostgreSQL + Qdrant in the ingestion stage — the microservices pattern that moves documents from source to vector store. The curation in this post plugs in between ingest and indexer.
RAG over Kafka and a datalake — streaming transport. Schema Registry materialises layer 1.
Evals for LLMs: the layer after tracing — the golden eval set is the RAG corpus’s “enemy by design”; layer 4 (anti-contamination) materialises the discipline between the two.
Prompt versioning: the contract that stops a five-word change sinking your system — the
prompt_idtravelling in the trace is the counterpart of the corpusdataset_hashin lineage.Retrain: closing the loop between the production incident and the adapter that fixes it — the retrain enriched corpus also needs the five layers, with additional emphasis on human feedback.
The OSS catalogue for LLMOps in six stages — entries for Presidio, Unstructured, Argilla, Great Expectations, OpenLineage.
Guardrails and safety in LLMs — the curation in this post is prevention at ingest; guardrails are mitigation at runtime when something slips through. Line 2 (retrieval GR) filters chunks with indirect prompt injection before they enter the LLM’s context.
ISO/IEC 42001: the operations manual for an AI system — the five curation layers in this post directly cover controls A.7.3 (acquisition), A.7.4 (quality), A.7.5 (provenance) and A.7.6 (preparation) of the AIMS Annex A.
References
- Presidio: https://microsoft.github.io/presidio/ — official docs, list of supported entities, guide to extending it with custom NER.
- OpenLineage: https://openlineage.io/ — event schema spec and libraries per language.
- Marquez: https://marquezproject.ai/ — server implementation of OpenLineage.
- datasketch (MinHash + LSH): https://ekzhu.com/datasketch/ — the reference Python library for near-duplicate deduplication at scale.
- Great Expectations: https://docs.greatexpectations.io/ — declarative data quality suites.
- Unstructured: https://docs.unstructured.io/ — parsing and normalisation of heterogeneous documents (PDF, HTML, DOCX, eml) before chunking.
- Argilla: https://docs.argilla.io/ — human annotation UI for building the PII golden set and other calibration sets.
- Llama Guard 4: Meta technical paper, multimodal safety classifier — useful as a second PII detection layer.
- GDPR, EU AI Act, ENS, NIS2 — the regulatory frameworks whose compliance depends, in practice, on the discipline of layers 3 (PII) and 5 (lineage). The CEN/CENELEC technical standards for conformity assessment of GenAI systems under the EU AI Act are still pending final publication.