Build a Multi-Document Production RAG Pipeline¶
Every other recipe on this site tests one technique against one small, single-document corpus, on purpose — isolating a variable is the right way to measure what a specific technique actually does. This tutorial does the opposite: it combines chunking-strategy choice, retrieval-method choice, a real vector database, and RAGAS evaluation into one pipeline, run against three real papers at once (not one), so the comparisons and failure modes below are the ones that only show up once several pieces are stacked together.
The code lives in rag-cookbook/multi-doc-production-rag/; every block below is pulled live from those exact files at build time.
Read these first
This tutorial assumes you've read Chunking Strategies, Retrieval Methods, Vector Databases, and RAGAS Deep Dive — it reuses their techniques directly and doesn't re-explain how each one works internally.
The corpus¶
Three real, closely-related papers — "Attention Is All You Need," "BERT," and "GPT-3" — roughly 230 pages combined, producing 700 to 1,000+ chunks depending on strategy. Being closely related (all about language model pretraining and architecture) makes cross-document confusion a real, testable risk: a question like "how many layers does the model have" is genuinely ambiguous across all three, each with a real, different, correct answer.
A 9-question eval set spans all three documents, with a real keyword from that paper's actual text backing each question — the same keyword-derived ground truth pattern used on the Evaluation Metrics page, extended here to also check that a retrieved chunk came from the right document, not just that it contains the right keyword.
1. Install¶
git clone https://github.com/DhruvMakwana/rag-cookbook.git
cd rag-cookbook/multi-doc-production-rag
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
sentence-transformers>=6.0
pypdf>=6.0
requests>=2.31
numpy>=2.0
langchain-text-splitters>=0.2
rank_bm25>=0.2
python-dotenv>=1.0
# Vector database
qdrant-client>=1.19
# RAGAS evaluation (needs langchain-community pinned -- see README)
langchain-community==0.3.31
ragas>=0.4.3
langchain-anthropic>=0.3
langchain-huggingface>=0.1
anthropic>=0.34
openai>=1.0
pandas>=2.0
2. Configure¶
The chunking and retrieval comparisons need no API key at all. The final RAGAS evaluation step needs an LLM — defaults to a local Ollama model (ollama serve, ollama pull qwen3:4b), or set LLM_PROVIDER=anthropic/openai in .env.
3. Chunking strategies, compared on the same multi-document corpus¶
def chunk_fixed_size(pages: list, size: int = 400, overlap: int = 50) -> list:
"""`pages` is a list of (doc_name, page_number, page_text) tuples --
chunking per page (rather than guessing offsets in a merged string)
makes page attribution exact by construction."""
chunks = []
chunk_id = 0
for doc_name, page_num, page_text in pages:
for start in range(0, len(page_text), size - overlap):
piece = page_text[start:start + size].strip()
if len(piece) < 40:
continue
chunks.append({"id": chunk_id, "text": piece, "doc": doc_name, "page": page_num})
chunk_id += 1
return chunks
def chunk_recursive(pages: list, size: int = 400, overlap: int = 50) -> list:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap, separators=["\n\n", "\n", ". ", " ", ""])
chunks = []
chunk_id = 0
for doc_name, page_num, page_text in pages:
for piece in splitter.split_text(page_text):
if len(piece.strip()) < 40:
continue
chunks.append({"id": chunk_id, "text": piece.strip(), "doc": doc_name, "page": page_num})
chunk_id += 1
return chunks
def chunk_sentence_window(pages: list, sentences_per_chunk: int = 5, overlap_sentences: int = 1) -> list:
"""Groups N sentences per chunk with a 1-sentence overlap, instead
of splitting mid-sentence at a fixed character count -- a cheap
proxy for "semantic" chunking with no embedding-based splitter."""
import re
chunks = []
chunk_id = 0
step = max(1, sentences_per_chunk - overlap_sentences)
for doc_name, page_num, page_text in pages:
sentences = re.split(r"(?<=[.!?])\s+", page_text)
for start in range(0, len(sentences), step):
piece = " ".join(sentences[start:start + sentences_per_chunk]).strip()
if len(piece) < 40:
continue
chunks.append({"id": chunk_id, "text": piece, "doc": doc_name, "page": page_num})
chunk_id += 1
return chunks
def evaluate_chunking_strategy(chunks: list, eval_set: list, model, k: int = 3) -> dict:
"""`eval_set` items need question/keyword/doc/reference keys.
Ground truth for a question is every chunk from the RIGHT document
that contains the keyword -- catching wrong-document attribution as
a real failure mode, not just wrong-chunk-within-the-right-doc."""
import numpy as np
texts = [c["text"] for c in chunks]
embeddings = model.encode(texts, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
recalls, hits = [], []
for item in eval_set:
relevant = {c["id"] for c in chunks if item["keyword"].lower() in c["text"].lower() and c["doc"] == item["doc"]}
query_vec = model.encode(item["question"], convert_to_numpy=True, normalize_embeddings=True)
sims = embeddings @ query_vec
retrieved = [chunks[i]["id"] for i in np.argsort(-sims)[:k]]
hits_in_k = sum(1 for cid in retrieved if cid in relevant)
recalls.append(hits_in_k / len(relevant) if relevant else 0.0)
hits.append(1.0 if hits_in_k > 0 else 0.0)
return {"num_chunks": len(chunks), "recall_at_k": float(np.mean(recalls)), "hit_rate_at_k": float(np.mean(hits))}
Measured Recall@3 / Hit Rate@3 across the 9-question eval set:
fixed chunks=1011 recall@3=0.139 hit_rate@3=0.222
recursive chunks=1017 recall@3=0.181 hit_rate@3=0.444
sentence chunks= 743 recall@3=0.333 hit_rate@3=0.667
Sentence-window chunking — grouping complete sentences instead of splitting at a fixed character count — clearly outperforms both fixed-size and recursive chunking here, roughly tripling hit rate over fixed-size despite producing fewer, not more, chunks. On a corpus with dense technical claims packed into single sentences (parameter counts, layer counts, specific scores), keeping each sentence whole mattered more than chunk-size tuning.
4. Dense-only vs. hybrid retrieval, on the winning chunking strategy¶
def hybrid_rrf_fusion(bm25_ranks: list, dense_ranks: list, k_const: int = 60) -> list:
"""Reciprocal Rank Fusion: each ranker contributes 1/(k+rank) per
item; scores from both rankers are summed. No mechanism exists to
detect a ranker that's contributing pure noise for a given query --
RRF trusts both rankers equally by construction."""
scores = {}
for rank, cid in enumerate(bm25_ranks, start=1):
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k_const + rank)
for rank, cid in enumerate(dense_ranks, start=1):
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k_const + rank)
return [cid for cid, _ in sorted(scores.items(), key=lambda x: -x[1])]
Measured against the same 9 questions, sentence-window chunks:
Hybrid search made retrieval worse here, and that's a real result
This isn't the usual outcome — Embedding Model Selection has a real case where hybrid RRF fusion clearly helps. Checking why hybrid underperforms on this eval set: none of the 9 questions have exact-match value (rare terms, product codes, IDs) that BM25 is actually good at finding. For a natural-language question like "how many attention heads does the base Transformer model use," BM25's own top-ranked result often isn't the chunk that actually answers it — its top hit for that exact question came from a different page entirely, one with high keyword overlap but the wrong content. RRF has no mechanism to detect that BM25 is contributing noise for a specific query; it sums both rankers' reciprocal-rank scores unconditionally, so a bad BM25 ranking can pull a correct dense-retrieval top-1 result out of the fused top-3. The lesson isn't "don't use hybrid search" — it's that hybrid search's benefit is conditional on the corpus and query style actually having exact-match value to contribute, which this particular eval set doesn't.
5. Indexing into a real vector database, with metadata filtering across documents¶
def index_and_query_qdrant(chunks: list, embeddings, query_vec: list, filter_doc: str = None, limit: int = 3):
"""Indexes a multi-document corpus into Qdrant with each chunk's
source document as metadata, then queries with an optional
document filter -- useful when a query is genuinely ambiguous
across documents in the corpus (e.g. "how many layers does the
model have" could be about any of several papers)."""
from qdrant_client import QdrantClient, models
client = QdrantClient(":memory:")
dim = embeddings.shape[1]
client.create_collection(
collection_name="corpus",
vectors_config=models.VectorParams(size=dim, distance=models.Distance.COSINE, hnsw_config=models.HnswConfigDiff(m=32, ef_construct=200)),
)
client.upsert(
collection_name="corpus",
points=[
models.PointStruct(id=c["id"], vector=embeddings[i].tolist(), payload={"doc": c["doc"], "page": c["page"]})
for i, c in enumerate(chunks)
],
)
query_filter = None
if filter_doc:
query_filter = models.Filter(must=[models.FieldCondition(key="doc", match=models.MatchValue(value=filter_doc))])
result = client.query_points(collection_name="corpus", query=query_vec, limit=limit, query_filter=query_filter)
return [(p.payload["doc"], p.payload["page"], p.score) for p in result.points]
Measured against the genuinely ambiguous question "How many layers does the model have?":
unfiltered: attention_is_all_you_need.pdf p3 (0.444), attention_is_all_you_need.pdf p3 (0.420), gpt3.pdf p20 (0.380)
filtered to bert.pdf: bert.pdf p3 (0.337), bert.pdf p5 (0.309), bert.pdf p8 (0.288)
Unfiltered, BERT's own answer (12 layers) never makes the top-3 at all — the Transformer and GPT-3 papers' own layer-count mentions score higher, since "how many layers" matches their phrasing at least as well. This is exactly the scenario metadata filtering exists for: when the calling application knows which document a question is actually about (a user viewing a specific paper, a chatbot scoped to one product's docs), a doc= filter turns an ambiguous multi-document retrieval into an unambiguous one — not a hypothetical use case, a real fix for a real miss measured above.
6. RAGAS evaluation of the assembled pipeline¶
Running naive RAG (sentence chunking, dense retrieval, top-3) over the full 9-question eval set and scoring with RAGAS's core four metrics:
faithfulness: 0.8889
answer_relevancy: 0.6067
llm_context_precision_with_reference: 0.6111
context_recall: 0.6667
Per-question, 3 of 9 questions show context_recall = 0.0 — a real retrieval miss, not a generation problem: two of them (the Transformer's optimizer, GPT-3's context window) got an honest "the context doesn't specify" from the model rather than a hallucinated guess, which is the correct behavior when retrieval genuinely fails to surface the answer.
The same grounding failure mode measured on RAGAS Deep Dive, reproduced here
The third miss is the interesting one: asked "What are BERT's two pretraining tasks called?", the naive RAG pipeline answered "BERT is pre-trained using masked language modeling (MLM) and next sentence prediction (NSP)" — completely correct. It still scored faithfulness = 0.0, context_precision = 0.0, context_recall = 0.0. The retrieved top-3 chunks for that specific question didn't actually contain "Masked LM" or "NSP" — the model answered from its own training knowledge about a well-known paper, not from what retrieval surfaced. This is the identical failure mode RAGAS Deep Dive measured on a single-document corpus, reproducing here on a genuinely different, multi-document one — real evidence that the pattern isn't specific to one paper or one eval set.
Run it yourself¶
python multi_doc_rag.py --part chunking
python multi_doc_rag.py --part retrieval
python multi_doc_rag.py --part qdrant
python multi_doc_rag.py --part ragas
python multi_doc_rag.py --part all
Where this still breaks¶
- The chunking and retrieval winners here are corpus-specific, not universal. Sentence-window chunking won on this eval set because its questions target single-sentence factual claims — a corpus with longer, multi-sentence arguments to retrieve might favor a different strategy entirely. Re-run the comparison on your own corpus before assuming these results transfer.
- The eval set is small (9 questions). Real production systems need eval sets in the dozens-to-hundreds range before trusting a metric difference as more than noise — this tutorial's numbers are illustrative of the method, not a claim that sentence-window chunking universally beats recursive chunking.
- This pipeline has no reranking, no query rewriting, and no retrieval-quality gate before generation. See Advanced RAG — Post-retrieval and Corrective RAG for the pieces this capstone deliberately leaves out to keep the comparison focused.
Sources & further reading¶
- Chunking Strategies — the fixed-size and recursive strategies compared here
- Retrieval Methods and Embedding Model Selection — the hybrid RRF fusion mechanism used here
- Vector Databases — Qdrant's real current API, used here for indexing and filtering
- RAGAS Deep Dive — the four-metric diagnostic table this tutorial's final evaluation step uses, including the same grounding-failure pattern reproduced here