索引与召回¶
召回阶段的目标不是选出最终答案,而是在可接受成本下构造高覆盖候选集。关键词、稠密向量和近似最近邻解决不同问题;它们首先受数据边界和过滤顺序约束。
从文档到检索单元¶
切分函数可以写成:
其中 \(c_i\) 是内容,\(m_i\) 是权限、时间和结构 metadata,\(s_i\) 是回到原文的 span。好的 chunk 必须同时满足:
- 单独取出时语义基本完整;
- 标题、列表、表格和代码边界没有被随意截断;
- 能回到唯一原文位置;
- 不把不同权限或版本混在同一单元;
- 长度适合 retriever 与 reranker,而非只适合生成窗口。
固定 token 长度加 overlap 是基线,不是最终答案。层级文档可先按章节结构切分,再对过长节点递归切分;表格、代码、对话和扫描 PDF 需要各自的解析契约。
保留 span 的窗口基线¶
输入是带稳定顺序的 token 序列、窗口大小与 overlap;输出保留半开原文区间 [start,end) 及其内容,因此任何候选都能精确回到同一 token snapshot。
def chunk_with_spans(tokens, size, overlap=0):
if size <= 0 or not 0 <= overlap < size:
raise ValueError("require 0 <= overlap < size")
chunks, step = [], size - overlap
for start in range(0, len(tokens), step):
end = min(start + size, len(tokens))
chunks.append({"start": start, "end": end, "tokens": tokens[start:end]})
if end == len(tokens):
break
return chunks
tokens = list("abcdefghijk")
chunks = chunk_with_spans(tokens, size=5, overlap=2)
assert chunks[0] == {"start": 0, "end": 5, "tokens": list("abcde")}
assert chunks[-1]["end"] == len(tokens)
assert all(tokens[c["start"]:c["end"]] == c["tokens"] for c in chunks)
assert all(a["end"] - b["start"] == 2 for a, b in zip(chunks, chunks[1:]))
不变量是 span 与内容可逆、最后一块不越界、相邻窗口共享明确的 overlap。生产解析还要把文档版本、字符 / byte offset、标题路径、权限和结构边界写入 chunk identity;结构化切分与边界测试见手撕:检索与智能体 · 带 span 的切分。
Sparse retrieval¶
BM25 对查询 \(q\) 和文档 \(d\) 的常用形式为:
\(f(t,d)\) 是词频,\(b\) 控制长度归一化,\(k_1\) 控制词频饱和。它对产品编号、报错文本、人名和稀有术语通常很有价值。BM25 的概率相关性脉络可从 Robertson 与 Spärck Jones 的技术报告回溯。
关键词方法依赖分词、字段权重和查询语言。代码符号、中文粒度、同义词扩展与停用词规则都会改变结果,必须把 analyzer 版本视为索引版本的一部分。
BM25 的原子实现应接收已经由同一 analyzer 处理的 token,而不是在评分函数里偷偷选择分词规则。下面返回一条 query 对每篇文档的分数;df 与 avgdl 必须来自同一索引 snapshot。
import math
from collections import Counter
def bm25(query, documents, k1=1.2, b=.75):
if not documents:
return []
counts = [Counter(doc) for doc in documents]
avgdl = sum(map(len, documents)) / len(documents)
if avgdl == 0:
return [0.] * len(documents)
df = {term: sum(term in c for c in counts) for term in set(query)}
scores = []
for doc, tf in zip(documents, counts):
score = 0.
for term in query:
freq = tf[term]
if not freq:
continue
idf = math.log(1 + (len(documents) - df[term] + .5) / (df[term] + .5))
score += idf * freq * (k1 + 1) / (freq + k1 * (1 - b + b * len(doc) / avgdl))
scores.append(score)
return scores
docs = [["red", "apple"], ["green", "apple", "tree"], ["banana"]]
scores = bm25(["green", "apple"], docs)
assert scores[1] > scores[0] > scores[2]
assert bm25(["missing"], docs) == [0., 0., 0.]
assert bm25(["apple"], []) == []
assert bm25(["apple"], [[], []]) == [0., 0.]
这里没有字段权重、权限过滤或索引增量;生产召回必须先把租户、版本与可见范围绑定到候选集合。重复 query term 是否重复计权也是 analyzer/query-language 契约的一部分,不能在不同服务中各自猜测。
Dense retrieval¶
双编码器分别编码 query 与 passage:
DPR 使用 in-batch negatives 训练开放域问答检索器。对 batch 中第 \(i\) 个正例,常见损失为:
负例决定模型学会区分什么。随机负例过易,hard negative 过度集中又可能包含未标注正例。训练、离线评测和线上语料之间的领域偏移,也会让高相似度不再等于可回答。
精确 cosine top-\(k\)¶
输入 query [D]、document matrix [N,D] 与 \(k\),输出完整扫描后的相似度和 document row ID,作为 ANN 的 correctness oracle。零向量的 cosine 未定义,因此 reference 明确拒绝,而不是静默产生一个伪分数。
import torch
def exact_cosine_topk(query, documents, k):
if query.ndim != 1 or documents.ndim != 2:
raise ValueError("expected query [D] and documents [N,D]")
if query.numel() != documents.shape[1] or not 0 < k <= documents.shape[0]:
raise ValueError("incompatible shape or k")
query, documents = query.float(), documents.float()
query_norm = query.norm()
document_norm = documents.norm(dim=-1)
if query_norm == 0 or torch.any(document_norm == 0):
raise ValueError("cosine similarity requires non-zero vectors")
score = (documents @ query) / (document_norm * query_norm)
index = torch.argsort(score, descending=True, stable=True)[:k]
return score[index], index
query = torch.tensor([1., 0.])
documents = torch.tensor([[1., 0.], [0., 1.], [-1., 0.], [1., 1.]])
score, index = exact_cosine_topk(query, documents, k=2)
assert index.tolist() == [0, 3]
torch.testing.assert_close(score, torch.tensor([1., 2 ** -0.5]))
assert torch.all(score[:-1] >= score[1:])
分数必须由同一 embedding revision、同一归一化规则和同一可见候选集产生;稳定排序只定义相同分数时的 row-order tie-break。生产检索应先做权限约束,再用这个 oracle 测 ANN recall–latency 曲线,而不是在线全扫;更完整的零向量与候选对照见手撕:检索与智能体 · 精确 dense retrieval。
ColBERT 保留 token 级表示并用 late interaction:
它比单向量保存更多细粒度匹配信息,代价是索引和检索计算更大。ColBERTv2 进一步研究了压缩与训练。
Hybrid retrieval¶
稀疏与稠密分数通常不在同一尺度。直接线性相加前必须校准;更稳健的起点是基于名次的 Reciprocal Rank Fusion:
原始 RRF 论文展示了这种简单融合。\(k\) 不是普适常数,应结合候选深度验证;不同检索器若高度相关,增加一路并不会带来等量新信息。
近似最近邻¶
精确扫描 \(N\) 个 \(d\) 维向量的代价近似 \(O(Nd)\)。大规模服务常使用 ANN,以少量召回损失换延迟和内存。
HNSW 建立分层近邻图:上层稀疏图负责远距离导航,底层稠密图负责局部搜索。关键参数至少包括:
- 构图连接数,影响内存和图质量;
- 构图搜索宽度,影响写入成本;
- 查询搜索宽度,影响 recall–latency 曲线;
- 距离度量和向量归一化;
- 删除、更新和增量插入策略。
ANN 只能近似给定 embedding 空间中的近邻。它不能修复 embedding 语义错误,也不能代替权限过滤。
过滤顺序¶
过滤有两种基本路线:
- pre-filter:先限定可见集合,再做相似度搜索;安全边界清楚,但小过滤集可能破坏索引效率;
- post-filter:先取 ANN 候选再过滤;实现简单,但过滤后可能不足 \(k\),且绝不能把未授权候选送入后续模型。
生产实现常使用可感知过滤的索引、迭代扩大候选集或按租户分片。无论方案如何,授权必须在返回证据前强制成立,不能依赖 prompt 要求模型忽略不可见内容。
新鲜度与版本¶
一次请求应能回答:
- 使用哪一版原始语料、解析器、embedding 与索引;
- 新文档何时完成全链写入;
- 更新是否留下旧版本重复候选;
- 删除是否同步清除缓存和派生索引;
- 回滚时 query encoder 与 document embedding 是否兼容。
双编码器升级若只重算一侧,会让空间失配。灰度期间应绑定兼容版本,并用相同查询集比较 exact search 与 ANN 的差额。
诊断矩阵¶
| 现象 | 优先检查 |
|---|---|
| 专有名词搜不到 | analyzer、字段、BM25 与原始文本 |
| 语义近似但实体错 | hard negative、metadata filter、reranker |
| exact 命中而 ANN 未命中 | 搜索宽度、构图质量、度量与归一化 |
| 更新后新旧答案混合 | 文档版本、增量索引、缓存键 |
| 有权限用户偶发空结果 | pre-filter 选择性与候选扩展 |
| 离线好、线上差 | 查询分布、语料新鲜度、解析失败 |
候选如何精排和压缩见重排与上下文工程,BM25、RRF 与精确向量检索代码见手撕:检索与智能体。