All contributions
AI & Machine Learningragcontext-engineeringqdrant

Context engineering: why your RAG breaks at 50k tokens and how to fix it

From naive RAG to production RAG: Chonkie for semantic chunking, Qdrant with hybrid search (dense + BM25), self-hosted BGE-reranker, Anthropic Contextual Retrieval, RAPTOR, RedisVL semantic cache, and Ragas + Langfuse evaluation.

Numoru EngineeringPublished on June 21, 202618 min read
Share
Implementation proposalgithub.com/numoru-ia/rag-production-stack

TL;DR

Naive RAG works in the demo and breaks in production. The pattern that has stabilized in 2026 combines seven techniques: Chonkie for semantic chunking (not character-based), Qdrant with hybrid dense + BM25 search, self-hosted BGE-reranker v2-m3 to rerank top-k, Contextual Retrieval (Anthropic's technique) which prepends context to each chunk via Haiku in batch, RAPTOR for hierarchical summaries, RedisVL as semantic cache, and continuous evaluation with Ragas + DeepEval traced in Langfuse. This article shows the end-to-end pipeline with code, real metrics (recall@5 rises from 0.62 to 0.91) and costs at scale. The point where "your RAG breaks" is almost always chunking + missing reranker.

+47%
Recall@5 vs naive RAG
0.62 → 0.91
-67%
Hallucination rate
Faithfulness 0.71 → 0.90
-85%
Cost per query with cache
p95 latency 1.6s → 0.2s
$15-45k
Rescue engagement ticket
Per dataset migrated

The symptoms

The agent worked perfectly with 20 documents in QA. It went to production with 500 and started to:

  • Fabricate details not in the source.
  • Retrieve the right chunks but incomplete.
  • Ignore recent docs because old ones have a stronger embedding.
  • Reply "no information found" when the answer was there.
  • Mix information from two different clients.

Each symptom has a distinct cause, and each cause has a solution.

The pipeline in 7 steps

  Raw document
       │
       ▼
  [1] Parsing with Unstructured.io or Firecrawl
       │
       ▼
  [2] Semantic chunking (Chonkie)
       │
       ▼
  [3] Contextual Retrieval: prepend context to each chunk
       │
       ▼
  [4] Dense embedding (OpenAI 3-small or BGE-m3) + BM25
       │
       ▼
  [5] Qdrant (hybrid collection)
       │
       ▼
  Query time:
       [6] Hybrid search (dense + sparse) → top-40
       ▼
       [7] Rerank with BGE-reranker → top-6
       ▼
       LLM gets clean context

Step 1 — Serious parsing

Forget PyPDF2. Unstructured.io extracts with structure: it distinguishes headers, tables, code, lists. Firecrawl does the equivalent for web pages, handling JS and pagination.

from unstructured.partition.auto import partition

elements = partition("sat-tax-manual-2026.pdf")
for el in elements:
    print(el.category, "|", el.text[:80])
# → Title, NarrativeText, Table, ListItem, FigureCaption, ...

Result: each chunk knows its content type. Tables stay as tables (not split in half), headers link to their paragraphs.

Step 2 — Semantic chunking with Chonkie

LangChain's RecursiveCharacterTextSplitter cuts every 1,000 characters. It ends up splitting an important table in half or chopping a sentence midway.

Chonkie has three better strategies:

  • SemanticChunker — splits where embedding similarity between consecutive sentences drops.
  • SDPMChunker — Semantic Double-Pass Merging: initial semantic chunks, then merge to respect target size.
  • LateChunker — embed the entire document first, then chunk. Preserves long-range context.
from chonkie import SDPMChunker

chunker = SDPMChunker(
    embedding_model="BAAI/bge-m3",
    chunk_size=512,
    threshold=0.75,
    skip_window=1,
)

chunks = chunker.chunk(document_text)

For manuals/contracts, SDPMChunker works best. For short tickets/emails, SemanticChunker.

Step 3 — Contextual Retrieval

Technique published by Anthropic in 2024, now standard. Problem: a chunk that says "the deadline is 30 days" is ambiguous without knowing which deadline it refers to.

Solution: before embedding, prepend to the chunk a context paragraph that the document gives it. Done once per chunk with a cheap model (Claude Haiku in batch API, cost ~$0.03 per 1,000 chunks).

CONTEXT_PROMPT = """Full document:
<document>
{document}
</document>

Here is the chunk we will index:
<chunk>
{chunk}
</chunk>

Write 1-3 sentences situating this chunk in the document. Include:
- topic
- referenced entity/client/product
- relevant date if applicable

Don't repeat info from the chunk; only context.
"""

def contextualize(document: str, chunk: str) -> str:
    ctx = call_haiku(CONTEXT_PROMPT.format(document=document, chunk=chunk))
    return f"{ctx}\n\n---\n\n{chunk}"

Anthropic-measured impact: recall@20 rises from 5.7% to 1.9% failures (67% reduction). We see analogous improvement in our clients.

Step 4 — Dense + sparse embedding

Dense embedding captures semantics ("monthly fee" ≈ "periodic subscription"). Sparse (BM25) embedding captures exact match (IDs, proper names, codes).

from fastembed import TextEmbedding, SparseTextEmbedding

dense = TextEmbedding("BAAI/bge-small-en-v1.5")  # or 'intfloat/multilingual-e5-large'
sparse = SparseTextEmbedding("Qdrant/bm25")

dense_vec = next(dense.embed([chunk_text]))
sparse_vec = next(sparse.embed([chunk_text]))

For Spanish, we use intfloat/multilingual-e5-large (768 dims) or jinaai/jina-embeddings-v3 (1024 dims). BGE-m3 produces dense + sparse in a single pass.

Collection with two named vectors:

from qdrant_client import QdrantClient, models

client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)

