相似不等于相关:两种编码器破解向量数据库的真正短板

本文解释为什么向量搜索找到相似内容不等于找到正确答案。现代搜索系统先用双编码器快速找回候选文档,再用交叉编码器仔细排序,两者配合才能又快又准。

为什么你不能只靠向量搜索

大多数人学语义搜索都是从一套简单做法开始的。先把文章变成向量,塞进向量数据库,用户提问的时候找出最接近的向量就行了。这个做法没错,但它会让你误以为向量搜索就是搜索的全部。

其实通常不是。

向量数据库能找到在向量空间里离你问题很近的文档。但近不代表有用。在实际项目里,一个结果只是语义上接近,和它真正能帮到你,这两者差别可以非常大。尤其是做检索增强生成、商品搜索、代码搜索、文档搜索的时候。

我习惯这么想:现代语义搜索是个两段式的系统。
第一段,一个跑得快的检索器从海量文档里拉出一批候选。
第二段,一个更仔细的重排序器决定哪些候选该排前面。

双编码器通常干第一段活,交叉编码器干第二段。

这篇文章就讲这个分工:为什么双编码器快,为什么交叉编码器准,为什么生产系统两个都要。

搜索既要快又要会判断

想象一个用户搜:如何降低向量搜索的延迟

你的系统可能有几百万份文档:技术博客、GitHub上的说明文件、研究论文、商品页面、官方文档、论坛讨论。理想情况下,模型会把问题和每一篇文档都比较一遍,然后返回最好的那些。问题是,这种深度比较非常费时。

如果文档库有一千万份文档,实时应用里不可能对每个问题和文档对都跑一个大型Transformer模型。搜索系统必须在几十毫秒到几秒内响应,同时还要足够准,不能只返回那些沾点边但实际没用的结果。

这就是为什么现代搜索系统通常长这样:

文档库 -> 检索器 -> 前K个候选 -> 重排序器 -> 最终排好序的结果

检索器负责召回。它要确保正确答案在候选集合里。重排序器负责精确。它要把最好的答案挪到最前面。

双编码器:跑得快的检索器

双编码器把问题和文档分开编码。

问题 -> 编码器 -> 问题向量
文档 -> 编码器 -> 文档向量

然后系统用一个相似度函数比较这两个向量。

相似度(问题向量, 文档向量)

常用的有余弦相似度、点积、欧氏距离。基本思路就是,问题向量离哪个文档向量近,那个文档就可能相关。

这类模型的例子有Sentence-BERT。它用了孪生网络结构,生成句子向量,然后用余弦相似度高效比较。这个设计好在文档向量可以在用户提问之前就提前算好。

建索引的时候,每个文档过一遍编码器:

文档1 -> 编码器 -> 向量1
文档2 -> 编码器 -> 向量2
文档3 -> 编码器 -> 向量3
...
文档N -> 编码器 -> 向量N

这些向量存到向量索引里。用户提问时,只需要把问题编码一次。

用户问题 -> 编码器 -> 问题向量

然后系统去搜索最近的文档向量。这就是为什么双编码器能和FAISS、Milvus、Pinecone、Weaviate、Qdrant、Elasticsearch的向量搜索等等这些系统配合得很好。

说白了,双编码器把语义搜索变成了一个找最近邻居的问题。

为什么双编码器快

双编码器快是因为它把建索引和查索引分开了。文档只编码一次,离线存好。问题在运行时只编码一次。然后在存好的向量上做近似最近邻搜索,这比每个问题和文档对都跑一个Transformer便宜太多了。

举个例子,假如问题是:Python的向量搜索库

文档库里有这些文档:

A: 一份FAISS、HNSW和Python近似最近邻搜索的指南。
B: 一份Python装饰器和函数包装器的教程。
C: 一份语义搜索的向量数据库介绍。

双编码器生成向量然后算相似度。

文档A相似度0.91
文档B相似度0.84
文档C相似度0.42

