Embeddings and Semantic Search

Semantic Search & Retrieval Pipelines


An electronics retailer replaces its keyword search with a vector index. The demo is convincing: a shopper types "something to keep my laptop cool" and gets cooling pads and stands, none of which share a word with the query. The old search returned nothing at all. Ship it.

Three weeks in, the support queue fills with a single complaint. Customers typing the exact model number XR-4400 — printed on the box, on the invoice, on the warranty card — get five vaguely related monitors and not the XR-4400. Meanwhile the head of compliance discovers that a search for "internal pricing" from a customer account is returning staff-only documents, because relevance ranking pushed them into the top five and nothing checked whether the user was allowed to see them.

Neither failure is a bug in the index. The index did exactly what it was asked: return the five nearest vectors. The problem is that "return the nearest vectors" is one stage of a search system, and this one had no other stages. Embeddings blur precise tokens — an alphanumeric SKU carries almost no semantic signal, so XR-4400 lands somewhere in the neighbourhood of "product identifier"-shaped strings rather than on the exact item. And a similarity score has no concept of permission.

A vector index answers "what is most similar?". A search system has to answer "what is most useful, that this person is allowed to see, right now" — and those are different questions.

The stages between a keystroke and a resultNormalise the query — carefully, not aggressivelyRetrieve broadly: dense top 50, BM25 top 50Fuse the two lists into one rankingRe-rank the survivors with a cross-encoderFilter for correctness: stock, region, permissions
Re-ranking can only reorder what retrieval handed it, so recall is bought in stage two and precision in stage four — and filtering is the one stage that is about correctness rather than relevance.

The stages between a keystroke and a result

Text
  raw user query        |  [1] normalise        trim, collapse whitespace, enforce token limit        |  [2] encode           SAME model that built the index - no exceptions        |  [3] retrieve         vector search -> top 50-200 candidates (optimise RECALL)        |  [4] fuse             blend with keyword scores for exact-match queries        |  [5] filter           hard constraints: permissions, stock, date, tenant        |  [6] rerank           cross-encoder over survivors (optimise PRECISION)        |  [7] format           scores, snippets, highlights, empty-state handling        |  final results

The single most important structural idea here is that stages 3 and 6 optimise opposite things. Retrieval is cheap per document and slightly blunt, so you cast a wide net and care only about not missing anything. Reranking is expensive per document and very sharp, so you run it over a handful of survivors and care only about getting the order right. Every production search system worth the name has this shape.

Stage 1: normalisation, and the trap inside it

Normalisation sounds trivial and is where a surprising amount of quality leaks away. Two rules matter.

Do not lowercase, and do not strip punctuation. Most modern sentence encoders are trained on natural, cased, punctuated text, so lowercasing "Apple" to "apple" destroys a signal the model was trained to use. (A few, including all-MiniLM-L6-v2, lowercase inside their own tokenizer; for them your lowercasing simply does nothing.) Stripping punctuation turns "XR-4400" into "XR 4400" and "don't" into "dont", both of which tokenise differently and embed differently. The aggressive normalisation that helped keyword search actively harms embedding search.

Do enforce the token limit, loudly. This is the trap. all-MiniLM-L6-v2 has max_seq_length = 256 word-piece tokens, roughly 190 English words. Feed it a 900-word document and it encodes the first 256 tokens and silently discards the rest. No warning, no exception. You get a perfectly valid 384-dimensional vector representing the first quarter of the document, and every search against the other three quarters fails for reasons no log will ever show.

Python
import redef normalise(text: str, tokenizer, max_tokens: int = 256) -> str:    text = re.sub(r"\s+", " ", text).strip()      # collapse whitespace only    n = len(tokenizer.tokenize(text)) + 2         # + [CLS] and [SEP]    if n > max_tokens:        raise ValueError(f"{n} tokens exceeds limit {max_tokens} - chunk this first")    return textprint(model.max_seq_length)      # 256 - check this for YOUR model, it varies

Raising an error rather than truncating forces the real decision: split long documents into overlapping chunks and index each one separately. Truncation is not a smaller version of chunking. It is data loss with a plausible-looking vector on top.

Assembling the retrieval core

