Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
CodeAtlas has a search failure that looks like a win. A developer asks which deprecation policy applies to parser-v2 SDK migration for service accounts and the large language model (LLM) returns a fluent answer based on an old policy or workspace-b's private roadmap. Search was fast; it still failed the user.
Multi-tenant serving made tenant identity, cost, and visibility part of every request. Search carries those boundaries into evidence: a response can be fast and fluent yet still be wrong if it uses an old policy, cites a private roadmap, or merges documents that apply to different workspaces.
You're the Staff AI Engineer at CodeAtlas. Developers ask, "can service accounts rotate OAuth tokens every 90 days?" They also ask, "what happens if parser-v2 deprecation affects our SDK migration?" For each query, the product must find allowed API docs, release notes, and workspace policies, then write a cited answer without letting private roadmaps cross a tenant boundary.
Before looking at components, predict the hard case. If a private roadmap is semantically closer than the public deprecation policy, which candidate is allowed to influence ranking? If two allowed documents disagree, what should the user see while the system checks their citations? Keep those answers in mind; each later stage owns one part of that contract.
If embeddings or chunking feel rusty: embeddings turn sentences into dense vectors so similar meanings sit close together. Chunking splits long documents into short passages so retrieval can select a useful span. You'll reuse both, plus the hybrid retriever from Hybrid Search, inside an interactive latency budget.
What makes LLM-powered search different from a chatbot over documents?
Answer
It must retrieve fresh, permission-safe evidence, rank it, synthesize only supported claims, attach citations, and stream an answer within an interactive search latency budget.
The parser-v2 query isn't a list of links
The old CodeAtlas product returned ranked hits for "parser-v2 deprecation." A developer still had to open the policy page, the service-account exception, and the SDK migration note, then decide which sentence governed their case.
The new product must retrieve those documents, remove private-roadmap from workspace-a's evidence, and show how each answer sentence is supported. That's Retrieval-Augmented Generation (RAG)[1] in product terms: generation reads retrieved evidence instead of treating model memory as the source of truth.
Once synthesis enters the product, citation binding, abstention, and authorization failures enter with it. The pipeline therefore retrieves with keyword and embedding signals, reranks candidates, applies visibility and freshness policy, and only then writes a short answer whose claims can be checked.
What CodeAtlas used to return vs what it returns now
| Feature | Old keyword search | Generative search for parser-v2 |
|---|---|---|
| Output | Ranked links to policy and SDK pages | Answer plus cited evidence |
| Interaction | Query and result navigation | Answer and optional follow-up |
| User validation | Developer opens each source | Developer inspects cited spans |
| Source tracking | The page is the result | Claim-to-source binding is an application duty |
What does the LLM add after traditional retrieval?
Answer
The LLM performs synthesis: it reads retrieved evidence, resolves conflicts, explains the answer in user language, and binds claims to citations. It should not replace retrieval or source verification.
What the parser-v2 answer has to hit
These are example SLOs for CodeAtlas, not universal constants. They exist so the later stages can argue about the same budget.
| Dimension | Constraint | Example target |
|---|---|---|
| Latency | Time to first token (TTFT) | ~0.8-1.5 seconds |
| Throughput | Peak queries per second (QPS) | 1K-10K QPS per region |
| Quality | Answer accuracy | High citation precision, low unsupported claims |
| Cost | Average query cost | Low single-digit cents at scale |
| Freshness | Information latency | Seconds to minutes for hot updates, hours for stable docs |
| Isolation | Tenant visibility | Retrieval, caches, and citations stay inside workspace isolation |
Why must "workspace isolation" be a first-class requirement in this design?
Answer
Search evidence can include private roadmaps, incident notes, contracts, and policy exceptions. Retrieval, caching, ranking, and citations must enforce tenant visibility before the LLM ever sees context.
How the pipeline works end to end
The architecture has four query-time layers: plan the query, retrieve candidate evidence, pack the best evidence into context, and generate an answer while verifying citations. Each layer narrows what the next one is allowed to see.
Separate from this online path, a background ingestion system crawls or receives source updates, cleans them, chunks them, and keeps sparse and dense indexes fresh. Without that path, an "AI search engine" becomes a prompt wrapper around stale data. With it, query-time code can spend its budget on choosing and checking evidence instead of parsing documents.
![Two-lane CodeAtlas search architecture for the parser-v2 SDK query. Offline: parser-v2.md is chunked to "within 30 days" and written into a hybrid BM25, vector, and ACL index. Online: the workspace-a question retrieves only authorized docs, a cross-encoder reranks 50 passages, packing keeps 8 source IDs, and the answer cites [1] and [2] with a support check.](/cdn/content-image/system-design/design-llm-powered-search-engine/illustrations/_generated/llm_search_dark.png?v=30aa3593901d)
Crawl and index before query time
Start with one update: parser-v2.md changes its migration window and arrives with an ACL tag for workspace-a. Before looking at query code, predict what must survive ingestion. The crawler needs the new revision, the parser needs stable text and source offsets, and the indexer needs chunks that carry tenant, document version, and freshness metadata alongside sparse and dense representations.
That metadata is part of searchable evidence, not a side table to consult after generation. If the crawler drops the ACL, an unauthorized chunk can enter candidate generation. If the index keeps the old revision, a fast answer can still be stale. Store enough lineage to trace each chunk back to its source and version, then publish the new index view only when those fields are present.
Tracing a concrete query
Use one request as the debugging case: "Which deprecation policy applies to parser-v2 SDK migration for service accounts?" Before reading the trace, predict two things: which sources should survive authorization, and which single passage you'd keep if the answer had room for only one. This request needs both policy and exception, so it exposes every shortcut that loses one.
| Stage | What the system does |
|---|---|
| Query plan | The router classifies the question as a comparison needing both API deprecation policy and service-account migration rules. It decomposes the query into sub-queries for "parser-v2 deprecation policy" and "service-account SDK migration exceptions." |
| Retrieval | BM25 (Best Match 25), a lexical ranking function from the probabilistic relevance framework, pulls the exact "Parser v2 Deprecation" and "Service Account Migration" pages.[2] Dense retrieval finds a related FAQ about legacy SDK workflows. Together the first stage recalls about 1,000 candidates. |
| Reranking | After authorization, a cross-encoder scores a 50-passage slice. Packing then keeps 8 chunks with source IDs. |
| Synthesis | The LLM reads those packed passages and writes a two-sentence answer that cites [1] and [2]. |
| Verification | A verifier returns support labels for answer claims against cited text. Product policy decides whether pending or unsupported claims are hidden, marked, or regenerated. |
Keep this trace as a debugging breadcrumb. When a final answer fails, we can ask whether the planner chose the wrong route, retrieval missed a source, reranking buried it, packing dropped it, or verification rejected the claim.
Why separate offline ingest from the online query path?
Answer
Ingest normalizes, chunks, indexes, and refreshes source material before users ask questions. The online path then stays focused: plan, retrieve, rerank, pack evidence, synthesize, and verify. That boundary also gives each freshness decision a home: source updates belong to ingest, while query urgency belongs to planning.
Plan the parser-v2 question before you retrieve
Query understanding
The first hop answers an operational question: what kind of truth does this query need? Classify intent, choose a route, and decide whether retrieval needs rewriting. A large synthesis model can do that work, but using it for every CodeAtlas query adds measured latency and cost that "find the parser-v2 deprecation policy" doesn't need.
Predict the route before naming the machinery. "What is build-1042 doing right now?" should bypass document search. "Compare service-account exceptions" needs several sources. "What changed in the latest guide?" needs freshness. A small router or encoder-class classifier can make those choices, then attach hints such as "prefer exact matches" or "fresh data required." Benchmark it on your hardware and traffic shape because routing errors become retrieval errors downstream.
If a query is vague, HyDE (Hypothetical Document Embeddings) is one recall lever: generate a hypothetical relevant document, embed that document, and use its vector as an alternate dense-search query.[3] That generated text may contain false details. Treat HyDE as a way to find candidates, never as answer evidence, and validate whether it improves recall for your query mix.
Routing targets
Not every query should hit the same retriever. Exact, structured questions often route to APIs, SQL, or an entity store first, then optionally use the LLM for verbalization. Treating every request like unstructured RAG adds latency and can make numeric answers less reliable because prose is standing in for live state.
| Query pattern | Best first backend | Why |
|---|---|---|
| "What is deploy job build-1042 doing right now?" | Live API | Freshness and exact values matter more than semantic similarity |
| "Find the parser-v2 deprecation policy" | BM25 + doc store | Exact title and keyword match dominate |
| "Compare OAuth rotation exceptions for service accounts" | Hybrid sparse + dense | Needs multi-document synthesis and terminology overlap |
| "What changed in the latest SDK migration guide?" | Fresh web / hot index | Recency matters, then reranking decides quality |

