Vector Indexes and Store Selection: HNSW vs. Inverted File, Quantization to Save Memory, Filtered Queries and Multi-Tenant Isolation
Push vector search from working to holding up under load: understand how the two index structures are built and how their parameters affect recall and latency, compress memory with half-precision and binary quantization, solve the trickiest problem — filtered queries — then give selection criteria between pgvector and a dedicated vector store.
Today's Goals
- Explain how Hierarchical Navigable Small World graphs and inverted files are each built, and what each of their tunable parameters affects
- Explain why filtered vector queries can under-recall, and state when iterative scanning versus pre-filtering each applies
- Give selection criteria for what scale and what constraints should stay on PostgreSQL, and when to move to a dedicated vector store
Yesterday you could cut documents into suitable chunks. Today handles what happens once chunks multiply: at ten thousand or a million chunks, is retrieval still fast, still accurate, and still correct once a condition is added? Once you have read this and finished the lab, scroll back to the top and tick off the three goals.
Plain-Language Walkthrough
Three hundred thousand books cannot be leafed through one by one
The query you wrote on day two essentially computes a distance from the question vector to every row's vector, sorts, and takes the top ten. With a few hundred chunks from thirty documents that is entirely fine and returns in a blink.
But its cost is proportional to the store's total size: ten times the rows, ten times the time. In our lab, twenty thousand rows at 384 dimensions on single-machine Docker gives a median latency of 5.3 milliseconds for one full comparison — tolerable-sounding, yet already six times the 0.8 milliseconds of an index scan, and that line is straight: at two million rows it becomes half a second, which the user experiences as a clear stall.
So from here retrieval changes its objective: no longer certainly finding the nearest ten, but probably finding the nearest ten within an acceptable time. Approaches of that kind are collectively approximate nearest neighbor search. Approximate is today's entire premise — every set of results you get from now on may be missing a few that belonged in it.
The metric for how many are missing is recall: of the true top ten, how many the index's top ten hit. Where does the truth come from? Turn the index off and scan the whole table; that is the ground truth. It is the foundation of all tuning work — without ground truth, every parameter you tune only changes something you cannot see. Today's lab's first exercise point is exactly that: if the query computing the ground truth also uses the index, the recall you measure is permanently 100% and you never notice.
That measuring apparatus is only a dozen lines and produces every table today:
// Ground truth: turn the index off and scan the whole table. Only that yields real recall;
// otherwise you compare the index against itself and the result is permanently near 100%
export async function groundTruth(sql, queryVector, k = 10) {
return sql.begin(async (tx) => {
await tx.unsafe('set local enable_indexscan = off')
await tx.unsafe('set local enable_bitmapscan = off')
const rows = await tx`
select chunk_id from chunk_embeddings
order by embedding <=> ${queryVector}::vector limit ${k}
`
return rows.map((row) => row.chunk_id)
})
}
// Divide by the ground truth's count, never a hard-coded k: with a filter there may be fewer than k rows
export function recallAt(returned, truth) {
if (truth.length === 0) return 1
const truthSet = new Set(truth)
return returned.filter((id) => truthSet.has(id)).length / truth.length
}async def ground_truth(conn, query_vector, k: int = 10) -> list[str]:
"""Ground truth: turn index scans off and compute over the whole table. Using index
results as truth gives permanently near-100% recall"""
async with conn.transaction():
await conn.execute("SET LOCAL enable_indexscan = off")
await conn.execute("SET LOCAL enable_bitmapscan = off")
rows = await conn.fetch(
"SELECT chunk_id FROM chunk_embeddings ORDER BY embedding <=> $1 LIMIT $2",
query_vector,
k,
)
return [row["chunk_id"] for row in rows]
def recall_at(returned: list[str], truth: list[str]) -> float:
"""Divide by the ground truth's count, never a hard-coded k: with a filter there may be fewer rows"""
if not truth:
return 1.0
hit = len(set(returned) & set(truth))
return hit / len(truth)Back to the library. Approximate retrieval admits one thing: you cannot search the whole building to find ten books and must rely on the catalog. And how the catalog is organized decides directly what you will miss.
HNSW: a layered map of acquaintances
The first catalog is the hierarchical navigable small world graph, universally abbreviated HNSW. An intimidating name holding only two ideas.
The first idea is a small world graph: connect each vector to several nearby neighbors and the store becomes a network. Finding a nearest neighbor needs no full scan — start anywhere and move each step to the neighbor closer to the target, like following a chain of acquaintances, and a few dozen steps reach the target's vicinity.
The second idea is layering. With only one network, walking from one end of the store to the other takes a long time. So stack several sparser networks above it: the top layer has few points connected far apart, like intercity rail; lower layers have denser points connected closer, like city buses. A query enters at the top, takes the express to roughly the right area, and descends layer by layer for a precise search. That is the same trick as a skip list.
pgvector turns it into two build parameters and one query parameter:
-- Building: m is how many neighbors each point connects to per layer (default 16),
-- ef_construction is how large each point's candidate neighbor list is while building (default 64)
CREATE INDEX ON chunk_embeddings
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- Querying: ef_search is the size of the dynamic candidate list during search (default 40)
SET hnsw.ef_search = 100;Three parameters govern three things, so do not confuse them: m decides how dense the graph is, with denser meaning higher recall but a larger index and slower builds; ef_construction decides how thoroughly neighbors are sought while building and affects only that one build; and ef_search is the only one adjustable per query. Changing the first two means rebuilding the index; the last takes effect immediately.
What does the trade-off curve look like? In the twenty-thousand-row lab, taking ef_search from 10 to 400 raises recall from 69.0% to 98.0% and median latency from 0.6 to 2.4 milliseconds (graph building is randomized, so your own run will differ by a point or two; read the trend, not the decimals). Note the shape: the first half is a bargain and the second half is not — going from 10 to 100 buys 26 percentage points for 0.5 milliseconds, and going from 200 to 400 buys 1 point for 0.8 milliseconds. Tuning here comes down to one sentence: find the knee.
IVFFlat: partition first, then search within a partition
The second catalog is the inverted file index (IVFFlat), whose approach is entirely different and easier to grasp.
At build time it runs k-means over all the vectors into lists clusters, each remembering its centroid. At query time it compares the question vector against every centroid, picks the nearest probes clusters, and scans only those. Rather as a library first divides into subject collections, and you first judge which collections to visit and then leaf through within them.
-- A starting point for lists: rows / 1000 up to a million rows, sqrt(rows) beyond
CREATE INDEX ON chunk_embeddings
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 20);
-- A starting point for probes: sqrt(lists). The default is 1 and is almost certainly not enough
SET ivfflat.probes = 10;The division is clean: lists decides how finely it partitions and is fixed at build time; probes decides how many clusters a query looks at and can change any time. Raise probes to equal lists and it degenerates into a full scan — and the lab produces exactly that: from 1 to 20, recall runs from 60.0% to 100.0% and latency from 0.5 to 5.3 milliseconds, with a full scan itself at 5.3 milliseconds.
Here is a pothole every beginner hits: probes defaults to 1. Build the index, tune nothing, and a query looks at one cluster with recall possibly barely over half. After building an IVFFlat index, setting probes is not an optimization but what makes it usable.
How to choose? Measured build costs are 1.5 seconds and 38.8 MB for HNSW against 0.3 seconds and 31.3 MB for IVFFlat: IVFFlat builds faster and takes less space, with higher latency at equal recall. There is a more consequential difference: IVFFlat's clusters are determined by the data at build time, so continued writes distort the clustering and require periodic rebuilds, whereas HNSW is a graph built by incremental insertion with no such problem. So static data with tight memory takes IVFFlat, and frequent writes with query quality first takes HNSW.
Quantization: swapping books for microfilm
An index saves time and quantization saves space. A 384-dimensional single-precision vector occupies 4 × dimensions + 8 bytes in pgvector, measured at 1,540 bytes per row, so twenty thousand rows make a 36.5 MB table with a 38.8 MB HNSW index — the index being larger than the table is normal in vector search. At tens of millions of rows, memory is the bulk of the bill.
The first tier is half precision (halfvec): 2 bytes per dimension instead of 4. Measured at 776 bytes per row, the index drops from 38.8 MB to 22.2 MB, and at the same ef_search = 40 recall is 90.5% against 92.0% — a difference inside the noise of a rerun, so effectively no loss. It also resolves a hard limit: vector indexes cap at 2,000 dimensions, halfvec at 4,000, and bit at 64,000 — anyone on a 3,072-dimensional model has only this road.
-- Half precision: an expression index, with no change to the column's type
CREATE INDEX ON chunk_embeddings
USING hnsw ((embedding::halfvec(384)) halfvec_cosine_ops);
SELECT chunk_id FROM chunk_embeddings
ORDER BY embedding::halfvec(384) <=> $1::halfvec(384) LIMIT 10;The second tier is binary quantization: keep only each dimension's sign, positive as 1 and the rest as 0, compressing 384 dimensions into 384 bits, measured at 56 bytes per row with the index down to 6.6 MB, nearly six times smaller. The cost is correspondingly large, so the standard usage is never taking its results directly but coarse-filtering a large batch with it and re-ranking that small batch with the original vectors:
SELECT chunk_id FROM (
SELECT chunk_id, embedding FROM chunk_embeddings
ORDER BY binary_quantize(embedding)::bit(384) <~> binary_quantize($1)::bit(384)
LIMIT 500
) coarse
ORDER BY embedding <=> $1 LIMIT 10;Add one WHERE and the results shrink
Everything so far was a warm-up; this section is where production most easily breaks.
Consider an utterly ordinary requirement: search only documents in a given department, tenant, or time range. Add a condition to the SQL. And you find something strange — the store clearly holds hundreds of matching rows and the query returns one or two.
The reason is stated plainly in pgvector's documentation: filtering on an approximate index happens after the index scan. The index first fetches ef_search candidates by distance (40 by default) and only then applies the WHERE to those 40. When matching rows are 1% of the table, on average 0.4 of those 40 survive.
We constructed that scene for real: of twenty thousand rows, one tenant has 200 (exactly 1%), and retrieval carries the tenant condition. Under the default configuration it returns 0.5 rows on average at 5.0% recall. Not a broken database and not missing data — turn the index off for a full scan and a complete ten come back immediately.
Since 0.8.0, pgvector has offered a switch called iterative scanning: when too many candidates are filtered out, it returns to the index and scans more until enough are gathered.
-- Strict order: results strictly ordered by distance, with slightly lower recall
SET hnsw.iterative_scan = strict_order;
-- Relaxed order: allows slight deviation in distance ordering in exchange for higher recall
SET hnsw.iterative_scan = relaxed_order;On the same batch of queries, relaxed_order brings recall from 5.0% back to 89.5% and strict_order to 77.5%, at the cost of median latency rising from 0.7 to around 6 milliseconds. That is the course's recurring three bills: the metric up 84 percentage points, latency up roughly ninefold, money unchanged. Whether it is worth it depends on how selective your filter is.
On the application side, both switches go into a transaction with SET LOCAL rather than being set globally, or unfiltered ordinary queries slow down too:
// Effective only within this transaction; unfiltered queries should not be slowed
export async function searchInTenant(sql, queryVector, tenantId, k = 10) {
return sql.begin(async (tx) => {
await tx.unsafe('set local hnsw.ef_search = 40')
await tx.unsafe('set local hnsw.iterative_scan = relaxed_order')
return tx`
select chunk_id, embedding <=> ${queryVector}::vector as distance
from chunk_embeddings
where tenant_id = ${tenantId}
order by distance
limit ${k}
`
})
}async def search_in_tenant(conn, query_vector, tenant_id: str, k: int = 10):
"""Effective only within this transaction; unfiltered queries should not be slowed"""
async with conn.transaction():
await conn.execute("SET LOCAL hnsw.ef_search = 40")
await conn.execute("SET LOCAL hnsw.iterative_scan = relaxed_order")
return await conn.fetch(
"""
SELECT chunk_id, embedding <=> $1 AS distance
FROM chunk_embeddings
WHERE tenant_id = $2
ORDER BY distance
LIMIT $3
""",
query_vector,
tenant_id,
k,
)Multi-tenancy: three isolations, three bills
The most common form of filtered retrieval is multi-tenancy: one system serving many customers, each able to search only their own material. Three approaches with entirely different cost structures.
The first is a filter field, the previous section's setup. It is the smallest change, and beyond under-recall it has a subtler problem: every tenant shares one graph, so tenant A loading a million rows genuinely degrades tenant B's retrieval quality and speed — B's candidate slots were taken by A. pgvector's documentation warns about this specifically.
The second is a partial index for high-traffic tenants: an index containing only that tenant's rows. The lab builds one for that 1% tenant, at only 408 KB and 0.1 seconds to build, giving 100% recall without iterative scanning and 0.4 milliseconds median latency, winning on all three metrics. The cost is elsewhere: an index predicate cannot use a bind parameter, so each tenant needs its own DDL, and a thousand tenants means a thousand indexes.
-- A partial index: the condition must be a constant, so one DDL per tenant
CREATE INDEX ON chunk_embeddings
USING hnsw (embedding vector_cosine_ops) WHERE (tenant_id = 't07');The third is list partitioning: split the table into partitions by tenant, each with its own index. That is pgvector's documented recommendation for multi-tenant isolation, giving the cleanest isolation with dropping a tenant becoming dropping a partition, at the cost of moving the complexity into DDL and operations.
The criterion is simple: few and stable tenants take partial indexes; many tenants each with modest data take a filter field plus iterative scanning; and a large customer crowding out small ones takes partitions. The three do not conflict, and many systems mix them — the largest customers in their own partitions and the long tail sharing one table.
One boundary: today covers making filtered retrieval not under-recall, and whether a given user is permitted to see a given row is a different matter, involving where permissions come from, invalidation, and incremental sync, handled on day 13.
When to move vectors out of PostgreSQL
The conclusion first: most teams' first version should stay on PostgreSQL, not because its vector search is outstanding but because of everything it saves you — transactions, backups, point-in-time recovery, permissions, joins against business tables, and the operational tooling you already know, all off the shelf. One more database means one more sync, one more consistency problem, and one more chance of being woken at night.
When a move genuinely deserves consideration, watch four lines, any one of which clearly crossed warrants a reassessment:
- Data volume. Once one table's vectors reach tens of millions and must stay resident in memory, dedicated stores' optimizations in memory layout and paging start to show a gap. The criterion is not how many rows I have but whether the index still fits in memory.
- Write frequency. Frequent inserts, updates, and deletes distort IVFFlat's clustering and continually inflate HNSW's graph. A minute-by-minute stream needs a dedicated store's incremental maintenance more than a daily batch import does.
- Filter complexity. The most practical line: when the filter is not one tenant field but an arbitrary combination of a dozen attributes, partial indexes and partitions cannot cover the combinatorics, while stores like Qdrant build filtering into the index structure itself and are inherently better suited.
- The team's operational capacity. The line in the other direction: if nobody is willing to look after a second database long term, do not move even if the first three hold.
There is one more often-overlooked road: not moving does not mean only one kind of retrieval. A keyword index, a vector index, and day 9's reranking should coexist anyway, and many "vector search is not good enough" problems have their real fix on day 9 rather than in a new store. Which embedding model and how many dimensions was settled on day two; revisit D2.
Source Reading
Hands-On Lab
Before starting, run docker compose up -d in the lab directory to bring up the database. There is no in-memory fallback today — what is being measured is the database index itself, so substituting a fake implementation measures nothing, and the script tells you how to start the database and exits if it cannot connect. MOCK=1 replaces only the one external dependency, embedding, and needs neither network nor key.
starter/ has four exercise points cut out in a considered order: get recall computed correctly first, then make the ground truth genuinely use a full scan, and only then tune and fix under-recall. Skip the first two and the latter two's numbers are all fictional — which is itself today's central methodology.
- Start the database, load twenty thousand rows, and look first at the brute-force full comparison's latency, remembering that number — it is the denominator of every later comparison.
- Complete the recall calculation and the ground truth query, rerun, and watch HNSW's recall go from "always 100%" to a real curve rising with
ef_search. - Change IVFFlat's
listsfrom a hard-coded 1 to a row-derived value, sweepprobesagain, and compare the two indexes' latency at equal recall. - Run the tenant-filtered set, first seeing the scene where under one row is returned on average, then turning iterative scanning on to fix it and recording how many times latency rose.
- Copy the space, speed, and recall columns for half precision and binary quantization into the closing selection memo, writing after each conclusion how many rows it holds for.
Interview Questions
Today's 4 questions are in the question bank below, weighted toward how approximate nearest neighbor indexes work and are tuned, under-recall in filtered queries, and vector store selection. 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 how Hierarchical Navigable Small World graphs and inverted files are each built, and what each of their tunable parameters affects
- Explain why filtered vector queries can under-recall, and state when iterative scanning versus pre-filtering each applies
- Give selection criteria for what scale and what constraints should stay on PostgreSQL, and when to move to a dedicated vector store
- Distinguish too-few-rows under-recall from enough-rows-wrong-order under-recall, and say which wrench each needs
- All 5 acceptance criteria of the lab pass, with every conclusion in the selection memo labeled with its data scale
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D6) moves the battlefield to the generation side: once material is fetched, how the context is ordered, how citations are labeled, when refusal is mandatory, and how to stream a response. Why that order? Because after today, fetching what should be fetched has a quantifiable guarantee, and only then is it fair to pursue fetched-and-answered-wrongly. A retrieval-side problem cannot be patched by generation-side means, nor the reverse — dividing the two sides' responsibilities first is what lets day 8's evaluation know which side each metric belongs to.
Interview questions
How do you choose between an HNSW index and an IVFFlat index? Give one scenario that forces each choice, and name the parameter you would tune first in each.分层可导航小世界图和倒排文件索引你会怎么选?各说一个必须选它的场景,以及各自最该调的参数。
Common in ChinaCommon overseasIntermediate#vector-index#hnsw#ivfflatHow to reason about it · think before answering
- The differentiator is not describing both structures, it is naming the condition that forces one over the other. Saying 'HNSW is faster, IVFFlat is cheaper' is what everyone says.
- Describe the structures in one line each: HNSW is a layered neighbor graph you navigate from sparse upper layers down to dense lower ones; IVFFlat clusters vectors into lists and only scans the lists closest to the query.
- Map the knobs: HNSW builds with m and ef_construction and queries with ef_search; IVFFlat builds with lists and queries with probes. Tune the query-side knob first, because it needs no rebuild and is the only one you can still move after launch.
- Give two forcing scenarios in opposite directions. Minute-level write traffic with tight memory and a short build window forces IVFFlat, since an HNSW graph keeps growing and is expensive to rebuild. A largely static corpus with a hard latency SLA forces HNSW, since it hits the same recall at lower latency.
- Add the operational detail people forget: IVFFlat clusters reflect the data at build time, so recall degrades silently as the distribution drifts and you need a scheduled rebuild. HNSW avoids that but its index is often larger than the table.
- Expected follow-up: what are the defaults? probes is 1 and ef_search is 40. Volunteer that leaving probes at 1 means scanning a single list, which is the single most common IVFFlat mistake.
分析过程 · 先想清楚再作答
- 这题的区分度不在能不能背出两种结构,而在你会不会给出触发条件。只说「HNSW 快、IVFFlat 省内存」的人一抓一大把,面试官等的是「什么情况下我必须选另一个」。
- 先用两句话把结构说清:HNSW 是分层的邻居图,查询从稀疏的上层跳到稠密的下层,逐步逼近;IVFFlat 是先聚类成若干个列表,查询时只在最近的几个列表里扫。一个是图上导航,一个是分区搜索。
- 再把参数对应上去:HNSW 建图有 m 与 ef_construction,查询有 ef_search;IVFFlat 建索引有 lists,查询有 probes。**先调查询侧参数**,因为它不用重建索引、能逐次查询调整,是唯一一个上线之后还能动的旋钮。
- 给两个反向的必须场景:数据分钟级高频写入、且内存和建索引窗口都紧张时必须选 IVFFlat,因为 HNSW 的图会持续膨胀、重建代价高;反过来,数据相对静态、查询延迟有硬性 SLA 时必须选 HNSW,因为同等召回下它的延迟更低。
- 补一条容易被忽略的工程细节:IVFFlat 的聚类是建索引那一刻的数据决定的,数据分布漂移之后召回会悄悄下滑,所以它需要一条定期重建的运维流程;HNSW 没有这个包袱,但它的索引往往比表本身还大。
- 可预期的追问:probes 和 ef_search 的默认值分别是多少?答 1 和 40,并且要主动说出 IVFFlat 默认 probes = 1 意味着只看一个列表,建完索引不设 probes 基本等于没调过——这是新手最常见的事故。
Key points
- HNSW is a layered neighbor graph; IVFFlat clusters first and scans a subset of lists. HNSW favors query quality, IVFFlat favors build cost and memory.
- Tune the query-side knob first: ef_search for HNSW, probes for IVFFlat. Neither needs a rebuild.
- Heavy write traffic with tight memory and build windows points to IVFFlat; a static corpus with a hard latency SLA points to HNSW.
- IVFFlat clusters drift with the data and need scheduled rebuilds; HNSW does not, but its index is often larger than the table.
- Know the defaults: probes 1, ef_search 40. Leaving probes at 1 wastes the index.
答题要点
- HNSW 是分层邻居图,IVFFlat 是先聚类再局部扫描;前者查询质量优先,后者建索引与内存开销优先。
- 先调查询侧参数:HNSW 调 ef_search,IVFFlat 调 probes,两者都不需要重建索引。
- 高频写入、内存与建索引窗口紧张选 IVFFlat;数据相对静态、延迟有硬性要求选 HNSW。
- IVFFlat 的聚类会随数据漂移失真,需要定期重建;HNSW 没这个问题但索引常常比表还大。
- 默认值要记住:probes 是 1、ef_search 是 40,建完索引不调 probes 等于没用上索引的能力。
Why does a vector search with a WHERE clause return fewer results than expected, and what are the fixes and their costs?为什么加了 WHERE 条件的向量检索会漏结果?有哪几种修法,代价分别是什么?
Common in ChinaCommon overseasDeep dive#filtering#iterative-scan#recallHow to reason about it · think before answering
- This is the question that separates people who ran a demo from people who ran this in production. The tell is whether you distinguish missing rows from mis-ordered rows.
- State the mechanism in one sentence: with approximate indexes, filtering is applied after the index scan. The index first collects ef_search candidates by distance, and only then applies the WHERE clause to that batch.
- Do the arithmetic out loud: a condition matching 1% of rows against a default candidate list of 40 leaves well under one row on average. That is why the query looks broken even though the rows exist.
- Split the failure into two kinds. Too few rows returned is one; enough rows but the wrong ones ranked first is the other. They have different fixes, and conflating them signals inexperience.
- Fix one is iterative scanning, available since pgvector 0.8.0: when too many candidates are filtered out, keep scanning more of the index until enough results are found. Strict ordering keeps exact distance order, relaxed ordering trades slight reordering for better recall, and both cost latency.
- Fix two is making the filter apply first: a plain index on the filter column for highly selective conditions, a partial index when there are only a few distinct values, list partitioning when there are many. The costs are losing the approximate speedup, index count exploding per value, and DDL plus operational complexity.
- Expected follow-up: how do you pick? Check the returned row count first. Too few means iterative scanning; enough rows with low recall means raising probes or ef_search, or switching to pre-filtering.
分析过程 · 先想清楚再作答
- 这题是本天的核心,也是最能筛掉「只跑过 demo」的人的一题。题眼在「漏」这个字:能不能说清楚漏的是条数还是排序,直接决定你被归到哪一档。
- 先讲机制,一句话就够:近似索引的过滤发生在索引扫描之后。索引先按距离取回 ef_search 个候选,然后才拿 WHERE 去筛这一批。条件命中率越低,活下来的越少——命中 1% 的条件配默认的 40 个候选,平均只剩零点几条。
- 然后把漏召回拆成两类,这是拿分点:一类是**结果条数不够**,十条只给了一两条;另一类是**条数够但排序不对**,十条都在只是排错了。两类的修法完全不同,混为一谈说明没真跑过。
- 修法一是迭代扫描(pgvector 0.8.0 起):候选被过滤掉太多时自动回索引里继续扫,直到凑够。它只解决第一类。两种模式的取舍要说清楚——严格顺序保证结果按距离排好,宽松顺序允许略微乱序换更高召回,代价都是延迟明显上升。
- 修法二是预过滤,即让过滤条件先生效:条件很挑剔时给过滤列建普通索引走精确检索,取值只有少数几个时建部分索引,取值很多时按值做列表分区。代价分别是失去近似索引的加速、索引数量随取值爆炸、以及 DDL 与运维复杂度上升。
- 可预期的追问:怎么判断该用哪一种?给一条可执行的判据——先看返回条数够不够。不够是第一类,先试迭代扫描;够了但召回低是第二类,只能加大 probes 或 ef_search,或者干脆改成预过滤。
Key points
- With approximate indexes the filter runs after the index scan, so a selective condition wipes out most candidates and the query returns too few rows.
- There are two failure modes: too few rows, and enough rows in the wrong order. Always check the returned count first.
- Iterative scanning fixes only the first. Strict ordering preserves distance order, relaxed ordering gives better recall, and both raise latency noticeably.
- Pre-filtering is the alternative: index the filter column for exact search, use a partial index for a few distinct values, partition by value for many. Costs are losing the approximate speedup, index sprawl, and operational complexity.
- The second failure mode is only fixed by raising probes or ef_search; iterative scanning does nothing for it.
答题要点
- 近似索引的过滤发生在索引扫描之后,条件命中率低时候选几乎被筛光,所以返回条数不够。
- 漏召回分两类:条数不够,和条数够但排序不对。判断顺序永远是先看返回条数。
- 迭代扫描只修第一类,严格顺序保序、宽松顺序召回更高,代价是延迟明显上升。
- 预过滤是另一条路:过滤列建索引走精确检索、取值少建部分索引、取值多按值分区,代价依次是失去索引加速、索引数量爆炸、运维复杂度上升。
- 第二类只能靠加大 probes 或 ef_search,迭代扫描对它完全无效。
If you switch your vectors from full precision to half precision or binary quantisation, how do you verify that recall has not dropped materially?把向量从全精度换成半精度或二值量化,你会用什么方法确认召回没有明显下降?
Common in ChinaCommon overseasIntermediate#quantization#evaluation#recallHow to reason about it · think before answering
- The question looks like it is about quantisation, but it is really about whether you know how to evaluate. Answering 'try a few queries and eyeball it' fails immediately.
- Pin down ground truth first: it must come from an exhaustive scan with the index disabled. Using index results as ground truth is the classic self-deception, because recall then looks close to 100% no matter what you changed.
- Give the procedure: fix a query set of at least a few dozen covering short and long queries across topics, compute ground truth at full precision, rerun with the quantised representation, and report recall at k. Report index size, build time, and median plus p95 latency alongside it, because recall alone is not a decision.
- Add the judgment rule: quantisation loss depends on your vector distribution, so published numbers do not transfer. Sparse vectors suffer badly under binary quantisation because only the sign bit survives and zeros collapse together.
- Land on something actionable: half precision is usually near lossless and raises the indexable dimension ceiling from 2000 to 4000, so it is a safe first step. Binary quantisation loses real recall and should be used as a cheap first pass, re-ranked with the original vectors over a wider candidate window.
- Expected follow-up: how much loss is acceptable? It depends on what comes next. With a re-ranker downstream, a couple of points off first-stage recall is usually invisible; if retrieval feeds the prompt directly, one point means one more unanswerable question per hundred. Tie the threshold to a product metric, not to a number you made up.
分析过程 · 先想清楚再作答
- 这题表面问量化,实际问的是你会不会做评估。只回答「跑几个问题看看结果对不对」的人会被直接判为没做过——面试官想听的是一套可复现的量法。
- 先把真值这件事说死:真值必须来自暴力全量比对,也就是把索引关掉、全表算距离取前 k。拿索引结果当真值是最常见的自欺,因为那样量出来的召回永远接近 100%,你会以为量化无损。
- 然后给流程:固定一批查询(几十条起步,覆盖长短查询和不同主题),先用全精度算出真值,再换量化重跑,计算召回率@k。同时记录三件事——索引大小、建索引耗时、查询延迟的中位数与 p95,只报召回是不够的。
- 补一条判据:量化损失有多大取决于向量分布,别人的数字不能抄。稀疏向量对二值量化尤其不友好,因为二值化只保留符号位,零和负数会被压成同一个值,信息几乎被抹平。所以换方案必须在自己的数据上重新量一次。
- 结论要给可操作的建议:半精度通常近乎无损,还能把建索引维度上限从 2000 提到 4000,是默认可以先上的一档;二值量化损失明显,标准用法是拿它粗筛一批候选,再用原始向量在这一小批里精排,粗筛窗口越宽召回补得越多、延迟也越高。
- 可预期的追问:召回掉了多少算可以接受?答这取决于下游——后面还有重排时,粗排召回掉两三个点通常无感;如果检索结果直接进提示词,掉一个点就意味着每一百次回答里多一次缺材料。要把这个判断挂到业务指标上,而不是拍一个阈值。
Key points
- Ground truth must come from an exhaustive scan with indexes disabled; using index output as truth pins recall near 100%.
- Run one fixed query set before and after, report recall at k together with index size, build time and latency percentiles.
- Quantisation loss depends on your own vector distribution, so measure it on your data instead of quoting benchmarks.
- Half precision is usually near lossless and raises the indexable dimension limit from 2000 to 4000, making it a safe default.
- Binary quantisation loses real recall; use it as a cheap first pass and re-rank with the original vectors over a wider window.
答题要点
- 真值必须来自关掉索引的暴力全量比对,拿索引结果当真值会让召回永远接近 100%。
- 固定一批查询,量化前后跑同一批,报召回率@k,同时报索引大小、建索引耗时和延迟分位数。
- 量化损失取决于向量分布,别人的数字不能抄,必须在自己的数据上重新量。
- 半精度通常近乎无损,还能把索引维度上限从 2000 提到 4000,可以作为默认第一档。
- 二值量化损失明显,正确用法是粗筛加原始向量重排,粗筛窗口越宽召回补得越多、延迟越高。
When should you move your vectors out of PostgreSQL into a dedicated vector database? Give measurable triggers, and also make the case for staying.什么时候应该把向量搬出 PostgreSQL?给出可量化的触发条件,也说说不该搬的理由。
Common in ChinaCommon overseasIntermediate#vector-database#architecture#trade-offsHow to reason about it · think before answering
- This tests engineering judgment, not tooling preference. Opening with 'dedicated vector databases are better' invites follow-ups you cannot answer.
- State the default position and justify it: keep the first version in PostgreSQL, because transactions, backups, point-in-time recovery, permissions, joins with business tables and the tooling your team already knows all come free. A second datastore adds synchronization, a consistency surface and an on-call burden that selection documents rarely price in.
- Then give four measurable triggers: data volume (the test is whether the index still fits in memory, not the raw row count), write frequency (minute-level streaming updates distort clusters and inflate graphs), filter complexity (arbitrary combinations of a dozen attributes defeat both partial indexes and partitioning), and operational capacity.
- Expand on filter complexity, because it is most often the real reason: dedicated vector databases push filtering into the index structure instead of applying it after the scan, which is a mechanical advantage rather than a reputational one.
- Volunteer the alternative people skip: many 'vector search is not good enough' problems are actually solved by hybrid retrieval plus re-ranking, not by a new database. Add the keyword path and a re-ranker first, then decide.
- Expected follow-up: how would you migrate? Dual-write, compare recall and latency on shadow traffic, shift read traffic gradually, and only then retire the old path. Stop at any step where the metrics regress.
分析过程 · 先想清楚再作答
- 这题考的是工程判断,不是技术偏好。开口就说「专用向量库更专业」的人会被追问到答不上来;面试官想看的是你有没有把迁移成本算进去。
- 先给默认立场并给出理由:第一版留在 PostgreSQL,因为事务、备份、时间点恢复、权限、跟业务表 JOIN 和现成的运维工具全是白送的。多一个数据库就多一份同步、一份一致性问题、一份值班负担,这些成本很少被写进选型文档。
- 然后给四条可量化的触发线:数据量(判据不是行数而是索引还塞不塞得进内存)、写入频率(分钟级流式更新会让聚类失真、让图持续膨胀)、过滤复杂度(十几个属性的任意组合让部分索引和分区都排列组合不过来)、团队运维能力(没人愿意长期照看第二个数据库,前三条再成立也别搬)。
- 第三条要展开一点,因为它最常是真正的原因:专用向量库把过滤做进了索引结构本身,而不是扫完索引再筛,所以在复杂过滤下天然占优。把这一点说出来,说明你理解的是机制而不是口碑。
- 还要主动给一条常被忽略的替代路径:很多「向量检索不够用」的问题,真正的解法是混合检索加重排,而不是换数据库。先把关键词一路加回来、把重排接上,再决定要不要搬——顺序搞反了会白搬一次。
- 可预期的追问:真要搬怎么迁?答分三步——先双写并在影子流量上比对两边的召回与延迟,再把读流量按比例切过去,最后才停掉旧路径。中间任何一步指标不达标就停下,这比一次性切换安全得多。
Key points
- Default to staying in PostgreSQL: transactions, backups, recovery, permissions, joins and familiar tooling are free, and a second store adds sync and on-call cost.
- Trigger one is data volume, measured by whether the index still fits in memory rather than by row count.
- Trigger two is write frequency: minute-level streaming updates distort clusters and inflate graphs.
- Trigger three is filter complexity: dedicated stores push filtering into the index structure, a mechanical advantage under complex predicates.
- Trigger four cuts the other way: without people to run a second database, do not move even if the first three hold. Often hybrid retrieval plus re-ranking is the real fix.
答题要点
- 默认留在 PostgreSQL:事务、备份、恢复、权限、JOIN 和现成运维都是白送的,多一个库就多一份同步与值班成本。
- 触发线一是数据量,判据是索引还塞不塞得进内存,而不是行数本身。
- 触发线二是写入频率,分钟级流式更新会让聚类失真、让图持续膨胀。
- 触发线三是过滤复杂度,专用库把过滤做进索引结构,复杂过滤下有机制上的优势。
- 触发线四反过来看:没有长期运维第二个数据库的人手,前三条成立也不该搬;很多问题的真正解法是混合检索加重排。