Understanding the Problem
Before you draw a single box on the whiteboard, you need to nail down what kind of search system you're building. A ranking system for e-commerce products looks nothing like one for web search or in-app document search. The latency budget, freshness requirements, and ranking signals are all different. Interviewers expect you to ask.
Start with the most important clarifying question: "Are we ranking web search results, e-commerce products, or in-app content?" For this lesson, we're designing a general-purpose web search ranking system at the scale of a major search engine. That means billions of indexed documents, real-time query traffic, and a multi-stage ranking pipeline that must return results in under 200ms end-to-end.
What is a Search Ranking System?
Product definition: A search ranking system takes a user's query and returns an ordered list of the most relevant documents from a large index, using a combination of retrieval, learned ranking models, and real-time personalization signals.
The system has two distinct lives: an offline life where documents are crawled, processed, and indexed, and an online life where queries arrive and must be answered in milliseconds. Most of the interesting engineering happens at the seam between those two worlds, where pre-computed signals meet real-time user context.
What separates a ranking system from a simple search engine is the learning component. Relevance isn't just keyword overlap. It's a function of query intent, document quality, user engagement history, and session context, all weighted by a model trained on billions of past interactions.
Functional Requirements
Core Requirements
- Query understanding: The system must handle spell correction, query expansion, and basic intent classification before retrieval begins.
- Candidate retrieval: Given a processed query, retrieve the top-K most relevant candidates from the index using a combination of BM25 keyword matching and approximate nearest neighbor (ANN) vector search.
- Multi-stage ranking: Score and re-rank retrieved candidates through at least two ranking stages: a fast L2 scorer that prunes to top-100, and a heavier L3 re-ranker that produces the final top-10.
- Result serving: Return an ordered result list with document metadata (title, snippet, URL) within the latency budget.
- Feedback logging: Log every served result alongside the exact feature vector used to score it, so user engagement events can be joined back for model training.
Below the line (out of scope)
- Crawling and raw document ingestion (we assume documents arrive via an ingest pipeline).
- Ads ranking and auction logic.
- Spelling correction model training (we treat this as a dependency).
Note: "Below the line" features are acknowledged but won't be designed in this lesson.
Non-Functional Requirements
- Latency: p99 end-to-end response time under 200ms, with retrieval completing in under 20ms to leave budget for ranking.
- Throughput: Support 10,000 QPS at peak load, with the ability to scale horizontally during traffic spikes.
- Index freshness: New documents must be searchable within 5 minutes of publication. Ranking model updates must deploy at least once daily.
- Availability: 99.99% uptime. A degraded ranking result is always preferable to a timeout, so the system must have graceful fallback to simpler scoring when the ML model is unavailable.
- Training-serving consistency: Features used at serving time must exactly match features used during training. Silent skew here is one of the most common production failure modes.
Tip: Always clarify requirements before jumping into design. Asking "what's our latency budget?" before proposing a neural re-ranker signals maturity. Proposing a transformer model and then discovering the latency budget is 50ms is a painful backtrack.
Back-of-Envelope Estimation
A few assumptions to ground the numbers: 10K peak QPS, average of 100 candidate documents scored per query at L2, and a document index of 50 billion documents with an average size of 10KB of stored features per document.
| Metric | Calculation | Result |
|---|---|---|
| Peak QPS | Given | 10,000 req/s |
| L2 scoring operations | 10K QPS × 100 candidates | 1M score ops/sec |
| Feature store reads | 1M ops/sec × ~20 features/doc | ~20M reads/sec |
| Daily queries | 10K × 86,400s (assume 50% avg load) | ~430M queries/day |
| Feature storage | 50B docs × 10KB features | ~500TB |
| Event log write throughput | 430M queries × 10 results × 200 bytes | ~860GB/day |
| Model serving bandwidth | 10K QPS × 100 docs × 1KB feature vector | ~1GB/s inbound to ranker |
The feature store read rate (20M reads/sec) is the number that should make you nervous. That's why the feature store must be an in-memory key-value store like Redis, not a relational database. And the 500TB of feature storage tells you immediately that you need sharding across many nodes, not a single machine.
The 860GB/day of event logs is also significant. That data is your training signal. Losing it, or logging it incorrectly, means your next model trains on garbage.
The Set Up
Before touching architecture, you need to nail down what the system is actually storing and moving. Interviewers will test whether you understand the data model well enough to reason about consistency, latency, and training pipelines. Get this right early and the rest of the design flows naturally.
Core Entities
Four entities drive this system. Each one maps to a distinct phase of the search lifecycle, and the relationships between them define your logging contract, your training pipeline, and your serving path. There's also a fifth table, users, that anchors identity across all of them.
Query represents a single search request. It carries not just the raw text, but the user's identity and session context. That session context is what makes personalization possible later.
Document is the indexed content unit. Critically, it holds pre-computed signals: the embedding vector for semantic retrieval, a quality score computed offline, and a freshness timestamp. These are computed before any query arrives, which is what keeps your online latency budget intact.
RankedResult is the join point between a Query and a set of Documents. The most important column here is feature_snapshot. This is the exact feature vector the model used to score this document at serve time. Without it, you cannot reconstruct training examples faithfully.
UserEvent is your feedback signal. Clicks, dwell time, conversions. These get joined back to RankedResult rows to produce labeled training data. A UserEvent without a corresponding RankedResult is useless for training, which is why the logging contract matters so much.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(255) NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
This table is intentionally minimal. In a real system it would live in an identity service and be referenced here as a foreign key anchor. Define it explicitly so the schema is self-contained and the interviewer doesn't have to ask.
CREATE TABLE queries (
query_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_text VARCHAR(1000) NOT NULL, -- original user input
user_id UUID NOT NULL REFERENCES users(id),
session_id UUID NOT NULL, -- groups queries within one session
parsed_intent JSONB NOT NULL DEFAULT '{}', -- expanded terms, detected category
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_queries_user_session ON queries(user_id, session_id, created_at DESC);
CREATE TABLE documents (
doc_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
body_embedding VECTOR(768) NOT NULL, -- dense embedding for ANN retrieval
quality_score FLOAT NOT NULL DEFAULT 0.0, -- PageRank-style authority signal
freshness_ts TIMESTAMP NOT NULL, -- last crawled or published time
metadata JSONB NOT NULL DEFAULT '{}', -- domain, author, content type, etc.
indexed_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_documents_quality ON documents(quality_score DESC);
CREATE INDEX idx_documents_freshness ON documents(freshness_ts DESC);
CREATE TABLE ranked_results (
result_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
query_id UUID NOT NULL REFERENCES queries(query_id),
doc_id UUID NOT NULL REFERENCES documents(doc_id),
position INT NOT NULL, -- 1-indexed rank in the result list
score FLOAT NOT NULL, -- final model output score
feature_snapshot JSONB NOT NULL, -- exact features used at serve time
served_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_ranked_results_query ON ranked_results(query_id, position ASC);
CREATE TABLE user_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
result_id UUID NOT NULL REFERENCES ranked_results(result_id),
user_id UUID NOT NULL REFERENCES users(id),
event_type VARCHAR(50) NOT NULL, -- 'click', 'dwell', 'conversion', 'skip'
dwell_ms INT, -- NULL for non-dwell events
occurred_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_user_events_result ON user_events(result_id);
CREATE INDEX idx_user_events_user ON user_events(user_id, occurred_at DESC);
Key insight: Thefeature_snapshotcolumn onranked_resultsis what prevents training-serving skew. If you recompute features at training time instead of replaying what was actually served, your model trains on data that never existed in production. Log the features at serve time. Always.

Documents live in two stores simultaneously: the inverted index for keyword retrieval and a vector store (FAISS or Pinecone) for semantic ANN search. Both are built offline. Queries hit both at runtime and the results get merged. This asymmetry, offline indexing versus online querying, is the fundamental architectural tension you'll be designing around.
API Design
The system exposes two external-facing endpoints and one internal logging endpoint. Keep them simple. The complexity lives in the ranking pipeline, not the API surface.
// Execute a search query and return ranked results
POST /v1/search
{
"query": "machine learning interview prep",
"user_id": "usr_abc123",
"session_id": "sess_xyz789",
"page": 1,
"page_size": 10
}
->
{
"query_id": "qry_def456",
"results": [
{
"result_id": "res_ghi012",
"doc_id": "doc_jkl345",
"url": "https://example.com/ml-prep",
"title": "ML Interview Prep Guide",
"snippet": "...",
"position": 1,
"score": 0.94
}
],
"total_candidates": 1240,
"latency_ms": 87
}
// Log a user engagement event against a served result
POST /v1/events
{
"result_id": "res_ghi012",
"user_id": "usr_abc123",
"event_type": "click",
"dwell_ms": null
}
->
{ "status": "accepted" }
// Retrieve document metadata and current ranking signals (internal/admin)
GET /v1/documents/{doc_id}
->
{
"doc_id": "doc_jkl345",
"url": "https://example.com/ml-prep",
"quality_score": 0.87,
"freshness_ts": "2024-01-15T10:30:00Z",
"indexed_at": "2024-01-15T10:35:22Z"
}
Search uses POST rather than GET even though it's a read operation. The request body can carry session context, filters, and personalization signals that would be unwieldy as query parameters, and you avoid logging sensitive query text in server access logs.
The event logging endpoint is fire-and-forget from the client's perspective. Return 202 Accepted immediately and process asynchronously. You don't want a slow Kafka write to add latency to the user's next interaction.
Common mistake: Candidates sometimes propose returning thefeature_snapshotin the search response so the client can send it back with events. Don't do this. Feature vectors can be hundreds of dimensions. Store them server-side keyed byresult_idand look them up when the event arrives.
Interview tip: When the interviewer asks why you chosePOST /searchinstead ofGET /search?q=..., explain the session context argument and the logging concern. It shows you've thought about production realities, not just REST conventions.
High-Level Design
A search ranking system has two distinct lives: the online path that must respond in milliseconds, and the offline pipeline that continuously improves the model doing the scoring. Get either one wrong and the whole system degrades. Let's walk through both.
1) Serving a Query: From Raw Text to Ranked Results
The online path has four jobs: understand what the user meant, retrieve a candidate set, score and rank it, then apply any business rules before returning results.
Core components: - Query Processor: spell correction, query expansion, intent classification - Retrieval Layer: inverted index (BM25) + ANN vector store (FAISS or ScaNN for self-hosted; Pinecone or Milvus as managed services) - Feature Store: low-latency Redis-backed store for pre-computed document and user features - Ranking Service: ML model inference, feature assembly, result scoring - Result Aggregator: deduplication, diversity rules, final response formatting
Data flow:
- User submits query "best wireles headphones under $100" (typo included)
- Query Processor corrects to "wireless headphones under $100", expands with synonyms ("bluetooth headphones"), and classifies intent as product search
- Retrieval Layer runs BM25 against the inverted index and ANN search against the embedding store in parallel, merges results, and returns top-1000 candidate documents
- Ranking Service fetches pre-computed features for each candidate from the Feature Store (document quality score, historical CTR, freshness), assembles a feature vector per candidate, and scores all 1000 with the ranking model
- Ranking Service logs the exact feature vector and score for each document, keyed by query ID, to the
RankedResultlog store (this is the logging contract that makes offline training possible, more on that shortly) - Result Aggregator prunes to top-10, applies diversity rules (no more than 3 results from the same domain), and returns the final ranked list

The most important design decision here is the split between retrieval and ranking. You cannot run your expensive ranking model over millions of documents per query. Retrieval is about recall: get everything plausibly relevant into the candidate set fast. Ranking is about precision: score that smaller set carefully. The boundary between them is a latency budget, not a quality judgment.
Interview tip: When you introduce the Retrieval Layer, your interviewer will almost certainly ask "why not just use BM25?" Be ready. BM25 is great at exact keyword matching but misses semantic similarity entirely. A query for "noise cancelling headphones" won't retrieve a document that only uses the phrase "ANC earbuds." ANN vector search covers that gap. You want both.
The Feature Store sitting between offline and online is not optional. If the Ranking Service computes features on the fly at serving time using different logic than the training pipeline used, your model is scoring inputs it was never trained on. The features must come from the same source, written by the same pipeline.
2) Keeping the Model Improving: The Offline Training Pipeline
Every click, dwell, and conversion from the online path is a training signal. The offline pipeline's job is to turn those raw events into labeled training examples and produce a better model.
Core components: - Event Log Store: append-only store of raw UserEvents (clicks, dwells, conversions) - Training Data Builder: joins UserEvents with RankedResult feature snapshots - Feature Pipeline: batch computes document and query features, writes to Feature Store - Training Platform: trains the ranking model (LambdaMART, neural ranker) on labeled data - Model Registry: versions artifacts, handles promotion to production
Data flow:
- UserEvents stream in continuously: user clicked result at position 3, dwelled for 45 seconds, did not click result at position 1
- Training Data Builder joins each UserEvent against the
RankedResultlog store usingresult_id, pulling the feature snapshot that was logged at serve time - This join produces a labeled row: (feature_vector, label=1 for clicked / label=0 for skipped, position, query_id)
- Feature Pipeline runs nightly batch jobs to recompute document-level signals (updated PageRank, refreshed CTR aggregates) and writes them back to the Feature Store
- Training Platform consumes the labeled dataset, trains a new model, and evaluates it on a held-out validation set using NDCG
- If the new model passes quality gates, Model Registry promotes it and the Ranking Service hot-reloads the artifact without restarting

The join in step 2 is where most teams get burned. If you log only the final ranking score instead of the full feature vector, you cannot reconstruct what the model saw. The score is an output, not an input. You need the inputs.
Common mistake: Candidates often describe the training pipeline as "we collect clicks and retrain." That's missing the point. The training data is only as good as the feature snapshot logged at serving time. If you change a feature's computation logic without re-logging, your historical training data becomes inconsistent with your new serving logic. The Feature Pipeline and the serving Feature Store must stay in sync.
# Simplified training data builder join
def build_training_examples(events_df, snapshots_df):
# Join on result_id to get features at the time of serving
joined = events_df.merge(
snapshots_df[["result_id", "feature_snapshot", "position"]],
on="result_id",
how="inner"
)
# Expand the JSON feature snapshot into columns
features = pd.json_normalize(joined["feature_snapshot"])
# Binary relevance label: clicked = 1, skipped = 0
joined["label"] = (joined["event_type"] == "click").astype(int)
return pd.concat([features, joined[["label", "position", "query_id"]]], axis=1)
The position column matters. A document at position 8 that got clicked is a stronger positive signal than one at position 1, because users are less likely to reach position 8 at all. Ignoring position introduces bias into your labels. You'll address this with inverse propensity scoring in the deep dives.
3) The Feature Store: Keeping Offline and Online Consistent
This is the piece most candidates skip over, and it's the one that breaks systems in production.
Core components: - Offline Feature Pipeline: batch jobs writing to the feature store - Online Feature Store: Redis-backed key-value store, keyed by doc_id and user_id - Feature Snapshot Logger: writes the exact feature vector used at serve time to the RankedResult log store
The contract is simple: the Ranking Service never computes features from raw data at serving time. It only reads from the Feature Store. The Feature Pipeline is the single writer. This means if you want to add a new feature, you add it to the pipeline, backfill it for all documents, and only then deploy a model trained with that feature.
// Feature Store entry for a document
{
"doc_id": "d8f3a1b2-...",
"features": {
"quality_score": 0.87,
"historical_ctr_7d": 0.043,
"freshness_hours": 12,
"avg_dwell_seconds": 38.2,
"pagerank_percentile": 0.91,
"embedding_norm": 1.0
},
"computed_at": "2024-01-15T06:00:00Z",
"version": "v2024-01-15"
}
Key insight: The version field on feature store entries is not cosmetic. When you train a new model, you tag the training data with the feature version it was built from. If the serving feature store is on a different version, you block the model deployment. This is the simplest way to catch training-serving skew before it hits production.4) Keeping the Index Fresh: Continuous Document Ingestion
New documents need to be searchable within minutes, not hours. That means you cannot rely on nightly full index rebuilds.
Core components: - Document Ingest Stream: Kafka topic receiving new and updated documents - Document Processor: parsing, cleaning, embedding generation, initial quality scoring - Incremental Index Writer: appends to live index shards without taking the retrieval layer offline - Cold-Start Scorer: assigns an initial ranking score for documents with no engagement history
The flow is straightforward: a new document arrives on the Kafka topic, the Document Processor generates its embedding and computes content-based quality signals, the Cold-Start Scorer assigns an initial score based purely on content (since there's no CTR data yet), and the Incremental Index Writer adds it to the live shard. The Retrieval Layer picks it up on the next shard refresh, which should happen within a few minutes.
Cold-start is the real design challenge. A brand-new document has zero clicks, zero dwell time, zero CTR history. If your ranking model relies heavily on engagement features, new documents will never surface and never accumulate engagement. The Cold-Start Scorer breaks this deadlock by using content signals: semantic similarity to high-quality documents, source authority, title quality. Think of it as a temporary prior that gets replaced by real engagement data as it accumulates.
Interview tip: If your interviewer asks "how do you handle a new document with no engagement history," don't just say "we use content signals." Explain the feedback loop: the cold-start score gets the document into results occasionally, those impressions generate clicks or skips, those events feed back into training, and within a few days the model has real signal. The cold-start score is a bootstrap, not a permanent solution.
Putting It All Together
The full system has two loops running simultaneously. The online loop serves queries in under 200ms: Query Processor parses intent, Retrieval Layer fetches candidates from the inverted index and vector store, Ranking Service scores them using features from the Feature Store, and Result Aggregator returns the final list. Every result served gets logged with its full feature vector to the RankedResult log store.
The offline loop improves the model continuously: the Training Data Builder joins those feature snapshots with UserEvents to produce labeled training data, the Feature Pipeline recomputes document signals and refreshes the Feature Store, and the Training Platform produces a new model artifact that gets promoted through the Model Registry back to the Ranking Service.
The Feature Store is the connective tissue between the two loops. The logging contract is what makes the offline loop trustworthy. Break either one and your model trains on data that doesn't reflect what it actually saw.
At 10K QPS, the Retrieval Layer is your scaling bottleneck. You'll shard the index horizontally, with each shard handling a partition of the document corpus. The Ranking Service scales independently since it's stateless; you just add more instances behind a load balancer. The Feature Store needs to handle 10K * ~1000 candidates = 10M feature lookups per second at peak, which is why Redis with connection pooling and local in-process caching for the hottest documents is essential.
Deep Dives
The high-level design gets you through the first 20 minutes. What separates candidates at this stage is how they handle the hard questions. Every system below looks simple until you're staring at a 200ms p99 budget and a model that takes 80ms just to load features.
"How do you retrieve thousands of candidates in under 20ms and then rank them with a heavy model?"
This is the question that exposes whether you understand the fundamental tension in search: the model that produces the best rankings is almost never the model fast enough to run on thousands of documents in real time.
Bad Solution: Run your ranking model directly on all candidates
The naive approach is to take every document in your index, score each one against the query using your best model, and return the top-K. Simple, correct in theory, and completely unworkable at scale.
Even a fast GBDT model scoring 100K documents takes hundreds of milliseconds. A neural ranker with cross-attention? You're looking at seconds. And that's before you account for feature fetching, network overhead, or the fact that your index has millions of documents, not thousands.
Warning: Candidates who propose "just cache the results" as the fix here miss the point. Caching helps for repeat queries, but your tail of unique queries is enormous. You need a structural solution, not a band-aid.
Good Solution: Two-stage pipeline with BM25 retrieval and GBDT scoring
Split the problem. Use a fast, cheap retrieval method to get a manageable candidate set, then run your expensive model only on that smaller set.
Stage one (L1) uses BM25 to fetch the top-1000 documents matching the query terms. BM25 is a bag-of-words TF-IDF variant that runs against a pre-built inverted index. It's not semantically deep, but it's extremely fast, often under 5ms for a well-sharded index. You can also add ANN (approximate nearest neighbor) vector search here to catch semantic matches that BM25 misses, using a pre-computed document embedding store like FAISS or Pinecone.
Stage two (L2) takes those 1000 candidates and scores them with a gradient-boosted tree model. GBDT inference on 1000 rows with ~50 features runs in 2-5ms on a single CPU core. You prune to the top-100 here. The tradeoff is that GBDT is a pointwise scorer: it scores each document independently, without considering how documents relate to each other in the ranked list.
import xgboost as xgb
import numpy as np
def score_candidates_l2(candidates: list[dict], query_features: dict) -> list[tuple]:
"""
candidates: list of dicts with pre-fetched doc features
returns: list of (doc_id, score) sorted descending
"""
feature_matrix = np.array([
[
candidate["bm25_score"],
candidate["doc_quality_score"],
candidate["freshness_hours"],
candidate["historical_ctr"],
query_features["query_doc_cosine_sim"][candidate["doc_id"]],
candidate["title_match_score"],
]
for candidate in candidates
])
scores = l2_model.predict(feature_matrix)
ranked = sorted(zip([c["doc_id"] for c in candidates], scores),
key=lambda x: x[1], reverse=True)
return ranked[:100]
The weakness here is quality at the top of the list. GBDT doesn't model inter-document relationships. It can't tell you "these two results are near-duplicates" or "this result is better given that the user already saw that one."
Great Solution: Three-stage pipeline with a neural listwise re-ranker at L3
Add a third stage. Take the top-100 from L2 and run a neural re-ranker that sees all 100 documents simultaneously and produces a final ordering. This is listwise ranking: the model's loss function is defined over the entire ranked list, not individual document scores.
The L3 model typically uses a transformer with cross-attention between the query and each candidate document's representation. It can model position-aware signals ("given what's already ranked above this document, how useful is it?") and produce diversity-aware rankings. You run this on GPU, and with a well-distilled model you can keep it under 30ms for 100 candidates.
Your total latency budget then looks like: L1 retrieval (8ms) + feature fetch (5ms) + L2 scoring (4ms) + L3 re-ranking (25ms) + overhead (10ms) = ~52ms. That leaves you room for the query processor and result aggregation while staying well under 200ms p99.
# Simplified L3 re-ranker inference with Triton/TorchScript
import torch
def rerank_l3(query_embedding: torch.Tensor,
candidate_embeddings: torch.Tensor, # shape: [100, 768]
candidate_features: torch.Tensor, # shape: [100, 32]
) -> torch.Tensor:
"""
Returns scores for each candidate, accounting for list-level context.
Model is a transformer encoder over the candidate set.
"""
# Prepend query as CLS-like token
query_expanded = query_embedding.unsqueeze(0) # [1, 768]
sequence = torch.cat([query_expanded, candidate_embeddings], dim=0) # [101, 768]
# Self-attention over the full list
attended = l3_transformer(sequence) # [101, 768]
candidate_repr = attended[1:] # [100, 768], drop query token
# Fuse with handcrafted features and score
fused = torch.cat([candidate_repr, candidate_features], dim=-1)
scores = l3_scorer(fused).squeeze(-1) # [100]
return scores
One thing interviewers love to probe here: what happens when L3 is too slow? You need a fallback. If GPU capacity is saturated or a model version is misbehaving, you should be able to serve L2 results directly. Build that circuit breaker in from the start.
Tip: Senior candidates mention that the result cache sits in front of L2, not L3. For high-frequency queries like "iphone 15 case", you cache the full ranked list with a short TTL (minutes, not hours). This bypasses both L2 and L3 for repeat traffic, which can represent 20-30% of your query volume.

"How do we ensure the features used at serving time match those used during training?"
Training-serving skew is one of the most insidious failure modes in ML systems. Your model trains on features computed one way, then serves using features computed slightly differently, and your ranking quality silently degrades. No exceptions are thrown. No alerts fire. You just get worse results.
Bad Solution: Recompute features at serving time using the same code
The instinct is reasonable: if you use the same feature computation code in both training and serving, they should match. In practice, they don't.
Training runs in batch on historical data. Serving runs in real time on live data. Even with identical code, you get divergence from time-zone handling bugs, different library versions, data type precision differences, and the fact that some features (like "document CTR over the last 7 days") have different values depending on exactly when they're computed. A document's CTR at training time reflects the world as it was then. At serving time, it reflects now.
Warning: This is where candidates lose points. Saying "we'll use the same feature pipeline code" sounds reasonable but doesn't address the root cause. The problem isn't code divergence, it's point-in-time consistency.
Good Solution: Feature Store with pre-computed, versioned features
Separate feature computation from feature serving. A feature store has two layers: an offline store (typically a data warehouse like BigQuery or Hive) where features are computed in batch and stored with timestamps, and an online store (Redis or DynamoDB) where the latest feature values are materialized for low-latency lookup at serving time.
The offline pipeline writes features to both stores. At serving time, the ranking service fetches features from the online store by document ID. At training time, you join your labeled examples against the offline store using point-in-time lookups: "what was this document's CTR at the moment this query was served?" This prevents future data leakage and ensures training features reflect the same world state as serving features.
-- Feature store: online materialized view in Redis (conceptual schema)
-- Key: doc:{doc_id}:features
-- Value: JSON blob with versioned features
-- Offline feature table for point-in-time joins
CREATE TABLE document_features_history (
doc_id UUID NOT NULL,
computed_at TIMESTAMP NOT NULL,
feature_version INT NOT NULL,
ctr_7d FLOAT, -- click-through rate, trailing 7 days
ctr_30d FLOAT,
quality_score FLOAT,
freshness_hours FLOAT,
embedding_norm FLOAT,
PRIMARY KEY (doc_id, computed_at)
);
CREATE INDEX idx_doc_features_lookup
ON document_features_history(doc_id, computed_at DESC);
The remaining gap: you still need to know which features were actually served for each ranked result, because the online store gets updated continuously. A document's CTR at 2pm is different from its CTR at 3pm.
Great Solution: Feature snapshot logging with distribution monitoring
At serving time, after the ranking service fetches features from the online store, it logs the exact feature vector used to score each document alongside the result ID. This snapshot is immutable. When you later build training data, you join against these snapshots, not against the current feature store state. You get exact point-in-time consistency with zero ambiguity.
import json
from dataclasses import dataclass, asdict
from datetime import datetime
@dataclass
class FeatureSnapshot:
result_id: str
doc_id: str
query_id: str
served_at: str
feature_version: int
features: dict # exact values used for scoring
def score_and_log(candidates, query_id, model):
results = []
snapshots = []
for doc_id, raw_features in candidates:
result_id = generate_uuid()
score = model.predict(raw_features)
snapshot = FeatureSnapshot(
result_id=result_id,
doc_id=doc_id,
query_id=query_id,
served_at=datetime.utcnow().isoformat(),
feature_version=model.feature_version,
features=raw_features,
)
snapshots.append(asdict(snapshot))
results.append((doc_id, score, result_id))
# Async write to snapshot log (Kafka -> S3/BigQuery)
snapshot_logger.publish_batch(snapshots)
return sorted(results, key=lambda x: x[1], reverse=True)
On top of this, run a skew monitor that compares the distribution of each feature in your training dataset against the distribution of that same feature in your serving snapshots from the last 24 hours. If ctr_7d has a mean of 0.04 in training but 0.09 in serving, something changed, either your feature pipeline, your traffic mix, or your data collection. You want to catch this before it silently tanks your NDCG.
Tip: The skew monitor is what distinguishes senior candidates from mid-level ones. Anyone can describe a feature store. Fewer candidates proactively talk about how you detect when it's broken. Mention that you'd alert on Jensen-Shannon divergence exceeding a threshold per feature, and you'll get a nod from any experienced interviewer.

"How do we incorporate real-time session signals without blowing our latency budget?"
A user who just clicked three results about "Python async" and spent 90 seconds on one of them is telling you something. Your ranking model should know that. The challenge is getting that signal into the ranking path in under 10ms.
Bad Solution: Query the event database on every request
The straightforward approach: when a query arrives, look up the user's recent events from your event store, compute session features, and pass them to the ranker. Clean, simple, and too slow.
Your event store (Kafka, Kinesis, or a database) is not built for sub-10ms point lookups under high concurrency. Even with a fast database, you're adding a synchronous network call to the critical path for every single query. At 10K QPS, that's 10,000 concurrent lookups. You'll either saturate the event store or add enough latency to blow your budget.
Good Solution: Session feature cache with async updates
Decouple session feature computation from the query path. A separate session feature aggregator consumes the event stream (Kafka) and maintains a rolling window of session features per user. Every time a user clicks or dwells, the aggregator updates a Redis key for that user with the latest session state.
At query time, the ranking service does a single Redis GET by user ID. Redis p99 latency at this scale is under 2ms. The session features are slightly stale (by however long the aggregator takes to process and write, typically under 500ms), but that's an acceptable tradeoff.
# Session feature aggregator (runs as a Kafka consumer)
from collections import deque
class SessionAggregator:
def __init__(self, redis_client, window_size=5):
self.redis = redis_client
self.window_size = window_size
self.user_sessions = {} # in-memory buffer before Redis write
def process_event(self, event: dict):
user_id = event["user_id"]
if user_id not in self.user_sessions:
self.user_sessions[user_id] = deque(maxlen=self.window_size)
self.user_sessions[user_id].append({
"doc_id": event["doc_id"],
"event_type": event["event_type"],
"dwell_ms": event.get("dwell_ms", 0),
"topic_embedding": event.get("topic_embedding"),
})
session_features = self._compute_features(user_id)
self.redis.setex(
f"session:{user_id}",
ttl=1800, # 30 min TTL
value=json.dumps(session_features)
)
def _compute_features(self, user_id) -> dict:
events = list(self.user_sessions[user_id])
return {
"last_n_doc_ids": [e["doc_id"] for e in events],
"avg_dwell_ms": sum(e["dwell_ms"] for e in events) / len(events),
"session_topic_affinity": self._mean_embedding(events),
"click_count": len(events),
}
The remaining gap is personalization depth. These session features are bag-of-signals: they tell you what the user clicked, but not a rich representation of their current intent.
Great Solution: Two-tower user embedding with session context
Pre-train a two-tower model where the user tower produces a dense embedding from session signals. At serving time, the user embedding is computed by the session feature aggregator (not on the query path) and written to the user context cache alongside the scalar session features.
The ranking service fetches both: the scalar features for the GBDT L2 scorer, and the user embedding for the neural L3 re-ranker. The L3 model uses the user embedding as an additional input to its cross-attention, effectively conditioning the entire ranked list on the user's current intent.
This means personalization quality scales with your model capacity without adding latency to the critical path. The expensive user embedding computation happens asynchronously, triggered by each event. The query path just does a cache lookup.
Tip: If the interviewer asks "what if the user is new or anonymous?", the answer is graceful degradation. No session cache entry means you fall back to non-personalized ranking. You can also use coarse signals like geographic region or device type as lightweight substitutes. Proactively raising this shows you've thought about the full distribution of users, not just the happy path.

"How do we get a new document indexed and rankable within minutes of publication?"
For a news search product or a marketplace with live inventory, index freshness is a first-class product requirement. A news article published 10 minutes ago that doesn't appear in search results is a failure. The question is how you get from "document published" to "document searchable" without rebuilding your entire index.
Bad Solution: Nightly full index rebuild
Most search systems start here. A batch job runs every night, processes all documents, rebuilds the inverted index from scratch, and swaps it in. Simple, consistent, and completely wrong for freshness-sensitive use cases.
A nightly rebuild means a document published at 9am isn't searchable until the next morning. Even a rebuild every hour means 30-60 minutes of lag. And full rebuilds are expensive: for a billion-document index, a full rebuild takes hours of compute and creates significant I/O pressure on your storage layer.
Warning: Candidates who propose "just run the rebuild more frequently" are thinking about the wrong lever. Frequent full rebuilds compound the cost problem and still don't get you to minutes-level freshness.
Good Solution: Streaming ingest with incremental index updates
Treat new documents as a stream. Publishers or crawlers emit documents to a Kafka topic. A document processor consumes from that topic, parses and cleans the document, generates an embedding using a pre-trained encoder, and computes initial quality signals (title quality, content length, domain authority).
The incremental index writer then appends the new document to a "hot" index shard without touching the main index. Your retrieval layer queries both the main index and the hot shard, merging results. This gets a document searchable within 2-3 minutes of publication.
The cold-start problem is real here. A brand-new document has no CTR history, no engagement signals, no quality score derived from user behavior. Your L2 GBDT model will score it poorly because all its engagement features are zero. You need a cold-start scorer that assigns an initial ranking score based purely on content signals: title quality, content length, domain authority, semantic similarity to the query, and freshness boost.
def compute_cold_start_score(doc: dict, query_embedding: np.ndarray) -> float:
"""
Score a new document with no engagement history.
Relies entirely on content and domain signals.
"""
content_sim = cosine_similarity(doc["body_embedding"], query_embedding)
title_quality = score_title_quality(doc["title"]) # length, capitalization, etc.
domain_authority = get_domain_authority(doc["url"]) # pre-computed, cached
freshness_boost = compute_freshness_boost(doc["published_at"])
# Exponential decay: 1.0 at publish time, 0.5 after 6 hours
# freshness_boost = exp(-lambda * hours_since_publish)
score = (
0.4 * content_sim +
0.2 * title_quality +
0.25 * domain_authority +
0.15 * freshness_boost
)
return float(score)
Great Solution: Streaming ingest with engagement bootstrapping and hot/warm/cold shard architecture
The incremental approach works, but a flat "hot shard" gets unwieldy as volume grows. A better architecture uses three tiers: a hot shard for documents under 24 hours old (small, fast to query), a warm shard for documents 1-30 days old (medium, rebuilt daily), and the main cold index for everything older (rebuilt weekly or on-demand).
Documents graduate between tiers automatically. After 24 hours, a document has accumulated enough engagement data (or hasn't, which is itself a signal) to be scored by the full L2 model. It gets merged into the warm shard with its real engagement features populated. The cold-start scorer is only needed for the first 24 hours.
For engagement bootstrapping, you can also use collaborative signals from similar documents. If a new article is about "Python async programming" and similar articles have high CTR for queries about "async await python", you can borrow those signals as a prior until the new document accumulates its own history. This is essentially a content-based fallback for the engagement features your GBDT model expects.
Tip: Staff-level candidates connect index freshness to infrastructure cost explicitly. Hot shards are small and cheap to query but expensive to keep updated at high write throughput. Warm shards are rebuilt in batch, which is cheaper but introduces lag. The right architecture depends on your freshness SLA and your write volume. Articulating that tradeoff, rather than just describing the mechanism, is what gets you to the staff bar.

What is Expected at Each Level
Mid-Level
- Articulate the multi-stage pipeline clearly: L1 retrieval narrows millions of documents to thousands, L2 scoring prunes to hundreds, L3 re-ranking produces the final list. You don't need to invent this; you need to explain why each stage exists and what it costs to skip one.
- Explain why BM25 alone fails. Keyword overlap doesn't capture semantic similarity, doesn't personalize, and has no awareness of document quality or user engagement history. A mid-level candidate names these gaps and points toward learned ranking as the fix.
- Show awareness of training-serving skew as a real risk, not just a theoretical one. You should be able to say: "If the features we log at serving time don't exactly match what the model saw during training, our ranking quality silently degrades and we won't know why."
- Define the core entities and their relationships correctly. Query, Document, RankedResult, UserEvent. Know that RankedResult is the join table that makes the feedback loop possible.
Senior
- Go deep on the feature store. Specifically: how snapshot logging works, why you log the feature vector and not just the score, and how point-in-time correctness prevents label leakage in training data. Interviewers will push on this.
- Own the latency math. If you propose a neural re-ranker at L3, you need a concrete answer for how you stay under 200ms p99. GPU inference, model distillation, batching, or aggressive candidate pruning before the expensive model runs. Pick one and defend it with numbers.
- Discuss ranking objective tradeoffs without being prompted. Pointwise models are easy to train but ignore list-level context. Pairwise models capture relative preference but scale poorly. Listwise models like LambdaMART or neural rankers with cross-attention are the most expressive but require more data and compute. Know where each fits.
- Instrument the system for quality monitoring in production. NDCG and MRR offline are necessary but not sufficient. You should propose online metrics (CTR, dwell time, zero-result rate) and explain how you'd detect ranking regressions before users notice them.
Staff+
- Drive toward the position bias problem unprompted. Clicks on position 1 are not evidence that position 1 is the best result; they're evidence that it was shown first. A staff candidate brings up inverse propensity scoring or counterfactual evaluation and explains how ignoring this poisons the training data over time.
- Architect the A/B testing layer for ranking models specifically. This is harder than standard feature flags. You need to hash users consistently across the retrieval and ranking stages, ensure the control and treatment groups see different models end-to-end, and design logging so you can attribute engagement signals back to the correct model version.
- Connect index freshness requirements to infrastructure cost explicitly. Getting a document indexed in minutes instead of hours requires a streaming ingest pipeline, incremental index writes, and a cold-start scorer. Each of those is a system you have to build and operate. A staff candidate frames this as a product tradeoff: how fresh does the index actually need to be, and what is the engineering cost of each freshness tier?
- Think about the feedback loop as a long-running system, not a one-time design. How does ranking quality improve over six months? What happens when query distribution shifts? How do you retrain without introducing drift? These are the questions that separate a staff answer from a senior one.
Key takeaway: Search ranking is a feedback loop masquerading as a serving system. The retrieval and ranking pipeline gets the product working; the logging contract, feature store, and training pipeline are what make it get better over time. Candidates who design only the online path are designing half the system.