The planner chooses the branch, not the answer. Live numeric state goes to an API, exact titles go to BM25, and comparisons pay for hybrid retrieval. A "latest" query starts with the hot tier, where freshness can be measured instead of guessed from wording.
| Component | Purpose | Example |
|---|---|---|
| Intent Classifier | Route to appropriate pipeline | "build-1042 status" -> direct API call |
| Query Decomposer | Break complex queries into sub-queries | "Compare service-account rotation exceptions" -> 3 sub-queries |
| Safety Filter | Block harmful queries early | Content policy enforcement |
| Freshness Detector | Determine if real-time data is needed | "latest SDK migration guide" -> force fresh crawl |
The planner below is a fast stand-in for a small classifier. Give it a raw query and inspect the contract it returns: intent, route, sub-queries, filters, and whether live data is required. Those fields aren't decoration; downstream retrieval uses them to enforce the route we just chose.
1import json
2from dataclasses import asdict, dataclass, field
3
4@dataclass(frozen=True)
5class QueryPlan:
6 intent: str
7 route: str
8 sub_queries: list[str] = field(default_factory=list)
9 filters: dict[str, str | bool] = field(default_factory=dict)
10 needs_fresh_data: bool = False
11 complexity: str = "simple"
12
13def understand_query(user_query: str) -> QueryPlan:
14 """Plan with fast routing logic; swap rules for a small classifier in production."""
15 text = user_query.lower()
16
17 if any(term in text for term in ("build-", "quota", "runner status")):
18 return QueryPlan(
19 intent="factual",
20 route="structured_api",
21 filters={"requires_live_data": True},
22 needs_fresh_data=True,
23 )
24
25 if "parser-v2" in text and "service account" in text:
26 return QueryPlan(
27 intent="comparison",
28 route="hybrid_search",
29 sub_queries=[
30 "parser-v2 deprecation policy",
31 "service-account SDK migration exceptions",
32 ],
33 filters={"doc_type": "policy", "tenant_scoped": True},
34 complexity="moderate",
35 )
36
37 if "latest" in text or "right now" in text:
38 return QueryPlan(
39 intent="exploratory",
40 route="hot_index",
41 sub_queries=[user_query],
42 needs_fresh_data=True,
43 complexity="moderate",
44 )
45
46 return QueryPlan(
47 intent="exploratory",
48 route="hybrid_search",
49 sub_queries=[user_query],
50 complexity="moderate",
51 )
52
53plan = understand_query("Which deprecation policy applies to parser-v2 SDK migration for service accounts?")
54assert understand_query("What changed in the latest SDK migration guide?").route == "hot_index"
55print(json.dumps(asdict(plan), indent=2))1{
2 "intent": "comparison",
3 "route": "hybrid_search",
4 "sub_queries": [
5 "parser-v2 deprecation policy",
6 "service-account SDK migration exceptions"
7 ],
8 "filters": {
9 "doc_type": "policy",
10 "tenant_scoped": true
11 },
12 "needs_fresh_data": false,
13 "complexity": "moderate"
14}When should a query planner route to a structured API instead of generic RAG?
Answer
Use a structured API for exact, live, or numeric facts such as deploy status, quota remaining, entitlement, or current feature-flag state. Generic semantic retrieval is weaker for those values and can return stale text.
Multi-stage retrieval
Now predict what broad recall should return for our parser-v2 question. BM25 should find the exact policy title and service-account phrase. Dense retrieval should add the FAQ that describes the same workflow in different words. Neither list is the answer; each is a candidate set with different blind spots.
We use hybrid retrieval to broaden that candidate recall before refining for precision. Keyword search can miss synonyms. Dense vector embeddings, which place queries and documents near one another when their meanings are related, can miss exact names, product IDs, or acronyms. Hybrid Search built this lane; this capstone adds freshness routing, workspace isolation, evidence packing, and a search latency budget.
At query time, run sparse retrieval and dense retrieval in parallel, then fuse their ranked lists with Reciprocal Rank Fusion (RRF).[4] A second stage applies a more expensive reranker, often a cross-encoder or late-interaction model,[5] to 50 passages. Packing then keeps 8 chunks with source IDs. The order matters: cheap methods widen the funnel, while expensive methods spend compute only on candidates that survived the first pass.
![Hybrid retrieval funnel for the parser-v2 query. Fan-out recalls 1000 BM25, dense, and hot-index candidates including private-roadmap. Access control drops private-roadmap before Reciprocal Rank Fusion. A cross-encoder reranks 50 passages, then packing keeps 8 chunks with source IDs for citations [1] and [2].](/cdn/content-image/system-design/design-llm-powered-search-engine/illustrations/_generated/retrieval_funnel_dark.png?v=aba0bdcf5ea1)
Why hybrid retrieval?
BM25 is strong at exact keyword matching; dense retrieval captures semantic similarity.[2] Dense retrieval typically uses bi-encoders, which encode queries and documents separately so document vectors can be indexed ahead of time. Some systems instead use ColBERT (Contextualized Late Interaction over BERT): query and document tokens interact after each side has been encoded.[5] On mixed traffic, the combination can surface candidates that either retriever would miss alone.
In practice, hybrid search is more than running two retrievers. Chunking decides what can be found, rank fusion decides how lists meet, and evidence assembly decides what the model can quote. A useful pattern embeds short child chunks for recall, then fetches their larger parent passage for synthesis so surrounding context survives.
Fuse on rank instead of raw score because BM25 and dense-similarity scores use different scales. Adding them without calibration is brittle. Reciprocal Rank Fusion scores documents from rank positions: score(d) = sum over lists of 1 / (k + rank(d)). Cormack et al. evaluated RRF with k = 60; use that as a starting point to validate, not a universal optimum.[4] Apply authorization filtering before fusion. Otherwise, a high-ranked forbidden document can change ranks even if you remove it later.
1from collections import defaultdict
2
3tenant = "workspace-a"
4acl = {"deprecation": {"workspace-a"}, "rotation": {"workspace-a"}, "private-roadmap": {"workspace-b"}}
5sparse_ranked = ["deprecation", "private-roadmap", "rotation"]
6dense_ranked = ["private-roadmap", "rotation", "deprecation"]
7
8def allowed_ranked(document_ids: list[str]) -> list[str]:
9 return [document_id for document_id in document_ids if tenant in acl[document_id]]
10
11def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
12 scores: defaultdict[str, float] = defaultdict(float)
13 for ranking in rankings:
14 for rank, document_id in enumerate(ranking, start=1):
15 scores[document_id] += 1 / (k + rank)
16 return sorted(scores, key=scores.get, reverse=True)
17
18fused_raw = reciprocal_rank_fusion([sparse_ranked, dense_ranked])
19fused = reciprocal_rank_fusion([allowed_ranked(sparse_ranked), allowed_ranked(dense_ranked)])
20assert fused_raw[0] == "private-roadmap"
21assert "private-roadmap" not in fused
22print("fused_without_acl:", fused_raw)
23print("authorized_fused_docs:", fused)1fused_without_acl: ['private-roadmap', 'deprecation', 'rotation']
2authorized_fused_docs: ['deprecation', 'rotation']Freshness and index tiers
Freshness isn't one global ranking switch. Real search products rarely use one monolithic index, because a changelog and a stable API reference need different update paths. A useful pattern is a hot-warm split:
| Tier | Typical content | Update pattern | Query behavior |
|---|---|---|---|
| Hot | News, changelogs, live API snapshots | Seconds to minutes | Queried when freshness detector fires |
| Warm | Product docs, internal wikis, help center content | Minutes to hours | Default hybrid search tier |
| Cold | Stable archive and long-tail corpus | Batch compaction | Backfill recall when warm tier misses |
Freshness should influence ranking, not replace relevance. Combine retrieval score with time decay, then let the reranker decide whether the newer document is better. If you rank by recency alone, a low-quality post from five minutes ago can outrank the canonical source. For "latest migration guide," the hot tier narrows the time window; it doesn't get to skip authorization or evidence checks.
Why does hybrid retrieval beat dense-only retrieval for developer-doc search?
Answer
Dense retrieval captures semantic paraphrases, but BM25 preserves exact identifiers such as API versions, issue IDs, feature flags, and policy titles. Rank fusion gives the reranker both kinds of candidates.
Latency budget
TTFT is a critical constraint of this system. The values below are an illustrative budget, not a promise. Before reading the total, predict the critical path: if planning overlaps retrieval, which stage contributes its full duration, and which stage is hidden under another? Validate the answer against product objectives and measured stage timings.
| Stage | Example latency | Method | Parallelism |
|---|---|---|---|
| Query planning | 10-50ms | Small router / rewriter | Can overlap with initial retrieval |
| Retrieval and fetch | 150-500ms | Sparse, dense, API, or web calls | Fan-out within allowed routes |
| Reranking | 50-150ms | Cross-encoder GPU batch | Batch allowed candidates together |
| Evidence packing | 10-30ms | Dedupe, trim, retain source IDs | Sequential after ranking |
| LLM prefill to first token | 300-800ms | Streaming generation | Sequential after context exists |
| Total to first token | ~0.8-1.5 seconds | Example product objective | Depends on overlap and tail latency |