系统返回A和C,因为它们的向量离问题向量最近。对于生成候选集来说,这通常够用了。但对于最终排序来说,可能还不够。

双编码器哪里开始不灵

双编码器的主要限制是,问题和文档在最后算相似度之前没有深度的交互。问题被压成一个向量,文档被压成另一个向量。比较是在压缩之后才做的。

这种压缩正是双编码器快的原因,但也是它不完美的地方。

看这个问题:搜索高维向量的工具,不要关键词搜索

现在比较下面两个文档:

文档A: FAISS是一个对稠密向量做高效相似度搜索的库。
文档B: 这篇文章解释关键词搜索引擎怎么用倒排索引给文档排序。

一个好的搜索系统应该更倾向于文档A。但一个弱一点的双编码器可能被文档B里那些泛泛的搜索相关词汇带偏。其中“不要关键词搜索”是一个很细的限制条件,单个向量表示可能没法把它保留得足够清楚。

这就是为什么向量搜索有时候感觉挺接近但就是不太对。返回的结果可能话题没跑偏,但排序不是用户想要的。很多团队会发现,前20个结果看着还行,但前3个不是人肉眼看会觉得最应该排前面的。

交叉编码器:仔细的裁判

交叉编码器用另一种方法。它不把问题和文档分开编码,而是一起喂给模型。

[CLS] 问题 [SEP] 文档 [SEP] -> Transformer -> 相关性分数

现在模型同时看到两段文字。Transformer的注意力机制可以直接把问题里的词和文档里的词进行比较。

举个例子,问题是:低延迟向量搜索的最佳ANN索引

候选文档里写着:降低向量搜索延迟的HNSW、IVF和乘积量化实用指南

交叉编码器能更直接地把重要词汇连起来。

问题里的“ANN索引”对应文档里的“HNSW、IVF”,“低延迟”对应“降低延迟”,“向量搜索”对应“向量搜索”,“最佳”对应“实用指南”。

双编码器问的是两个向量近不近。交叉编码器问的是一个更具体的问题:给定这个确切的问题和这个确切的文档,这个文档有多相关?

这就是为什么交叉编码器常用来做重排序。

为什么交叉编码器更准

交叉编码器通常更准,因为它把问题和文档放一起处理。它能判断文档是不是真的回答了问题,重要的限制条件是不是满足了,匹配是精确的还是只是沾边。

这在那些词级别交互很重要的场景尤其有用。比如,用户问的是“降低向量搜索的延迟”,他可不想要一篇泛泛讲API延迟的文章,尽管两篇都可能出现“延迟”这个词。交叉编码器更有机会区分这种差别,因为它同时评估问题和候选文档。

基于BERT的交叉编码器在段落重排序里影响很大,就是因为它们在给出相关性分数之前,直接建模了问题和段落之间的交互。

为什么交叉编码器贵

问题在于成本。交叉编码器不能提前单独算好文档向量,因为相关性分数取决于具体的问题和文档对。

每一个新问题来了,模型都要重新处理每个候选:

问题 + 文档1 -> Transformer -> 分数
问题 + 文档2 -> Transformer -> 分数
问题 + 文档3 -> Transformer -> 分数
...
问题 + 文档N -> Transformer -> 分数

如果N是100,这还能接受。如果N是一千万,那就完蛋了。

最简单粗暴的理解方式:交叉编码器是个厉害的裁判,但用它来做搜索太贵了。它不该搜整个文档库,它只该评判那个更快的检索器找出来的候选名单。

双编码器vs交叉编码器

关键点不是哪个模型更好。它们是给不同活优化的。

双编码器输入是分开编码的,输出是向量,速度快,准确度良好,文档向量能提前算好,能和向量数据库配合,最佳角色是生成候选,能扩展到百万级文档,没有词级别的交互。

交叉编码器输入是一起编码的,输出是相关性分数,速度慢,准确度通常更好,文档向量不能提前算好,不能直接和向量数据库配合,最佳角色是重排序,不能直接扩展到百万级文档,有词级别的交互。