Python
import faissimport numpy as npfrom sentence_transformers import SentenceTransformerclass SemanticSearch:    def __init__(self, model_name="all-MiniLM-L6-v2"):        self.model_name = model_name              # store it - the index depends on it        self.model = SentenceTransformer(model_name)        self.documents, self.metadata, self.index = [], [], None    def index_documents(self, documents, metadata=None):        self.documents = documents        self.metadata = metadata or [{} for _ in documents]        emb = self.model.encode(            documents, batch_size=64, normalize_embeddings=True,            show_progress_bar=True,        ).astype("float32")        self.index = faiss.IndexFlatIP(emb.shape[1])   # IP on unit vectors = cosine        self.index.add(emb)    def retrieve(self, query, top_k=50):        q = self.model.encode(query, normalize_embeddings=True)        scores, ids = self.index.search(q.astype("float32").reshape(1, -1), top_k)        return [            {"doc": self.documents[i], "meta": self.metadata[i],             "id": int(i), "score": float(s), "rank": r + 1}            for r, (i, s) in enumerate(zip(ids[0], scores[0])) if i >= 0        ]

Three deliberate choices in that class. Normalising at encode time and using IndexFlatIP means the returned numbers are cosine similarities already, running from 0 to 1 with higher meaning better — no conversion, no chance of reading a distance backwards. The if i >= 0 guard matters because FAISS pads short result sets with -1, and self.documents[-1] quietly returns the last document instead of raising. And top_k defaults to 50, not 5, because this is the recall stage: it feeds later stages rather than the user.

Retrieve broadly, rerank narrowly

The model that built your index is a bi-encoder: it encodes the query and each document separately, then compares the two vectors. That separation is precisely what makes search fast — every document vector is computed once, offline, and reused forever. It is also the ceiling on precision, because the model never sees the query and the document at the same time. It cannot notice that the query asks about refunds after 30 days and the document is about refunds within 30 days.

A cross-encoder does the opposite. It concatenates query and document into one input, runs a full transformer over the pair, and outputs a single relevance score. It can attend to the word "after" against the word "within". It is also unusable at scale, for a simple reason: nothing can be precomputed. Every (query, document) pair needs its own forward pass.

Bi-encoder (retrieval)Cross-encoder (reranking)
Sees query and document togetherNoYes
Document work precomputableYes, once, offlineNo, ever
Throughput on CPUMillions of comparisons/second~800 pairs/second
Cost over 1M documents~30 ms~20 minutes per query
Cost over 50 candidatesnegligible~60 ms
OutputCosine similarity, 0–1Unbounded logit, often −11 to +11

That table contains the entire argument. Reranking one million documents costs twenty minutes; reranking the fifty candidates the bi-encoder already found costs sixty milliseconds. (The throughput figures are for a typical CPU and passages of about 100 words; measure your own.) For a sense of the quality gain, the Sentence Transformers model tables report MRR@10 on the MS MARCO dev set of 32.3 for a MiniLM-L6 bi-encoder (msmarco-MiniLM-L6-v3) and 39.0 for the reranker cross-encoder/ms-marco-MiniLM-L6-v2. The two were measured in different set-ups, so read the gap as a rough size, not a promise.

Python
from sentence_transformers import CrossEncoderranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")    # load ONCE, at startupdef rerank(query, candidates, top_k=5):    pairs = [[query, c["doc"]] for c in candidates]    scores = ranker.predict(pairs, batch_size=32)    for c, s in zip(candidates, scores):        c["rerank_score"] = float(s)    return sorted(candidates, key=lambda c: -c["rerank_score"])[:top_k]

Named failure mode: constructing the CrossEncoder inside the request handler. Loading the model takes 1–3 seconds; doing it per query turns a 60 ms rerank into a 2-second request and looks, from the outside, like "the reranker is slow". Load it once at process start.

Reranking cannot recover what retrieval missed

This is the constraint people most often miss. A reranker only reorders the candidate list it was handed. If the right document sat at rank 240 and you retrieved 50, no reranker will ever find it. Your recall@candidate-depth is a hard ceiling on final quality, so it is the number to measure first.

Candidates retrievedTypical recall (ceiling on final quality)Rerank latency at ~800 pairs/sVerdict
100.7113 ms29% of queries are unfixable
500.9463 msThe usual sweet spot
2000.98250 ms4 points of recall for 4× the latency
10000.9951.25 sOnly for offline or batch work

