Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Multi-tenant serving made tenant identity, cost, and visibility part of every request. Large language model (LLM)-powered search applies that discipline to evidence: retrieval, ranking, caches, citations, and generated claims must all respect freshness and authorization before the model writes an answer.
You're the Staff AI Engineer at CodeAtlas. Developers type questions such as "can service accounts rotate OAuth tokens every 90 days?" and "what happens if parser-v2 deprecation affects our SDK migration?" A generative search experience must retrieve allowed API docs, release notes, and workspace policies, synthesize a cited answer, and keep private roadmaps and incident notes outside unauthorized contexts.
An LLM-powered search engine combines retrieval, ranking, synthesis, and citation into a user-facing answer system. The core engineering challenge is balancing retrieval and generation latency with factual accuracy, freshness, and workspace isolation.
Building a search engine that generates answers rather than just lists of links requires orchestrating retrieval, reranking, and LLM synthesis inside an interactive latency budget. This CodeAtlas case study designs that system around evidence and measurable service objectives.
If the ideas of embeddings or chunking feel rusty, here's a quick reminder: embeddings turn sentences into dense vectors so similar meanings sit close together in math space, and chunking splits long documents into short, self-contained passages so retrieval can grab the right snippet. Both are prerequisites for everything that follows.
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.
From keywords to reasoning
Traditional search points users to candidate documents. LLM-powered search retrieves API docs, policy pages, release notes, and incident records, then returns a cited answer with a support state. This relies on Retrieval-Augmented Generation (RAG)[1], which conditions generation on retrieved evidence rather than treating model memory as the source of truth.
Traditional search leaves synthesis to the user. Generative search performs that synthesis, which means it also owns citation binding, abstention, and authorization failures.
This capstone uses a practical answer-engine architecture: retrieve with keyword and embedding signals, rerank candidates, apply visibility and freshness policy, then synthesize a short cited answer whose claims can be checked.
Traditional vs LLM search
| Feature | Traditional Search | Generative Search |
|---|---|---|
| Output | Ranked links or snippets | Answer plus cited evidence |
| Interaction | Query and result navigation | Answer and optional follow-up |
| User validation | User opens sources directly | User must be able to inspect cited sources |
| Source tracking | Source 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 system must deliver
Before selecting components, define performance and functional constraints. These are example SLOs for this interactive developer-product case study, not universal constants.
| 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 |
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.
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 ingest path, an "AI search engine" quickly becomes a prompt wrapper around stale data.

