SQL / 索引 / 向量检索
目录 · 14 题
题目与答案
1.为什么用 B+ 树而不是 B 树或哈希 🟡
展开答案
对比哈希索引:哈希是 O(1) 单点查找,但不支持范围查询和排序——哈希打散了顺序。业务查询里 between、>、order by、like 'abc%' 太常见了,所以哈希只适合纯等值场景(Memory 引擎、Redis)。
对比 B 树:B+ 树的两个关键差异都是为磁盘 IO 优化的:
- 只有叶子节点存数据,非叶子节点只存键 —— 同样大小的页能装更多键,树更矮。三层 B+ 树就能索引千万级数据,意味着一次查找只要 3 次 IO(而且根节点常驻内存,实际更少)
- 叶子节点用双向链表串起来 —— 范围查询找到起点后顺着链表扫,不用回到上层节点。这让范围查询的效率接近顺序读
B 树的数据分散在所有层级,范围查询要在树里来回跳,随机 IO 多。
追问「三层 B+ 树能存多少数据,怎么算的」:按 InnoDB 默认 16KB 页算。假设主键 bigint(8 字节)+ 指针(6 字节)= 14 字节,一个非叶子页能存约 16384 / 14 ≈ 1170 个键。叶子页存实际行,假设一行 1KB,一页存 16 行。所以三层是 1170 × 1170 × 16 ≈ 2190 万行。这个数量级估算能说出来,说明你不是背概念——而且它解释了为什么「加索引」对大表效果那么显著:从扫两千万行变成 3 次 IO。
2.联合索引的最左前缀和索引下推 🔴
展开答案
联合索引 (a, b, c) 的排序规则是:先按 a 排,a 相同再按 b,b 相同再按 c。所以必须从最左边开始连续使用才能利用有序性。
-- 索引 idx(a, b, c)
where a = 1 -- 用到 a
where a = 1 and b = 2 -- 用到 a, b
where a = 1 and b = 2 and c = 3 -- 全用到
where b = 2 -- 用不到(跳过了 a)
where a = 1 and c = 3 -- 只用到 a(b 断了,c 用不上有序性)
where a > 1 and b = 2 -- a 走范围后,b 用不上有序性
最后一条是重点:范围查询之后的列失去有序性。因为 a 有多个值时,b 在整体上不是有序的。所以建索引时要把等值查询的列放前面、范围查询的列放后面。
索引下推(ICP,Index Condition Pushdown,MySQL 5.6+) 解决的是上面第 4 种情况的效率问题:where a = 1 and c = 3,以前是拿 a=1 的所有记录回表,再逐行判断 c;有了 ICP,c 的判断被下推到存储引擎层、在索引里就完成过滤,只有 c 也匹配的才回表。回表次数大幅减少。
EXPLAIN 的 Extra 里出现 Using index condition 就是 ICP 生效了。
追问「既然有索引下推,那最左前缀还重要吗」:重要,两者解决的不是一个问题。最左前缀决定的是能不能利用 B+ 树的有序性快速定位(把扫描范围从全表缩小到一小段),索引下推只是在已确定的扫描范围内减少回表次数。如果最左列没用上,扫描范围就是整个索引,下推只是让「扫完整个索引但少回表」,仍然比不上正确利用最左前缀。下推是优化,不是替代。
3.什么情况下索引失效 🔴
展开答案
按出现频率排:
-- 1. 索引列参与运算或包在函数里
where DATE(created_at) = '2026-09-02' -- 失效
where created_at >= '2026-09-02 00:00:00' -- 改成范围,走索引
and created_at < '2026-09-03 00:00:00'
-- 2. 隐式类型转换(最阴的一个)
where phone = 13800138000 -- phone 是 varchar,数字比较导致转换,失效
where phone = '13800138000' -- 加引号,走索引
-- 3. 前导模糊
where name like '%张' -- 失效(无法定位起点)
where name like '张%' -- 走索引
-- 4. OR 连接的列有一个没索引
where a = 1 or no_idx = 2 -- 整体退化为全表扫
-- 5. 不等于 / not in / is not null(视选择性而定)
where status != 1 -- 优化器可能判断走索引不如全表扫
-- 6. 联合索引跳过最左列
第 2 条隐式转换特别值得强调:MySQL 的规则是字符串和数字比较时,把字符串转成数字,所以 varchar 列被转换了,索引失效。反过来(int 列传字符串)不会失效,因为转换发生在传入的常量上。ORM 里参数类型没对齐是这个问题的主要来源。
另外一类不是「失效」而是「优化器主动不用」:选择性太低。比如 where gender = 'M' 命中一半数据,走索引要大量回表,优化器判断全表扫更快。这不是 bug。
追问「明明该走索引,优化器却选了全表扫,怎么办」:先用 EXPLAIN 看它估算的 rows 和实际差多少。差得多通常是统计信息过期,ANALYZE TABLE 更新一下。仍然不对的话可以用 FORCE INDEX 强制,但这是最后手段——强制索引把优化器的判断写死在 SQL 里,数据分布变化后可能反而变慢,而且它掩盖了真实问题(可能是索引设计不合适、或者查询该改写)。生产上加 FORCE INDEX 应该留注释说明原因和复查时间。
4.覆盖索引和回表是怎么回事 🔴
展开答案
InnoDB 的索引分两类:
- 聚簇索引(主键索引) —— 叶子节点直接存整行数据
- 二级索引 —— 叶子节点存的是索引列 + 主键值
所以用二级索引查询时,如果要的字段不在这个索引里,就得拿主键值再去聚簇索引查一次完整行——这就是回表。回表是随机 IO,量大时代价明显。
覆盖索引就是让查询需要的所有列都在索引里,不用回表:
-- 索引 idx(user_id, status, amount)
select amount from orders where user_id = 1 and status = 2;
-- 三个列都在索引里 → Extra 显示 Using index → 不回表
这是最有效的 SQL 优化手段之一,尤其是对高频查询:把 select 的少量字段加进索引,一次查询能省掉几百次随机 IO。
代价要说出来:索引变宽 → 占更多空间、写入时维护成本更高、一个页能装的键变少(树可能变高)。所以不要把所有字段都塞进索引,只针对确认的高频查询做。
追问「select * 为什么在这个场景下危害更大」:因为它让覆盖索引永远不可能生效——总有字段不在索引里,必然回表。而且回表拿的是整行,包括那些你压根不用的大字段(text、blob),IO 和网络传输都被浪费。深分页场景下这个问题被放大:扫描一万行、回表一万次、传输一万行完整数据,其中你只用了两三个字段。select * 的代价不是「多传了几个字段」,是把可优化的查询变成不可优化的。
5.EXPLAIN 的关键字段怎么读 🔴
展开答案
看五个字段就够解决绝大部分问题:
type(访问类型) —— 从好到差:
system > const > eq_ref > ref > range > index > ALL
分界线在 range:range 及以上算正常,index(扫整个索引树)和 ALL(全表扫)需要处理。
key —— 实际用了哪个索引。为 NULL 就是没走索引。配合 possible_keys 看:候选里有但没选,通常是统计信息或选择性问题。
rows —— 优化器估算要扫多少行。这是估算值不是实际值,但和实际差一个数量级以上就说明统计信息不准。
filtered —— 估算过滤后剩余百分比。rows × filtered 才是估算的最终结果集大小。
Extra —— 信息量最大:
| 值 | 含义 | 处理 |
|---|---|---|
Using index |
覆盖索引,不回表 | 好 |
Using index condition |
索引下推生效 | 好 |
Using where |
在 server 层过滤 | 一般,看是否能下推 |
Using filesort |
额外排序 | 要处理:让 order by 走索引顺序 |
Using temporary |
用了临时表 | 要处理:常见于 group by / distinct / union |
Using join buffer |
关联字段没索引 | 要处理:加索引 |
Using filesort 和 Using temporary 是两个最该盯的信号,它们意味着内存或磁盘上的额外工作。
追问「EXPLAIN 说走索引了,但线上还是慢,怎么继续查」:EXPLAIN 只给执行计划,不给实际耗时。用 EXPLAIN ANALYZE(MySQL 8.0.18+)看实际执行时间和实际行数,和估算值对比。还慢就往执行计划之外找:锁等待(information_schema.innodb_trx 看有没有阻塞)、回表次数太多(估算行数大但结果集小)、结果集本身太大(传输耗时)、连接池耗尽导致排队。「执行计划正确」和「查询快」是两件事,这个区分说出来比只会看 EXPLAIN 强一个档。
事务与锁
6.四种隔离级别各自防什么 🔴
展开答案
三类异常和四个级别的对应关系是死的,先背准:
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | ✗ | ✗ | ✗ |
| READ COMMITTED | ✓ | ✗ | ✗ |
| REPEATABLE READ | ✓ | ✓ | ✗(标准)/ ✓(InnoDB) |
| SERIALIZABLE | ✓ | ✓ | ✓ |
三类异常的区别在于读到了什么:
- 脏读 —— 读到别人还没提交的数据,对方回滚后你手上的值根本没存在过
- 不可重复读 —— 同一事务内两次读同一行,值变了(别人 UPDATE 并提交)
- 幻读 —— 同一事务内两次读同一范围,行数变了(别人 INSERT 并提交)
两个必须说出来的实现细节,只背表格会被追问穿:
MySQL 默认 REPEATABLE READ,而 PostgreSQL / Oracle 默认 READ COMMITTED。 这不是谁更对,是历史原因:MySQL 早期的 statement 格式 binlog 在 RC 下会导致主从数据不一致,所以默认选了 RR。现在用 row 格式 binlog 已经没这个问题,很多团队反而主动把 MySQL 调成 RC —— 因为 RR 的间隙锁会显著增加死锁概率(见 03-sql.md 第 8 题)。
InnoDB 的 RR 通过间隙锁基本防住了幻读,比标准 SQL 要求的更强。但「基本」不是「完全」:快照读看不到别人新插入的行,可当前读(select ... for update)能看到,同一事务里混用两种读法就会看到不一致的行数。
具体现象层面,RR 最容易踩的坑是长事务读到过期数据。一个跑了十分钟的事务,它的快照停在开始那一刻,中间别人改的数据它全看不到。批处理任务里「读出来的库存是十分钟前的」就是这么来的,而且不报错,只是算错。
追问「为什么 InnoDB 在 RR 下还是能出现丢失更新」:因为快照读不加锁。两个事务同时 select stock from t where id=1 都读到 100,各自算出 100-1=99,然后都 update t set stock=99,最终库存少扣了一次。隔离级别管的是「读到什么」,不管「读完之后你拿它算了什么再写回去」。修法有三种:update t set stock = stock - 1 where id=1 and stock > 0(把计算交给数据库,靠行锁串行化)、乐观锁版本号(where version = ? + 判断影响行数)、或者读的时候就用当前读加锁(for update)。「隔离级别不解决丢失更新」这句话能说出来,比背完四个级别更能证明你写过并发代码。
7.MVCC 的快照读和当前读 🟡
展开答案
MVCC 让读不阻塞写、写不阻塞读,靠的是每行的两个隐藏字段加一条版本链:
DB_TRX_ID—— 最后修改这行的事务 IDDB_ROLL_PTR—— 指向 undo log 里的上一个版本
一行被反复修改就形成一条版本链,链上每个节点是一个历史版本。读的时候拿着 ReadView(一个「哪些事务在我开始时还没提交」的快照)沿链往下找,找到第一个对自己可见的版本。
快照读 vs 当前读是这题的考点:
| 快照读 | 当前读 | |
|---|---|---|
| 语句 | 普通 select |
select ... for update / lock in share mode / update / delete / insert |
| 读的是 | 历史版本(ReadView 决定) | 最新已提交版本 |
| 加锁 | 不加 | 加行锁(+ RR 下的间隙锁) |
RC 和 RR 的实现差别就一句话:RC 每次 select 都重新生成 ReadView,RR 只在事务内第一次快照读时生成一次并复用。所以 RR 下同一事务两次读结果一致,RC 下会变。
最容易翻车的现象是同一事务里混用两种读:
-- RR 下,事务 A
begin;
select count(*) from orders where user_id = 1; -- 快照读,返回 3
-- 此时事务 B 插入一条 user_id=1 并提交
select count(*) from orders where user_id = 1; -- 快照读,仍然 3
select count(*) from orders where user_id = 1 for update; -- 当前读,返回 4
同一个事务、同一个条件、两个数字。这不是 bug,是你自己把两种读混了。ORM 里这个问题特别隐蔽——先用普通查询判断「有没有」,再用 save() 写入,中间的可见性完全不一致。
追问「长事务对 MVCC 有什么代价」:undo log 清不掉。purge 线程只能删除「所有活跃事务都不再需要的版本」,一个开着不提交的长事务会把 undo 一直钉住,版本链越来越长——查一行要沿链走几百个节点,undo 表空间也持续膨胀(MySQL 8.0 起 undo 在独立表空间,能在线 truncate 回收,但前提是那个长事务先结束)。排查手段:information_schema.innodb_trx 按 trx_started 排序找最老的事务,看 trx_rows_modified 和空闲时长。「没提交的空闲事务也是长事务」是个高频误解——很多人以为只有在执行 SQL 才算,实际 begin 之后什么都不干挂着也一样钉住 undo。
8.间隙锁和死锁的常见来源 🟡
展开答案
InnoDB 的行锁实际是三种,区别在锁的范围:
- 记录锁(Record Lock) —— 锁一行索引记录
- 间隙锁(Gap Lock) —— 锁两条记录之间的开区间,目的是阻止 INSERT
- 临键锁(Next-Key Lock) —— 记录锁 + 前面的间隙,左开右闭。RR 下的默认行为
RR 下 select * from t where id > 10 for update 不只锁住已存在的行,还锁住 (10, +∞) 这个区间——别人插 id=15 会被阻塞。这就是 InnoDB 防幻读的机制,也是死锁的主要来源。
死锁的三个高频来源:
一、两个事务反向加锁。 最经典的形态:
-- 事务 A -- 事务 B
update t set x=1 where id=1; update t set x=1 where id=2;
update t set x=1 where id=2; update t set x=1 where id=1;
各持一把等对方,直接死锁。修法是统一加锁顺序——批量更新前先按主键排序,这是最有效的一招。
二、唯一索引冲突 + 间隙锁。 两个事务都先 select ... for update 判断不存在,再 INSERT。判断阶段各自拿到了同一个间隙的间隙锁(间隙锁之间不互斥),插入阶段各自要插入意图锁,互相等待。这个场景的正确写法不是加锁判断,而是直接 INSERT 靠唯一索引冲突,或者用 INSERT ... ON DUPLICATE KEY UPDATE。
三、无索引导致锁范围放大。 update t set x=1 where no_index_col = 5 在没有索引时会退化成扫全表并锁住扫过的每一行(实际是锁所有记录 + 所有间隙)。一条本该锁 1 行的语句锁了全表,任何并发写都会撞上。这一条是「加索引」除了查询性能之外的第二个理由,很多人只知道前者。
排查手段要具体:show engine innodb status 的 LATEST DETECTED DEADLOCK 段落给出两个事务各自持有和等待的锁;MySQL 8.0 用 performance_schema.data_locks 和 data_lock_waits 能实时看当前锁的持有和等待关系,比解析文本输出方便。生产上建议把 innodb_print_all_deadlocks 打开,否则只能看到最后一次死锁。
追问「死锁一定要避免吗,重试不行吗」:死锁本身不必消灭,但要区分「偶发」和「设计问题」。InnoDB 有死锁检测,会自动回滚代价较小的那个事务并报 1213,应用层捕获后重试通常就成功了——所以偶发死锁靠重试完全可以接受,而且重试比引入更复杂的加锁协议更划算。但如果死锁率高到影响吞吐,说明是设计问题:加锁顺序不一致、事务持锁时间太长、或者隔离级别选得过严(RR 的间隙锁不需要就换 RC)。判断依据是死锁率和业务量的关系——随并发线性上升说明是竞争加剧,随并发平方级上升说明加锁顺序有问题。另外要注意区分死锁(1213,立即回滚)和锁等待超时(1205,等 innodb_lock_wait_timeout 秒后报错,默认 50 秒):后者不是死锁,是有事务持锁太久,排查方向完全不同。
查询优化
9.深分页为什么慢,怎么优化 🔴
展开答案
慢的原因是 limit m, n 的语义:扫描并丢弃前 m 行,只为返回后 n 行。
select * from orders order by id limit 1000000, 20;
-- 扫 1000020 行,丢掉 1000000 行,返回 20 行
而且走二级索引时更糟:每行都要回表拿完整数据,回表一百万次,然后把这一百万行全扔掉。翻到第五万页的用户,代价是数据库扫了一百万次随机 IO。
三种优化,按适用场景选:
一、游标分页(keyset pagination) —— 最优解,但要求有序且唯一的游标列:
-- 第一页
select * from orders order by id limit 20;
-- 后续页:带上上一页最后一条的 id
select * from orders where id > 1000000 order by id limit 20;
-- 直接定位到 id=1000000,扫 20 行
代价是不能跳页——只能上一页/下一页,因为算不出「第 5000 页的起点 id 是多少」。这是产品取舍,不是技术缺陷:信息流、聊天记录这类场景本来就不需要跳页。
二、延迟关联(deferred join) —— 需要跳页时用这个:
select o.* from orders o
join (select id from orders order by id limit 1000000, 20) t
on o.id = t.id;
子查询只扫索引不回表(覆盖索引),拿到 20 个 id 后才回表 20 次。扫描行数没变,但回表从一百万次降到 20 次,这是主要开销。
三、限制最大页数 —— 产品层解法。搜索引擎只给前 100 页就是这个道理,没人真的翻到第五万页,翻的都是爬虫。
order by 的列必须有索引,否则先 filesort 再分页,比上面任何情况都糟。
追问「游标分页遇到排序字段不唯一怎么办」:比如按 created_at 排序,同一秒有多条记录,where created_at > ? 会漏掉或重复同一秒内的行。解法是复合游标——排序键加一个唯一列做 tie-breaker:
order by created_at, id
where (created_at, id) > ('2026-09-02 10:00:00', 12345)
MySQL 支持这种行值比较,PostgreSQL 也支持,而且能走 (created_at, id) 的联合索引。手写成 created_at > ? or (created_at = ? and id > ?) 也对,但优化器有时候不能把它转成索引范围扫描,写行值比较更稳。这题问的是「你有没有真的上线过游标分页」——只在教程里见过的人不会遇到重复行,实际做过的一定被这个坑过。
向量检索
10.HNSW 和 IVF-Flat 怎么取舍 🟡
展开答案
两者的索引结构完全不同,取舍点在写入模式和内存预算,不是「谁更快」。
HNSW(分层可导航小世界图) —— 多层图结构,上层稀疏做粗定位,逐层下沉到底层精搜。查询是从入口点开始的贪心游走。
- 关键参数:
m(每个节点的连接数,默认常见 16)、ef_construction(建图时的候选集大小)、ef_search(查询时的候选集大小,直接换召回率) - 优点:召回率-延迟曲线最好,支持增量插入
- 缺点:内存占用大(图的边要常驻内存),构建慢,删除只能做标记删除,删多了要重建
IVF-Flat(倒排文件 + 精确距离) —— 先用 k-means 把向量空间分成 nlist 个簇,查询时只搜最近的 nprobe 个簇。
- 关键参数:
nlist(簇数量)、nprobe(查询时搜几个簇,直接换召回率) - 优点:内存省得多,构建快
- 缺点:必须先训练(要有代表性的样本数据才能聚类),数据分布漂移后簇质量下降需要重训;召回率-延迟曲线不如 HNSW
选择依据:
| 场景 | 选 |
|---|---|
| 数据持续增量写入 | HNSW(IVF 要重训) |
| 数据量大、内存紧 | IVF-Flat(或加 PQ 量化) |
| 追求最高召回率 | HNSW |
| 一次性批量导入、之后只读 | 两者都行,IVF 建索引快 |
| 百万级以下 | 说实话都够用,别过度优化 |
RAG 场景绝大多数选 HNSW,因为知识库天然是持续新增文档的。
追问「召回率怎么测,你怎么知道调对了」:召回率必须有 ground truth 才能测,做法是用暴力精确检索(Flat / brute force)跑一遍作为基准:取一批真实查询(几百条就够),对每条用暴力检索算出真正的 top-k,再用 ANN 索引查同样的 k,计算 交集大小 / k 就是 recall@k。然后画 ef_search(或 nprobe)从小到大扫出来的召回率-延迟曲线,在业务能接受的 P99 延迟上取召回率最高的那个点。关键是这个基准要用你自己的真实查询和真实数据——公开 benchmark 的数据分布和你的完全不同,抄它的参数没意义。能说出「我拿 Flat 建了 ground truth」的人,和只会调参数看感觉的人,是两个档。
11.为什么向量检索是近似的 ⚪
展开答案
精确最近邻在高维空间下没有比暴力扫描更快的通用算法。
传统的空间索引(KD-tree、R-tree)在低维下有效,但维度上升后会退化到接近全扫——因为高维空间里「划分空间来剪枝」这件事失效了:任何一个划分平面,查询点的邻域几乎总是跨越平面两侧,剪不掉分支。这个现象叫维度灾难。
具体表现是:在高维随机分布下,所有点到查询点的距离趋于集中,最近点和最远点的距离比接近 1。既然「远近」的区分度本身就低,靠距离剪枝自然没效果。
所以工程上换了目标:不要求精确,只要求足够准。ANN(Approximate Nearest Neighbor)用可控的召回率损失换几个数量级的速度,典型工作点是 95%~99% 召回率下延迟毫秒级。
对 RAG 来说这个取舍格外划算,因为下游本来就有容错:召回 20 条给重排器,重排后取 5 条进上下文。漏掉第 18 名的那条文档,对最终答案几乎没影响。真正致命的是漏掉第 1 名——所以召回率要看 recall@1 还是 recall@20,取决于你后面接不接重排。
数据量小的时候(比如几万条)暴力检索完全可行,延迟也在几十毫秒内。先算清楚自己的规模,再决定要不要上 ANN 索引。
追问「什么情况下你会宁愿要精确检索」:三种。一是数据量本来就小——几万向量暴力扫也就几十毫秒,上索引反而多了内存和维护成本,pgvector 里干脆不建索引就是精确的。二是过滤条件很强——比如「只在这个用户的 50 篇文档里搜」,先按元数据过滤剩下 50 条再暴力算,比在全库 ANN 索引上带过滤搜更准更快(这一点和 03-sql.md 第 14 题的预过滤是同一个逻辑)。三是召回漏一条的代价极高——法律条款检索、合规检查这类场景,漏检不是「答得差一点」而是「答错了要担责」,宁愿慢也要精确。判断标准是下游有没有容错:接了重排和人工复核的可以容忍近似,直接把检索结果当结论的不行。
12.pgvector 和专用向量库怎么选 🟡
展开答案
判断依据是你的过滤条件有多复杂和数据量在什么量级,不是「谁性能好」。
pgvector —— PostgreSQL 扩展。0.5.0(2023-08)加入 HNSW 索引,0.8.0(2024-10)加了迭代索引扫描和更好的代价估算。
选它的理由都是工程性的:
- 事务一致性 —— 向量和业务数据在同一个库同一个事务里,不存在「主库写了向量库没写」的不一致。这是专用向量库最大的痛点
- 过滤是原生 SQL ——
where tenant_id = ? and status = 'published' and created_at > ?加向量排序,任意复杂的条件都能写,还能 JOIN - 少一个组件 —— 不用额外部署、监控、备份一套系统
专用向量库(Milvus / Qdrant / Weaviate) 选它的理由:
- 数据量上亿 —— pgvector 在千万级以上开始吃力,专用库有分片和分布式方案
- 需要标量量化 / PQ 压缩 —— 内存不够时把向量压缩到 1/4,pgvector 支持有限
- 需要过滤感知的索引 —— 这是关键差异,见追问
- 多路检索、稀疏向量、内置重排 —— 这些是产品化功能,pgvector 要自己拼
实践建议按量级切:百万级以下、且已经在用 PostgreSQL,直接 pgvector,不要为了「以后可能大」提前上专用库——多一个组件的运维成本立刻就要付,而「以后」可能永远不来。千万级开始评估,上亿基本必须换。
一个容易忽略的点:pgvector 的 HNSW 索引建索引时要吃大量内存,maintenance_work_mem 不够会退化成磁盘构建,慢几十倍。百万级向量建索引前先把这个参数调大。
追问「pgvector 加 where 条件后召回率变差,为什么」:这是后过滤(post-filter) 的典型症状。PostgreSQL 的执行路径是先用 HNSW 索引取出 ef_search 个候选,再对候选应用 where 条件 —— 如果条件过滤掉了 95%,原本 40 个候选只剩 2 个,你要的 top-10 根本凑不齐,结果集莫名变短甚至为空。pgvector 0.8.0 的迭代索引扫描(hnsw.iterative_scan)就是为这个加的:候选不够时继续往索引深处搜,直到 hnsw.max_scan_tuples 上限。另一条路是让优化器别走向量索引——过滤后剩的行少到几千条时,走 B-tree 索引精确过滤再暴力算距离更快也更准,0.8.0 改进代价估算就是让它更容易做出这个选择。说出「这是后过滤问题」而不是「pgvector 不行」,是这题的分水岭(完整的预过滤/后过滤取舍见 03-sql.md 第 14 题)。
13.混合检索的结果怎么融合 🟡
展开答案
混合检索是向量检索 + 关键词检索(BM25)并行跑,然后融合两个结果列表。要它的原因很具体:向量擅长语义相似但对精确串无能——产品型号 X-2000、错误码 ERR_5013、人名,向量会召回一堆「意思差不多」的东西,而 BM25 精确命中。反过来「怎么退货」这种语义查询,BM25 匹配不上写成「退换货流程」的文档。
融合有两种做法,优先选 RRF:
分数加权融合(final = α × vec_score + (1-α) × bm25_score)—— 看起来自然,实际很难调好。因为两个分数不可比:余弦相似度在 0~1 且分布集中(同一批文档可能都在 0.7~0.85),BM25 分数无上界且随查询词数变化。要先做 min-max 归一化,而归一化又依赖当前结果集的极值,同一个查询换个数据量分数就变了。
RRF(Reciprocal Rank Fusion,倒数排名融合) —— 只用排名,不用分数:
score(d) = Σ over all retrievers 1 / (k + rank_i(d))
rank_i(d) 是文档 d 在第 i 个检索器结果里的排名(从 1 开始),没出现就不计入这一项。k 是平滑常数,Elasticsearch 的 rank_constant 默认 60,Weaviate 的 rankedFusion 也用 60。
举个具体的算例(k=1,两个检索器):
| 文档 | 向量排名 | BM25 排名 | RRF 分数 |
|---|---|---|---|
| A | 1 | 4 | 1/2 + 1/5 = 0.70 |
| B | 2 | 5 | 1/3 + 1/6 = 0.50 |
| D | 4 | 2 | 1/5 + 1/3 = 0.53 |
| E | 未召回 | 1 | 0 + 1/2 = 0.50 |
RRF 比加权分数稳的根本原因:排名是可比的,分数不是。它不需要归一化、不需要调 α、两个检索器的相关性信号完全无关也能用。原论文的论证就是加权分数融合方差更大 —— 因为某些系统的分数「碰巧」比别的更适合当权重,这是偶然而非规律。
两个实践细节:
k越大,低排名文档影响越大。默认 60 意味着第 1 名和第 10 名的差距被压得比较平,适合「两个检索器都不太可信」的情况;想让头部结果更强势就调小 k。- 每路取多少候选要显式设。Elasticsearch 的
rank_window_size控制每路取几条参与融合,取太小会让某一路的好结果进不了融合池。典型做法是每路取 50~100,融合后取 top-20 再进重排。
追问「RRF 之后还需要重排吗」:需要,两者解决的不是一个问题。RRF 只做列表合并,它对文档内容一无所知——它不知道排名第 3 的那条其实和查询无关,只是在两路里都恰好排中游。重排器(cross-encoder)是唯一真正读了 query 和文档全文再打分的环节,它能识别「关键词都命中但语义相反」这种 BM25 和向量都抓不住的情况。合理的管线是:两路各召回 50~100 条 → RRF 融合成 20~30 条 → 重排器精排取 top-5 进上下文。RRF 负责「别漏」,重排负责「别错」。反过来说,如果只能选一个,在预算允许时选重排——它对最终答案质量的影响比融合算法大得多。成本上重排要额外一次模型调用(几十毫秒到几百毫秒),这是它唯一的代价。
14.向量库的多租户和权限过滤怎么做 🟡
展开答案
这题是真实生产事故的高发区,核心是过滤发生在检索之前还是之后,两种做法都有坑。
后过滤(post-filter) —— 先 ANN 检索拿 top-k,再用元数据条件筛掉不该看的。
失败现象很具体:用户 A 只能看 5% 的文档,你查 top-10,ANN 返回的 10 个候选里可能 0 个属于他,接口返回空列表。而日志上一切正常——索引查到了 10 条,只是全被过滤了。更隐蔽的是「有结果但很差」:候选里恰好有 1 条属于他,排在第 40 名,于是这条勉强相关的文档变成了「最佳答案」。
预过滤(pre-filter) —— 先按条件缩小候选集,再在其中检索。
看起来才是对的,但在 HNSW 上有个反直觉的代价:过滤会破坏图的连通性。HNSW 靠节点间的边做贪心游走,默认 m=16 时底层节点平均约 21 条边。如果过滤掉 96% 的节点,剩下的节点之间大部分边都断了,游走走不通,召回率骤降——不是慢,是搜不到本该搜到的东西。
所以专用向量库做了专门的工程:
| 方案 | 做法 | 适用 |
|---|---|---|
| 物理隔离(Milvus partition / Qdrant collection) | 每个租户独立分区或独立集合 | 租户数少(几十到几百)、隔离要求强 |
| Partition Key(Milvus) | 按租户字段分区,查询自动路由到对应分区 | 租户数多,不想建几千个集合 |
| 过滤感知索引(Qdrant filterable HNSW) | 建图时为高频过滤字段额外加边,保证子图连通 | 过滤字段固定且已知 |
| ACORN 类算法(Weaviate 1.27+) | 游走时把被过滤掉的节点当「跳板」而不是死路 | 过滤条件任意组合 |
选择依据是租户数量级和过滤字段是否固定:几十个大客户就物理隔离,几万个小租户用 partition key,过滤条件千变万化就要靠过滤感知的索引算法。
安全上还有一条硬要求:权限过滤必须在服务端做,不能靠前端传参。把 tenant_id 当普通查询参数、由客户端提供,等于把越权读取的开关交给了攻击者。正确做法是从会话/token 里取租户标识,在检索层强制注入,客户端无法覆盖。
追问「一篇文档被多个部门共享,权限是列表而不是单值,怎么过滤」:单值分区就不适用了,因为一篇文档要同时属于多个分区。做法是把权限存成标签数组(acl: ["dept_a", "dept_c"]),检索时用数组包含类型的条件(Qdrant 的 match any、Milvus 的 array_contains_any、pgvector 里就是 PostgreSQL 的数组操作符 &&)。但这类条件的选择性通常很差——「有权限的文档」可能占全库一半,过滤感知索引帮不上多少忙。这时候正确的架构是把 ACL 从向量库里拿出去:向量库只负责语义检索并多召回一些(比如 top-100),权限判定交给已有的授权服务批量校验,过滤后取前 10。理由是权限逻辑本来就复杂(继承、临时授权、时效),把它塞进向量库的过滤表达式会变成一个维护不了的东西,而且权限规则变更时向量索引不需要重建。代价是要多召回、多一次 RPC。说得出「ACL 不该进向量库」并给出多召回补偿,是这题的最高分答案。