client.create_collection(
    collection_name="kb_numoru",
    vectors_config={"dense": models.VectorParams(size=768, distance=models.Distance.COSINE)},
    sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)

client.upsert(
    collection_name="kb_numoru",
    points=[
        models.PointStruct(
            id=uuid4().hex,
            vector={"dense": dense_vec, "bm25": sparse_vec.as_object()},
            payload={
                "text": chunk,
                "source": doc_id,
                "tenant_id": tenant_id,
                "created_at": doc.created_at.isoformat(),
                "category": doc.category,
            },
        )
    ],
)

Hybrid query with RRF fusion:

results = client.query_points(
    collection_name="kb_numoru",
    prefetch=[
        models.Prefetch(query=dense_vec, using="dense", limit=40),
        models.Prefetch(query=sparse_vec.as_object(), using="bm25", limit=40),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    query_filter=models.Filter(must=[
        models.FieldCondition(key="tenant_id", match=models.MatchValue(value=tenant_id))
    ]),
    limit=40,
)

Step 6 — Reranking with BGE-reranker v2-m3

The top-40 from hybrid search has decent precision but still includes noise. The reranker re-evaluates (query, chunk) pairs with a cross-encoder and reorders.

from FlagEmbedding import FlagLLMReranker

reranker = FlagLLMReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

def rerank(query: str, hits, top_k=6):
    pairs = [[query, h.payload["text"]] for h in hits]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(hits, scores), key=lambda x: -x[1])
    return [h for h, _ in ranked[:top_k]]

Running the self-hosted reranker on CPU for 40 pairs takes ~250 ms. On a small GPU, <50 ms. Typical nDCG@5 improvement: +15-22 points.

Alternatives:

  • Cohere Rerank v3 (SaaS) — excellent multilingual, $0.002 per rerank.
  • Jina Reranker v2 — competitive open source.

Step 7 — RAPTOR for hierarchical documentation

When documents are long (manuals, regulations, books), flat retrieval fails on questions like "what's the general theme of chapter 3?". RAPTOR builds a tree of summaries:

  1. Base chunks (tree leaves).
  2. Cluster related chunks (by similarity).
  3. Generate a summary per cluster (parent).
  4. Repeat until you have a root.
  5. Embed each level.

At query time you search every level simultaneously. Specific questions land at leaves; general questions land at summaries.

from raptor import RetrievalAugmentation

RA = RetrievalAugmentation(config=raptor_config)
RA.add_documents(document_texts)
answer = RA.answer_question(question=query)

For most cases (short docs, FAQs, emails) RAPTOR is overkill. We use it for legal and technical manuals.

Semantic cache with RedisVL

Before querying Qdrant, check Redis: did anyone make a "sufficiently similar" query in the last 24h?

from redisvl.extensions.llmcache import SemanticCache

cache = SemanticCache(
    name="rag_cache",
    redis_url=os.getenv("REDIS_URL"),
    distance_threshold=0.12,  # ≈ similarity 0.88
    ttl=86400,
)

def answer(query: str) -> str:
    if hit := cache.check(query):
        return hit[0]["response"]

    ctx = rag_retrieve(query)
    response = llm(ctx, query)
    cache.store(prompt=query, response=response)
    return response

Typical hit rate in support agents: 35-55%. Saves latency and tokens.

Evaluation with Ragas + DeepEval

RAG is non-deterministic; it needs systematic eval. Three indispensable metrics:

  • Faithfulness — is the answer derivable from retrieved chunks?
  • Answer Relevancy — does the answer address the query?
  • Context Recall — do retrieved chunks cover the expected answer?
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall, context_precision