Those recall figures come from a specific corpus and you must measure your own, but the shape holds everywhere: recall climbs steeply then flattens, latency climbs linearly and never flattens. Somewhere around 50 to 100 candidates the two curves cross.

Hybrid search: giving exact matches a route back in

Return to XR-4400. An embedding model has seen that token roughly never during training, so it decomposes into subword pieces carrying almost no meaning, and the resulting vector points into a bland region of the space. Semantic search genuinely cannot solve this. Keyword search solves it trivially, because exactly three documents in the corpus contain that string.

BM25 is the standard keyword ranking function, and it is worth understanding rather than importing blindly:

BM25(q,D)=∑t∈qIDF(t)⋅f(t,D) (k1+1)f(t,D)+k1(1−b+b⋅∣D∣avgdl)\text{BM25}(q, D) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, D)\,(k_1 + 1)}{f(t, D) + k_1\left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}

where f(t,D)f(t,D) counts occurrences of term tt in document DD, ∣D∣|D| is the document length, avgdl the average length, and k1k_1 and bb are tuning constants. b=0.75b = 0.75 is standard everywhere; for k1k_1, Lucene and Elasticsearch default to 1.2 and the Python rank_bm25 package to 1.5, which is what this lesson uses. The inverse document frequency is

IDF(t)=ln⁡ ⁣(N−nt+0.5nt+0.5+1)\text{IDF}(t) = \ln\!\left(\frac{N - n_t + 0.5}{n_t + 0.5} + 1\right)

This is the Lucene form. rank_bm25's BM25Okapi leaves out the +1+1, so a word found in more than half the documents gets a negative IDF, which the package replaces with a small positive floor. The rare-word numbers below barely change; common words score slightly differently.

Work it on the retailer's catalogue: N=1000N = 1000 products, average description length 100 words.

Why rare tokens dominate

For XR-4400, appearing in nt=3n_t = 3 documents:

IDF=ln⁡ ⁣(1000−3+0.53+0.5+1)=ln⁡(285+1)=ln⁡(286)=5.656\text{IDF} = \ln\!\left(\frac{1000 - 3 + 0.5}{3 + 0.5} + 1\right) = \ln(285 + 1) = \ln(286) = 5.656

For the word "the", appearing in 950 documents:

IDF=ln⁡ ⁣(50.5950.5+1)=ln⁡(1.0531)=0.0518\text{IDF} = \ln\!\left(\frac{50.5}{950.5} + 1\right) = \ln(1.0531) = 0.0518

The rare identifier is weighted 109 times more heavily than the common word. This is exactly the property embeddings lack, and exactly why blending the two recovers the failing case.

Frequency saturates, length penalises

Take a product page containing XR-4400 twice, 80 words long. The length term is k1(1−b+b∣D∣/avgdl)=1.5(0.25+0.75×0.8)=1.5×0.85=1.275k_1(1 - b + b|D|/\text{avgdl}) = 1.5(0.25 + 0.75 \times 0.8) = 1.5 \times 0.85 = 1.275, so

score=5.656×2×2.52+1.275=5.656×53.275=5.656×1.527=8.64\text{score} = 5.656 \times \frac{2 \times 2.5}{2 + 1.275} = 5.656 \times \frac{5}{3.275} = 5.656 \times 1.527 = 8.64

Occurrences of the termBM25 contributionGain over previous row
16.22—
28.64+2.42
1012.54+3.90
10013.96+1.42
∞14.14the hard ceiling, IDF×(k1+1)\text{IDF} \times (k_1{+}1)

That ceiling is the anti-keyword-stuffing property: repeating a term 100 times instead of 10 buys almost nothing. And length normalisation bites too — the same two occurrences in a 400-word description give 1.5(0.25+3.0)=4.8751.5(0.25 + 3.0) = 4.875, so the score falls to 5.656×5/6.875=4.115.656 \times 5/6.875 = 4.11, less than half. A term is more meaningful in a short document.

Fusing the two score lists

Cosine similarities live in 0 to 1. BM25 scores are unbounded and, as just shown, routinely reach 14. Adding them directly means BM25 wins every time. The naive fix is min-max normalisation followed by a weighted blend:

Python
import numpy as npfrom rank_bm25 import BM25Okapidef minmax(x):    x = np.asarray(x, dtype="float64")    span = x.max() - x.min()    return np.zeros_like(x) if span == 0 else (x - x.min()) / spandef blend(sem_scores, bm25_scores, alpha=0.6):    return alpha * minmax(sem_scores) + (1 - alpha) * minmax(bm25_scores)

It works, and it has a real weakness: min-max normalisation is defined by the single highest score, so one outlier compresses everything else towards zero. With BM25 scores of [8.64, 0.05, 0.02, 0, 0], normalisation gives [1.0, 0.006, 0.002, 0, 0] — the second and third documents are now indistinguishable from irrelevant ones, even though they did match something.

Reciprocal Rank Fusion avoids the problem by discarding scores entirely and using only ranks:

RRF(d)=∑i1k+ranki(d),k=60\text{RRF}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)}, \qquad k = 60

Python
def rrf(rank_lists, k=60, top_n=10):    """rank_lists: list of lists of doc ids, each already ordered best-first."""    scores = {}    for ranking in rank_lists:        for position, doc_id in enumerate(ranking, start=1):            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + position)    return sorted(scores, key=scores.get, reverse=True)[:top_n]

Three documents, worked out:

DocumentSemantic rankBM25 rankRRF scoreFinal
Z221/62 + 1/62 = 0.032261st
Y521/65 + 1/62 = 0.031512nd
X1301/61 + 1/90 = 0.027503rd

Document X was the top semantic hit and still finishes third, because RRF rewards agreement between retrievers over a single strong opinion. The constant k=60k = 60 flattens the difference between ranks 1 and 2 so that no single system can dominate on its own. RRF needs no tuning, no score calibration and no per-corpus alpha, which is why it has quietly become the default fusion method in production search.

Weighted blending needs a tuned alpha for every corpus and query mix; rank fusion needs nothing. Start with RRF, and only reach for a weighted blend when you have measurements showing one retriever really should count more than the other.

Filtering: correctness, not relevance

The compliance failure at the start has a precise cause: the filter was applied in application code, after retrieval.

Python
# WRONG - post-filterhits = collection.query(query_texts=[q], n_results=10)visible = [d for d, m in zip(hits["documents"][0], hits["metadatas"][0])           if m["visibility"] == "public"]        # often ends up with 2 of 10# RIGHT - pre-filter, evaluated inside the queryhits = collection.query(    query_texts=[q],    n_results=10,    where={"$and": [{"visibility": {"$eq": "public"}},                   {"region": {"$in": ["EU", "UK"]}}]},)

Post-filtering produces two distinct failures. The visible one is result starvation: ask for 10, discard 8 as ineligible, show 2, while thousands of eligible documents sit unexamined further down the ranking. The user sees a nearly empty page and concludes search is broken. The dangerous one is that the retrieval, the logs and often the response payload all touched documents this user should never have been near. A permission check that runs after the data has been fetched is not a permission boundary.

Anything that determines whether a user is allowed to see a document belongs in the query, not in a list comprehension after it. Relevance ranking is not an access control mechanism.

One honest caveat: pre-filtering over a graph index is not free. If a filter matches only 40 documents out of two million, the index has very few eligible neighbours to traverse and recall degrades, or the engine falls back to scanning. When filters are that selective, resolve the eligible set through a conventional database index first and run vector search only over what survives.

Where this leads: grounding a language model

Text
question -> [retrieve top-k passages] -> [insert into prompt] -> [LLM] -> answer

Retrieval-augmented generation is this pipeline with a generation step bolted on the end: fetch relevant passages, paste them into a prompt as context, and ask a language model to answer using only that context. The appeal is that answers become grounded in your documents rather than in whatever the model absorbed during training.

The number that governs whether it works is retrieval recall. If recall@5 is 0.70, then in 30% of requests the passage containing the answer was never retrieved — and a language model handed five irrelevant passages does not say "I could not find this". It writes a fluent, confident, wrong answer from whatever it was given. Retrieval quality is a hard ceiling on answer quality, and no amount of prompt engineering raises it.

Which means every technique above is a RAG improvement. Reranking puts the passage that actually contains the answer at position 1 rather than 7, where a context window might cut it off. Hybrid retrieval rescues questions containing an error code or a version number. Pre-filtering stops the model from citing a document the asker was never permitted to see. Fix retrieval first; measure it in isolation, with recall@k on questions whose answers you already know, before adding a generation step that will disguise the failures.