两段式检索流程

一个实用的语义搜索系统经常把两个模型结合起来:用户问题 -> 双编码器 -> 向量搜索 -> 前K个候选 -> 交叉编码器重排序 -> 最终排好序的结果

双编码器给系统规模能力。交叉编码器给系统更好的最终排序。

第一段生成候选。这个阶段用双编码器搜整个文档库。目标是高召回,不是完美排序。系统要确保相关文档出现在候选集合里的某个位置就行。
比如,文档库大小一千万篇文档,双编码器找回前1000个候选,交叉编码器重排前100个或前1000个候选,最终输出前10个结果。
在这个阶段,检索器不需要知道完美的顺序。它只需要别漏掉重要的候选。

第二段重排序。这个阶段用交叉编码器给候选名单重新排序。我们不要求交叉编码器给一千万篇文档打分,只要求它给检索回来的候选打分。
问题 + 候选1 -> 交叉编码器 -> 分数
问题 + 候选2 -> 交叉编码器 -> 分数
问题 + 候选3 -> 交叉编码器 -> 分数
...
最终排序

这就是搜索系统变得更准的地方。交叉编码器能把真正有用的结果挪到那些只是语义相似的结果前面。

实际例子

假设你在给技术文章做搜索。用户搜:如何降低向量搜索中的延迟

系统有五百万篇文章。双编码器找回500个候选。有些是很强的匹配:

A: 为低延迟向量搜索优化FAISS索引
B: HNSW vs IVF: 选择合适的ANN索引
C: 内存高效的向量检索之乘积量化

有些只是沾边:

D: 如何降低REST API的延迟
E: 搜索引擎优化介绍
F: 前端开发中的矢量图形

双编码器已经做了有用的事:把五百万篇文章减少到了一个可控的集合。但候选名单里还是有噪音。现在交叉编码器可以重新排序这些候选。它能识别出“向量搜索延迟”和通用的后端延迟或者搜索引擎优化不是一回事。

最终排序可能变成这样:

1. 为低延迟向量搜索优化FAISS索引
2. HNSW vs IVF: 选择合适的ANN索引
3. 内存高效的向量检索之乘积量化
4. 如何降低REST API的延迟
5. 搜索引擎优化介绍

这一点很多人会忽略。向量搜索阶段并没有失败。它找出了一个合理的候选名单。重排序器只是让最终顺序变得更好。

为什么光靠向量搜索不够

一个常见错误是把向量搜索当成整个搜索系统。它通常只是第一阶段。

这一点在RAG系统、商品搜索、代码搜索、企业搜索、学术搜索、推荐系统、AI知识助手里都很重要。在这些系统里,一个只是泛泛相关的结果可能仍然是个糟糕的顶部结果。

搜索质量差也不一定是向量数据库的锅。有时候是嵌入模型太弱,有时候是分块策略太差,有时候是相似度度量选错了,有时候是系统找出了不错的候选但从来没给它们重排序。

我不会上来就假设数据库是瓶颈。我会先问三个问题:

第一,相关的文档有没有出现在检索回来的前K个候选里?
第二,如果出现了,它的排名是不是太靠后了?
第三,加上重排序之后,顶部结果质量的提升值不值得那点额外延迟?

这三个问题把检索问题和排序问题分开了。没有这种区分,你会一直改向量数据库的设置,而真正的问题其实在排序阶段。

这对RAG意味着什么

检索增强生成系统通常也走这个模式:用户问题 -> 双编码器检索 -> 前K个文本块 -> 可选的交叉编码器重排序 -> 大模型生成答案

如果检索器给大模型送去了弱相关的上下文,答案很可能就是弱的。这就是为什么很多RAG的失败其实是检索的失败。大模型可能说得很自信,但它是在错误的证据上生成的。

假设用户问:HNSW和IVF索引之间的权衡是什么?

