检索通道
两条通道、一个谓词。这一篇讲清楚 PostgreSQL FTS 为什么需要改写查询、向量检索为什么暂时不建索引,以及排序为什么必须是确定的。
一、先说结论
- 稀疏通道是 PostgreSQL FTS,不是 BM25:
ts_rank_cd不使用 IDF 等语料统计,如实命名才能让 hybrid 的提升可被正确解读。 plainto_tsquery会 AND 所有词,自然语言问题几乎命中不了任何文档,必须改写成 OR。- 向量检索用精确余弦,v0.1 不建 ANN 索引:过滤不会损失召回,这条不变量因此构造性成立。
- 排序必须确定:同分时按文档 key、版本、chunk 序号打破平局,否则重复运行的评测结果不可比较。
- 两条通道共用同一个授权谓词对象,不是「各写一份条件」。
二、稀疏通道:OR 改写
PostgreSQL 内置的 plainto_tsquery 把查询里的所有词元用 & 连接。问一句 "How many paid volunteer days do EU employees receive?",它会要求同一个 chunk 里同时出现 paid、volunteer、day、EU、employe、receiv——真实文档几乎不可能全部命中,召回接近于零。
做法是取出词元后改用 | 连接,再按覆盖度排序:
cast(replace(plainto_tsquery('english', :query)::text, '&', '|') as tsquery)排序函数是 ts_rank_cd,它衡量查询词元在文档中的覆盖与邻近程度,不考虑词在整个语料中的稀有程度。这就是它和 BM25 的本质差距:BM25 会让 "volunteer" 这种稀有词权重远高于 "how"、"receive",而 ts_rank_cd 不会。
因此项目在文档、README 与报告里一律称它为 PostgreSQL FTS。评测还计划增加一行 bm25-reference:用 Python 在同一批已授权 chunk 上跑 BM25,专门回答「hybrid 的提升有多少只是因为稀疏通道太弱」。
三、稠密通道:精确检索
select ..., 1 - (c.embedding <=> cast(:vector as vector)) as score
from chunk c
join document d on d.active_version_id = c.version_id and d.status = 'active'
join document_version v on v.id = c.version_id
where (c.tenant_id = :auth_tenant_id) and c.embedding is not null
order by (c.embedding <=> cast(:vector as vector)), d.external_key, v.version_no, c.ordinal
limit :limit没有 HNSW 或 IVFFlat 索引,这是刻意的:
- demo 规模(约 1500 个 chunk)下顺序扫描足够快;
- 精确检索意味着所有通过授权的行都是候选,「加过滤不损召回」无需证明;
- 一旦建了近似索引,就必须专门测量高选择性过滤(例如某项目只占 2% 文档)下的 Recall@k,并比较
hnsw.iterative_scan、按租户分区、提高ef_search等方案。这项实验被明确列入后续工作,而不是默认「支持过滤就等于没问题」。
查询向量由模型服务实时计算,语料向量在入库时算好。同一部署只允许一个活跃 embedding 模型:两个模型即使维度相同,向量空间也不可比较,因此只要发现活跃文档由其他模型生成,应用直接拒绝启动。
四、排序确定性
开发过程中出现过一个真实问题:两次全新导入后跑同一份评测,sparse 的 MRR 从 0.705 变成 0.788。原因是同分结果按随机生成的 chunk UUID 排序,而 UUID 每次导入都不同。
对一个把「可复现」当作卖点的项目,这是致命的。修复是用稳定键打破平局:
order by score desc, d.external_key, v.version_no, c.ordinal改完之后,在同一台机器上两次全新导入,逐条排名完全一致。跨机器则要分通道看:关键词通道在任何机器上都一致;向量通道在 arm64 与 x86_64 之间可能交换得分只差末几位的候选,因为 ONNX Runtime 在两种架构上用的向量内核不同。发布的数字因此统一取自 CI 环境,见评测方法。
五、只读当前版本
两条通道的 SQL 都 join 了 d.active_version_id = c.version_id and d.status = 'active',因此:
- 旧版本的 chunk 立即不可见,不需要删除它们;
- 文档停用只是改状态,下一次查询即生效;
- 版本切换是单事务内的指针翻转,读者只会看到完整的旧版本或完整的新版本。
六、结果里有什么
| 字段 | 用途 |
|---|---|
documentKey、versionNo | 定位到具体文档版本,评测据此映射证据 |
sectionPath、charStart、charEnd | 标题路径与规范化文本中的字符区间,用于 span 标注 |
channel、rank | 来自哪条通道、在该通道中的名次 |
score | 原始分数,只对带 debug 作用域的 token 返回 |
policyVersion | 这次检索使用的策略版本 |
score 受限是有意的:它能被用来推断被过滤掉的内容分布。评测 token 带 debug,普通 demo 身份不带。
七、接下来
M1 的顺序是先把数据集做难,再做 hybrid。M1a 已经完成这一步:数据集扩到 70 条(其中 30 条可回答用例与证据几乎没有共同词汇),加上 BM25 参考行与配对置信区间后,dense 与 FTS、BM25 与 FTS 之间都出现了区间不跨零的差异。M1b 在同一批用例上加入了 hybrid-rrf(两个通道各取 50 个候选,按 Σ 1/(60 + rank) 融合,只合并名次、不比较分数,再去掉与更高名次 chunk 重叠的结果)。结论是没有超过 dense:MRR@10 −0.05 [−0.12, +0.03],没有可检测的差异;它能救回 dense 漏掉的用例,却更常把排第一的正确结果往下挤。详见评测方法末尾的分析。
小结
两条通道都很朴素:一个 FTS、一个精确余弦。项目的工程含量不在检索算法,而在它们共用同一个授权谓词、排序可复现、以及每个决定都有对应的测量计划。下一篇看评测方法如何把这些主张变成数字。