重排序:为了更好的相关性
检索相关文档只是工作的一半。重排序帮助我们识别哪些结果真正值得成为上下文。
梯形图转SCL | 自然语言生成梯形图 | 自然语言生成SCL | 梯形图在线查看 | 博途编程文档MCP | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace
我已经使用了混合检索,我可以把所有块都给LLM总结吗?
在上一篇文章中,我们讨论了改进向量数据库检索的各种方法。
其中一个有效的方法是使用混合检索,结合向量和基于关键词的搜索方法。
但仅仅检索文档/块是不够的,因为
- 你可能有太多向量存储检索到的候选者,这可能会膨胀LLM的上下文窗口。
- 纯检索涉及孤立地查看查询和上下文,仅仅向量相似性并不是相关性的指标。
- 你可能想强制执行一些策略、过滤器等(删除重复项,为检索到的块收集周围的块)
以及更多…
我们如何确定检索到的块是否与查询相关并量化其相关性?
我们转向重排序。
1、检索给我们候选者。重排序给我们相关性。
此时,我们已经做了很多工作。
我们清理了查询,在需要时扩展了它,将其路由到正确的源,并检索了一组可能包含答案的文档。
但仍然有一个问题。
检索器返回的文档不一定以最佳方式排序。
假设我们的查询是:
"如何减少Python API的响应时间?"
我们的向量搜索可能会返回如下内容:
1. Python应用程序性能最佳实践
2. 监控API响应时间
3. 优化Python应用程序中的数据库查询
4. 使用异步端点提高FastAPI性能
5. Python内存管理
6. API速率限制策略
所有这些结果都与查询有些相关。
但它们同样有用吗?
可能不是。
对于这个特定查询,关于FastAPI性能或数据库查询优化的文档实际上可能比关于Python性能的通用文章更有用。
这就是重排序的用武之地。
我们不是立即将前几个检索到的文档发送给LLM,而是引入另一个步骤:
用户查询
|
v
检索器
|
v
前20-50个候选者
|
v
重排序器
|
v
前5个最相关的块
|
v
LLM
检索器的工作是确保我们不会错过潜在有用的信息。
重排序器的工作是决定哪些候选者真正值得排在顶部。
2、但我们不是在检索时已经排序了吗?
是的。当我们执行向量搜索时,文档已经使用相似度分数排序了。那么为什么我们还需要另一个模型?
答案在于嵌入的工作方式。
基于嵌入的检索,查询和文档通常独立编码。
概念上:
查询
|
v
嵌入模型
|
v
查询向量
文档
|
v
嵌入模型
|
v
文档向量
然后我们使用余弦相似度等方法比较这两个向量。
这种方法非常有用,因为文档嵌入可以预先计算并存储在向量数据库中。
当查询到来时,我们只需要计算一个查询嵌入并搜索附近的向量。
这使得即使有数千或数百万文档,检索也很快速。
但有一个权衡。
因为查询和文档是分别编码的,模型在决定它们的相关性时永远没有机会一起查看它们。
重排序模型可以做到这一点。
3、交叉编码器的引入
重排序最常用的方法之一是交叉编码器。
交叉编码器不是独立嵌入查询和文档,而是将两者一起处理:
[ 查询 + 文档 ]
|
v
交叉编码器
|
v
相关性分数
模型现在可以直接检查查询中的单词和文档中的单词之间的关系。
对于每个检索到的文档,我们计算类似的内容:
score(query, document_1)
score(query, document_2)
score(query, document_3)
...
然后我们使用这些新分数对文档进行排序。
交叉编码器通常提供更强的相关性评分,因为查询和文档可以在模型内交互。
缺点是每个查询-文档对都需要单独处理,这就是为什么它们通常只用于初始检索器返回的较小候选集。
这给了我们一个两阶段检索系统:
阶段1 — 检索
优化速度和召回率。
找到可能相关的文档。
阶段2 — 重排序
优化精度。
确定哪些文档实际上最相关。
这种区别很重要。
我们永远不想对包含数百万块的知识库中的每个文档运行交叉编码器。
那会使检索变得极其缓慢。
相反,向量搜索给我们一个可管理的候选集,交叉编码器只对这些候选者执行昂贵的推理。
4、代码中的重排序
sentence-transformers库使这变得相当简单。
我们可以使用预训练的交叉编码器:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder(
"cross-encoder/ms-marco-MiniLM-L6-v2"
)
假设我们的检索器返回了一组文档:
query = "How can I improve the response time of my Python API?"
retrieved_documents = [
"Python applications can consume large amounts of memory...",
"FastAPI endpoints can use async functions for non-blocking I/O...",
"Database indexes can significantly improve query performance...",
"Rate limiting protects APIs from excessive requests...",
"Caching frequently requested data can reduce API latency..."
]
我们为每个结果创建一个查询-文档对:
pairs = [
(query, document)
for document in retrieved_documents
]
并将它们传递给交叉编码器:
scores = reranker.predict(pairs)
现在我们只需将分数附加到文档并排序:
reranked_documents = sorted(
zip(scores, retrieved_documents),
key=lambda x: x[0],
reverse=True
)
我们的最终结果可能如下所示:
0.91 FastAPI端点可以使用异步函数...
0.86 缓存频繁请求的数据可以减少API延迟...
0.81 数据库索引可以显著提高查询性能...
0.42 Python应用程序可能消耗大量内存...
0.25 速率限制保护API免受过多请求...
注意这里发生了什么。
所有五个文档都有足够的语义相似性通过我们的检索阶段。
但一旦我们直接将它们与问题进行比较,它们之间的差异就变得更加清晰。
我们现在只取前几个结果:
top_documents = [
document
for score, document in reranked_documents[:3]
]
并将它们传递给我们的LLM。
5、为什么不一开始就只检索3个文档?
如果我们的LLM最终只需要三个块,为什么不直接从向量数据库检索top_k=3?
因为这样做对第一阶段检索器寄予了很大信任。
想象一下,真正有用的文档在向量搜索中出现在第7位。
如果我们只检索三个文档:
1. 相关
2. 有些相关
3. 有些相关
----------------
7. 高度相关 <-- 从未检索到
重排序器永远没有机会修正排名。
一个更有用的策略通常是检索更大的候选集:
向量搜索
前20个
|
v
重排序器
|
v
前5个
确切的数字取决于你的应用程序。
增加候选者数量可以提高正确信息到达重排序器的机会,但它也会增加延迟和计算。
因此,我们不应该随机选择这些数字,这成为我们应该评估的内容。
6、重排序无法修复糟糕的检索
这里有一个重要的限制。
重排序器只能重排序你给它的内容。
假设相关文档从未出现在候选集中:
检索到的文档
A
B
C
D
E
但实际答案存在于文档X中:
无论我们的重排序器多么强大,它都无法神奇地引入文档X。
这就是为什么本系列前面讨论的技术仍然重要。
查询预处理、查询扩展、元数据过滤、混合搜索、分块和检索策略都会影响重排序器可用的候选集。
重排序不会取代良好的检索。
它改进了它。
我喜欢这样想:
糟糕的检索 + 出色的重排序
=
仍然是糟糕的上下文
第一阶段需要良好的召回率。
第二阶段有助于提高精度。
7、使用LLM作为重排序器怎么样?
我们已经在到处使用LLM,所以一个明显的问题是:
为什么不直接询问LLM哪些文档相关?
我们可以。
例如,我们可以提供一个查询和几个块:
查询:
如何提高Python API的响应时间?
文档:
1. ...
2. ...
3. ...
4. ...
根据这些文档与查询的相关性进行排序。
LLM可以推理较小的重排序模型可能难以处理的事情。
它还可以考虑特定于应用程序的指令,例如:
- 优先选择包含实现细节的文档。
- 优先选择文档而非讨论线程。
- 对于故障排除问题,优先选择包含根本原因和解决步骤的文档。
这给了我们更多的控制权。
但这是有代价的。
LLM重排序通常意味着:
- 更多的token
- 更高的延迟
- 更高的成本
- 更复杂的提示
- 运行之间的潜在变化
所以我不会立即在每个查询和每个检索到的文档之间放置一个LLM。
交叉编码器或专用重排序模型通常是更简单的起点。
当相关性本身需要比简单查询-文档匹配更复杂的推理时,基于LLM的重排序变得有趣。
8、重排序即服务
我们也不一定需要自己托管交叉编码器。
有专门的重排序API,我们提供:
查询 + 检索到的文档
并接收:
文档 + 相关性分数
例如,Cohere提供专门设计用于重新排序现有词汇或语义搜索系统文档的重排序模型。
概念上,工作流保持完全相同:
向量数据库 / 搜索引擎
|
| 候选文档
v
重排序API
|
| 重新排序的文档
v
LLM
你是使用本地托管的交叉编码器还是托管的重排序API取决于应用程序的要求。
对于延迟、隐私或基础设施控制很重要的应用程序,自己托管模型可能是有意义的。
对于你想要快速试验重排序质量而不管理另一个模型的应用程序,API可能很方便。
架构本身不会改变。
9、不要将重排序分数视为真理
另一个错误是查看重排序分数并开始创建任意规则:
if score > 0.8:
relevant = True
分数对于排序文档很有用,但其绝对值应该谨慎解释。
相同的分数阈值对于不同的数据集、查询和重排序模型可能表现非常不同。
甚至提供标准化相关性分数的供应商也建议根据代表性查询评估阈值,而不是假设特定分数普遍意味着"相关"。
如果我们想使用阈值删除低质量文档,我们应该从自己的评估数据集中得出该阈值。
这引出了另一个重要的观点。
10、重排序实际上改进了什么?
添加重排序器因为它听起来像一个好的RAG技术很容易。
知道它是否实际上改进了你的系统更重要。
假设在重排序之前我们得到:
查询:"如何优化数据库连接?"
检索结果:
1. Python通用优化
2. 数据库监控
3. 连接池配置
4. 数据库备份策略
5. 连接超时配置
重排序后:
1. 连接池配置
2. 连接超时配置
3. 数据库监控
4. Python通用优化
5. 数据库备份策略
这看起来更好。
但我们不应该只依赖视觉检查。
使用一组查询和已知的相关文档,我们可以使用以下指标比较重排序前后的检索:
- MRR — 平均倒数排名
- nDCG — 标准化折扣累积增益
- MAP — 平均精度均值
例如,Sentence Transformers提供重排序评估器,为交叉编码器模型计算包括MRR、NDCG和MAP在内的指标。
对于RAG系统,我还会超越检索指标。
最终,我们关心的是更好的排名是否改善了最终答案。
因此我们的评估可以逐步通过管道:
检索评估
我们检索到了正确的信息吗?
|
v
重排序评估
正确的信息是否移向顶部?
|
v
生成评估
最终响应是否使用了正确的信息?
这使得调试变得更加容易。
如果正确的文档在检索期间从未出现,修复发送给LLM的提示可能无济于事。
如果正确的文档被检索到但始终排名过低,重排序可能正是我们需要关注的地方。
11、更多重排序并不总是更好
不断添加层是很诱人的:
向量搜索
|
v
混合搜索
|
v
交叉编码器
|
v
LLM重排序器
|
v
另一个相关性过滤器
|
v
LLM
最终,我们可能将相关性提高几个百分点,同时使应用程序明显变慢且更昂贵。
每个额外的阶段都应该回答一个问题:
我试图修复什么故障?
如果你的检索器已经为大多数查询在位置1返回正确的文档,添加强大的重排序器可能不会给你足够的改进来证明额外的延迟是合理的。
另一方面,如果相关文档经常出现在候选集中的某个位置但不在顶部附近,重排序可能会产生显著差异。
这就是为什么在优化检索管道之前构建小型评估集如此有用。
它让我们基于故障而非假设进行改进。
12、将一切整合在一起
在这个阶段,我们的RAG管道开始看起来与我们开始时的简单示例大不相同。
而不是:
查询
|
v
向量数据库
|
v
LLM
我们现在更接近:
用户查询
|
v
查询预处理
|
v
查询路由
|
v
查询扩展
|
v
搜索
|
v
候选文档
|
v
重排序器
|
v
最佳上下文块
|
v
LLM
每个组件都有非常具体的责任。
检索器不需要完美理解哪个文档是最好的。
它需要确保有用的文档进入候选集。
重排序器不需要搜索数百万文档。
它只需要仔细比较一个更小的集合与查询。
LLM不需要弄清楚嘈杂集合中的哪些部分是有用的。
理想情况下,当上下文到达LLM时,大部分工作已经完成。
这种职责分离使得管道更容易改进。
13、那么,每个RAG系统都应该有重排序器吗?
不一定。
对于具有高度不同文档的小型知识库,简单的语义检索可能已经工作得非常好。
但随着知识库的增长,文档变得越来越相似。
你可能有:
如何配置功能X
如何排除功能X的故障
功能X已知限制
功能X发行说明
功能X迁移指南
所有这些块在语义上都很接近。
检索相关的内容变得容易。
检索针对特定问题的正确信息变得更加困难。
这就是重排序变得更有价值的地方。
检索器给我们选项。
重排序器给我们优先级。
一旦我们优先考虑了最佳上下文,我们终于准备好RAG管道的最后部分:
从该上下文生成答案。
原文链接:Reranking for better relevance
汇智网翻译整理,转载请标明出处