sql · 面试题库

SQL / 索引 / 向量检索

目录 · 14 题

#问题标记
1为什么用 B+ 树而不是 B 树或哈希🟡
2联合索引的最左前缀和索引下推🔴
3什么情况下索引失效🔴
4覆盖索引和回表是怎么回事🔴
5EXPLAIN 的关键字段怎么读🔴
6四种隔离级别各自防什么🔴
7MVCC 的快照读和当前读🟡
8间隙锁和死锁的常见来源🟡
9深分页为什么慢,怎么优化🔴
10HNSW 和 IVF-Flat 怎么取舍🟡
11为什么向量检索是近似的
12pgvector 和专用向量库怎么选🟡
13混合检索的结果怎么融合🟡
14向量库的多租户和权限过滤怎么做🟡

题目与答案

1.为什么用 B+ 树而不是 B 树或哈希 🟡

展开答案

对比哈希索引:哈希是 O(1) 单点查找,但不支持范围查询和排序——哈希打散了顺序。业务查询里 between>order bylike 'abc%' 太常见了,所以哈希只适合纯等值场景(Memory 引擎、Redis)。

对比 B 树:B+ 树的两个关键差异都是为磁盘 IO 优化的:

  1. 只有叶子节点存数据,非叶子节点只存键 —— 同样大小的页能装更多键,树更矮。三层 B+ 树就能索引千万级数据,意味着一次查找只要 3 次 IO(而且根节点常驻内存,实际更少)
  2. 叶子节点用双向链表串起来 —— 范围查询找到起点后顺着链表扫,不用回到上层节点。这让范围查询的效率接近顺序读

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 也匹配的才回表。回表次数大幅减少。

EXPLAINExtra 里出现 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

分界线在 rangerange 及以上算正常,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 filesortUsing 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 —— 最后修改这行的事务 ID
  • DB_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_trxtrx_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 statusLATEST DETECTED DEADLOCK 段落给出两个事务各自持有和等待的锁;MySQL 8.0 用 performance_schema.data_locksdata_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 不该进向量库」并给出多召回补偿,是这题的最高分答案。