Note: If you support speculative retrieval, cheap lexical or dense search can begin before rewrite finishes. If you don't, planning and retrieval stack sequentially. Make that overlap decision explicit in the TTFT budget; otherwise a mean stage time can hide the real critical path.
1planner_ms = 40
2initial_retrieval_ms = 260
3rerank_ms = 90
4pack_ms = 20
5prefill_to_first_token_ms = 430
6
7sequential_ttft_ms = planner_ms + initial_retrieval_ms + rerank_ms + pack_ms + prefill_to_first_token_ms
8overlapped_ttft_ms = max(planner_ms, initial_retrieval_ms) + rerank_ms + pack_ms + prefill_to_first_token_ms
9
10assert sequential_ttft_ms == 840
11assert overlapped_ttft_ms == 800
12print("sequential_ttft_ms:", sequential_ttft_ms)
13print("overlapped_ttft_ms:", overlapped_ttft_ms)1sequential_ttft_ms: 840
2overlapped_ttft_ms: 800What is the main latency tradeoff in query planning?
Answer
A better rewrite can improve recall, but waiting for it serially delays retrieval. Speculative retrieval starts cheap searches from the raw query while the planner finishes, then merges or cancels results.
Grounded synthesis with citations
The synthesis prompt can require citations and abstention, but prompting can't prove grounding. The product must keep source IDs attached to evidence, check generated claims, and decide what may be shown while support is pending or missing.
Before packing, predict the failure. If the correct exception lands in the middle of 40 passages, will the model use it? If a citation points to a document title but not to the sentence that supports the claim, can a reviewer verify it? These questions make context layout and citation binding part of the system, not prompt decoration.
Grounding has a context problem too. Put the strongest chunks at the start and end of the prompt, leave room for the answer, and avoid stuffing mediocre passages into the middle of a huge context. Long-context models still show "lost in the middle" behavior: Liu et al. measured a U-shaped use of context, where evidence in the center of the context window is used less reliably than evidence at the edges.[6] Reranking and tight top-K therefore protect both quality and latency.
Visible citations can be a subset of retrieved evidence, but each one must bind to a specific claim and source span. A source list is provenance metadata, not proof that every generated sentence is supported.
Before generation, pack allowed passages into a bounded context while preserving source IDs:
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Passage:
5 source_id: int
6 tenant: str
7 text: str
8 tokens: int
9 rerank_score: float
10
11def pack_allowed(passages: list[Passage], tenant: str, budget: int) -> list[Passage]:
12 selected: list[Passage] = []
13 used = 0
14 for passage in sorted(passages, key=lambda item: item.rerank_score, reverse=True):
15 if passage.tenant != tenant or used + passage.tokens > budget:
16 continue
17 selected.append(passage)
18 used += passage.tokens
19 return selected
20
21candidates = [
22 Passage(1, "workspace-a", "Parser-v2 migration starts within 30 days.", 7, 0.95),
23 Passage(2, "workspace-b", "Private roadmap milestone.", 4, 0.99),
24 Passage(3, "workspace-a", "Service-account exceptions require security approval.", 5, 0.82),
25]
26packed = pack_allowed(candidates, tenant="workspace-a", budget=12)
27assert [passage.source_id for passage in packed] == [1, 3]
28print("packed_source_ids:", [passage.source_id for passage in packed])1packed_source_ids: [1, 3]Compression after selection, not instead of it
Sometimes authorized, reranked evidence is still too large for the prefill budget. Query-aware prompt compression is a packing lever after access filtering, deduplication, and passage selection. It isn't permission filtering, retrieval, or a license to keep every weak result.
LLMLingua uses a smaller language model's cross-perplexity signal to estimate which prompt tokens carry less information for the target model, then applies coarse-to-fine pruning.[7] The paper reports compression ratios as high as 20× on GSM8K, BBH, ShareGPT, and Arxiv-March23 with limited performance loss. That result doesn't guarantee safe compression for cited search evidence. Those tasks don't test claim-to-span verification, so evaluate support and answer quality at each target ratio.
Protect exact spans before compression: source IDs, document titles, code symbols, version numbers, dates, quantities, negation, and quoted policy language. Keep a mapping from compressed text back to original source offsets. The synthesizer may read compressed evidence, but citation verification should resolve claims against the original passage.
| Packing step | Input | Failure if skipped |
|---|---|---|
| Authorize and rerank | Raw retrieved candidates | Compression can retain forbidden or irrelevant text |
| Select and deduplicate | Allowed ranked passages | Repeated weak evidence consumes the budget |
| Protect evidence spans | IDs, numbers, quotations, and source anchors | Compression can change the fact the answer must preserve |
| Compress remaining text | Query-aware candidate context | Prefill stays larger than the latency and cost envelope |
| Verify against originals | Draft claims plus source-offset map | A fluent answer can cite wording that compression distorted |
Track raw_tokens, selected_tokens, compressed_tokens, protected-span survival, retrieval recall, citation support, and TTFT together. If the correct exception or negation disappears, a smaller prompt is a regression.
Where does LLMLingua-class compression belong in evidence packing?
Answer
After authorization, reranking, deduplication, and passage selection. Protect exact evidence spans, compress only the remaining text, retain source-offset mappings, and verify generated claims against original passages.
Keeping temperature low reduces answer variance, but it doesn't create factuality. Predict what should happen when one of the two required passages is absent: the synthesizer may draft, but publication must wait for evidence checks. The next deterministic example turns that support gate into code.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Source:
5 source_id: int
6 title: str
7 content: str
8
9def contains_all(source: Source | None, required_terms: tuple[str, ...]) -> bool:
10 if source is None:
11 return False
12 text = source.content.lower()
13 return all(term in text for term in required_terms)
14
15def grounded_answer(sources: list[Source]) -> str:
16 by_id = {source.source_id: source for source in sources}
17 if not contains_all(by_id.get(1), ("parser-v2", "within 30 days")):
18 return "Insufficient cited evidence to answer."
19 if not contains_all(by_id.get(2), ("security approval", "service-account")):
20 return "Insufficient cited evidence to answer."
21 return (
22 "Start the parser-v2 SDK migration within 30 days [1]. "
23 "Service-account OAuth rotation exceptions require security approval [2]."
24 )
25
26sources = [
27 Source(
28 source_id=1,
29 title="Parser v2 Deprecation Policy",
30 content="Parser-v2 SDK migrations must start within 30 days.",
31 ),
32 Source(
33 source_id=2,
34 title="Service Account Rotation Exceptions",
35 content="Service-account OAuth rotation exceptions require security approval.",
36 ),
37]
38
39answer = grounded_answer(sources)
40assert grounded_answer(sources[:1]) == "Insufficient cited evidence to answer."
41assert grounded_answer(sources[:1] + [Source(2, "Service Account Rotation Exceptions", "No approval rule.")]) == "Insufficient cited evidence to answer."
42print(answer)1Start the parser-v2 SDK migration within 30 days [1]. Service-account OAuth rotation exceptions require security approval [2].Why is low temperature not enough to make generated search factual?
Answer
Low temperature reduces variance, but factuality comes from retrieved evidence, source IDs, evidence packing, and claim-level verification. A deterministic unsupported answer is still unsupported.
Streaming with pending support
Once evidence is selected, the user still has to wait for synthesis and verification. For one-way progressive delivery, Server-Sent Events (SSE) work through the browser EventSource API over HTTP and are easy to render.[8] The transport is simple; the policy isn't. Hold factual claims until verification completes, or label streamed text as provisional and update or retract unsupported claims.