Making it survive production

Caching, and the bug that lives inside it

Real query traffic is heavily repetitive — a modest LRU cache over a support search typically absorbs 55–70% of requests. The arithmetic is worth doing: at a 60% hit rate with a 0.4 ms cached response and a 65 ms uncached one, mean latency becomes 0.6×0.4+0.4×65=26.20.6 \times 0.4 + 0.4 \times 65 = 26.2 ms, a 2.5× improvement, and the reranker load drops by 60%.

Python
from functools import lru_cacheimport hashlib, jsondef cache_key(query, top_k, filters, user_scopes):    payload = json.dumps(        {"q": query.strip(), "k": top_k, "f": filters,         "s": sorted(user_scopes)},         # NEVER omit this        sort_keys=True,    )    return hashlib.sha256(payload.encode()).hexdigest()

Named failure mode, and it is a serious one: keying the cache on the query text alone. Two users type "quarterly revenue". The first is a finance administrator whose filtered search legitimately returns internal figures; the result is cached under the key "quarterly revenue". The second is a contractor, hits the same key, and is served the administrator's results — filters bypassed entirely, because the cache answered before any filter ran. Every input that changes what a user may see must be part of the key. Add a TTL as well, or edited documents keep serving their old text indefinitely.

What to log, so that failures are diagnosable

The most useful single metric a search system can emit is its zero-result rate: the share of queries returning nothing. It is a direct measure of users hitting a dead end, and it moves before anyone files a complaint. But the aggregate only tells you that something is wrong; the per-stage counts tell you what.

Python
import time, logginglog = logging.getLogger("search")def search(query, top_k=5, filters=None, scopes=()):    t0 = time.perf_counter()    candidates = retriever.retrieve(normalise(query), top_k=50)    t1 = time.perf_counter()    eligible = apply_filters(candidates, filters, scopes)    t2 = time.perf_counter()    final = rerank(query, eligible, top_k=top_k) if eligible else []    t3 = time.perf_counter()    log.info(        "search",        extra={"query": query, "n_retrieved": len(candidates),               "n_eligible": len(eligible), "n_returned": len(final),               "top_score": final[0]["rerank_score"] if final else None,               "ms_retrieve": round((t1 - t0) * 1000, 1),               "ms_filter": round((t2 - t1) * 1000, 1),               "ms_rerank": round((t3 - t2) * 1000, 1),               "zero_result": not final},    )    return final

Those three counts diagnose an empty result set on their own. n_retrieved = 0 means the index is empty or the query failed to encode. n_retrieved = 50, n_eligible = 0 means the filter is too tight — a permission scope, a date range, a category that no longer exists. n_eligible = 50, n_returned = 0 means your relevance threshold is rejecting everything. Without the intermediate counts, all three look identical in the logs, and you will spend an afternoon guessing.

What this means when you build something

Build the pipeline in the order that exposes problems earliest. Retrieval first, and measure recall@50 against fifty real queries with hand-labelled answers before writing anything else. That single number caps everything downstream, and if it is 0.6 no reranker, no fusion strategy and no prompt will save you — the fix is in chunking, or the model, or the data. Add reranking second, because it is the largest precision gain for the least code. Add hybrid retrieval when your logs show queries containing identifiers, and not before.

Draw a hard line between relevance and eligibility, and put it in the schema. Relevance is a score, it is fuzzy, and it belongs to the ranking stages. Eligibility is a boolean, it is not negotiable, and it belongs in the query and in the cache key. Teams that blur the two end up relying on the ranker to hide documents, which works right up until the day a query happens to rank one highly.

Instrument the stage boundaries from day one, not after the first incident. Counts in and out of each stage, timing per stage, and a zero-result flag cost about ten lines and turn every future "search is broken for this query" report into a two-minute investigation instead of a reproduction attempt against a corpus that has since changed.

And keep a small evaluation set — fifty to two hundred queries with known correct answers — in version control next to the code. Every parameter in this pipeline (candidate depth, fusion constant, rerank cut-off, similarity threshold) is a number someone will eventually want to change. With an evaluation set, changing one is an experiment with a result. Without it, it is a matter of opinion, and search quality drifts in whatever direction the last opinion pointed.