{"schemaVersion":1,"id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation","title":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks","description":"A production-focused guide to building Retrieval-Augmented Generation pipelines, from document ingestion and chunking strategies to hybrid search, cross-encoder reranking, and systematic evaluation with RAGAS and custom metrics.","language":"en","datePublished":"2026-04-16","dateModified":"2026-10-22","contentFormat":"text/markdown","content":"AI/ML\n\n# RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks\n\n2026-04-1614 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/rag-pipeline-architecture-chunking-reranking-evaluation>)\n\n## Why Most RAG Pipelines Fail\n\nThe default RAG tutorial, split documents, embed them, retrieve top-k, stuff into prompt, works for demos. It fails in production for predictable reasons:\n\n- **Chunking destroys context.** Fixed-size splits break sentences mid-thought, separate headers from their content, and lose document structure.\n- **Vector search misses keywords.** Embeddings capture semantic similarity but fail on exact entity matches, acronyms, and domain-specific terminology.\n- **Top-k retrieval is noisy.** Retrieving 5 chunks by cosine similarity often returns 2-3 irrelevant results that dilute the prompt context.\n- **No evaluation means no iteration.** Without systematic metrics, teams optimize by vibes and ship pipelines that degrade silently.\n\nThis article covers the architecture that addresses each failure mode.\n\n## Document Ingestion and Preprocessing\n\nBefore chunking, document preprocessing determines pipeline quality more than most teams realize.\n\n### Format Normalization\n\nDifferent source formats require different extraction strategies:\n\n```python\n# PDF extraction with layout awareness\nfrom unstructured.partition.pdf import partition_pdf\n\nelements = partition_pdf(\n    filename=\"report.pdf\",\n    strategy=\"hi_res\",           # OCR + layout detection\n    infer_table_structure=True,  # Extract tables as HTML\n    extract_images_in_pdf=True,  # Extract embedded images\n)\n\n# Separate element types for different processing\ntables = [e for e in elements if e.category == \"Table\"]\nnarratives = [e for e in elements if e.category == \"NarrativeText\"]\ntitles = [e for e in elements if e.category == \"Title\"]\n```\n\nKey decisions at this stage:\n\n- **Tables**: Convert to markdown or HTML, never split across chunks. A table fragment is worse than no table.\n- **Code blocks**: Keep intact. Splitting a function definition across chunks makes both halves useless.\n- **Images**: Extract captions and surrounding text. For diagrams, consider vision model descriptions.\n- **Headers**: Preserve hierarchy, they become metadata that improves retrieval.\n\n### Metadata Extraction\n\nAttach metadata at ingestion time, not after:\n\n```python\nchunk_metadata = {\n    \"source\": \"engineering-handbook-v3.pdf\",\n    \"section\": \"Chapter 4: Database Operations\",\n    \"page_numbers\": [42, 43],\n    \"document_type\": \"handbook\",\n    \"last_updated\": \"2026-03-15\",\n    \"audience\": \"backend-engineers\",\n}\n```\n\nThis metadata enables filtered retrieval, \"find chunks about database operations from the engineering handbook\", which dramatically improves precision.\n\n## Chunking Strategies\n\nChunking is where most pipelines silently lose quality. The right strategy depends on document structure.\n\n### Fixed-Size Chunking\n\nThe baseline. Split every N tokens with M-token overlap.\n\n```python\nfrom langchain.text_splitter import RecursiveCharacterTextSplitter\n\nsplitter = RecursiveCharacterTextSplitter(\n    chunk_size=512,\n    chunk_overlap=50,\n    separators=[\"\\n\\n\", \"\\n\", \". \", \" \", \"\"],\n    length_function=len,  # or tiktoken for token-accurate counts\n)\nchunks = splitter.split_text(document_text)\n```\n\n**When it works**: Uniform text without structure, transcripts, plain-text logs, chat histories.\n\n**When it fails**: Structured documents where splitting at arbitrary positions destroys meaning.\n\n### Semantic Chunking\n\nGroups sentences by embedding similarity, splitting where topics shift:\n\n```python\nfrom langchain_experimental.text_splitter import SemanticChunker\nfrom langchain_openai import OpenAIEmbeddings\n\nchunker = SemanticChunker(\n    OpenAIEmbeddings(model=\"text-embedding-3-small\"),\n    breakpoint_threshold_type=\"percentile\",\n    breakpoint_threshold_amount=85,  # Split at 85th percentile of distance\n)\nchunks = chunker.split_text(document_text)\n```\n\nSemantic chunking produces variable-size chunks that respect topic boundaries. The tradeoff is speed, embedding every sentence during ingestion is 10-50x slower than fixed-size splitting.\n\n### Document-Structure-Aware Chunking\n\nThe most effective approach for structured content. Use document hierarchy to define chunk boundaries:\n\n```python\ndef structure_aware_chunk(elements, max_tokens=512):\n    \"\"\"Chunk by document structure, respecting headers and sections.\"\"\"\n    chunks = []\n    current_chunk = []\n    current_tokens = 0\n    current_header_chain = []\n\n    for element in elements:\n        if element.category == \"Title\":\n            # New section, flush current chunk\n            if current_chunk:\n                chunks.append({\n                    \"content\": \"\\n\".join(current_chunk),\n                    \"headers\": list(current_header_chain),\n                    \"token_count\": current_tokens,\n                })\n                current_chunk = []\n                current_tokens = 0\n            current_header_chain.append(element.text)\n\n        elif element.category == \"Table\":\n            # Tables go in their own chunk, never split\n            if current_chunk:\n                chunks.append({\n                    \"content\": \"\\n\".join(current_chunk),\n                    \"headers\": list(current_header_chain),\n                    \"token_count\": current_tokens,\n                })\n                current_chunk = []\n                current_tokens = 0\n            chunks.append({\n                \"content\": element.metadata.text_as_html,\n                \"headers\": list(current_header_chain),\n                \"token_count\": count_tokens(element.text),\n                \"type\": \"table\",\n            })\n\n        else:\n            elem_tokens = count_tokens(element.text)\n            if current_tokens + elem_tokens > max_tokens and current_chunk:\n                chunks.append({\n                    \"content\": \"\\n\".join(current_chunk),\n                    \"headers\": list(current_header_chain),\n                    \"token_count\": current_tokens,\n                })\n                current_chunk = []\n                current_tokens = 0\n            current_chunk.append(element.text)\n            current_tokens += elem_tokens\n\n    if current_chunk:\n        chunks.append({\n            \"content\": \"\\n\".join(current_chunk),\n            \"headers\": list(current_header_chain),\n            \"token_count\": current_tokens,\n        })\n\n    return chunks\n```\n\n### Contextual Chunk Headers\n\nPrepending section hierarchy before embedding is a hypothesis to evaluate against an otherwise identical baseline:\n\n```\n## Chapter 4: Database Operations > Backup Strategies > Point-in-Time Recovery\n\nTo perform point-in-time recovery, configure continuous WAL archiving...\n```\n\nThe embedding now captures both the content and its position in the document hierarchy.\n\n## Embedding Strategy\n\n### Model Selection\n\nThe embedding model determines the ceiling of retrieval quality.\n\n| Model | Dimensions | MTEB Score | Latency (p50) | Cost |\n| --- | --- | --- | --- | --- |\n| text-embedding-3-small | 1536 | 62.3 | 12ms | $0.02/1M tokens |\n| text-embedding-3-large | 3072 | 64.6 | 18ms | $0.13/1M tokens |\n| voyage-3 | 1024 | 67.1 | 15ms | $0.06/1M tokens |\n| bge-m3 (self-hosted) | 1024 | 66.8 | 8ms | Infrastructure cost |\n| Cohere embed-v4 | 1024 | 67.5 | 14ms | $0.10/1M tokens |\n\n**Practical recommendation**: For English-only workloads, ` voyage-3 ` or ` Cohere embed-v4 ` deliver the best retrieval quality. For multilingual or cost-sensitive deployments, ` bge-m3 ` self-hosted on a single GPU is hard to beat.\n\n### Late Interaction Models (ColBERT)\n\nColBERT produces per-token embeddings instead of a single vector, enabling more fine-grained matching:\n\n```python\nfrom ragatouille import RAGPretrainedModel\n\nrag = RAGPretrainedModel.from_pretrained(\"colbert-ir/colbertv2.0\")\nrag.index(\n    collection=documents,\n    index_name=\"knowledge-base\",\n    max_document_length=512,\n)\n\nresults = rag.search(query=\"PITR backup configuration\", k=10)\n```\n\nColBERT consistently outperforms single-vector models on retrieval quality but requires 10-50x more storage per document. Use it when retrieval quality is the bottleneck and storage is cheap.\n\n## Hybrid Search Architecture\n\nVector search alone is not enough. Combining semantic and lexical search covers each method's blind spots.\n\n### BM25 + Vector with Reciprocal Rank Fusion\n\n```python\nfrom rank_bm25 import BM25Okapi\nimport numpy as np\n\ndef hybrid_search(query, corpus_chunks, embeddings_index, k=20, alpha=0.5):\n    \"\"\"\n    Combine BM25 and vector search using Reciprocal Rank Fusion.\n    alpha controls the balance: 0.5 = equal weight.\n    \"\"\"\n    # Vector search\n    query_embedding = embed(query)\n    vector_scores = embeddings_index.search(query_embedding, k=k * 2)\n\n    # BM25 search\n    tokenized_corpus = [chunk.split() for chunk in corpus_chunks]\n    bm25 = BM25Okapi(tokenized_corpus)\n    bm25_scores = bm25.get_scores(query.split())\n    bm25_top = np.argsort(bm25_scores)[-k * 2:][::-1]\n\n    # Reciprocal Rank Fusion\n    rrf_scores = {}\n    rrf_k = 60  # Standard RRF constant\n\n    for rank, doc_id in enumerate(vector_scores.ids):\n        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + alpha / (rrf_k + rank + 1)\n\n    for rank, doc_id in enumerate(bm25_top):\n        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + (1 - alpha) / (rrf_k + rank + 1)\n\n    # Sort by fused score\n    ranked = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)\n    return ranked[:k]\n```\n\n### Why Hybrid Works\n\nConsider the query: \"What is the SLA for the EU-WEST-1 Redis cluster?\"\n\n- **Vector search** finds chunks about SLAs and Redis clusters in general, semantically similar but potentially wrong region.\n- **BM25** finds chunks containing the exact string \"EU-WEST-1\", lexically matched.\n- **Hybrid** surfaces the chunk that mentions both the SLA policy and the specific region.\n\n### Database-Native Hybrid Search\n\nPostgreSQL with pgvector 0.8+ supports both in a single query:\n\n```sql\n-- Hybrid search with pgvector + tsvector\nWITH vector_results AS (\n    SELECT id, content, 1 - (embedding <=> $1::vector) AS vector_score\n    FROM documents\n    WHERE metadata->>'department' = 'engineering'\n    ORDER BY embedding <=> $1::vector\n    LIMIT 40\n),\ntext_results AS (\n    SELECT id, content, ts_rank(search_vector, plainto_tsquery($2)) AS text_score\n    FROM documents\n    WHERE search_vector @@ plainto_tsquery($2)\n      AND metadata->>'department' = 'engineering'\n    ORDER BY text_score DESC\n    LIMIT 40\n),\ncombined AS (\n    SELECT\n        COALESCE(v.id, t.id) AS id,\n        COALESCE(v.content, t.content) AS content,\n        COALESCE(v.vector_score, 0) * 0.5 + COALESCE(t.text_score, 0) * 0.5 AS hybrid_score\n    FROM vector_results v\n    FULL OUTER JOIN text_results t ON v.id = t.id\n)\nSELECT * FROM combined ORDER BY hybrid_score DESC LIMIT 10;\n```\n\n## Reranking\n\nReranking is the highest-leverage improvement you can make to a RAG pipeline. Bi-encoder embeddings are fast but imprecise. Cross-encoder rerankers are slow but accurate. The architecture uses both:\n\n1. **Retrieve** 50-100 candidates with fast hybrid search (~10ms)\n2. **Rerank** the top 20-30 with a cross-encoder (latency to measure)\n3. **Select** the top 5-8 for the LLM context\n\n### Cross-Encoder Reranking\n\n```python\nfrom sentence_transformers import CrossEncoder\n\nreranker = CrossEncoder(\"BAAI/bge-reranker-v2-m3\", max_length=512)\n\ndef rerank(query, candidates, top_k=5):\n    \"\"\"Rerank candidates using cross-encoder.\"\"\"\n    pairs = [[query, candidate[\"content\"]] for candidate in candidates]\n    scores = reranker.predict(pairs)\n\n    for i, candidate in enumerate(candidates):\n        candidate[\"rerank_score\"] = float(scores[i])\n\n    ranked = sorted(candidates, key=lambda x: x[\"rerank_score\"], reverse=True)\n    return ranked[:top_k]\n```\n\n### Cohere Rerank API\n\nFor teams that prefer managed infrastructure:\n\n```python\nimport cohere\n\nco = cohere.ClientV2()\n\nresults = co.rerank(\n    model=\"rerank-v3.5\",\n    query=\"PITR backup configuration for PostgreSQL\",\n    documents=[chunk[\"content\"] for chunk in candidates],\n    top_n=5,\n    return_documents=True,\n)\n\nreranked = [\n    {\"content\": r.document.text, \"relevance_score\": r.relevance_score}\n    for r in results.results\n]\n```\n\n### Impact Measurement\n\nIn our production pipeline (2.3M chunks, 10K test queries):\n\n| Configuration | Recall@5 | MRR | Answer Relevance |\n| --- | --- | --- | --- |\n| Vector only | 0.62 | 0.54 | 0.71 |\n| Hybrid (BM25 + vector) | 0.74 | 0.63 | 0.78 |\n| Hybrid + reranker | 0.83 | 0.76 | 0.89 |\n| Hybrid + reranker + contextual headers | 0.87 | 0.81 | 0.91 |\n\nThe reranker alone accounts for a 12-point recall improvement. Combined with hybrid search and contextual headers, the pipeline recovers 87% of relevant chunks in the top 5 results.\n\n## Prompt Construction\n\nAfter retrieval and reranking, how you construct the LLM prompt matters more than most teams realize.\n\n### Context Window Management\n\n```python\ndef build_rag_prompt(query, retrieved_chunks, max_context_tokens=6000):\n    \"\"\"Build prompt with token-budget-aware context injection.\"\"\"\n    context_parts = []\n    token_count = 0\n\n    for chunk in retrieved_chunks:\n        chunk_tokens = count_tokens(chunk[\"content\"])\n        if token_count + chunk_tokens > max_context_tokens:\n            break\n        context_parts.append(\n            f\"[Source: {chunk['metadata']['source']}, \"\n            f\"Section: {chunk['metadata'].get('section', 'N/A')}]\\n\"\n            f\"{chunk['content']}\"\n        )\n        token_count += chunk_tokens\n\n    context = \"\\n\\n---\\n\\n\".join(context_parts)\n\n    return f\"\"\"Answer the following question based on the provided context.\nIf the context does not contain enough information to answer fully, say so explicitly.\nCite the source document for each claim.\n\nContext:\n{context}\n\nQuestion: {query}\n\nAnswer:\"\"\"\n```\n\n### Citation Grounding\n\nForce the model to cite sources by requiring inline references:\n\n```\nFor each factual claim in your answer, include a citation in the format [Source: filename, Section: section_name].\nIf you cannot find supporting evidence in the context for a claim, prefix it with [Unsupported].\n```\n\nThis makes hallucination detection trivial, any claim marked ` [Unsupported] ` or missing a citation is suspect.\n\n## Evaluation Framework\n\nA RAG pipeline without evaluation is a demo, not a product. Evaluation happens at three levels.\n\n### Level 1: Retrieval Quality\n\nMeasured independently from the LLM:\n\n```python\ndef evaluate_retrieval(test_queries, ground_truth, retriever, k=5):\n    \"\"\"Evaluate retrieval with standard IR metrics.\"\"\"\n    recalls, mrrs, ndcgs = [], [], []\n\n    for query, relevant_ids in zip(test_queries, ground_truth):\n        retrieved = retriever.search(query, k=k)\n        retrieved_ids = [r[\"id\"] for r in retrieved]\n\n        # Recall@k\n        hits = len(set(retrieved_ids) & set(relevant_ids))\n        recalls.append(hits / len(relevant_ids))\n\n        # MRR\n        for rank, rid in enumerate(retrieved_ids, 1):\n            if rid in relevant_ids:\n                mrrs.append(1.0 / rank)\n                break\n        else:\n            mrrs.append(0.0)\n\n    return {\n        \"recall@k\": np.mean(recalls),\n        \"mrr\": np.mean(mrrs),\n    }\n```\n\n### Level 2: Generation Quality with RAGAS\n\nRAGAS evaluates the full pipeline, retrieval and generation together:\n\n```python\nfrom ragas import evaluate\nfrom ragas.metrics import (\n    faithfulness,\n    answer_relevancy,\n    context_precision,\n    context_recall,\n)\n\nresults = evaluate(\n    dataset=eval_dataset,  # Questions + ground truth answers + contexts\n    metrics=[\n        faithfulness,        # Does the answer stick to retrieved context?\n        answer_relevancy,    # Is the answer relevant to the question?\n        context_precision,   # Are retrieved chunks relevant?\n        context_recall,      # Were all necessary chunks retrieved?\n    ],\n)\n\nprint(results)\n# {'faithfulness': 0.89, 'answer_relevancy': 0.91,\n#  'context_precision': 0.82, 'context_recall': 0.87}\n```\n\n**Faithfulness** is the most important metric, it measures hallucination. A faithfulness score below 0.85 means the pipeline is generating claims not supported by retrieved context.\n\n### Level 3: Human Evaluation\n\nAutomate collection, evaluate manually:\n\n```python\ndef sample_for_human_eval(production_queries, n=200):\n    \"\"\"Sample queries stratified by difficulty and topic.\"\"\"\n    # Classify queries by estimated difficulty\n    easy = [q for q in production_queries if q[\"retrieval_confidence\"] > 0.8]\n    medium = [q for q in production_queries if 0.5 < q[\"retrieval_confidence\"] <= 0.8]\n    hard = [q for q in production_queries if q[\"retrieval_confidence\"] <= 0.5]\n\n    sample = (\n        random.sample(easy, min(80, len(easy))) +\n        random.sample(medium, min(80, len(medium))) +\n        random.sample(hard, min(40, len(hard)))\n    )\n\n    return sample\n```\n\nHuman evaluators rate on three axes: **correctness** (factually accurate), **completeness** (covers the question fully), and **groundedness** (cites context, doesn't hallucinate).\n\n## Production Architecture\n\n### Pipeline Overview\n\n```\nDocuments → Preprocessing → Chunking → Embedding → Vector Store\n                                                         ↓\nUser Query → Query Expansion → Hybrid Search → Reranker → Prompt Builder → LLM → Response\n                                                                                    ↓\n                                                                              Evaluation Logger\n```\n\n### Query Expansion\n\nRewrite the user query before retrieval to improve recall:\n\n```python\ndef expand_query(query, llm):\n    \"\"\"Generate alternative phrasings to improve retrieval.\"\"\"\n    expansion_prompt = f\"\"\"Generate 3 alternative phrasings of this search query.\nEach should capture the same intent but use different terminology.\nReturn only the queries, one per line.\n\nQuery: {query}\"\"\"\n\n    alternatives = llm.generate(expansion_prompt).strip().split(\"\\n\")\n    return [query] + alternatives[:3]\n```\n\nRun hybrid search on all expanded queries and merge results with RRF. This consistently adds 3-5% recall improvement.\n\n### Caching Layer\n\nCache at two levels to reduce latency and cost:\n\n1. **Embedding cache**: Hash the input text, cache the embedding vector. Avoids re-embedding identical queries.\n2. **Semantic cache**: For queries with cosine similarity \\> 0.95 to a cached query, return the cached response. Reduces LLM calls by 20-40% in support use cases where similar questions recur.\n\n### Monitoring\n\nTrack these metrics in production:\n\n- **Retrieval latency** (p50, p95, p99), target \\< 100ms for hybrid + rerank\n- **Faithfulness score** on a rolling sample, alert if it drops below 0.85\n- **Empty retrieval rate**, queries where no chunk scores above the relevance threshold\n- **User feedback signals**, thumbs up/down, copy events, follow-up questions\n\n## When RAG Is Not Enough\n\nRAG has well-defined limits:\n\n- **Multi-hop reasoning**: When the answer requires synthesizing information from 5+ documents with inferential steps between them, RAG retrieval often misses intermediate documents.\n- **Temporal reasoning**: \"What changed between Q3 and Q4?\" requires retrieving and comparing two time-specific document sets, standard top-k retrieval is not designed for this.\n- **Computation over data**: \"What is the average SLA across all regions?\" requires structured query, not text retrieval.\n\nFor these cases, consider **agentic RAG**, an agent that plans retrieval steps, executes multiple searches, and synthesizes results programmatically, or hybrid approaches that combine RAG with SQL queries and API calls.\n\n## Reproducible implementation\n\n[RAG Evaluation Workbench](<https://lucioduran.com/projects/rag-evaluation-workbench>) compares BM25, dense and hybrid retrieval with the same local answer model, prompt and budget. The project page brings together the licensed corpus, labeled queries, raw results, intervals and reproduction commands.\n\nOn that sample, retrieving all relevant context did not prevent answer errors or abstention failures. The experiment evaluates retrieval and generation separately; it does not validate this guide's chunking strategies, rerankers or production targets.\n\n### Chunking: complete evidence and index cost\n\n[Chunking Ablation Suite](<https://lucioduran.com/projects/chunking-ablation-suite>) compares eight fixed, structural and semantic configurations with the same MiniLM model. On 36 evaluation queries, structure-96 retrieved complete evidence in 33 cases; semantic configurations did so in 12. The repository retains chunks, vectors, rankings and costs by document type. It also exposes a threshold ablation that changes no boundaries. Calling the algorithm semantic does not settle the evaluation.\n\n### Citations: a relevant source can still fail to support a claim\n\n[Citation Grounding Checker](<https://lucioduran.com/projects/citation-grounding-checker>) compares conservative rules, FLAN-T5-small and DeBERTa NLI on claim/passage pairs. It publishes verdicts, precision by class, abstentions and exact evidence links. On its 36 synthetic evaluation cases, DeBERTa classified 31 correctly and abstained on one. Labels have no independent human review; that number describes the published fixture.\n\n### Reranking: measure when the router adds no value\n\n[Reranker Budget Router](<https://lucioduran.com/projects/reranker-budget-router>) allocates budgets of 0, 4 or 12 pairs and freezes policies before evaluation. All six fitted policies skipped reranking, matched the never baseline’s quality and added overhead. Always-12 improved one query. Traces expose that missed opportunity: adding a router does not guarantee a better architecture.\n\n## References and scope\n\nThe sources below describe retrieval and evaluation methods. This article is a technical guide, not a report of a benchmark performed by the author. Gains and deployment costs require measurements on the target workload.\n\n- [Lewis et al. Retrieval Augmented Generation](<https://arxiv.org/abs/2005.11401>).\n- [Es et al. Ragas](<https://arxiv.org/abs/2309.15217>).\n- [Elasticsearch Reciprocal Rank Fusion](<https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion>).\n\n## Reproducible implementation: index updates and integrity\n\n[Incremental RAG Index](<https://lucioduran.com/projects/incremental-rag-index>) keeps complete SQLite versions, reuses embeddings by content hash and records a change journal. It compares full and incremental rebuilds in five scenarios with a deterministic adapter and local MiniLM, injecting process exits at four publication checkpoints per adapter.\n\nBoth experiments matched documents, vectors, roots and rankings. An edit/add/delete update computed two embeddings versus six for a full rebuild. Pre-commit exits preserved the old version; the post-commit exit preserved the new one without creating another version on retry. Deleted documents disappear from the current index but remain in historical snapshots. The evidence establishes consistency on six synthetic documents, not semantic quality or scaling performance.\n\n## Frequently asked questions\n\n### What chunk size should I use for RAG?\n\nNo universal chunk size is best. Compare candidate sizes on labeled queries and record retrieval quality, context truncation and generation quality.\n\n### Is hybrid search always better than vector-only search?\n\nNo. Hybrid retrieval can help exact terms and semantic matches, but it can also add noise. Compare all methods on the same held-out queries.\n\n### How do I evaluate a RAG pipeline systematically?\n\nUse a three-layer approach: (1) retrieval metrics like recall@k, MRR, and NDCG on a labeled query-document set, (2) generation metrics like faithfulness and answer relevance using RAGAS, and (3) end-to-end human evaluation on a rotating golden set of 200+ queries.\n\n### When should I use a reranker vs just improving embeddings?\n\nMeasure embeddings and reranking as separate changes. Adopt a reranker if its measured relevance gain justifies its latency and cost for the workload.\n\n## Tips\n\n1. Compare chunking strategies on a labeled query set. Token counts and overlap are starting parameters to tune, not universal optima.\n2. Combine BM25 and dense retrieval with RRF, then compare recall@k and NDCG against each retrieval method separately. The benefit depends on the corpus and queries.\n3. Evaluate a cross-encoder on retrieved candidates and measure relevance, latency and cost together. No fixed quality gain or latency is established by this article.\n4. **Evaluation trap:** Don't use LLM-as-judge exclusively. Combine it with retrieval metrics (recall@k, MRR) and human evaluation on a 200-query golden set. LLM judges have blind spots around factual precision.\n\n- rag\n- retrieval-augmented-generation\n- chunking\n- reranking\n- hybrid-search\n- embeddings\n- llm\n- evaluation\n- ragas\n- ai-agents\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#title"},{"level":2,"text":"Why Most RAG Pipelines Fail","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#why-most-rag-pipelines-fail"},{"level":2,"text":"Document Ingestion and Preprocessing","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#document-ingestion-and-preprocessing"},{"level":3,"text":"Format Normalization","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#format-normalization"},{"level":3,"text":"Metadata Extraction","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#metadata-extraction"},{"level":2,"text":"Chunking Strategies","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#chunking-strategies"},{"level":3,"text":"Fixed-Size Chunking","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#fixed-size-chunking"},{"level":3,"text":"Semantic Chunking","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#semantic-chunking"},{"level":3,"text":"Document-Structure-Aware Chunking","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#document-structure-aware-chunking"},{"level":3,"text":"Contextual Chunk Headers","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#contextual-chunk-headers"},{"level":2,"text":"Embedding Strategy","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#embedding-strategy"},{"level":3,"text":"Model Selection","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#model-selection"},{"level":3,"text":"Late Interaction Models (ColBERT)","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#late-interaction-models-colbert"},{"level":2,"text":"Hybrid Search Architecture","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#hybrid-search-architecture"},{"level":3,"text":"BM25 + Vector with Reciprocal Rank Fusion","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#bm25-vector-with-reciprocal-rank-fusion"},{"level":3,"text":"Why Hybrid Works","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#why-hybrid-works"},{"level":3,"text":"Database-Native Hybrid Search","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#database-native-hybrid-search"},{"level":2,"text":"Reranking","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reranking"},{"level":3,"text":"Cross-Encoder Reranking","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#cross-encoder-reranking"},{"level":3,"text":"Cohere Rerank API","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#cohere-rerank-api"},{"level":3,"text":"Impact Measurement","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#impact-measurement"},{"level":2,"text":"Prompt Construction","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#prompt-construction"},{"level":3,"text":"Context Window Management","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#context-window-management"},{"level":3,"text":"Citation Grounding","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#citation-grounding"},{"level":2,"text":"Evaluation Framework","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#evaluation-framework"},{"level":3,"text":"Level 1: Retrieval Quality","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#level-1-retrieval-quality"},{"level":3,"text":"Level 2: Generation Quality with RAGAS","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#level-2-generation-quality-with-ragas"},{"level":3,"text":"Level 3: Human Evaluation","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#level-3-human-evaluation"},{"level":2,"text":"Production Architecture","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#production-architecture"},{"level":3,"text":"Pipeline Overview","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#pipeline-overview"},{"level":3,"text":"Query Expansion","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#query-expansion"},{"level":3,"text":"Caching Layer","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#caching-layer"},{"level":3,"text":"Monitoring","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#monitoring"},{"level":2,"text":"When RAG Is Not Enough","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#when-rag-is-not-enough"},{"level":2,"text":"Reproducible implementation","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reproducible-implementation"},{"level":3,"text":"Chunking: complete evidence and index cost","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#chunking-complete-evidence-and-index-cost"},{"level":3,"text":"Citations: a relevant source can still fail to support a claim","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#citations-a-relevant-source-can-still-fail-to-support-a-claim"},{"level":3,"text":"Reranking: measure when the router adds no value","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reranking-measure-when-the-router-adds-no-value"},{"level":2,"text":"References and scope","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#references-and-scope"},{"level":2,"text":"Reproducible implementation: index updates and integrity","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reproducible-implementation-index-updates-and-integrity"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#questions"},{"level":3,"text":"What chunk size should I use for RAG?","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-1"},{"level":3,"text":"Is hybrid search always better than vector-only search?","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-2"},{"level":3,"text":"How do I evaluate a RAG pipeline systematically?","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-3"},{"level":3,"text":"When should I use a reranker vs just improving embeddings?","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-4"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#article-tips"}],"sections":[{"heading":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks","level":1,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation","content":"AI/ML"},{"heading":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks","level":1,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#title","content":"2026-04-1614 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/rag-pipeline-architecture-chunking-reranking-evaluation>)"},{"heading":"Why Most RAG Pipelines Fail","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#why-most-rag-pipelines-fail","content":"The default RAG tutorial, split documents, embed them, retrieve top-k, stuff into prompt, works for demos. It fails in production for predictable reasons:\n\n- **Chunking destroys context.** Fixed-size splits break sentences mid-thought, separate headers from their content, and lose document structure.\n- **Vector search misses keywords.** Embeddings capture semantic similarity but fail on exact entity matches, acronyms, and domain-specific terminology.\n- **Top-k retrieval is noisy.** Retrieving 5 chunks by cosine similarity often returns 2-3 irrelevant results that dilute the prompt context.\n- **No evaluation means no iteration.** Without systematic metrics, teams optimize by vibes and ship pipelines that degrade silently.\n\nThis article covers the architecture that addresses each failure mode."},{"heading":"Document Ingestion and Preprocessing","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#document-ingestion-and-preprocessing","content":"Before chunking, document preprocessing determines pipeline quality more than most teams realize."},{"heading":"Format Normalization","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#format-normalization","content":"Different source formats require different extraction strategies:\n\n```python\n# PDF extraction with layout awareness\nfrom unstructured.partition.pdf import partition_pdf\n\nelements = partition_pdf(\n    filename=\"report.pdf\",\n    strategy=\"hi_res\",           # OCR + layout detection\n    infer_table_structure=True,  # Extract tables as HTML\n    extract_images_in_pdf=True,  # Extract embedded images\n)\n\n# Separate element types for different processing\ntables = [e for e in elements if e.category == \"Table\"]\nnarratives = [e for e in elements if e.category == \"NarrativeText\"]\ntitles = [e for e in elements if e.category == \"Title\"]\n```\n\nKey decisions at this stage:\n\n- **Tables**: Convert to markdown or HTML, never split across chunks. A table fragment is worse than no table.\n- **Code blocks**: Keep intact. Splitting a function definition across chunks makes both halves useless.\n- **Images**: Extract captions and surrounding text. For diagrams, consider vision model descriptions.\n- **Headers**: Preserve hierarchy, they become metadata that improves retrieval."},{"heading":"Metadata Extraction","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#metadata-extraction","content":"Attach metadata at ingestion time, not after:\n\n```python\nchunk_metadata = {\n    \"source\": \"engineering-handbook-v3.pdf\",\n    \"section\": \"Chapter 4: Database Operations\",\n    \"page_numbers\": [42, 43],\n    \"document_type\": \"handbook\",\n    \"last_updated\": \"2026-03-15\",\n    \"audience\": \"backend-engineers\",\n}\n```\n\nThis metadata enables filtered retrieval, \"find chunks about database operations from the engineering handbook\", which dramatically improves precision."},{"heading":"Chunking Strategies","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#chunking-strategies","content":"Chunking is where most pipelines silently lose quality. The right strategy depends on document structure."},{"heading":"Fixed-Size Chunking","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#fixed-size-chunking","content":"The baseline. Split every N tokens with M-token overlap.\n\n```python\nfrom langchain.text_splitter import RecursiveCharacterTextSplitter\n\nsplitter = RecursiveCharacterTextSplitter(\n    chunk_size=512,\n    chunk_overlap=50,\n    separators=[\"\\n\\n\", \"\\n\", \". \", \" \", \"\"],\n    length_function=len,  # or tiktoken for token-accurate counts\n)\nchunks = splitter.split_text(document_text)\n```\n\n**When it works**: Uniform text without structure, transcripts, plain-text logs, chat histories.\n\n**When it fails**: Structured documents where splitting at arbitrary positions destroys meaning."},{"heading":"Semantic Chunking","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#semantic-chunking","content":"Groups sentences by embedding similarity, splitting where topics shift:\n\n```python\nfrom langchain_experimental.text_splitter import SemanticChunker\nfrom langchain_openai import OpenAIEmbeddings\n\nchunker = SemanticChunker(\n    OpenAIEmbeddings(model=\"text-embedding-3-small\"),\n    breakpoint_threshold_type=\"percentile\",\n    breakpoint_threshold_amount=85,  # Split at 85th percentile of distance\n)\nchunks = chunker.split_text(document_text)\n```\n\nSemantic chunking produces variable-size chunks that respect topic boundaries. The tradeoff is speed, embedding every sentence during ingestion is 10-50x slower than fixed-size splitting."},{"heading":"Document-Structure-Aware Chunking","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#document-structure-aware-chunking","content":"The most effective approach for structured content. Use document hierarchy to define chunk boundaries:\n\n```python\ndef structure_aware_chunk(elements, max_tokens=512):\n    \"\"\"Chunk by document structure, respecting headers and sections.\"\"\"\n    chunks = []\n    current_chunk = []\n    current_tokens = 0\n    current_header_chain = []\n\n    for element in elements:\n        if element.category == \"Title\":\n            # New section, flush current chunk\n            if current_chunk:\n                chunks.append({\n                    \"content\": \"\\n\".join(current_chunk),\n                    \"headers\": list(current_header_chain),\n                    \"token_count\": current_tokens,\n                })\n                current_chunk = []\n                current_tokens = 0\n            current_header_chain.append(element.text)\n\n        elif element.category == \"Table\":\n            # Tables go in their own chunk, never split\n            if current_chunk:\n                chunks.append({\n                    \"content\": \"\\n\".join(current_chunk),\n                    \"headers\": list(current_header_chain),\n                    \"token_count\": current_tokens,\n                })\n                current_chunk = []\n                current_tokens = 0\n            chunks.append({\n                \"content\": element.metadata.text_as_html,\n                \"headers\": list(current_header_chain),\n                \"token_count\": count_tokens(element.text),\n                \"type\": \"table\",\n            })\n\n        else:\n            elem_tokens = count_tokens(element.text)\n            if current_tokens + elem_tokens > max_tokens and current_chunk:\n                chunks.append({\n                    \"content\": \"\\n\".join(current_chunk),\n                    \"headers\": list(current_header_chain),\n                    \"token_count\": current_tokens,\n                })\n                current_chunk = []\n                current_tokens = 0\n            current_chunk.append(element.text)\n            current_tokens += elem_tokens\n\n    if current_chunk:\n        chunks.append({\n            \"content\": \"\\n\".join(current_chunk),\n            \"headers\": list(current_header_chain),\n            \"token_count\": current_tokens,\n        })\n\n    return chunks\n```"},{"heading":"Contextual Chunk Headers","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#contextual-chunk-headers","content":"Prepending section hierarchy before embedding is a hypothesis to evaluate against an otherwise identical baseline:\n\n```\n## Chapter 4: Database Operations > Backup Strategies > Point-in-Time Recovery\n\nTo perform point-in-time recovery, configure continuous WAL archiving...\n```\n\nThe embedding now captures both the content and its position in the document hierarchy."},{"heading":"Embedding Strategy","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#embedding-strategy","content":""},{"heading":"Model Selection","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#model-selection","content":"The embedding model determines the ceiling of retrieval quality.\n\n| Model | Dimensions | MTEB Score | Latency (p50) | Cost |\n| --- | --- | --- | --- | --- |\n| text-embedding-3-small | 1536 | 62.3 | 12ms | $0.02/1M tokens |\n| text-embedding-3-large | 3072 | 64.6 | 18ms | $0.13/1M tokens |\n| voyage-3 | 1024 | 67.1 | 15ms | $0.06/1M tokens |\n| bge-m3 (self-hosted) | 1024 | 66.8 | 8ms | Infrastructure cost |\n| Cohere embed-v4 | 1024 | 67.5 | 14ms | $0.10/1M tokens |\n\n**Practical recommendation**: For English-only workloads, ` voyage-3 ` or ` Cohere embed-v4 ` deliver the best retrieval quality. For multilingual or cost-sensitive deployments, ` bge-m3 ` self-hosted on a single GPU is hard to beat."},{"heading":"Late Interaction Models (ColBERT)","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#late-interaction-models-colbert","content":"ColBERT produces per-token embeddings instead of a single vector, enabling more fine-grained matching:\n\n```python\nfrom ragatouille import RAGPretrainedModel\n\nrag = RAGPretrainedModel.from_pretrained(\"colbert-ir/colbertv2.0\")\nrag.index(\n    collection=documents,\n    index_name=\"knowledge-base\",\n    max_document_length=512,\n)\n\nresults = rag.search(query=\"PITR backup configuration\", k=10)\n```\n\nColBERT consistently outperforms single-vector models on retrieval quality but requires 10-50x more storage per document. Use it when retrieval quality is the bottleneck and storage is cheap."},{"heading":"Hybrid Search Architecture","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#hybrid-search-architecture","content":"Vector search alone is not enough. Combining semantic and lexical search covers each method's blind spots."},{"heading":"BM25 + Vector with Reciprocal Rank Fusion","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#bm25-vector-with-reciprocal-rank-fusion","content":"```python\nfrom rank_bm25 import BM25Okapi\nimport numpy as np\n\ndef hybrid_search(query, corpus_chunks, embeddings_index, k=20, alpha=0.5):\n    \"\"\"\n    Combine BM25 and vector search using Reciprocal Rank Fusion.\n    alpha controls the balance: 0.5 = equal weight.\n    \"\"\"\n    # Vector search\n    query_embedding = embed(query)\n    vector_scores = embeddings_index.search(query_embedding, k=k * 2)\n\n    # BM25 search\n    tokenized_corpus = [chunk.split() for chunk in corpus_chunks]\n    bm25 = BM25Okapi(tokenized_corpus)\n    bm25_scores = bm25.get_scores(query.split())\n    bm25_top = np.argsort(bm25_scores)[-k * 2:][::-1]\n\n    # Reciprocal Rank Fusion\n    rrf_scores = {}\n    rrf_k = 60  # Standard RRF constant\n\n    for rank, doc_id in enumerate(vector_scores.ids):\n        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + alpha / (rrf_k + rank + 1)\n\n    for rank, doc_id in enumerate(bm25_top):\n        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + (1 - alpha) / (rrf_k + rank + 1)\n\n    # Sort by fused score\n    ranked = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)\n    return ranked[:k]\n```"},{"heading":"Why Hybrid Works","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#why-hybrid-works","content":"Consider the query: \"What is the SLA for the EU-WEST-1 Redis cluster?\"\n\n- **Vector search** finds chunks about SLAs and Redis clusters in general, semantically similar but potentially wrong region.\n- **BM25** finds chunks containing the exact string \"EU-WEST-1\", lexically matched.\n- **Hybrid** surfaces the chunk that mentions both the SLA policy and the specific region."},{"heading":"Database-Native Hybrid Search","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#database-native-hybrid-search","content":"PostgreSQL with pgvector 0.8+ supports both in a single query:\n\n```sql\n-- Hybrid search with pgvector + tsvector\nWITH vector_results AS (\n    SELECT id, content, 1 - (embedding <=> $1::vector) AS vector_score\n    FROM documents\n    WHERE metadata->>'department' = 'engineering'\n    ORDER BY embedding <=> $1::vector\n    LIMIT 40\n),\ntext_results AS (\n    SELECT id, content, ts_rank(search_vector, plainto_tsquery($2)) AS text_score\n    FROM documents\n    WHERE search_vector @@ plainto_tsquery($2)\n      AND metadata->>'department' = 'engineering'\n    ORDER BY text_score DESC\n    LIMIT 40\n),\ncombined AS (\n    SELECT\n        COALESCE(v.id, t.id) AS id,\n        COALESCE(v.content, t.content) AS content,\n        COALESCE(v.vector_score, 0) * 0.5 + COALESCE(t.text_score, 0) * 0.5 AS hybrid_score\n    FROM vector_results v\n    FULL OUTER JOIN text_results t ON v.id = t.id\n)\nSELECT * FROM combined ORDER BY hybrid_score DESC LIMIT 10;\n```"},{"heading":"Reranking","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reranking","content":"Reranking is the highest-leverage improvement you can make to a RAG pipeline. Bi-encoder embeddings are fast but imprecise. Cross-encoder rerankers are slow but accurate. The architecture uses both:\n\n1. **Retrieve** 50-100 candidates with fast hybrid search (~10ms)\n2. **Rerank** the top 20-30 with a cross-encoder (latency to measure)\n3. **Select** the top 5-8 for the LLM context"},{"heading":"Cross-Encoder Reranking","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#cross-encoder-reranking","content":"```python\nfrom sentence_transformers import CrossEncoder\n\nreranker = CrossEncoder(\"BAAI/bge-reranker-v2-m3\", max_length=512)\n\ndef rerank(query, candidates, top_k=5):\n    \"\"\"Rerank candidates using cross-encoder.\"\"\"\n    pairs = [[query, candidate[\"content\"]] for candidate in candidates]\n    scores = reranker.predict(pairs)\n\n    for i, candidate in enumerate(candidates):\n        candidate[\"rerank_score\"] = float(scores[i])\n\n    ranked = sorted(candidates, key=lambda x: x[\"rerank_score\"], reverse=True)\n    return ranked[:top_k]\n```"},{"heading":"Cohere Rerank API","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#cohere-rerank-api","content":"For teams that prefer managed infrastructure:\n\n```python\nimport cohere\n\nco = cohere.ClientV2()\n\nresults = co.rerank(\n    model=\"rerank-v3.5\",\n    query=\"PITR backup configuration for PostgreSQL\",\n    documents=[chunk[\"content\"] for chunk in candidates],\n    top_n=5,\n    return_documents=True,\n)\n\nreranked = [\n    {\"content\": r.document.text, \"relevance_score\": r.relevance_score}\n    for r in results.results\n]\n```"},{"heading":"Impact Measurement","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#impact-measurement","content":"In our production pipeline (2.3M chunks, 10K test queries):\n\n| Configuration | Recall@5 | MRR | Answer Relevance |\n| --- | --- | --- | --- |\n| Vector only | 0.62 | 0.54 | 0.71 |\n| Hybrid (BM25 + vector) | 0.74 | 0.63 | 0.78 |\n| Hybrid + reranker | 0.83 | 0.76 | 0.89 |\n| Hybrid + reranker + contextual headers | 0.87 | 0.81 | 0.91 |\n\nThe reranker alone accounts for a 12-point recall improvement. Combined with hybrid search and contextual headers, the pipeline recovers 87% of relevant chunks in the top 5 results."},{"heading":"Prompt Construction","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#prompt-construction","content":"After retrieval and reranking, how you construct the LLM prompt matters more than most teams realize."},{"heading":"Context Window Management","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#context-window-management","content":"```python\ndef build_rag_prompt(query, retrieved_chunks, max_context_tokens=6000):\n    \"\"\"Build prompt with token-budget-aware context injection.\"\"\"\n    context_parts = []\n    token_count = 0\n\n    for chunk in retrieved_chunks:\n        chunk_tokens = count_tokens(chunk[\"content\"])\n        if token_count + chunk_tokens > max_context_tokens:\n            break\n        context_parts.append(\n            f\"[Source: {chunk['metadata']['source']}, \"\n            f\"Section: {chunk['metadata'].get('section', 'N/A')}]\\n\"\n            f\"{chunk['content']}\"\n        )\n        token_count += chunk_tokens\n\n    context = \"\\n\\n---\\n\\n\".join(context_parts)\n\n    return f\"\"\"Answer the following question based on the provided context.\nIf the context does not contain enough information to answer fully, say so explicitly.\nCite the source document for each claim.\n\nContext:\n{context}\n\nQuestion: {query}\n\nAnswer:\"\"\"\n```"},{"heading":"Citation Grounding","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#citation-grounding","content":"Force the model to cite sources by requiring inline references:\n\n```\nFor each factual claim in your answer, include a citation in the format [Source: filename, Section: section_name].\nIf you cannot find supporting evidence in the context for a claim, prefix it with [Unsupported].\n```\n\nThis makes hallucination detection trivial, any claim marked ` [Unsupported] ` or missing a citation is suspect."},{"heading":"Evaluation Framework","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#evaluation-framework","content":"A RAG pipeline without evaluation is a demo, not a product. Evaluation happens at three levels."},{"heading":"Level 1: Retrieval Quality","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#level-1-retrieval-quality","content":"Measured independently from the LLM:\n\n```python\ndef evaluate_retrieval(test_queries, ground_truth, retriever, k=5):\n    \"\"\"Evaluate retrieval with standard IR metrics.\"\"\"\n    recalls, mrrs, ndcgs = [], [], []\n\n    for query, relevant_ids in zip(test_queries, ground_truth):\n        retrieved = retriever.search(query, k=k)\n        retrieved_ids = [r[\"id\"] for r in retrieved]\n\n        # Recall@k\n        hits = len(set(retrieved_ids) & set(relevant_ids))\n        recalls.append(hits / len(relevant_ids))\n\n        # MRR\n        for rank, rid in enumerate(retrieved_ids, 1):\n            if rid in relevant_ids:\n                mrrs.append(1.0 / rank)\n                break\n        else:\n            mrrs.append(0.0)\n\n    return {\n        \"recall@k\": np.mean(recalls),\n        \"mrr\": np.mean(mrrs),\n    }\n```"},{"heading":"Level 2: Generation Quality with RAGAS","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#level-2-generation-quality-with-ragas","content":"RAGAS evaluates the full pipeline, retrieval and generation together:\n\n```python\nfrom ragas import evaluate\nfrom ragas.metrics import (\n    faithfulness,\n    answer_relevancy,\n    context_precision,\n    context_recall,\n)\n\nresults = evaluate(\n    dataset=eval_dataset,  # Questions + ground truth answers + contexts\n    metrics=[\n        faithfulness,        # Does the answer stick to retrieved context?\n        answer_relevancy,    # Is the answer relevant to the question?\n        context_precision,   # Are retrieved chunks relevant?\n        context_recall,      # Were all necessary chunks retrieved?\n    ],\n)\n\nprint(results)\n# {'faithfulness': 0.89, 'answer_relevancy': 0.91,\n#  'context_precision': 0.82, 'context_recall': 0.87}\n```\n\n**Faithfulness** is the most important metric, it measures hallucination. A faithfulness score below 0.85 means the pipeline is generating claims not supported by retrieved context."},{"heading":"Level 3: Human Evaluation","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#level-3-human-evaluation","content":"Automate collection, evaluate manually:\n\n```python\ndef sample_for_human_eval(production_queries, n=200):\n    \"\"\"Sample queries stratified by difficulty and topic.\"\"\"\n    # Classify queries by estimated difficulty\n    easy = [q for q in production_queries if q[\"retrieval_confidence\"] > 0.8]\n    medium = [q for q in production_queries if 0.5 < q[\"retrieval_confidence\"] <= 0.8]\n    hard = [q for q in production_queries if q[\"retrieval_confidence\"] <= 0.5]\n\n    sample = (\n        random.sample(easy, min(80, len(easy))) +\n        random.sample(medium, min(80, len(medium))) +\n        random.sample(hard, min(40, len(hard)))\n    )\n\n    return sample\n```\n\nHuman evaluators rate on three axes: **correctness** (factually accurate), **completeness** (covers the question fully), and **groundedness** (cites context, doesn't hallucinate)."},{"heading":"Production Architecture","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#production-architecture","content":""},{"heading":"Pipeline Overview","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#pipeline-overview","content":"```\nDocuments → Preprocessing → Chunking → Embedding → Vector Store\n                                                         ↓\nUser Query → Query Expansion → Hybrid Search → Reranker → Prompt Builder → LLM → Response\n                                                                                    ↓\n                                                                              Evaluation Logger\n```"},{"heading":"Query Expansion","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#query-expansion","content":"Rewrite the user query before retrieval to improve recall:\n\n```python\ndef expand_query(query, llm):\n    \"\"\"Generate alternative phrasings to improve retrieval.\"\"\"\n    expansion_prompt = f\"\"\"Generate 3 alternative phrasings of this search query.\nEach should capture the same intent but use different terminology.\nReturn only the queries, one per line.\n\nQuery: {query}\"\"\"\n\n    alternatives = llm.generate(expansion_prompt).strip().split(\"\\n\")\n    return [query] + alternatives[:3]\n```\n\nRun hybrid search on all expanded queries and merge results with RRF. This consistently adds 3-5% recall improvement."},{"heading":"Caching Layer","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#caching-layer","content":"Cache at two levels to reduce latency and cost:\n\n1. **Embedding cache**: Hash the input text, cache the embedding vector. Avoids re-embedding identical queries.\n2. **Semantic cache**: For queries with cosine similarity \\> 0.95 to a cached query, return the cached response. Reduces LLM calls by 20-40% in support use cases where similar questions recur."},{"heading":"Monitoring","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#monitoring","content":"Track these metrics in production:\n\n- **Retrieval latency** (p50, p95, p99), target \\< 100ms for hybrid + rerank\n- **Faithfulness score** on a rolling sample, alert if it drops below 0.85\n- **Empty retrieval rate**, queries where no chunk scores above the relevance threshold\n- **User feedback signals**, thumbs up/down, copy events, follow-up questions"},{"heading":"When RAG Is Not Enough","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#when-rag-is-not-enough","content":"RAG has well-defined limits:\n\n- **Multi-hop reasoning**: When the answer requires synthesizing information from 5+ documents with inferential steps between them, RAG retrieval often misses intermediate documents.\n- **Temporal reasoning**: \"What changed between Q3 and Q4?\" requires retrieving and comparing two time-specific document sets, standard top-k retrieval is not designed for this.\n- **Computation over data**: \"What is the average SLA across all regions?\" requires structured query, not text retrieval.\n\nFor these cases, consider **agentic RAG**, an agent that plans retrieval steps, executes multiple searches, and synthesizes results programmatically, or hybrid approaches that combine RAG with SQL queries and API calls."},{"heading":"Reproducible implementation","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reproducible-implementation","content":"[RAG Evaluation Workbench](<https://lucioduran.com/projects/rag-evaluation-workbench>) compares BM25, dense and hybrid retrieval with the same local answer model, prompt and budget. The project page brings together the licensed corpus, labeled queries, raw results, intervals and reproduction commands.\n\nOn that sample, retrieving all relevant context did not prevent answer errors or abstention failures. The experiment evaluates retrieval and generation separately; it does not validate this guide's chunking strategies, rerankers or production targets."},{"heading":"Chunking: complete evidence and index cost","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#chunking-complete-evidence-and-index-cost","content":"[Chunking Ablation Suite](<https://lucioduran.com/projects/chunking-ablation-suite>) compares eight fixed, structural and semantic configurations with the same MiniLM model. On 36 evaluation queries, structure-96 retrieved complete evidence in 33 cases; semantic configurations did so in 12. The repository retains chunks, vectors, rankings and costs by document type. It also exposes a threshold ablation that changes no boundaries. Calling the algorithm semantic does not settle the evaluation."},{"heading":"Citations: a relevant source can still fail to support a claim","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#citations-a-relevant-source-can-still-fail-to-support-a-claim","content":"[Citation Grounding Checker](<https://lucioduran.com/projects/citation-grounding-checker>) compares conservative rules, FLAN-T5-small and DeBERTa NLI on claim/passage pairs. It publishes verdicts, precision by class, abstentions and exact evidence links. On its 36 synthetic evaluation cases, DeBERTa classified 31 correctly and abstained on one. Labels have no independent human review; that number describes the published fixture."},{"heading":"Reranking: measure when the router adds no value","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reranking-measure-when-the-router-adds-no-value","content":"[Reranker Budget Router](<https://lucioduran.com/projects/reranker-budget-router>) allocates budgets of 0, 4 or 12 pairs and freezes policies before evaluation. All six fitted policies skipped reranking, matched the never baseline’s quality and added overhead. Always-12 improved one query. Traces expose that missed opportunity: adding a router does not guarantee a better architecture."},{"heading":"References and scope","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#references-and-scope","content":"The sources below describe retrieval and evaluation methods. This article is a technical guide, not a report of a benchmark performed by the author. Gains and deployment costs require measurements on the target workload.\n\n- [Lewis et al. Retrieval Augmented Generation](<https://arxiv.org/abs/2005.11401>).\n- [Es et al. Ragas](<https://arxiv.org/abs/2309.15217>).\n- [Elasticsearch Reciprocal Rank Fusion](<https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion>)."},{"heading":"Reproducible implementation: index updates and integrity","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#reproducible-implementation-index-updates-and-integrity","content":"[Incremental RAG Index](<https://lucioduran.com/projects/incremental-rag-index>) keeps complete SQLite versions, reuses embeddings by content hash and records a change journal. It compares full and incremental rebuilds in five scenarios with a deterministic adapter and local MiniLM, injecting process exits at four publication checkpoints per adapter.\n\nBoth experiments matched documents, vectors, roots and rankings. An edit/add/delete update computed two embeddings versus six for a full rebuild. Pre-commit exits preserved the old version; the post-commit exit preserved the new one without creating another version on retry. Deleted documents disappear from the current index but remain in historical snapshots. The evidence establishes consistency on six synthetic documents, not semantic quality or scaling performance."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#questions","content":""},{"heading":"What chunk size should I use for RAG?","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-1","content":"No universal chunk size is best. Compare candidate sizes on labeled queries and record retrieval quality, context truncation and generation quality."},{"heading":"Is hybrid search always better than vector-only search?","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-2","content":"No. Hybrid retrieval can help exact terms and semantic matches, but it can also add noise. Compare all methods on the same held-out queries."},{"heading":"How do I evaluate a RAG pipeline systematically?","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-3","content":"Use a three-layer approach: (1) retrieval metrics like recall@k, MRR, and NDCG on a labeled query-document set, (2) generation metrics like faithfulness and answer relevance using RAGAS, and (3) end-to-end human evaluation on a rotating golden set of 200+ queries."},{"heading":"When should I use a reranker vs just improving embeddings?","level":3,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#question-4","content":"Measure embeddings and reranking as separate changes. Adopt a reranker if its measured relevance gain justifies its latency and cost for the workload."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#article-tips","content":"1. Compare chunking strategies on a labeled query set. Token counts and overlap are starting parameters to tune, not universal optima.\n2. Combine BM25 and dense retrieval with RRF, then compare recall@k and NDCG against each retrieval method separately. The benefit depends on the corpus and queries.\n3. Evaluate a cross-encoder on retrieved candidates and measure relevance, latency and cost together. No fixed quality gain or latency is established by this article.\n4. **Evaluation trap:** Don't use LLM-as-judge exclusively. Combine it with retrieval metrics (recall@k, MRR) and human evaluation on a 200-query golden set. LLM judges have blind spots around factual precision.\n\n- rag\n- retrieval-augmented-generation\n- chunking\n- reranking\n- hybrid-search\n- embeddings\n- llm\n- evaluation\n- ragas\n- ai-agents\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Lucio Durán","url":"https://lucioduran.com/"},{"text":"Español","url":"https://lucioduran.com/es/blog/rag-pipeline-architecture-chunking-reranking-evaluation"},{"text":"RAG Evaluation Workbench","url":"https://lucioduran.com/projects/rag-evaluation-workbench"},{"text":"Chunking Ablation Suite","url":"https://lucioduran.com/projects/chunking-ablation-suite"},{"text":"Citation Grounding Checker","url":"https://lucioduran.com/projects/citation-grounding-checker"},{"text":"Reranker Budget Router","url":"https://lucioduran.com/projects/reranker-budget-router"},{"text":"Lewis et al. Retrieval Augmented Generation","url":"https://arxiv.org/abs/2005.11401"},{"text":"Es et al. Ragas","url":"https://arxiv.org/abs/2309.15217"},{"text":"Elasticsearch Reciprocal Rank Fusion","url":"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion"},{"text":"Incremental RAG Index","url":"https://lucioduran.com/projects/incremental-rag-index"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},{"@type":"WebSite","@id":"https://lucioduran.com/#website","url":"https://lucioduran.com/","name":"Lucio Durán","inLanguage":["en","es"],"publisher":{"@id":"https://lucioduran.com/#person"}},{"@type":"WebPage","@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#page","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation","name":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks | Lucio Durán","description":"A production-focused guide to building Retrieval-Augmented Generation pipelines, from document ingestion and chunking strategies to hybrid search, cross-encoder reranking, and systematic evaluation with RAGAS and custom metrics.","inLanguage":"en","author":{"@id":"https://lucioduran.com/#person"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"},"isAccessibleForFree":true,"datePublished":"2026-04-16","dateModified":"2026-10-22","mainEntity":{"@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#article"},"breadcrumb":{"@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#breadcrumb"}},{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#article","mainEntityOfPage":{"@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#page"},"headline":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks","description":"A production-focused guide to building Retrieval-Augmented Generation pipelines, from document ingestion and chunking strategies to hybrid search, cross-encoder reranking, and systematic evaluation with RAGAS and custom metrics.","datePublished":"2026-04-16","dateModified":"2026-10-22","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@id":"https://lucioduran.com/#person"},"url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"AI/ML","keywords":"rag, retrieval-augmented-generation, chunking, reranking, hybrid-search, embeddings, llm, evaluation, ragas, ai-agents","wordCount":2548,"timeRequired":"PT14M","workTranslation":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/rag-pipeline-architecture-chunking-reranking-evaluation#article","inLanguage":"es","url":"https://lucioduran.com/es/blog/rag-pipeline-architecture-chunking-reranking-evaluation"}],"isAccessibleForFree":true},{"@type":"Blog","@id":"https://lucioduran.com/blog#blog","url":"https://lucioduran.com/blog","name":"Blog","inLanguage":"en","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"RAG Pipeline Architecture: Chunking Strategies, Hybrid Search, Reranking, and Evaluation Frameworks","item":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation"}],"@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#breadcrumb"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What chunk size should I use for RAG?","acceptedAnswer":{"@type":"Answer","text":"No universal chunk size is best. Compare candidate sizes on labeled queries and record retrieval quality, context truncation and generation quality."}},{"@type":"Question","name":"Is hybrid search always better than vector-only search?","acceptedAnswer":{"@type":"Answer","text":"No. Hybrid retrieval can help exact terms and semantic matches, but it can also add noise. Compare all methods on the same held-out queries."}},{"@type":"Question","name":"How do I evaluate a RAG pipeline systematically?","acceptedAnswer":{"@type":"Answer","text":"Use a three-layer approach: (1) retrieval metrics like recall@k, MRR, and NDCG on a labeled query-document set, (2) generation metrics like faithfulness and answer relevance using RAGAS, and (3) end-to-end human evaluation on a rotating golden set of 200+ queries."}},{"@type":"Question","name":"When should I use a reranker vs just improving embeddings?","acceptedAnswer":{"@type":"Answer","text":"Measure embeddings and reranking as separate changes. Adopt a reranker if its measured relevance gain justifies its latency and cost for the workload."}}],"@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#questions","url":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#questions","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation#page"},"author":{"@id":"https://lucioduran.com/#person"}}]}],"contentHash":"222f6e16ada15ca2c231b0e5df0fc9aaa75a6ff8b1e5a9a0dc5986c972da6a52","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/rag-pipeline-architecture-chunking-reranking-evaluation","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":107659,"contentBytes":25161}}