Streaming endpoint
Predict the safe event sequence for the parser-v2 answer: plan and sources can appear early, but a policy claim should remain visibly pending until its cited span passes. This event contract fits a low-risk path that permits provisional rendering. It emits the plan and sources, declares pending support, streams text, then attaches claim-check results. A higher-risk workflow can omit text frames until support becomes supported.
1import json
2
3def sse(event_type: str, data: object) -> str:
4 payload = {"type": event_type, "data": data}
5 return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n"
6
7def event_stream() -> list[str]:
8 return [
9 sse("plan", {"intent": "comparison", "route": "hybrid_search"}),
10 sse("sources", [{"title": "Parser v2 Deprecation Policy", "source_id": 1}]),
11 sse("support_state", {"state": "pending", "display": "provisional"}),
12 sse("text", "For parser-v2 SDK migration"),
13 sse("text", ", start within 30 days [1]."),
14 sse(
15 "support_state",
16 {"state": "supported", "claim_ids": ["migration-window"]},
17 ),
18 "data: [DONE]\n\n",
19 ]
20
21for frame in event_stream():
22 print(frame, end="")1data: {"type":"plan","data":{"intent":"comparison","route":"hybrid_search"}}
2
3data: {"type":"sources","data":[{"title":"Parser v2 Deprecation Policy","source_id":1}]}
4
5data: {"type":"support_state","data":{"state":"pending","display":"provisional"}}
6
7data: {"type":"text","data":"For parser-v2 SDK migration"}
8
9data: {"type":"text","data":", start within 30 days [1]."}
10
11data: {"type":"support_state","data":{"state":"supported","claim_ids":["migration-window"]}}
12
13data: [DONE]A live CodeAtlas stream also needs periodic heartbeat events and upstream proxies that don't buffer. Log support-state transitions so a provisional parser-v2 sentence that later gets blocked can be audited. The audit trail should show which claim, source span, and verifier decision changed the UI state.
Why use SSE for search-style answer streaming?
Answer
SSE fits one-way progressive token delivery over HTTP when the browser needs plan, source, text, and support-state events from the server. It doesn't decide whether unverified facts may be displayed.
A fluent parser-v2 sentence can still be unsupported
Suppose CodeAtlas writes, "Start the migration within 30 days," while its citation only says that migration planning has begun. The sentence sounds plausible, and its citation exists, but the cited span doesn't support its deadline. RAG reduces this risk; it doesn't remove it.
FActScore makes the remaining error visible by splitting long-form text into atomic facts and scoring the fraction supported by a knowledge source.[9] On biography generation, Min et al. reported FActScores of 42% for InstructGPT, 58% for ChatGPT, and 71% for search-augmented PerplexityAI. Retrieval helped, yet nearly three in ten atomic facts remained unsupported on that task.
CodeAtlas can reuse that decomposition at request time: extract claims, bind each one to a cited span, then verify entailment before the UI treats the text as final. Predict the failure response now: a weak claim should be blocked, marked provisional, or regenerated, not hidden behind a correct-looking source list.