dataset = load_golden_rag_v2()
result = evaluate(
    dataset,
    metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
    llm=claude_sonnet,
)

Scores are written to Langfuse as evals on the dataset run. In CI we integrate with Promptfoo + DeepEval: a PR that drops context_recall more than 5 points is blocked.

Real metrics from a client case

Legal client, 8,000 documents, 120 golden queries.

ConfigurationRecall@5Faithfulnessp95 latency
Naive (1k recursive, e5 base, flat search)0.620.711.2 s
+ SDPMChunker0.710.761.2 s
+ Contextual Retrieval0.780.831.3 s
+ Hybrid search (dense + BM25)0.850.851.4 s
+ BGE-reranker0.910.901.6 s
+ RedisVL cache (on repeated queries)0.910.900.2 s

Each layer contributes. The combination separates production from demo.

Recall@5 lift as each production technique is added

Same 120-query golden set against 8,000 legal documents. Each bar is the cumulative configuration; the last step is with RedisVL cache on repeated queries.

NaiveSemantic chunk+ Context retrieval+ Hybrid search+ BGE reranker0.500.650.801.00
  • Recall@5
  • Faithfulness

Numoru client engagement telemetry, 2026 Q1.

Cost at scale

Case: 50,000 indexed chunks, 100k queries/month.

ItemCost/month (USD)
Contextual Retrieval (Haiku batch) — initial indexing15 (one-time)
Dense embedding (100k queries × 1 call + weekly re-indexing)40
Qdrant ($40 droplet shared)10 (pro-rata)
Self-hosted BGE-reranker (shared CPU)5
Cohere Rerank SaaS alternative200
LiteLLM LLM calls (Claude Sonnet, ~2k tokens avg)350
Redis cache5
Monthly total (self-hosted reranker)425

Without cache, Sonnet alone, no reranker: $800-950. The stack cuts cost 45% while raising precision.

Business & commercial impact

Business & commercial impact

The "RAG rescue" service

Dozens of companies shipped a RAG feature in 2024-2025 that stopped working as their corpus grew. The failure mode is consistent: users stop trusting it, usage drops, exec team wants to cancel the project. "RAG rescue" is a 6-8 week engagement that replaces the naive pipeline with the production pattern above and proves it with evals.

Who buys RAG rescue

Ticket per rescue engagement by buyer (Numoru, USD)

Legal / contract AI
Clause retrieval + precedent search over 5-50k docs.
$28,000 – 65,000
8 wks + $1,800 / mo
Customer support (SaaS)
Knowledge base for bot tier-1. Hot-reload required.
$15,000 – 35,000
6 wks + $1,200 / mo
Healthcare / clinical
Guideline retrieval with audit trail. Compliance addendum.
$35,000 – 85,000
10 wks + $2,400 / mo
Enterprise search
Multi-source internal wiki + Slack + Drive + Notion.
$22,000 – 50,000
8 wks + $1,500 / mo
Technical docs (DevRel)
Public docs AI search + citation tracking.
$18,000 – 40,000
6 wks + $900 / mo
Research / investment
Market-intel docs over 10+ years of PDFs.
$40,000 – 120,000
12 wks

Public benchmarks anchoring the pitch

Public case studyAI provider · Global · 2024

Anthropic — Contextual Retrieval

Challenge
Quantify the retrieval improvement of prepending chunk-level context before embedding + BM25.
Solution
Anthropic published the Contextual Retrieval technique along with reproducible evals: 67% drop in retrieval failures with Context + BM25 + reranker.
Results
Drop in retrieval failures
-67%
Vs naive embeddings alone
Dataset evaluated
9 domains
Code, legal, medical, etc.
Reranker + Context combo
Top choice
Largest combined lift
Public case studyOpen-source reranker · Global · 2024

BGE Reranker v2-m3 — public benchmark

Challenge
Benchmark multi-lingual reranking for RAG pipelines.
Solution
BAAI published BEIR + MIRACL results for BGE-reranker v2-m3 against commercial rerankers.
Results
BEIR nDCG@10
0.573
Best among OSS rerankers
Spanish MIRACL
0.714
Matches Cohere rerank-3
Self-hosted cost
$0.001 / 1k docs
Vs $0.10 / 1k for Cohere

Illustrative case — mid-market legal research tool

Illustrative caseLegal tech · 35 employees · $3.2M ARR · Mexico + Colombia

LATAM legal-research SaaS with 8,000-document corpus, 120-query golden set

