Embeddings and Vector Search: Similarity, Dimensionality, and Model Choice; Storing Text in pgvector
Understand what an embedding actually encodes through the coordinate-system analogy, get clear on the difference between cosine similarity and dot product and normalization's precondition, weigh model choice and dimensionality against a leaderboard, then write a corpus into pgvector and query its nearest neighbors.
Today's Goals
- Explain what an embedding turns into coordinates, and why cosine similarity becomes equivalent to a dot product after normalization
- List the four things you must check when choosing an embedding model: multilingual capability, dimensionality and storage cost, maximum input length, and whether it needs separate query and document prefixes
- Create a table in pgvector, insert vectors, build an index, query nearest neighbors, and read whether the query plan actually used the index
Yesterday's BM25 goes by the letter: rephrase "the file upload limit" as "attachment size" and not one term matches. Today the system gets a second eye, turning similar in meaning into near in coordinates. Once you have read this and finished the lab, scroll back to the top and tick off the three goals.
Plain-Language Walkthrough
Coordinates on a map
Look at a map. Beijing is a pair of numbers on it and Shanghai is another pair, and whether the two cities are close needs no reading of names, only measuring the distance between two points. What makes a map powerful is that it turns the unsayable matter of geographic position into two numbers you can subtract. You can even ask which three cities are nearest to Hangzhou — a hard question given only a list of names, and merely a sort given coordinates.
An embedding does the same thing, except its coordinates are not two-dimensional but hundreds or thousands of dimensions, and what they measure is not geographic distance but nearness in meaning. "How long does the recycle bin keep things" and "can I get back a file I deleted" share almost no characters, and on this map of meaning they nearly overlap; "how long does the recycle bin keep things" and "which data center is the server in" sit far apart. Turning a passage into such a set of coordinates is embedding, and the resulting set of numbers is a vector.
Why so many dimensions? Because meaning is not a position on one line. A sentence carries topic, tone, tense, professional domain, whether it is a question, and much else at once, and two coordinates cannot hold that. 1536 dimensions means the model uses 1536 numbers to describe the sentence's many facets. Those dimensions have no names — nobody can say what dimension 37 stands for, because it was trained rather than designed. That matters: a vector is not explainable, and you cannot decompose a score back into which term contributed how much as you could yesterday. That is its cost against BM25 and why BM25 came first.
One more counter-intuitive thing to settle now: a vector encodes resemblance, not correctness. Two opposite sentences — "export to PDF is supported" and "export to PDF is not supported" — sit very close in vector space, because they discuss the same thing. Expecting vectors to distinguish affirmation from negation will fail. Their job is fetching topically relevant material, and judging correctness belongs to the later generation step.
Three distances, and normalization as their precondition
With coordinates comes measuring distance. Three are common, and pgvector gives each an operator.
Euclidean distance (<->) is the most intuitive: the straight line between two points. It is affected by both direction and length — two vectors pointing identically, one long and one short, still have a non-zero Euclidean distance.
The dot product (<#>) multiplies dimension by dimension and sums. It carries two layers at once: how aligned the directions are and how long each vector is. Length is trouble here: the longer the text, the larger the model's output vector norm tends to be, so ordering by dot product systematically favors long documents — exactly the problem BM25's b parameter handles, surfacing in a new place.
Cosine similarity (with distance operator <=>) looks only at direction: treat both vectors as arrows from the origin and measure the angle. Smaller angle, more similar. It is inherently unaffected by length, which makes it the default for text retrieval.
The relationship among the three fits in one sentence: once every vector is L2-normalized (its length squeezed to 1), cosine similarity equals the dot product, and Euclidean distance becomes a monotonic function of cosine distance. The reason is simple: cosine similarity is defined as the dot product divided by the product of the norms, and with both norms at 1 the divisor is 1. That is not a mathematical game but an engineering convention that eliminates a whole class of bugs — normalize once at the embedding's exit and every operator ranks identically thereafter, with no more worrying about whether a store defaults to dot product or cosine.
export function l2Normalize(vec) {
const norm = Math.sqrt(vec.reduce((sum, x) => sum + x * x, 0))
return norm === 0 ? vec : vec.map((x) => x / norm) // return a zero vector as-is; never divide by 0
}
export function dot(a, b) {
let sum = 0
for (let i = 0; i < a.length; i += 1) sum += a[i] * b[i]
return sum
}
// After normalization, cosine similarity is the dot product; cosine distance = 1 - similarity, smaller is nearer
export function cosineDistance(a, b) {
return 1 - dot(a, b)
}import math
def l2_normalize(vec: list[float]) -> list[float]:
norm = math.sqrt(sum(x * x for x in vec))
return vec if norm == 0 else [x / norm for x in vec] # return a zero vector as-is; never divide by 0
def dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b))
def cosine_distance(a: list[float], b: list[float]) -> float:
"""After normalization, cosine similarity is the dot product; cosine distance = 1 - similarity"""
return 1 - dot(a, b)More dimensions is not better
Leaderboard models run to 1536 or 3072 dimensions, which easily suggests that more dimensions is more accurate. Do the arithmetic first.
Thirty documents at 1536 dimensions and 4 bytes per dimension is under 200 KB, storable anywhere. Now a million-chunk knowledge base: 1,000,000 times 1536 times 4 bytes is about 6 GB. Those 6 GB are not only disk — an approximate nearest neighbor index such as HNSW needs the graph structure and the vectors in memory to be fast, so it is essentially one machine's memory budget. Dimensionality decides that bill directly.
Dimensionality affects two other things. One is computation: each comparison is a few thousand multiply-adds, so doubling the dimensions roughly doubles retrieval latency. The other is quality, with diminishing returns: going from 256 to 512 usually shows a clear gain, while 1536 to 3072 often buys very little for double the storage and latency.
The good news is that mainstream models now support Matryoshka representation: training packs the most important information into the leading dimensions, so you can truncate a 1536-dimensional vector to 512 and renormalize, and it still works. That is what text-embedding-3-small's dimensions parameter does — not a separately trained small model but the same vector with its tail cut. Note two things: truncation is inherently lossy and how much requires an evaluation on your own data (day 8's job); and every vector in an index must have the same dimensionality, so changing it midway means rebuilding the whole store.
Four questions for choosing a model
Facing a leaderboard, the four questions below are enough, without agonizing over rank.
First, language. Is your corpus Chinese, mixed Chinese and English, or multilingual? A model topping an English leaderboard may be quite ordinary at Chinese retrieval. Read the multilingual retrieval column rather than the overall score.
Second, dimensionality and storage. The arithmetic from the last section. While you are there, confirm whether the model supports reduced dimensions; if not, you are locked to its native dimensionality.
Third, maximum input length. Every model has a token limit, and exceeding it either errors or truncates silently — silent truncation is the worse kind, because you believe the whole document was encoded when the second half never entered the vector. This constrains directly how large day 4's chunks may be.
Fourth, whether queries and documents need different prefixes. This is the most easily missed. One family of open models (the e5 series being typical) was trained on paired data and requires prefixing queries with query: and documents with passage: . Without the prefixes it still returns vectors and still computes distances, only noticeably worse, and with no error at all. Read the model card before use and confirm whether it belongs to that family.
The API or a local model: make them look the same
Concretely, computing vectors has two roads.
Call an API (this course's main line uses OpenAI's text-embedding-3-small): no model downloads, no GPU, and a batch request computes dozens at once, with essentially no engineering. The price is paying for every passage, and the initial build is the most expensive one — a hundred thousand chunks is a hundred thousand billed calls; plus an often-overlooked issue: the content leaves your gateway, and whether compliance allows that has to be asked first.
Run an open model locally (Xenova/multilingual-e5-small at 384 dimensions, say): no network, nothing leaving your gateway, no marginal cost per call. The price is managing model loading and inference speed yourself, with quality usually a notch below the leading APIs.
Those two roads should not be written twice in business code. The right approach is one interface with a swappable backend:
embed(texts: string[], opts?: { dimensions?: number }): Promise<number[][]>That signature serves all fourteen days, and every later lab copies it. Its three backends are the offline deterministic hash vector (used when MOCK=1), a local open model, and the default OpenAI API. Business code knows only this function and needs not one line changed to swap backends.
// The only backend selection point: business code gets an embed function and does not know who is behind it
export function createEmbedBackend() {
if (process.env.MOCK === '1') {
return { name: 'mock-hash', dimensions: 384, embed: mockEmbed }
}
if (process.env.EMBED_BACKEND === 'local') {
// The e5 family needs different prefixes for queries and documents; the caller composes them
return { name: 'local:e5-small', dimensions: 384, embed: localEmbed }
}
const dimensions = Number(process.env.EMBED_DIM ?? 1536)
return { name: 'openai:text-embedding-3-small', dimensions, embed: openaiEmbed }
}import os
def create_embed_backend() -> dict:
"""The only backend selection point: business code gets an embed function, not who is behind it"""
if os.getenv("MOCK") == "1":
return {"name": "mock-hash", "dimensions": 384, "embed": mock_embed}
if os.getenv("EMBED_BACKEND") == "local":
# The e5 family needs different prefixes for queries and documents; the caller composes them
return {"name": "local:e5-small", "dimensions": 384, "embed": local_embed}
dimensions = int(os.getenv("EMBED_DIM", "1536"))
return {"name": "openai:text-embedding-3-small", "dimensions": dimensions, "embed": openai_embed}That offline backend deserves a sentence more. It does not pretend a call succeeded; it is a set of deterministic hash vectors: cut the text into adjacent bigrams, FNV-1a hash each one, use the hash to decide which dimension to add to and with what sign, then normalize. The same text always gives the same vector, consistently across days. What it preserves is literal overlap, not meaning — so vector retrieval in offline mode behaves much like keyword retrieval. It validates the code path, not retrieval quality, and that sentence appears in every day's lab README.
Getting started with pgvector: create, insert, query nearest neighbors
Where do vectors live? There are plenty of dedicated vector stores, but if your data is already in PostgreSQL, the pgvector extension is often the best value: vectors and business data in one database and one transaction, permission filtering written as a plain where, and no consistency to maintain between two stores. Day 12's memory store uses it too, and day 12 of the 30-day course covers another use of the same extension.
Creating a table means adding a vector-typed column with the dimensionality fixed in the type:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunk_embeddings (
chunk_id text PRIMARY KEY REFERENCES chunks(chunk_id) ON DELETE CASCADE,
embedding vector(1536) NOT NULL,
embedding_half halfvec(1536) NOT NULL
);halfvec is the half-precision type at 2 bytes per dimension instead of 4, halving the footprint outright. The retrieval quality loss is usually small — today's lab prints both columns' top three side by side so you can see how much.
Querying nearest neighbors is ordering by distance and taking the top few, with <=> as cosine distance:
SELECT c.chunk_id, c.doc_id, e.embedding <=> $1 AS distance
FROM chunk_embeddings e
JOIN chunks c ON c.chunk_id = e.chunk_id
ORDER BY e.embedding <=> $1, c.chunk_id
LIMIT 3;Note the extra c.chunk_id after ORDER BY: two equal distances need a tie-breaker, or the ranking shifts with scan order and will not match on a rerun tomorrow.
With no index, that query is a sequential scan, which is fast enough below tens of thousands of rows. Indexing uses HNSW, and today only one thing matters — the index's operator class must match the operator in the query:
CREATE INDEX ON chunk_embeddings USING hnsw (embedding vector_cosine_ops);Build it as vector_l2_ops and query with <=> and the index is wasted; the planner simply scans the table, with no error whatsoever. How do you confirm? Read the query plan:
default -> Seq Scan on chunk_embeddings
sequential scan off -> Index Scan using chunk_embeddings_hnsw on chunk_embeddingsAt only 30 rows, the planner choosing a sequential scan is correct — building an index does not mean it will be used, and using one is slower when the row count is tiny. To confirm the index is usable at all, turn enable_seqscan off and look again; an Index Scan appearing means the operator classes match. How index parameters are tuned and how much recall drops is day 5's material; today reaches only "an index exists and it queries."
import { toSql } from 'pgvector'
// Inserting: toSql turns an array into text pgvector understands, and ::vector casts it to the vector type
export async function upsert(sql, row) {
await sql.unsafe(
`INSERT INTO chunk_embeddings (chunk_id, embedding, embedding_half)
VALUES ($1, $2::vector, $2::vector::halfvec)
ON CONFLICT (chunk_id) DO UPDATE SET embedding = EXCLUDED.embedding`,
[row.chunkId, toSql(row.embedding)],
)
}
export async function nearest(sql, queryVec, topK) {
return sql.unsafe(
`SELECT chunk_id, embedding <=> $1::vector AS distance
FROM chunk_embeddings
ORDER BY embedding <=> $1::vector, chunk_id
LIMIT ${topK}`,
[toSql(queryVec)],
)
}from pgvector.psycopg import register_vector
def upsert(conn, chunk_id: str, embedding: list[float]) -> None:
"""After register_vector you can pass a list directly and the driver converts it to pgvector's type"""
register_vector(conn)
conn.execute(
"""INSERT INTO chunk_embeddings (chunk_id, embedding, embedding_half)
VALUES (%s, %s, %s::halfvec)
ON CONFLICT (chunk_id) DO UPDATE SET embedding = EXCLUDED.embedding""",
(chunk_id, embedding, embedding),
)
def nearest(conn, query_vec: list[float], top_k: int) -> list[tuple]:
register_vector(conn)
return conn.execute(
"""SELECT chunk_id, embedding <=> %s AS distance
FROM chunk_embeddings
ORDER BY embedding <=> %s, chunk_id
LIMIT %s""",
(query_vec, query_vec, top_k),
).fetchall()At this point you hold two different retrievers: one that goes by the letter and one that goes by meaning. The lab shows where each fails — asked whether exceeding the rate limit returns 429, BM25 steadily hits the document containing 429 while the vector path ranks a topically similar product manual that never mentions 429 above it. Neither is perfect, and they fail differently, which is precisely the reason day 9 fuses the two paths.
Source Reading
Hands-On Lab
The lab's corpus/ is a complete copy of day 1's, with questions overlapping the baseline questions so you can compare the two retrievers' rankings directly. starter/ has four exercise points cut out: hash vectors, L2 normalization, cosine distance, and half-precision quantization. Run as-is, every document's distance is 0.0000 and the top three are always doc-001 through doc-003 — because the distance function is unimplemented and every document is equally near. It can all be done offline without an API key or Docker.
- Run only
MOCK=1 pnpm startfirst and observe the starter's initial state: the BM25 column is normal and every distance in the vector column is 0. - Complete the hash vector and L2 normalization, run again, and watch the vector column's distances start to differ with a top three broadly matching BM25.
- Complete cosine distance and half-precision quantization, confirming half precision takes half the space with an unchanged ranking and deviations only beyond the fifth decimal place.
- Run
docker compose up -din the lab root to bring up pgvector, setDATABASE_URL, rerun, and verify the ranking against a real database matches the in-memory version. - Read the query plan section at the end: a sequential scan by default and an HNSW index scan after
SET enable_seqscan = off, and work out why a sequential scan is correct for a 30-row table.
Interview Questions
Today's 4 questions are in the question bank below, weighted toward the mathematical preconditions of vector similarity, the relationship between dimensionality and recall quality, and the trade-offs in choosing an embedding model. Expand each one and read the analysis before the key points — practicing the derivation beats memorizing bullets. The high-frequency labels for the domestic and overseas markets are there so you can pick by target market.
Checklist and Tomorrow
- Explain what an embedding turns into coordinates, and why cosine similarity becomes equivalent to a dot product after normalization
- List the four things you must check when choosing an embedding model: multilingual capability, dimensionality and storage cost, maximum input length, and whether it needs separate query and document prefixes
- Create a table in pgvector, insert vectors, build an index, query nearest neighbors, and read whether the query plan actually used the index
- Explain why a vector cannot tell supported from not supported, and which step has to cover that
- All 5 acceptance criteria of the lab pass, including the run against a real database
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D3) we step back to a stage further upstream: how documents get in. Today we computed vectors over a clean Markdown corpus, and reality brings PDFs, web pages, and scans — multi-column layouts scramble the reading order, tables collapse into one line, and headers and footers mix into the body. Parsing quality is the ceiling on retrieval quality: text already scrambled at parse time is beyond rescue however accurate the vectors. Getting today's path running and knowing what downstream needs is what tells you tomorrow what parsing must preserve.
Interview questions
When are cosine similarity and inner product equivalent? What goes wrong if you rank by inner product on vectors that are not normalized?余弦相似度和内积什么时候等价?如果向量没有归一化,用内积排序会出什么问题?
Common in ChinaCommon overseasBasic#embeddings#similarity#normalisationHow to reason about it · think before answering
- This starts as a giveaway, but the second half is where candidates separate. Many can say 'they are equivalent after normalization'; few can describe what breaks without it.
- State the definition: cosine similarity is the inner product divided by the product of the two magnitudes. When both magnitudes are 1, the divisor is 1 and cosine reduces to the inner product. That is the whole argument.
- Then the failure mode: an un-normalized inner product mixes 'how aligned' with 'how long'. Longer texts tend to produce larger-magnitude vectors, so ranking drifts systematically toward long documents, the same bias BM25's b parameter exists to counter.
- Stress that this bug is silent. Nothing throws, results still look plausible, and only an offline evaluation reveals the drift. Hence the engineering rule: normalize once at the embedding boundary, never at each call site.
- Add Euclidean distance for completeness: on normalized vectors, squared L2 equals 2 minus twice the inner product, a monotone function of cosine distance, so all three metrics produce the same ranking.
- Expected follow-up: which pgvector operator should you use? Since the vectors are normalized, `<=>` and `<#>` rank identically; prefer `<=>` for readability and because it stays correct if someone later forgets to normalize.
分析过程 · 先想清楚再作答
- 这题是送分题,但区分度藏在后半句。只答「归一化之后两者等价」的人很多,面试官真正想听的是「没归一化会怎么坏」,因为那是线上真的会发生的事。
- 先把定义摆出来:余弦相似度等于内积除以两个向量模长的乘积。模长都是 1 时除数就是 1,所以余弦相似度就是内积——这一句话就是等价的全部理由,不需要额外的假设。
- 再说没归一化的后果:内积里混着「方向有多一致」和「向量有多长」两层信息。文本越长,模型输出的向量模长往往越大,于是排序会系统性地偏向长文档——这跟 BM25 里 b 参数要压的是同一个毛病,只是换了个地方冒出来。
- 点出这类 bug 的性质:它不报错。程序照常跑、结果照常出,只是名次悄悄偏了,你要跑一轮离线评估才可能发现。所以工程上的做法是在 embedding 的出口统一归一化一次,而不是靠每个调用点自觉。
- 补一句欧氏距离:向量都归一化之后,欧氏距离的平方等于 2 减去 2 倍内积,也就是余弦距离的单调函数,三种距离排出来的名次完全一致。这一句能说明你理解的是关系而不是三条并列的规则。
- 可预期的追问:那 pgvector 里该用哪个运算符?答案是既然已经归一化,`<=>`(余弦距离)和 `<#>`(负内积)名次一样,选 `<=>` 的理由是可读性和「就算哪天有人漏了归一化也不至于错」。
Key points
- Cosine equals inner product divided by both magnitudes; with unit magnitudes the divisor is 1, so they coincide.
- Without normalization the inner product carries magnitude, and longer documents usually have larger magnitudes, biasing the ranking.
- The failure is silent, so normalize once at the embedding boundary and verify with offline evaluation.
- On normalized vectors L2 and cosine are monotonically related, so all operators rank the same.
- In pgvector the operators are `<->` for L2, `<#>` for negative inner product and `<=>` for cosine distance.
答题要点
- 余弦相似度 = 内积 / 两个模长之积,模长为 1 时除数为 1,两者等价。
- 没归一化时内积混入模长信息,长文档的向量模长普遍更大,排序会系统性偏向长文档。
- 这类错误不报错,只能靠离线评估发现,所以要在 embed 出口统一归一化。
- 归一化之后欧氏距离与余弦距离互为单调函数,三种运算符名次一致。
- pgvector 里对应 `<->`(L2)、`<#>`(负内积)、`<=>`(余弦距离)三个运算符。
What do you lose when you cut embedding dimensions from 1536 to 512, and when is that loss acceptable?把 embedding 维度从 1536 降到 512,你会损失什么?什么场景下这个损失可以接受?
Common in ChinaCommon overseasIntermediate#embeddings#dimensions#costHow to reason about it · think before answering
- This is a cost-modeling question. 'Lower dimensions are cheaper but less accurate' earns nothing; the interviewer wants a cost model and a decision order.
- Lay out three costs: storage and memory (vector count times dimensions times bytes per dimension, which an ANN index must hold in RAM), query latency (roughly linear in dimensions), and retrieval quality, whose returns diminish sharply at the high end.
- Explain why truncation works at all: models trained with Matryoshka representations pack the most important information into the leading dimensions, so truncating and re-normalizing keeps the vector usable. It is still lossy, and how lossy is an empirical question on your own data.
- Give the decision order: derive a dimension ceiling from your memory budget, then step down two or three notches and measure the metric drop. Choosing the largest model first and optimizing cost later usually means redoing the work.
- Name the acceptable cases: large corpora of low individual value, pipelines where a reranker recovers some of the loss, and latency-critical online paths. Be conservative where a single miss is expensive, such as legal or clinical retrieval.
- Expected follow-up: can different documents use different dimensions? No. Every vector in an index must share one dimension, so changing it means rebuilding the whole index, the same migration cost as changing models.
分析过程 · 先想清楚再作答
- 这题考的是你会不会算账。只说「维度越低越省、精度越低」的答案没有区分度,面试官在等一个具体的成本模型和一个决策顺序。
- 先把三笔账列出来:存储与内存(向量数量乘维度乘每维字节数,近似最近邻索引要把它放进内存,所以基本等于机器预算)、检索延迟(每次比较就是一轮乘加,维度大致线性影响耗时)、检索质量(收益递减,低维段每加一档提升明显,高维段加倍只换来很小的改善)。
- 再说清降维为什么可行:主流模型用套娃式表示训练,重要信息压在靠前的维度上,所以直接截短再归一化仍然可用,这不是另训了一个小模型。截短必然有损失,损失多少只能在自己的数据上跑评估才知道。
- 给出决策顺序:先按存储与内存预算倒推一个维度上限,再从上限往下试两三档,看指标掉多少,掉得能接受就用低的。反过来「先选最高维再想办法省钱」基本都会返工。
- 点出可接受的典型场景:库很大而单条价值不高(比如日志、工单)、召回之后还有重排兜底(重排能把粗排的损失补回来一部分)、或者对延迟极敏感的在线场景。反过来法务、医疗这类一条都不能漏的场景就要谨慎。
- 可预期的追问:能不能不同文档用不同维度?不能——同一个索引里所有向量必须同维,改维度等于全库重建,这跟换模型是同一类迁移成本。
Key points
- Three costs: storage and index memory, query latency, and retrieval quality; the first two scale with dimensions, the third has diminishing returns.
- Matryoshka representations make truncation viable, but it is lossy and the loss must be measured on your own data.
- Decide by deriving a ceiling from the memory budget, then stepping down and measuring.
- Truncation pays off for large corpora, low-value items, latency-sensitive paths, and pipelines with a reranker.
- All vectors in one index share a dimension, so changing it forces a full rebuild.
答题要点
- 三笔账:存储与索引内存、检索延迟、检索质量,前两笔随维度近似线性,第三笔收益递减。
- 套娃式表示让截短再归一化仍然可用,但一定有损失,损失多少要在自己的数据上评估。
- 决策顺序是先按内存预算定上限,再往下试档位看指标掉多少。
- 库大、单条价值低、后面还有重排兜底、对延迟敏感的场景,降维划算。
- 同一索引里维度必须一致,改维度等于全库重建。
Why do some embedding models require different prefixes for queries and documents? What happens if you skip them, and how would you catch it before shipping?为什么有些 embedding 模型要求查询和文档加不同的前缀?不加会怎样,你怎么在上线前发现这个问题?
Common in ChinaCommon overseasIntermediate#embeddings#model-selection#evaluationHow to reason about it · think before answering
- The core of this question is silent failure. Reciting 'e5 needs query: and passage: prefixes' is the baseline; explaining why nothing errors out and how you would catch it is what shows experience.
- The reason: these models are trained on pairs, short questions on one side and longer passages on the other, two genuinely different distributions. The prefix is a role marker learned during training. Omit it at inference and you are off-distribution.
- The consequence: the model still returns vectors, distances still compute, results still have an order, quality just degrades. Nothing throws, exactly like forgetting to normalize.
- How to catch it: run a small labeled question set against the same corpus twice, with and without prefixes, and compare hit rate. That is the evaluation gate built on day 8, and catching silent regressions is precisely what it is for.
- Mention the sneakier variant: prefixing at index time but not at query time, or using the same prefix on both sides. Everything sits in one coordinate space and looks healthier, yet the query-document alignment is wrong and the loss is just as invisible. Encapsulate prefixes in the embedding call convention rather than hand-writing them everywhere.
- Expected follow-up: do OpenAI models need prefixes? No, they are not in that family, so this is not a universal rule but a per-model detail you re-check on the model card every time you switch.
分析过程 · 先想清楚再作答
- 这题的题眼是「静默失效」。会背「e5 要加 query 和 passage 前缀」只能拿基础分,能说清它为什么不报错、以及怎么在上线前抓住它,才是做过的人。
- 先讲原因:这一族模型是拿成对数据训练的,一侧是短问句、一侧是长段落,两者的分布本来就不一样。前缀是训练时给模型的角色标记,告诉它这一段该按查询编码还是按文档编码。推理时不给,模型就落在了训练分布之外。
- 再讲后果的性质:不加前缀模型照样输出向量、照样能算距离、名次照样有先后,只是整体质量下滑。**没有任何报错**——这跟忘了归一化是同一类问题:错误不会自己浮出来。
- 怎么发现:唯一可靠的办法是一小份标注问题集,用同一批文档跑两遍(加前缀与不加前缀),比命中率。这就是第 8 天要做的评估闸门,它的价值恰恰在于抓这类静默错误。上线前跑一遍,比读十遍文档管用。
- 补一个更容易踩的变体:**建库时加了前缀、查询时忘了加**,或者两边加成同一个前缀。这种情况下所有向量都在同一个坐标系里,看起来更「正常」,但查询与文档的对齐关系是错的,掉分同样查不出来。所以前缀应该封装在 embed 的调用约定里,而不是散在各处手拼。
- 可预期的追问:OpenAI 的模型要不要加前缀?不需要——它不属于这一族。所以这不是一条普遍规则,而是**每换一个模型都要重新读模型卡片确认**的事。
Key points
- These models are trained on question-passage pairs; the prefix marks which role a text plays, and omitting it puts you off-distribution.
- Skipping prefixes never errors, it only degrades quality, so the failure is silent.
- The reliable detection is an A/B run over a small labeled question set, comparing hit rate.
- A subtler bug is mismatched or identical prefixes on both sides, which looks healthier but misaligns queries and documents.
- Keep prefixes inside the embedding call convention, and re-read the model card whenever you switch models.
答题要点
- 这类模型用问句与段落的成对数据训练,前缀是区分两种角色的标记,缺了就落在训练分布之外。
- 不加前缀不会报错,只会整体掉分,属于静默失效。
- 唯一可靠的发现方式是拿一份标注问题集跑 A/B 对比命中率。
- 更隐蔽的错法是两边前缀不一致或用了同一个前缀,看起来更正常但对齐是错的。
- 前缀应封装在 embed 的调用约定里;换模型必须重读模型卡片,它不是普遍规则。
Can vector search fully replace keyword search? Give a query where vectors are bound to fail, and say how you would fix it.向量检索能完全取代关键词检索吗?举一个向量必然失手的查询,并说说你会怎么补。
Common in ChinaCommon overseasIntermediate#hybrid-search#embeddings#retrieval-failureHow to reason about it · think before answering
- This is a stance question where the stance matters less than the counter-example. Without a concrete, reproducible failing query, the rest of the answer reads as theory.
- Enumerate the failure classes up front: error and status codes, version numbers and SKUs, names and employee IDs, order or document identifiers, and negation. The first four share one property: their value lies in exact literal identity, which embeddings deliberately blur into semantic neighbourhoods.
- Give a reproducible example: ask whether rate limiting returns 429. BM25 lands on the API document that literally contains 429, while vector search may rank a topically similar product manual that never mentions the code.
- Call out negation separately: 'supports PDF export' and 'does not support PDF export' sit almost on top of each other because they discuss the same thing. Vectors cannot carry that distinction; the generation step reading the source has to.
- The fix: run both retrievers and fuse the rankings, BM25 on the lexical side and nearest neighbor on the vector side, combined with reciprocal rank fusion. That is hybrid search, covered on day 9. Fusion helps precisely because the two systems fail on different queries.
- Expected follow-up: could you drop the keyword path and rewrite queries instead? Rewriting helps with vocabulary mismatch, but it cannot rescue exact identifiers, since there is no paraphrase of 429.
分析过程 · 先想清楚再作答
- 这题是典型的「立场题」,答「能」或「不能」都不重要,重要的是你能不能举出一个具体到能复现的反例。举不出例子,前面说得再漂亮也会被判成没做过。
- 先给失手的类型,一次给全:错误码与状态码(429、E1032)、版本号与型号(v2.3.1、X20 Pro)、人名与工号、订单号与文档编号、以及否定表达。前四类的共同点是**这些词的价值在于字面唯一,而向量只保留语义邻近**,模型会把 429 和「限流」「超时」这些话题相近的东西编到一起,反而把真正写着 429 的那篇挤下去。
- 拿一个能复现的例子说:问「限流超了返回 429 吗」,BM25 稳稳命中写着 429 的接口文档,向量却可能把话题相近但没提 429 的产品手册排在前面。这个现象在本课第 2 天的实验里就能亲眼看到。
- 否定表达要单独强调:「支持导出 PDF」和「不支持导出 PDF」在向量空间里几乎重合,因为它们谈的是同一件事。指望向量区分肯定与否定一定翻车,这一层要靠生成侧读原文来判断。
- 怎么补:两路并行跑再融合,关键词一路用 BM25、向量一路用最近邻,用倒数排名融合把两个名次合成一个。这就是混合检索,本课第 9 天展开。要点是**两套的错法不一样**,所以合起来才有增益——如果两套错在同一批查询上,融合是白做的。
- 可预期的追问:那关键词一路能不能扔掉、改成让模型改写查询?可以缓解一部分(第 10 天的查询改写),但改写救不了字面唯一的标识符——你没法把 429 改写成别的说法。
Key points
- No: codes, version numbers, names and IDs matter as exact literals, which embeddings blur into neighbourhoods.
- Concrete example: asking whether rate limiting returns 429, where BM25 hits the document containing 429 and vectors surface a topically similar one that never mentions it.
- Negation is a second failure class, since affirmative and negative statements sit almost on top of each other.
- The remedy is hybrid retrieval: run both paths and merge with reciprocal rank fusion.
- Fusion pays off because the two paths fail differently; query rewriting helps vocabulary mismatch but not exact identifiers.
答题要点
- 不能取代:错误码、版本号、人名、单号这类词的价值在于字面唯一,向量只保留语义邻近。
- 具体反例:问「限流超了返回 429 吗」,BM25 命中写着 429 的文档,向量把话题相近却没提 429 的文档排前面。
- 否定表达是另一类失手:肯定句与否定句在向量空间里几乎重合。
- 补法是混合检索:两路并行再用倒数排名融合合并名次。
- 融合有增益的前提是两套的错法不同;查询改写能缓解词汇不匹配,但救不了字面唯一的标识符。