小白概念指南
小白概念指南
KnowledgeHub 正文各章会出现四条检索腿、RRF、MaxSim、点 ID 这些机制名。本文把它们的通用原理写清楚,写到「能讲公式、能答为什么不用另一种做法」的深度。项目里各机制的实际取值都在对应正文章节,本文只给机制本身与常见的取值区间。
1. 检索的三大路线
信息检索按「怎么表示文档、怎么算相关」分三条路线,项目的四条腿里有三条分别属于这三条路线。
| 路线 | 文档表示 | 打分方式 | 训练 | 优势 | 短板 |
|---|---|---|---|---|---|
| 稀疏(BM25) | 词表维度上的权重向量,绝大多数为 0 | 各词的词权重相加 | 不需要训练 | 精确匹配、可解释、成本低 | 换个说法就搜不到 |
| 稠密(双塔) | 数百维实数向量 | 余弦或内积 | 需要模型 | 语义泛化,同义改写也能命中 | 数字串和生僻专名不敏感 |
| 多向量(late interaction) | 每个 token 一个向量 | 逐 token 取相似度最大值再求和 | 需要模型 | 细粒度,接近精排精度 | 存储和打分都按 token 数放大 |
另有两种机制常被混在「召回」里说起,需要注意区分:
- cross-encoder 精排:query 与文档拼成一个输入过模型,只在召回后对少量候选重排,不做全库召回。
- trgm 子串匹配:Postgres 按 trigram(三字组)重合度算字符相似度,是数据库自带的一种模糊匹配能力,既不是检索模型也不需要训练。
三者的对应关系与项目里的实现:《架构总览》的数据平面表。
2. BM25
2.1 它在解决什么
Okapi BM25 是 1990 年代 TREC 评测中胜出的概率检索模型。它把「文档与查询的相关性」拆成三个信号:词在该文档里出现了几次、这个词在整个库里有多稀有、这篇文档有多长。三个信号各自独立调节,分别对应 TF-IDF 的两个失真假设加一个参数修正:
- 词频饱和:相关性不随词频线性增长。一篇讲算法的文章里提到「算法」10 次,与 5 次相比,相关性几乎没有差别。BM25 用饱和项
k1描述这个衰减,到某些频次后贡献收敛到上界。 - 文档修正:相关性受文档长度影响。400 字的短文提到一次「记忆优化」的信号强度,要高于 5,000 字的长文顺带提一句,因此需要长度归一化,由
b控制。 - 稀有度:IDF(inverse document frequency)给出词在全库中的稀有程度,包含它的文档数越少,权重越高。
2.2 公式
BM25 的打分公式:
score(D, Q) = Σ_{q∈Q} IDF(q) · tf(q,D)·(k1+1)
─────────────────────────────────────────────
tf(q,D) + k1·(1 - b + b·|D|/avgdl)符号说明:
tf(q,D):词q在文档D里的出现次数|D|:文档长度(通常为 token 数),avgdl为全库平均长度IDF(q):词q的稀有度权重k1、b:两个可调参数
IDF 的常用写法是:
IDF(q) = ln(1 + (N - df(q) + 0.5) / (df(q) + 0.5))其中 N 为全库文档数,df 为包含该词的文档数。多加的 ln(1 + …) 是相对经典写法 log((N−df+0.5)/(df+0.5)) 的修正:经典 IDF 在词出现在超过一半的文档时取到负值,会把总分往下拉。加一取对数保证 IDF 恒为正。
2.3 两个参数的直观影响
| 参数 | 典型取值 | 调大负面影响 | 调小影响 |
|---|---|---|---|
k1 | 1.2 ~ 2.0(Lucene 默认 1.2) | 词频饱和更慢,表现为接近线性 | 饱和更快;k1→0 退化为二值模型,仅以出现过/未出现论得分 |
b | 0 ~ 1(默认 0.75) | 长度惩罚更重;b=1 时得分几乎由文档长度决定 | 无长度修正;长文档不因长而在排序中处于劣势 |
b = 1 有一个在切分系统里特有的副作用:若内容被切成不等长的块,块的得分完全由块长而非内容决定,切分策略造成的差异会直接变成排序差异。
2.4 成本与前提
BM25 的打分复杂度约 O(|Q| · 命中文档数),与全库规模无关——这是它比稠密检索便宜的原因:不需要 ANN,查询也不需要 GPU 前向编码。但还有两个成本绕不开:
- 维护全局统计。
df与avgdl是全库量,入库、删除、重建都要同步维护。 - 能接受增量漂移。新文档入库会改变
N与各词的df,而已经算好的文档侧权重不会自动重算,漂移到可感的程度就应重建。
这一机制的运行成本就是两张表:词表(存 df 与 term id)和语料统计(存 N 与 avgdl)。以及中文场景里多出来的一层问题:分词粒度直接决定命中,所以同一语料库在入库与删除两侧必须用同一个分词函数,否则 df 无从谈起。
2.5 效果评估
调整参数或新增一条腿之后,效果要有对照(改前与改后、加腿前与加腿后),不能凭一组主观感受下结论。三个常用指标:
| 指标 | 回答的问题 |
|---|---|
| recall@k | 应命中的 k 个里实际找回几个 |
| MRR | 首个正确结果排在第几名 |
| nDCG@k | 相关性有强弱分级时,排序是否照顾了强弱 |
BM25 的常见失效不是「排序排错」,而是「完全召回不到」——分词切错颗粒度、词未入词表之类。所以先看 recall@k,命中存在之后再看 MRR。
3. 稠密检索与 ANN
3.1 双塔与余弦相似度
双塔(bi-encoder)将 query 与文档分别编码为向量,相关性用一次点积计算。核心性质是文档向量可以离线完成并进索引,线上只需编码一次 query 属性。这一性质让稠密检索能实现毫秒级响应的全库检索,是它与 cross-encoder 在工程上的分水岭。
余弦相似度衡量两向量的夹角:
cos(a, b) = a·b / (|a|·|b|)向量若先进行 L2 归一化(|a| = |b| = 1),余弦与内积等价,两种打分可以互换。很多论文里余弦检索与内积检索混用,都是基于此的特性。
3.2 近似最近邻检索(ANN)
精确最近邻是 O(N·d),全库扫描在毫秒级延迟约束下不可行。实际系统用近似最近邻,用召回率换时间:
| 索引 | 结构 | 关键参数 | 使用权衡 |
|---|---|---|---|
| HNSW | 多层可导航小世界图 | M、efConstruction、efSearch | efSearch 越大召回越高、延迟越长,工业界普遍选择 |
| IVF | 先聚类,查询时只搜最近的几个簇 | nlist、nprobe | nprobe 越大召回越高;需训练,数据分布一变就要重新训练 |
| PQ | 分段量化,压缩存储 | 段数、每段比特数 | 存储成本下降 1~2 个数量级,精度有损 |
「召回率怎么评估」通常这样做:用精确检索(暴力检索)的 top-k 作基准,两者重叠计算 recall@k。召回不足时优先调 efSearch / nprobe 这类搜索参数,而不是急于换编码模型。
3.3 稠密检索的边界
- 输入长度有上限。 常见的 embedding 模型单次编码最多 512 token,超长内容被截断,截断之后的内容对该向量是不可见的。
- 数字串与生僻专名不敏感。 tokenizer 会把「工号 A1234」这类字符串切碎,相近的编号在语义空间里几乎重合,排序没有信号。
- 不可解释。 输出是一个距离值,无法回答「因为哪个词命中了」。
这三条边界正是 sparse 腿与 trgm 腿存在的意义:keyword 与字置匹配补足语义的偏差。维度本身同样是取舍之一:索引内存与检索算力与 d 大致正比,512 维相对 768/1024 维能省一半以上,代价是表达能力略窄。项目选小维度模型的直接原因:该模型要常驻于每个进程中(不同进程不可能都加载几百 MB,另见《架构总览》的模型边界)。
4. 混合检索与 RRF
4.1 为什么分数不能直接相加
多路检索的结果要合成一个排序列表,但不同腿的分数分布不同:
- 余弦相似度集中在 0.2~0.9。
- BM25 可能到 40 以上,且随语料规模增长。
word_similarity在 0.25 以下才没有区分意义(项目取>= 0.25为命中阈值)。- MaxSim 在 0~1 之间。
直接加权求和的实际效果是「数值天生大的那条腿说了算」, 与「更准确的那条腿说了算」没有关系。分数校准(min-max 归一化、z-score)可以缓解,但校准参数来自每批候选的分布,会随查询变化波动,本身就引入了不确定性。
4.2 RRF 的二进制形式
Reciprocal Rank Fusion(Cormack 等,2009):
score(d) = Σ_{leg} w_leg / (k + rank_leg(d))rank 从 1 开始,k 为平滑常数,原论文取 60。RRF 只使用名次,不使用分数,因此天然跨量纲:一分被多条腿同时排在前面的项,累加出的分数明显更高。
k 的作用是压平头部差距,它方向会让第一条腿与第二条腿的差距。k=60 时相邻名次的贡献仅差约 1.6%,所以「第二条腿也把它排得很靠前」几乎总能压过「第一条腿独行多一两个名次」。k 调小则单腿头部的绝对优势变大。k 调大则趋近于「被多少条腿同时提到」的投票机制。
4.3 与学习型融合的分界
RRF 不需要标注、不需要训练、为检索新增一条腿也不必调整融合层,这些是它在小团队自部署上高性价比的理由。它的上限同样是这些理由的另一面:RRF 不会学到「照片类查询应重视视觉腿」这种类别规律。
再上一档是 learning-to-rank:用点击日志或人工标注训练融合模型,或做分数校准加线性加权。事项上需要标注、离线评估集,且新腿加入时点名重训。折中方案是在这部分系统外另加一层路由(项目做法:《检索与问答》)——先按 query 意图分类,再给各腿赋不同权重,融合仍用 RRF。
5. 精排(cross-encoder)
双塔的精度上限源于交互时机:query 与文档在编码阶段互不可见,全部交互被压进一次点积。cross-encoder 把两者拼接成一个输入过模型,BERT 类模型会看到每个 token 之间都交互,因此精度显著高于双塔。
代价是不能预计算。每个 (query, doc) 对都要一次前向推理,n 个候选就是 n 次推理,因此它只能放在召回后,对 top-k 重排。
这条约束推导出的参数关系:精排单对耗时 × 候选数 = 额外延迟。候选数加倍,延迟近乎加倍,所以候选上限要小。两个常被追问的边界:
- 精排不召回。 真值不在候选集里,精排无法把它带回。
- 精排同样有输入长度限制。 超过模型最大长度的输入会被截断,截断范围之外的内容对精排不可见。
6. late interaction 与 MaxSim
ColBERT 的思路是不压缩文档级单向量:保留 token 级表示,打分时才发生交互。
score(q, d) = Σ_{i∈q} max_{j∈d} q_i · d_j每个 query token 在文档的所有 token 里找最像的那一个,取最大相似度,最后对 query 求和。用 max 而非 mean 的原因是语义上的:max 对应「文档里有没有一处与这个词高度对应」,mean 会被无关 token 与文档长度稀释。max 的已知代价是噪声放大:长文档里出现一个偶然的极值就能拉高整体分数。
三种交互方式在存储、延迟与精度上的对比:
| 交互方式 | 交互发生时机 | 存储 | 精度 | 典型角色 |
|---|---|---|---|---|
| 双塔 | 只比较层的单向量 | 1 个向量/文档 | 低 | 大规模召回 |
| late interaction | 打分时逐 token 交互 | N 个向量/文档 | 中高 | 细粒度召回 |
| cross-encoder | 编码时全交互 | 不存储 | 最高 | 精排 |
late interaction 的代价同样直接:存储是 N × d(视觉场景下 N 是 patch 数),打分是 |q| × N 次点积。因此通常配合降维(ColBERT 用 128 维而非 768)与量化使用,并只对页级这类总量有限的对象建索引——对全文每个 token 建多向量的存储与打分代价,只有页级图像承受得起。
扫描件为什么不走 OCR:OCR 会破坏版面信息(表格行列、图注位置),识别错误又不可逆。而项目的引用要能定位到原页的框。视觉索引直接用页图,引用落在整个页图上,误差只剩「这一页对不对」而没有「识别对不对」。
7. 点 ID:内容寻址
7.1 为什么不用自增序号
自增序号是存储状态的一部分:重建索引就变、插入顺序一变就漂移。在向量库里这会带来三类问题——
- 缓存、标注、反馈等外部引用依赖点 ID,号变了它们全部错位。
- 同一份数据拆在多个 collection(如一条分项腿各一个 collection)时,各 collection 各自自增,同一 chunk 在几处 ID 不一致,融合需要一个映射表。
- 哈希出的点 ID 则是
(库, 文档, 第几块)三元组的一元函数,在任何机器、任何时刻都算出同一个值。这意味着upsert幂等、重建不换号、多 collection 天然对齐。
7.2 实现上的三个问题
- 编码不能有歧义:
(1, 23, 4)与(12, 3, 4)不能拼出同一个输入。用固定宽度二进制拼接,不要用字符串拼接。 - 值域要跨语言安全:向量库的 id 若是 uint64,而客户端栈里常被当作有符号 64 位(protobuf
int64、Java 无 unsigned long、JSNumber只有 53 位整数精度),通常会把值右移一位压进正数区间,用 1 bit 熵换序列化安全。 - 用符号做命名空间:页级点与块级点可以用符号区分(例如页级传负数),第 0 页与第 0 块才不会撞号。
7.3 碰撞概率
n 个点落在 64 位哈希空间里的碰撞期望约为 n² / 2^64(若右移一位则为 2^63):
| 点数 | 碰撞概率量级 |
|---|---|
| 10⁶ | 10⁻⁷ 量级 |
| 10⁸ | 10⁻³ 量级 |
百万级可以忽略。真到了十亿级,要么换成 128 位哈希或 UUID,要么在写冲突时加盐重算。
7.4 前提
稳定点 ID 有一个前提:块序号必须稳定,即同一份内容的切分结果不随时间变化,且一个文档内序号唯一(数据库层通常用 UNIQUE (doc_id, chunk_index) 兜住)。改变切分策略后,同一个元组对应的是不同内容的块,必须重新摄取整份文档,而不能只改向量。
8. 引用溯源:文档、页、框
「点回原文」需要三层信息,逐层收紧:
| 层级 | 信息 | 来源 |
|---|---|---|
| L1 | 文档 id | documents 行 |
| L2 | 页号 | PDF 有文本层的页 |
| L3 | 页面上的框 | 归一化的 bbox,直接可渲染 |
L3 坐标做归一化(除以页面宽高,缩放到 0~1),好处是渲染分辨率、缩放倍数、DPI 完全无关,前端拿到后直接乘回页图的像素尺寸就是画框位置。坐标来自 PDF 的文本层:解析库(如 PyMuPDF)给出每个 block/word 的 bbox,按阅读顺序(多栏需要自行重排)合并成段落与表格的框。表格可以细到单个单元格,并带上行列下标,「表头第三列」才能高亮到具体格子而非整张表。
扫描件没有文本层,坐标退化为整页,这正是它必须走视觉索引的原因:没有位置信息就没有 L3 引用。
不用字符偏移(char_start/char_end)做引用定位的两个原因:偏移只对纯文本有意义,PDF 的换行、分栏、页眉页脚都要单独还原。以及文本清洗(如 PII 掩码)会改变文本长度,清洗后的偏移与原页对不上。bbox 不受这两条影响。
9. 软删与删除链
删除在索引型系统里拆两步:逻辑删除(打时间戳,让检索看不见,通常配合回收站)先发生,物理删除(词表、向量库、磁盘文件一并清掉)延迟到安全窗口到期之后。
拆两步的原因是物理删除不可逆且跨三个存储:Postgres 行可事务回滚,Qdrant 点与磁盘文件不能。一次删除若在三者之间留下半完成状态,要么丢掉还被引用的文件,要么留下永远搜得删不掉的点到词表等问题。
「检索看不见」与「存储真的干净」之间必然存在一个时间窗。兜住这个窗靠两个东西:
- 检索侧二次过滤(把
is_current与deleted_at IS NULL纳入查询条件),先让已删内容从结果里消失。 - 延迟的物理清理(定时任务 + 回收站数组到期),之后让存储慢慢对齐。
内容寻址的引用计数也属于删除链的一部分:多个文档版本引用同一份原件时只存一份,引用数减到 0 才允许删磁盘目录。减多了删掉别人还在用的文件,少了则磁盘只增不减。
10. 术语速查
| 术语 | 一句话 |
|---|---|
| 稀疏检索 | 维度为词表的权重向量,绝大多数为 0,词权重求和打分 |
| df / idf | 含该词的文档数;由它算出的稀有度权重 |
| BM25 | 词频饱和 + 长度归一化,k1 与 b 分别控制二者 |
| 稠密检索 | 数百维实数向量表示,余弦/内积衡量语义相近 |
| 双塔(bi-encoder) | query 与文档分别编码,文档向量可离线预计算 |
| cross-encoder | query 与文档联合编码,精度最高但延迟最高 |
| ANN / HNSW / IVF / PQ | 近似最近邻的几种主流索引(图 / 聚类倒排 / 乘积量化) |
| 词汇鸿沟 / 语义鸿沟 | 稀疏检索换说法搜不到;稠密检索语义对但字不合 |
| RRF | 只按名次的多路融合,跨量纲免训练 |
| late interaction / MaxSim | token 级向量,打分时逐 token 取最大相似度求和 |
| 点 ID | 三元组哈希出的稳定编号,重建不换号 |
| 内容寻址 | 用内容哈希当存储路径,同内容仅存一份 |
| 归一化 bbox | 缩放到 0~1 的坐标框,与分辨率无关 |
| fail-closed | 拿不到授权时把条件判成恒假,而不是不过滤 |
| recall@k / MRR / nDCG | 召回量 / 首个正确项排名倒数均值 / 分级相关度排序质量 |