Strong systems stack several checks rather than betting on one verifier. Cheap checks can reject obvious misses; more expensive checks decide whether meaning, numbers, and negation survive.
| Method | What it checks | Relative cost | Main weakness |
|---|---|---|---|
| Quote / span matching | Does the cited span appear in the source? | Very low | Misses paraphrases and logical contradictions |
| Natural Language Inference (NLI) | Does the cited passage entail the claim? | Medium | Can struggle on ambiguous or very long passages |
| LLM-as-judge / fallback pass | Should unsupported claims be flagged or regenerated? | High | Requires measured latency, cost, and calibration |
NLI-based verification estimates whether a cited passage entails a claim rather than merely sharing words. TRUE[10] found large-scale NLI methods and question-generation-plus-answering methods to be strong automatic factual-consistency checks on its benchmarks. NLI is therefore a useful measured layer, not proof of correctness.
The function below is deliberately only a lexical precheck. It can cheaply reject a missing phrase; it can't label entailment. Claims that pass still need a calibrated verifier or a policy that keeps them provisional.
1import json
2import re
3
4def lexical_precheck(claim: str, source_text: str) -> dict[str, str | bool]:
5 claim_terms = set(re.findall(r"[a-z0-9]+", claim.lower()))
6 source_terms = set(re.findall(r"[a-z0-9]+", source_text.lower()))
7 term_match = claim_terms.issubset(source_terms)
8
9 return {
10 "claim": claim,
11 "term_match": term_match,
12 "next_step": "entailment_check" if term_match else "block_claim",
13 }
14
15result = lexical_precheck(
16 claim="Parser-v2 migrations must start within 30 days",
17 source_text="Parser-v2 SDK migrations must start within 30 days.",
18)
19
20print(json.dumps(result, indent=2))1{
2 "claim": "Parser-v2 migrations must start within 30 days",
3 "term_match": true,
4 "next_step": "entailment_check"
5}Why can lexical matching not replace NLI or another calibrated verifier?
Answer
Lexical matching checks whether words appear in a source. Entailment evaluation estimates whether the passage supports the meaning of the claim, including paraphrases or contradictions.
Verification is another billable stage. At search QPS, even a one-cent synthesizer plus a claim checker becomes a budget problem. That's why quality gates and cost gates have to be designed together.
What running this costs
At scale, every extra candidate, token, and claim becomes a cost decision. Exact dollar figures drift with vendor pricing, model choice, and answer length, so reason from units of work rather than one fixed price table. Before looking at the scenario, predict which route should be cheapest: a live structured lookup, a cache hit, or full synthesis with verification.