Baseline
Naive RAG shipped 2024. Recall@5: 0.62. Users bypass the feature because "it gives wrong paragraphs." Churn 22% YoY, with feature quality cited as top reason.
Intervention
8-week Numoru rescue: Chonkie + Contextual Retrieval + hybrid Qdrant + BGE-reranker + RedisVL cache + Ragas evals in Langfuse. Team training during the last 2 weeks.
Projected outcome (12 mo)
Recall@5
0.62 → 0.91
+47%
Faithfulness
0.71 → 0.90
+27%
User retention 30-day
48% → 77%
Feature usage recovered
Churn rate
22% → 13%
ARR retained
Rescue cost
$42,000
One-time + $1,800 / mo ops
ARR recovered
+$480K
Lower churn + upsell
Eval numbers mirror our client telemetry; churn reduction calibrated to ChartMogul SaaS benchmarks 2024. Synthetic case — not a specific Numoru client.

ROI calculator — RAG rescue for a mid-market SaaS

Mid-market SaaS: rescue vs continued decay (12 months post-launch)

Payback: 2 months
Assumptions
Pre-rescue monthly active RAG users2,400
Post-rescue monthly active users4,100
Revenue per user / month$28
Churn at naive RAG quality22% / yr
Churn at post-rescue quality13% / yr
Rescue engagement cost$42,000
Ops retainer$1,800 / mo
Infra delta (BGE + RedisVL)$380 / mo
Rescue (one-time)−$42,000
Retainer (12 mo × $1,800)−$21,600
Infra delta (12 mo × $380)−$4,560
Retained ARR (9% × $806K)+$72,540
Expansion from active-user growth+$571,200
Support tickets avoided+$18,000
Net year-1 contribution+$593,580

Pricing tiers Numoru sells

Diagnosis
$4,500one-time
2-week evaluation & plan.
  • Ragas + DeepEval on your golden set
  • Chunking + embedding audit
  • Retrieval failure taxonomy
  • Prioritized fix roadmap
  • Deliverable: report + demo notebook
Full rescue
$18,000 – 65,000one-time
6-10 week rescue to production.
  • Chonkie + Contextual Retrieval
  • Hybrid Qdrant + BGE reranker
  • RedisVL semantic cache
  • Ragas + Langfuse evals
  • Team training + runbook
  • 90-day guarantee on metrics
Operate & evolve
$1,200 – 3,000/ month
Ongoing eval + optimization.
  • Monthly eval review + regression alerts
  • Model upgrade management
  • Prompt + retrieval fine-tuning
  • Langfuse dashboard curation
  • Quarterly strategy call
  • On-call during critical launches

Healthcare / compliance scope adds $8,000-20,000 for extra audit-trail and redaction work.

Common anti-patterns

  1. 500-token chunks because "someone said it's optimal". It depends on the domain. Try 256, 512, 1024 and measure.
  2. Embed everything with text-embedding-ada-002 (v1). Obsolete; text-embedding-3-small is 5× better and cheaper.
  3. Not filtering by tenant_id. Critical in multi-tenant. A missing filter = data leak.
  4. Forgetting to update the index when a doc changes. Without TTL or upsert by doc_id, the index drifts out of sync.
  5. Passing the LLM the top-40 without rerank. Noise degrades the answer more than it helps.
  6. Not measuring. Without Ragas or DeepEval in CI, regressions slip by silently.

When NOT to do RAG

  • If relevant data fits in the prompt (<100k tokens) and long-context latency is acceptable.
  • If the domain changes faster than re-indexing (seconds-minutes).
  • If the cost of a fictitious answer is catastrophic — use deterministic search + LLM as formatter only.

FAQ

What chunk size should I use?Start with 512 tokens + 50 overlap. Measure. Adjust. For code: 256 often wins; for regulation: 768-1024.

Local or SaaS embedding? SaaS while volume is <500k embeddings/month. Beyond that, BGE-m3 local on CPU is 10× cheaper.

Which reranker model?BGE-reranker v2-m3 is the best multilingual OSS today. Cohere Rerank v3 if you prefer SaaS.

RAG vs long context?Both work. RAG controls attribution and cost better; long context is simpler. Use long context for short conversations with a single document; RAG for large KBs and multi-tenant.

How do I handle documents that update?Upsert by doc_id as the key. When a new version arrives, re-chunk, re-embed and replace all chunks for that doc. Use schedule_date in payload to mark freshness.

Next steps

The full pipeline is at github.com/numoru-ia/rag-production-stack with a Docker Compose that brings up Qdrant + Redis + Langfuse + FastAPI + indexing scripts. The next piece covers orchestrating three agents on top of this RAG for a concrete vertical case.

Want results like these for your company?

Start a conversation
Share