Skip to content

检索通道 ​

两条通道、一个谓词。这一篇讲清楚 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——真实文档几乎不可能全部命中,召回接近于零。

做法是取出词元后改用 | 连接,再按覆盖度排序:

sql
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 的提升有多少只是因为稀疏通道太弱」。

三、稠密通道:精确检索 ​

sql
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 每次导入都不同。

对一个把「可复现」当作卖点的项目,这是致命的。修复是用稳定键打破平局:

sql
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、一个精确余弦。项目的工程含量不在检索算法,而在它们共用同一个授权谓词、排序可复现、以及每个决定都有对应的测量计划。下一篇看评测方法如何把这些主张变成数字。

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权