Tracing a concrete query
Follow the same question through every stage of the pipeline: "Which deprecation policy applies to parser-v2 SDK migration for service accounts?"
| 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 keyword-scoring algorithm) pulls the exact "Parser v2 Deprecation" and "Service Account Migration" pages from the doc store. Dense retrieval finds a related FAQ about legacy SDK workflows. |
| Reranking | The cross-encoder scores the candidate passages and keeps the top 3 that mention both parser-v2 and service accounts. |
| Synthesis | The LLM receives the packed passages with source IDs and writes a two-sentence answer with inline citations like [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. |
This trace will reappear as we look at each component in detail.
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 must stay fast: plan, retrieve, rerank, pack evidence, synthesize, and verify.
Inside the pipeline
1. Query understanding
The first step is classifying intent and deciding whether retrieval needs rewriting. A large synthesis model can perform this task, but using it on every query adds measured latency and cost that many simple routes don't need.
A common approach uses specialized, smaller models or encoder-class classifiers for routing, rewriting, and decomposition. Benchmark the chosen planner on your hardware and traffic shape; its job is to distinguish a simple fact lookup from an exploratory or freshness-sensitive request and attach retrieval hints such as "prefer exact matches" or "fresh data required."
One useful trick is HyDE (Hypothetical Document Embeddings): generate a hypothetical relevant document, embed that document, and use its vector as an alternate dense-search query.[2] The generated text may contain false details, so HyDE is a retrieval technique, not answer evidence. 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 only for verbalization. Treating every request like unstructured RAG adds latency and often makes numeric answers less reliable.
| 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 |
| 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 |
This Python code structures a low-latency query planner. It takes the raw user query as input and returns a structured execution plan, including classified intent and decomposed sub-queries. In production, the decision can come from a small classifier, rules plus embeddings, or a compact model rather than a frontier synthesis model.
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 return QueryPlan(
38 intent="exploratory",
39 route="hybrid_search",
40 sub_queries=[user_query],
41 complexity="moderate",
42 )
43
44plan = understand_query("Which deprecation policy applies to parser-v2 SDK migration for service accounts?")
45print(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.
2. Multi-stage retrieval
We use a hybrid retrieval strategy to broaden candidate recall before refining for precision. A single retrieval method is rarely sufficient for complex queries. Relying only on keyword search might miss documents that use synonyms, while relying entirely on dense vector embeddings (encoding data as points in a high-dimensional space so related concepts are near each other) can overlook exact matches for specific names, product IDs, or acronyms.
By designing a multi-stage pipeline, we capture the best of both approaches. The first stage runs sparse retrieval and dense retrieval in parallel, then fuses the ranked lists with Reciprocal Rank Fusion (RRF)[3]. The second stage applies a more expensive reranker (often a cross-encoder or late-interaction model[4]) to a much smaller candidate set, passing only the most relevant chunks or parent passages to the LLM.
This visual outlines the flow of this multi-stage retrieval process.

Why hybrid retrieval?
BM25 (Best Match 25, a reliable keyword search algorithm) excels at exact keyword matching; dense retrieval captures semantic similarity. Dense retrieval typically uses bi-encoders (which encode queries and documents separately for fast lookup), though more advanced systems may use architectures like ColBERT (Contextualized Late Interaction over BERT) for late interaction between query and document tokens.[4] On a mixed workload, combining retrievers can surface candidates that either one would miss alone.
In practice, hybrid search isn't just "run two retrievers." You also need rank fusion, chunking, and evidence assembly. A common pattern is to embed short child chunks for recall, then retrieve the larger parent passage for synthesis so the model sees enough surrounding context to answer cleanly.
Why fuse on rank instead of raw score? BM25 and dense-similarity scores have different scales, so 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; treat that value as a starting point to validate, not a universal optimum.[3] Apply authorization filtering before fusion so a high-ranked forbidden document never becomes generation context.
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 = reciprocal_rank_fusion([allowed_ranked(sparse_ranked), allowed_ranked(dense_ranked)])
19assert "private-roadmap" not in fused
20print("authorized_fused_docs:", fused)1authorized_fused_docs: ['deprecation', 'rotation']Freshness and index tiers
Real search products rarely use one monolithic index. 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. A common pattern is to combine retrieval score with time decay, then let the reranker decide whether the newer document is better. If you rank purely by recency, a low-quality post from five minutes ago can outrank the canonical source.
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; validate them 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 decision explicit when you budget TTFT.
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.
3. LLM synthesis with grounded 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.
Grounding isn't just a prompt problem. Evidence packing matters too. Put the strongest chunks first, leave room for the model to answer, and avoid stuffing dozens of mediocre passages into the middle of a huge prompt. Long-context models still show "lost in the middle" behavior, where evidence in the center of the context window gets used less reliably.[5] That's one more reason reranking and tight top-K beat dumping everything into a large context window.
The citations a user sees can be a subset of retrieved evidence, but each visible citation must bind to a specific claim and its source span. Don't treat a source list as 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 the authorized, reranked evidence is still too large for the prefill budget. Query-aware prompt compression is another 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.[6] Its paper reports compression ratios as high as 20× on evaluated tasks with limited performance loss, but that number isn't a service guarantee. Search evidence needs its own support and answer-quality evaluation 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 uncompressed 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. A smaller prompt isn't a win if the correct exception or negation disappears.
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. The next deterministic example represents a support gate around generated claims: a real synthesizer can draft text, while publication depends on evidence checks.
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.
4. Streaming architecture
For one-way progressive delivery, Server-Sent Events (SSE) work through the browser EventSource API over HTTP and are straightforward to render[7]. Streaming creates a product-policy choice: either hold factual claims until verification completes, or label streamed text as provisional and update or retract unsupported claims.

Streaming endpoint
This event contract fits a low-risk answer path that permits provisional rendering. It emits the plan and sources, declares pending support, streams text, then attaches the result of claim checks. 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]In production, add periodic heartbeat events and configure upstream proxies not to buffer the stream. Also log state transitions so provisional output that becomes blocked can be audited.
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.
Why answers still hallucinate and how to catch them
One of the most critical challenges in search is hallucination, when the LLM generates plausible but incorrect facts. Even with RAG, models can misinterpret complex details. Strong systems usually stack multiple checks rather than betting on one verifier:
| 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[8] found that large-scale NLI methods and question-generation-plus-answering methods were strong automatic factual-consistency checks on its benchmarks, so NLI is a useful measured layer, not proof of correctness.
This function is deliberately only a lexical precheck. It can cheaply reject a missing phrase; it can't label entailment. Claims that pass it 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.
Skills to defend in review
| Skill | What good looks like |
|---|---|
| Offline vs online split | You separate ingestion, cleanup, chunking, and index refresh from the latency-sensitive query path. |
| Multi-stage query path | You can explain planning, hybrid retrieval, reranking, evidence packing, synthesis, and verification as distinct stages. |
| Recall improvements | You know when to use query rewriting, decomposition, routing, and HyDE-style hypothetical documents. |
| Citation verification | You check claims against cited sources with span checks, NLI, and selective fallback regeneration. |
| Streaming UX | You design progressive rendering with SSE events for plan, sources, text, and verification state. |
| Latency budget | You allocate TTFT across planning, retrieval, reranking, prefill, and generation instead of treating "LLM latency" as one number. |
| Serving levers | You know where PagedAttention and speculative decoding help, and why they don't replace retrieval discipline. |
| Evaluation loop | You combine RAGAS-style reference-free metrics, labeled retrieval metrics, citation metrics, and online feedback. |
Production questions to answer
How do you handle queries that need real-time data? Real-time data needs its own freshness pipeline. Use a hot index for newly crawled content plus direct routes to live APIs or structured stores for exact numeric fields. When the planner marks a query as freshness-sensitive, search the hot tier first, apply recency-aware scoring, then merge with the warmer corpus so the LLM sees both new evidence and canonical background context.
How would you detect and handle hallucinated citations? Decompose the generated answer into atomic claims, attach each claim to its cited source, and verify entailment with NLI or a calibrated lighter check. If support is weak or contradictory, flag the statement, remove it, or regenerate with stricter grounding and a smaller evidence set.
What's your latency budget for each pipeline stage? For a consumer-facing experience, a reasonable example SLO is roughly 0.8-1.5 seconds to first token. Query planning often fits in 10-50ms, retrieval fan-out in 150-500ms, reranking in 50-150ms, evidence packing in 10-30ms, and synthesis in roughly 300-800ms to first token. Split synthesis into prefill and decode because prompt length drives prefill cost while answer length drives decode cost.
How do you evaluate answer quality at scale? Start with reference-free metrics such as faithfulness, answer relevance, and context relevance. Add labeled retrieval metrics such as Recall@k or nDCG on benchmark sets, plus citation precision and unsupported-claim rate. In production, track citation click-through, reformulation rate, dwell time, abandonment, and explicit user feedback.
Common design failures
- Designing everything around a single LLM call instead of specialized routers, retrievers, rerankers, and verifiers.
- Treating structured or freshness-critical queries like generic vector search.
- Ignoring latency requirements for interactive search experiences.
- Skipping rigorous citation verification and trusting prompt instructions alone.
- Treating long context windows as a replacement for reranking and evidence packing.
- Overlooking inference cost per query when scaling to millions of users.
Common mistakes when building generative search
| Symptom | Cause | Fix |
|---|---|---|
| TTFT is consistently >2 seconds | Running a frontier LLM on every query, even simple lookups | Add a lightweight router that sends factual queries to a small model or cache |
| Answers cite the wrong source | Evidence packing stuffs too many mediocre passages into the prompt | Reduce top-K after reranking and place the strongest chunks at the start and end of context |
| Hallucinations persist despite RAG | Missing claim-level verification; relying only on prompt instructions | Stack span matching, NLI, and selective LLM-as-judge checks |
| Cost per query spikes unpredictably | No freshness tiering; every query hits real-time web retrieval | Route only freshness-marked queries to the hot tier and cache stable answers |
| Retrieval misses exact API versions or build IDs | Using only dense semantic search without keyword fallback | Hybrid sparse + dense retrieval with BM25 for exact matches |
What running this costs
Building a search engine at scale requires careful cost management. Exact dollar figures drift with vendor pricing, model choice, and answer length, so reason from units of work rather than one fixed price table.

| 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 |
Before writing the formula, use scenario inputs rather than presenting provider pricing as fixed:
- Web retrieval: ~$0.002
- Reranking 200 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, which is why routing and caching need their own budgets.
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, then route cacheable or structured queries away from unnecessary synthesis and trim top-K 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. Profile the critical path before choosing an optimization.
PagedAttention improves KV-cache memory management and can increase concurrency when KV allocation is the binding constraint.[9] If decode is the bottleneck, speculative decoding can reduce latency when a draft model is accepted often enough; benchmark it for the answer shapes and target model in this service.[10]
These serving optimizations matter only after retrieval depth and prompt length are under control. If you keep sending 40 mediocre passages to the model, no amount of serving cleverness will rescue TTFT.
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), while end-to-end generation (full answer) and KV hold time are longer.
For a 1K QPS design point (the low end of the 1K to 10K QPS per-region range; size peak regions separately):
Little's Law still holds (); use for admission of streaming sessions and for decode concurrency and KV residency. The 1500 figure is not first-token-only concurrency.
1from math import ceil
2
3queries_per_second = 1000
4average_e2e_latency_seconds = 1.5
5average_output_tokens = 120
6effective_decode_tokens_per_second_per_gpu = 6000
7
8in_flight_e2e = queries_per_second * average_e2e_latency_seconds
9decode_gpus = ceil(
10 queries_per_second
11 * average_output_tokens
12 / effective_decode_tokens_per_second_per_gpu
13)
14
15assert in_flight_e2e == 1500
16print("in_flight_e2e_requests:", int(in_flight_e2e))
17print("decode_gpus_before_headroom:", decode_gpus)1in_flight_e2e_requests: 1500
2decode_gpus_before_headroom: 20Decode throughput alone is a lower bound. Twenty GPUs at 6k tok/s can emit the tokens, but 1500 concurrent contexts may bind KV HBM first. Using the multi-tenant lesson's roughly 1.22 GiB per 4K-token request as a sketch: if average prompt plus generated context is about 2K tokens (roughly 0.61 GiB each) and packing leaves 60% of each 80 GiB GPU usable for KV, then GiB of raw HBM capacity, or about 20 80-GiB GPUs before weight residency and headroom. Formula sketch:
PagedAttention improves packing; it doesn't remove the byte accounting.
The next step is to translate concurrency into throughput and memory requirements for your actual serving stack. Don't assume a universal "requests per GPU" number. Measure your own model, context length, batching strategy, and quantization settings. PagedAttention improves packing efficiency and reduces KV-cache fragmentation under concurrent load[9], but capacity still depends on empirical tokens-per-second benchmarks.
For long prompts, also budget prefill separately:
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: either shard regions, raise cache hit rates, or replicate every pool ~10× after you remeasure stage hit rates. Don't pretend the 1K QPS GPU count covers the top of the requirements range.
In practice, size the system against whichever pool saturates first: retriever GPUs, reranker GPUs, prefill workers, or decode workers. The answer model may be an expensive tier, but measured retrieval or reranking can still be the first bottleneck.
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
RAGAS[11] introduced reference-free evaluation around faithfulness, answer relevance, and context relevance. It'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. Its citation-resolution metric 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.
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.