如果检索器送过去的是些泛泛的关于向量数据库的文本块,大模型可能给出一个含糊的答案。如果交叉编码器把最相关的那些专门讲HNSW和IVF对比的文本块推到最前面,那答案就有大得多的机会变得具体。

在RAG里,生成器吸引了大部分注意力,但检索器往往决定了答案能好到什么程度。

怎么评估这个流程

搜索不能靠看五六个例子然后说结果不错来评估。不同阶段需要不同的指标。

召回率@K:相关文档有没有出现在前K个结果里。
精确率@K:前K个结果里有多少是相关的。
平均倒数排名:第一个相关结果排得有多靠前。
归一化折损累计增益@K:高度相关的结果有没有排到前面。
延迟:系统返回结果要多久。
吞吐量:系统每秒能处理多少查询。

对双编码器阶段来说,召回率@K尤其重要。如果检索器漏掉了相关文档,交叉编码器也没法把它找回来。

对交叉编码器阶段来说,平均倒数排名、归一化折损累计增益@K、精确率@K这些指标变得更重要,因为目标是改善排序。

在生产环境里,准确率只是一半的故事。你还需要测平均延迟、95分位延迟、99分位延迟、GPU或CPU成本、内存用量、索引更新速度、用户行为。一个重排序器虽然提升了归一化折损累计增益,但让系统变得太慢,那可能不值得上线。

实际上,我宁愿部署一个稍微弱一点但能保持延迟稳定的重排序器,也不要一个更强但让每次查询都觉得卡顿的重排序器。

什么时候用什么

一个简单的经验法则:用双编码器找候选,用交叉编码器决定哪些候选最好。

你需要搜几百万篇文档,用双编码器。
你需要低延迟检索,用双编码器。
你想用向量数据库,用双编码器。
你需要对一个小候选集做精确排序,用交叉编码器。
你需要词级别的问题和文档交互,用交叉编码器。
你在重排前100个搜索结果,用交叉编码器。
你在搭建生产级别的语义搜索,两个都用。
你在搭一个高质量的RAG流程,两个都用。

具体设计取决于延迟预算。一个小型RAG应用可能只重排20个文本块。一个商品搜索系统可能重排几百个候选。更大的系统可能会多加几个排序阶段。原理是一样的:先广泛检索,再仔细排序。

常见错误

错误一,把向量搜索当成整个搜索系统。向量搜索通常只是生成候选。如果顶部结果只是泛泛相关但不是真正有用,那系统可能需要重排序,而不是换个向量数据库。
错误二,对整个文档库用交叉编码器。这没法扩展。交叉编码器通常应该在检索之后用,而不是之前。双编码器搜索文档库。交叉编码器重排候选名单。
错误三,忽视第一阶段的检索器。重排序没法补救一个丢失的候选。如果双编码器没找回正确的文档,交叉编码器永远没机会给它们打分。第一阶段的检索器决定了整个流程的天花板。

一个简单的思考模型

把双编码器想象成那个负责建候选名单的模型。它优化的是速度、规模和召回率。把交叉编码器想象成那个仔细检查候选名单的模型。它优化的是精确率。
这个思考模型比问哪个模型更好更有用。双编码器和交叉编码器不是在抢同一个活。它们通常是在解决同一个搜索问题的不同部分。

结论

语义搜索里最大的错误就是以为嵌入向量加一个向量数据库就能自动得到一个好的搜索引擎。它们产生的只是一个候选生成器。有时候这够了。但通常不够。

双编码器快是因为它把问题和文档分开编码。这让文档向量能提前算好,然后通过向量索引高效搜索。交叉编码器更准是因为它同时看问题和文档,但准度高伴随着更高的运行时成本。

我实用的建议很简单:别只靠向量数据库的结果来评判一个语义搜索系统。先检查检索器有没有找出正确的候选。再检查正确的候选有没有被排得足够靠前。如果第一个问题的答案是否,那就改进检索。如果第二个问题的答案是否,那就加上或改进重排序。

在很多系统里,缺失的那一块并不是一个更好的向量数据库。而是一个更好的排序阶段。