| Cost center | Billable unit | What makes it expensive |
|---|---|---|
| Web retrieval | API calls and fetched documents | More sub-queries, more URLs fetched |
| Reranking | Query-document pairs | Larger candidate set or heavier reranker |
| Synthesis | Prompt + completion tokens | Long context windows and long answers |
| Verification | Claims checked against passages | Dense citation requirements, many claims |
Use scenario inputs rather than presenting provider pricing as fixed:
- Web retrieval: ~$0.002
- Reranking 50 query-document pairs: ~$0.001
- 1,500 prompt tokens + 400 output tokens at $0.003 per 1K tokens: ~$0.006
- Verifying 5 claims: ~$0.001
For these inputs, total cost is roughly $0.01 per query. At 1,000 QPS, that scenario approaches $600 per minute. Routing and caching are therefore not polish; they keep avoidable requests off the synthesis path.
1retrieval_usd = 0.002
2rerank_usd = 0.001
3prompt_tokens = 1500
4output_tokens = 400
5token_usd_per_thousand = 0.003
6verification_usd = 0.001
7queries_per_second = 1000
8
9query_usd = (
10 retrieval_usd
11 + rerank_usd
12 + (prompt_tokens + output_tokens) / 1000 * token_usd_per_thousand
13 + verification_usd
14)
15per_minute_usd = query_usd * queries_per_second * 60
16
17assert round(query_usd, 4) == 0.0097
18print("scenario_query_usd:", round(query_usd, 4))
19print("scenario_per_minute_usd:", round(per_minute_usd, 2))1scenario_query_usd: 0.0097
2scenario_per_minute_usd: 582.0Which stage dominates variable cost depends on provider rates, fan-out, prompt length, and verification policy. Measure the breakdown first. Then route cacheable or structured queries away from unnecessary synthesis and trim top-K only where retrieval evaluation allows it.
In the worked 1,000 QPS cost scenario, why does routing matter?
Answer
The scenario costs hundreds of dollars per minute. Cache hits, structured routes, smaller models, and evaluated top-K reduction can change that envelope before traffic reaches synthesis.
Serving-side latency levers
TTFT can be dominated by retrieval, prompt assembly, or prefill; long answers can shift pressure toward decode. Before choosing an optimization, identify which bar is actually long in a trace. A serving fix aimed at decode can't shorten a slow reranker.
PagedAttention improves KV-cache memory management and can increase concurrency when KV allocation is the binding constraint.[11] If decode is the bottleneck, speculative decoding can reduce latency when a draft model is accepted often enough; benchmark it for answer shapes and the target model in this service.[12]
These serving optimizations matter after retrieval depth and prompt length are under control. If 40 mediocre passages still reach the model, no serving trick can rescue TTFT. First reduce work that should never have entered the context; then optimize the work that remains.
GPU capacity planning
Split concurrency by which latency you mean. Product SLOs here are often TTFT (about 0.8 to 1.5 seconds to first token). End-to-end generation is longer because it includes first token plus the rest of the answer. A dashboard that reports only TTFT can hide contexts that remain resident during decode.
For a 1K QPS design point, the low end of the 1K to 10K QPS per-region range, use the overlapped 800 ms TTFT as about 1.0 s in round numbers and budget remaining decode separately. A 120-token answer at roughly 80 tokens/s after the first token needs about 1.5 s more, so end-to-end is about 2.5 s:
Little's Law still holds (). Use for admission of streaming sessions and for decode concurrency and KV residency. The 2500 figure is not first-token-only concurrency: it's the number of answers still in flight across their full 2.5-second lifetime.
1from math import ceil
2
3queries_per_second = 1000
4ttft_seconds = 1.0
5average_e2e_latency_seconds = 2.5
6average_output_tokens = 120
7effective_decode_tokens_per_second_per_gpu = 6000
8kv_gib_per_2k_tokens = 0.61 # half of 1.22 GiB @ 4K from the multi-tenant lesson
9usable_kv_fraction = 0.6
10gpu_hbm_gib = 80
11
12in_flight_ttft = queries_per_second * ttft_seconds
13in_flight_e2e = queries_per_second * average_e2e_latency_seconds
14decode_gpus = ceil(
15 queries_per_second
16 * average_output_tokens
17 / effective_decode_tokens_per_second_per_gpu
18)
19raw_kv_gib = in_flight_e2e * kv_gib_per_2k_tokens
20kv_gpus = ceil(raw_kv_gib / (usable_kv_fraction * gpu_hbm_gib))
21
22assert in_flight_ttft == 1000
23assert in_flight_e2e == 2500
24assert decode_gpus == 20
25assert kv_gpus == 32
26print("in_flight_ttft_sessions:", int(in_flight_ttft))
27print("in_flight_e2e_requests:", int(in_flight_e2e))
28print("decode_gpus_before_headroom:", decode_gpus)
29print("kv_gpus_before_headroom:", kv_gpus)1in_flight_ttft_sessions: 1000
2in_flight_e2e_requests: 2500
3decode_gpus_before_headroom: 20
4kv_gpus_before_headroom: 32Decode throughput alone is a lower bound. Twenty GPUs at 6k tok/s can emit the tokens, but 2500 concurrent contexts may first exhaust high-bandwidth memory (HBM) reserved for KV state. That's a different constraint from tokens per second.
Reuse the multi-tenant lesson's roughly 1.22 GiB per 4K-token request: 2K tokens is about 0.61 GiB each. If packing leaves 60% of each 80 GiB GPU usable for KV, then GPUs before weight residency and headroom. Formula sketch:
PagedAttention improves packing; it doesn't remove byte accounting. Don't assume a universal "requests per GPU" number. Measure your model, context length, batching, and quantization. The decode line and KV line can disagree, as they do here: 20 GPUs can emit the tokens while 32 GPUs are needed to hold 2500 contexts.
For long prompts, budget prefill separately too:
At the same 1K QPS design point, split pools explicitly (illustrative shares; measure yours):
| Pool | Role | Sketch share of work | Sizing note |
|---|---|---|---|
| Planner / router | Query plan, rewrite | ~all queries, CPU-ms | Stateless CPU replicas |
| Sparse retrieval | BM25 / lexical | ~all authorized queries | Shard by corpus |
| Dense ANN | Vector search | ~all hybrid queries | Index RAM + QPS |
| Rerank GPU | Cross-encoder | Top- pairs only | Batch GPU workers |
| Synthesis GPU | Prefill + decode | Answer-generating routes | Decode + KV lines above |
Peak 10K QPS is an order of magnitude above this worked example. Shard regions, raise cache hit rates, or replicate pools only after remeasuring stage hit rates. The 1K QPS GPU count doesn't cover the top of the requirements range.
Size the system against whichever pool saturates first: retriever GPUs, reranker GPUs, prefill workers, or decode workers. The answer model may be the expensive tier, but measured retrieval or reranking can still be the first bottleneck. When a request misses its TTFT SLO, locate that pool before adding model capacity.
Why can reranking or retrieval be the bottleneck even when the LLM is expensive?
Answer
Large fan-out multiplies query-document pairs, web/API calls, and cleanup work before synthesis starts. If retrieval depth or reranker batches saturate first, faster decoding won't fix TTFT.
How to know it's working
A fluent answer can cite two passages and still omit the service-account exception. Which metric should fail first? RAGAS[13] introduced reference-free evaluation around faithfulness, answer relevance, and context relevance. That's one offline layer when gold answers are scarce, not a replacement for labeled retrieval metrics, citation checks, or online product metrics. A response can be consistent with mediocre evidence and still fail the user.
| Layer | Metric | What it catches | Typical requirement |
|---|---|---|---|
| Reference-free offline | Faithfulness | Unsupported claims | LLM/NLI judge over answer vs context |
| Reference-free offline | Answer relevance | Missed user intent | Generated-question similarity or judge |
| Reference-free offline | Context relevance | Noisy, redundant retrieved context | Judge over question vs retrieved passages |
| Labeled offline | Recall@k / nDCG / MRR | Retriever missed or buried the right source | Gold passages or reliable click labels |
| Citation quality | Citation precision / unsupported-claim rate | Wrong citations or uncited claims | Claim extraction + source verification |
| Online | Reformulation rate / citation CTR / abandonment | Real user dissatisfaction | Production logging and experiments |
This example wires a scorecard together with simple token-overlap proxies so it stays runnable. Read the output as a diagnosis prompt, not a quality badge: citation resolution only checks whether a visible citation ID maps to an available passage; it doesn't prove claim support. These proxies aren't RAGAS or calibrated faithfulness judges. Replace them in an evaluation service while keeping labeled retrieval metrics separate.
1import json
2import re
3from dataclasses import dataclass
4from collections.abc import Mapping
5from math import log2
6
7@dataclass(frozen=True)
8class Passage:
9 id: str
10 text: str
11
12def tokens(text: str) -> set[str]:
13 return set(text.lower().replace(",", "").replace(".", "").split())
14
15def overlap(a: str, b: str) -> float:
16 left = tokens(a)
17 right = tokens(b)
18 return len(left & right) / max(1, len(left))
19
20def citation_resolution_precision(answer: str, cited_passages: list[Passage]) -> float:
21 available_ids = {passage.id for passage in cited_passages}
22 cited_ids = re.findall(r"\[([^\[\]]+)\]", answer)
23 return sum(cited_id in available_ids for cited_id in cited_ids) / max(1, len(cited_ids))
24
25def recall_at_k(retrieved_ids: list[str], gold_ids: set[str], k: int) -> float:
26 return len(set(retrieved_ids[:k]) & gold_ids) / max(1, len(gold_ids))
27
28def ndcg_at_k(retrieved_ids: list[str], gold_ids: set[str], k: int) -> float:
29 gains = [1.0 if item in gold_ids else 0.0 for item in retrieved_ids[:k]]
30 dcg = sum(gain / log2(rank + 2) for rank, gain in enumerate(gains))
31 ideal = sum(1.0 / log2(rank + 2) for rank in range(min(len(gold_ids), k)))
32 return dcg / ideal if ideal else 0.0
33
34def score_search_response(
35 query: str,
36 answer: str,
37 retrieved_passages: list[Passage],
38 cited_passages: list[Passage],
39 gold_passage_ids: set[str] | None = None,
40) -> Mapping[str, float]:
41 context = " ".join(passage.text for passage in retrieved_passages)
42 scorecard = {
43 "answer_context_token_overlap": overlap(answer, context),
44 "query_answer_token_overlap": overlap(query, answer),
45 "query_context_token_overlap": overlap(query, context),
46 "citation_resolution_precision": citation_resolution_precision(answer, cited_passages),
47 }
48
49 if gold_passage_ids is not None:
50 retrieved_ids = [p.id for p in retrieved_passages]
51 scorecard["recall_at_20"] = recall_at_k(retrieved_ids, gold_passage_ids, k=20)
52 scorecard["ndcg_at_10"] = ndcg_at_k(retrieved_ids, gold_passage_ids, k=10)
53
54 return {metric: round(value, 2) for metric, value in scorecard.items()}
55
56retrieved = [
57 Passage("1", "Parser-v2 SDK migrations must start within 30 days."),
58 Passage("2", "Service-account OAuth rotation exceptions require security approval."),
59 Passage("3", "Legacy parser-v1 migrations follow a separate workflow."),
60]
61answer = "Parser-v2 SDK migrations must start within 30 days [1]."
62
63print(json.dumps(
64 score_search_response(
65 query="Which deprecation policy applies to parser-v2 SDK migration for service accounts?",
66 answer=answer,
67 retrieved_passages=retrieved,
68 cited_passages=retrieved[:1],
69 gold_passage_ids={"1", "2"},
70 ),
71 indent=2,
72))1{
2 "answer_context_token_overlap": 0.89,
3 "query_answer_token_overlap": 0.18,
4 "query_context_token_overlap": 0.18,
5 "citation_resolution_precision": 1.0,
6 "recall_at_20": 1.0,
7 "ndcg_at_10": 1.0
8}Why keep reference-free RAGAS-style metrics separate from labeled retrieval metrics?
Answer
They answer different questions. Reference-free metrics judge answer/context consistency; labeled metrics reveal whether the retriever found and ranked known relevant passages.
Use the scorecard to locate the failing handoff. Low Recall@k points upstream to crawling, chunking, query planning, or candidate retrieval. Good recall with poor context relevance points to selection or packing. Good context with unsupported claims points to synthesis or citation verification. This turns an evaluation result into an owner and a next experiment.
Release only when the relevant gates agree: authorized retrieval is recalled, citations resolve to source spans, claims pass support checks, and TTFT and cost stay inside their budgets. A green answer-quality score can't waive an authorization failure, and a fast stream can't waive a missing source.
Scaling without blowing the budget
As traffic grows, evaluate each latency or cost lever against quality and authorization tests:
| Strategy | Implementation Details | Primary Benefit |
|---|---|---|
| Semantic response cache | Key answers by authorization scope and evidence version; reuse only after similarity and freshness thresholds pass. | Can avoid synthesis work for eligible repeat queries. |
| Model routing | Route exact live facts to structured backends and measured simple paths to smaller models. | Can lower cost without weakening answer routes that require synthesis. |
| Speculative retrieval | Start an allowed cheap retriever while rewrite or decomposition runs, then merge or cancel. | Can shorten measured critical path when early retrieval is useful. |
| Asynchronous verification | Mark streamed claims pending, or hold high-risk facts until checks return. | Trades display latency against unsupported-claim exposure explicitly. |
| Geographic distribution | Place permitted retrieval and caches near users when data residency policy allows it. | Can reduce network latency for eligible data paths. |
| Feedback integration | Log interactions with privacy controls and use labeled review before training rerankers. | Supplies evidence for retriever or reranker changes. |
Why is semantic response caching risky without freshness and permission checks?
Answer
Similar queries can have different tenant visibility, quota state, policy version, or time sensitivity. Cache reuse must satisfy similarity, freshness, and authorization thresholds.
Failure patterns worth replaying
Replay the same parser-v2 query with one fault at a time: remove the fresh revision, admit private-roadmap, drop the exception during packing, or flip a citation's negation. Observe which metric and trace span change. This isolates the broken handoff instead of blaming "the LLM" for every bad answer.
| Symptom | Cause | Fix |
|---|---|---|
| TTFT stays above 2 seconds | A frontier model runs on every query, including live lookups | Route factual queries to a small model, structured API, or cache |
| Answers cite the wrong source | Packing stuffs too many mediocre passages into the prompt | Cut top-K after rerank and put the strongest chunks at both edges of context |
| Hallucinations persist despite RAG | Prompt instructions are the only grounding check | Stack span matching, NLI, and selective LLM-as-judge checks |
| Cost per query spikes | Every query hits the hot web tier | Route only freshness-marked queries there and cache stable answers |
| Retrieval misses API versions or build IDs | Dense search runs without a keyword lane | Hybrid sparse + dense with BM25 for exact matches |
| Decode GPUs look fine, HBM is full | Capacity used TTFT concurrency for KV residency | Size KV from end-to-end in-flight answers, not first-token sessions |