RAG 实战:向量检索 + 重排序,怎么防“答非所问”
别背“RAG = 向量检索 + LLM”这个公式了——你线上 80% 的“答非所问”,根本不是模型不够聪明,而是你的检索链路在“召回”阶段就把正确答案排到了第 50 名开外,重排序模型连见它一面的机会都没有。
我是 order-service 的 owner。上周三凌晨,客服机器人把“订单超时未支付自动关闭”解释成了“平台强制砍单”,用户投诉量直接打爆了 on-call 群。我查了日志,问题不在 LLM 的 temperature,而在 KNN 检索返回的 top-20 里,真正包含“自动关闭”语义的文档排在第 47 位。今天我们就拿 order-service 的真实代码,把 RAG 这条链路的“为什么”一层层剥开。
一、 先看事故现场:你的向量检索在“召回”阶段就瞎了
order-service 的文档库有 120 万条工单记录和操作手册。我们最初用的是最朴素的 Spring AI + PGVector,核心代码如下:
@Service
public class NaiveRetriever {
private final VectorStore vectorStore;
public List<Document> retrieve(String userQuery, int topK) {
// 只做向量相似度,topK=20
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(userQuery)
.topK(topK)
.similarityThreshold(0.55) // 你以为阈值能兜底?
.build()
);
}
}
事故当天的用户问题是:“我昨晚下的单没付款,早上起来发现没了,是不是被你们强制取消了?”
我们跑了一次离线评测,看这个 query 在向量库里的真实排名:
Query embedding vs Doc embedding (cosine similarity):
- Doc#2341 (订单超时自动关闭规则): 0.48
- Doc#8902 (支付超时处理流程): 0.51
- Doc#1123 (用户主动取消订单): 0.73
- Doc#7788 (平台风控冻结说明): 0.69
看到了吗?语义最相关的“自动关闭规则”相似度只有 0.48,排在第 47 位。为什么?因为用户问的是“没付款没了”,向量模型把“没了”和“取消”“冻结”在语义空间里拉得很近,而“自动关闭”这个动作在文档里是 @Scheduled(cron = "0 0/5 * * * ?") 这种技术描述,和口语化的“没了”根本不是同一个向量簇。
根因:向量检索是“语义近似”而非“逻辑精确”。它擅长处理“同义改写”,但扛不住“口语省略”和“业务状态机”的差异。你的 topK 设得再大,也只是把噪声一起捞回来,而不是把正确答案捞回来。
二、 破局第一步:混合检索(Hybrid Search)不是可选项,是必选项
我们第一版修复,是把 NaiveRetriever 彻底扔掉,换成 BM25 稀疏检索 + 向量稠密检索的加权融合。别觉得 BM25 老土,它在 order-service 这种“业务术语强、口语化弱”的场景里,比向量模型可靠得多。
@Service
public class HybridRetriever {
private final JdbcTemplate jdbcTemplate; // 用于 BM25 (PostgreSQL ts_rank)
private final VectorStore vectorStore; // 用于 Dense Retrieval
public List<ScoredDocument> hybridSearch(String userQuery, int topK) {
// 1. 稀疏检索:利用 PostgreSQL 全文检索,匹配“自动关闭”“超时”等强 token
String bm25Sql = """
SELECT id, content,
ts_rank(to_tsvector('simple', content), plainto_tsquery('simple', ?)) AS bm25_score
FROM order_docs
WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', ?)
ORDER BY bm25_score DESC
LIMIT ?;
""";
List<ScoredDocument> bm25Hits = jdbcTemplate.query(bm25Sql,
(rs, rowNum) -> new ScoredDocument(rs.getString("id"), rs.getDouble("bm25_score")),
userQuery, userQuery, topK * 2);
// 2. 稠密检索:向量相似度
List<ScoredDocument> denseHits = vectorStore.similaritySearch(
SearchRequest.builder().query(userQuery).topK(topK * 2).build()
).stream()
.map(doc -> new ScoredDocument(doc.getId(), doc.getScore()))
.toList();
// 3. RRF (Reciprocal Rank Fusion) 融合,而不是简单加权平均
return rrfFuse(bm25Hits, denseHits, topK);
}
private List<ScoredDocument> rrfFuse(List<ScoredDocument> sparse, List<ScoredDocument> dense, int k) {
Map<String, Double> rrfScore = new HashMap<>();
// RRF 公式: 1 / (k + rank),k 通常取 60
addRank(rrfScore, sparse, 60);
addRank(rrfScore, dense, 60);
return rrfScore.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(k)
.map(e -> new ScoredDocument(e.getKey(), e.getValue()))
.toList();
}
}
为什么用 RRF 而不是加权平均? 因为向量分数和 BM25 分数不在同一个量纲上——向量是余弦相似度(0~1),BM25 是词频统计(0~几十)。你加权平均就得调权重,而权重在数据分布变化时就会失效。RRF 只看排名不看分数,鲁棒性高得多。这是从 Elasticsearch 的 rank_feature 和 Weaviate 的 hybrid 实现里抄来的成熟方案。
版本差异注意:如果你用的是 Spring AI 1.0.0-M6 之前的版本,VectorStore 接口没有 similaritySearch 的 SearchRequest 重载,需要自己拼 filter 表达式。升级到 1.0.0 之后才统一了 API。
三、 重排序(Rerank)才是“答非所问”的终极解药
混合检索把正确文档从第 47 位提到了第 8 位,但还不够——LLM 的上下文窗口有限,我们只塞 top-5。第 8 位还是进不了。这时候必须上 Cross-Encoder 重排序。
3.1 为什么 Bi-Encoder 不行,必须 Cross-Encoder
向量检索用的模型(如 text-embedding-ada-002)是 Bi-Encoder:query 和 doc 分别编码成向量,再算余弦。这种结构为了性能牺牲了精度——它无法让 query 和 doc 的 token 做交互。
Cross-Encoder(如 bge-reranker-base)把 query 和 doc 拼接成一个序列喂给模型,输出一个相关性分数。它能看到“没付款”和“自动关闭”之间的细粒度交互。
@Configuration
public class RerankConfig {
@Bean
public CrossEncoder reranker() {
// 使用 ONNX Runtime 加载 bge-reranker-base,避免 Python 服务依赖
String modelPath = "/models/bge-reranker-base.onnx";
return new OnnxCrossEncoder(modelPath,
new OnnxSessionOptions().setIntraOpNumThreads(4));
}
}
@Service
public class RerankService {
private final CrossEncoder reranker;
public List<ScoredDocument> rerank(String query, List<ScoredDocument> candidates, int topK) {
// 构造拼接对,batch 推理
List<String> pairs = candidates.stream()
.map(doc -> query + "[SEP]" + doc.content())
.toList();
float[] scores = reranker.score(pairs); // 批量推理,返回相关性分数
// 按新分数重排,取 topK
List<ScoredDocument> reranked = new ArrayList<>();
for (int i = 0; i < candidates.size(); i++) {
reranked.add(new ScoredDocument(candidates.get(i).id(), scores[i]));
}
reranked.sort((a, b) -> Double.compare(b.score(), a.score()));
return reranked.subList(0, Math.min(topK, reranked.size()));
}
}
效果对比(order-service 线上 A/B 测试 2 万条真实用户 query):
| 检索策略 | Recall@5 | 客服机器人“答非所问”率 | 平均延迟 (P99) |
|---|---|---|---|
| 纯向量 (topK=20) | 38.2% | 17.4% | 210ms |
| 混合检索 (BM25+Vector, RRF) | 67.5% | 8.9% | 280ms |
| 混合检索 + Rerank (topK=10) | 89.3% | 1.2% | 650ms |
注意边界:Rerank 模型也有幻觉。bge-reranker-base 对“否定句”特别敏感——比如“不要自动关闭”,它会误判为“要关闭”。我们在 order-service 里加了规则兜底:如果 query 包含“不要/别/取消关闭”等否定前缀,直接过滤掉 Rerank 结果,走人工规则匹配。
四、 决策表:什么时候该上什么检索策略
| 业务场景 | query 特点 | 推荐策略 | 理由 |
|---|---|---|---|
| 订单状态查询 | 口语化、状态动词多 | 混合检索 + Rerank | 口语句式需要向量泛化,状态机需要 BM25 精确匹配 |
| 售后政策问答 | 术语固定、长文档 | 纯 BM25 或 BM25 + Rerank | 政策文档关键词高度区分,向量反而引入噪声 |
| 商品推荐 | 无明确实体、语义模糊 | 纯向量 + 多样性采样(MMR) | 没有强 token 可匹配,BM25 无意义 |
| 日志/异常排查 | 代码符号、英文变量 | 纯 BM25 (tsvector) | 向量模型对代码符号几乎无泛化能力 |
五、 什么时候别用 / 别踩的坑(反直觉清单)
- 别对短文本做向量化。order-service 的工单标题平均只有 12 个字,向量模型对短文本的语义区分度极差——你把“订单关闭”和“订单取消”编码后,余弦相似度高达 0.91,等于白做。短文本直接用 BM25 + 同义词扩展。
- 别把 Rerank 的分数当置信度。Cross-Encoder 的输出是 logits,不是概率。0.8 分不代表 80% 置信。我们踩过坑——设置
score > 0.7才进入 LLM,结果误杀了一半正确回答。正确做法是只做排序,不做阈值过滤。 - 别在事务里调 Rerank。Rerank 模型推理 50ms 左右,但如果你在
@Transactional里调用,会持有数据库连接,导致连接池耗尽。order-service 就因为这个在高峰期 OOM 过。必须把检索和 Rerank 移到事务边界之外。 - 别用同一个向量模型处理所有语言。我们的文档有中英混合,
text-embedding-ada-002对中文口语泛化差,但对英文代码好。我们最终训练了一个双塔模型,中文用bge-large-zh,英文用e5-large,然后按 query 的语言路由。别迷信“一个模型打天下”。 - 别忽略 BM25 的
simple配置。PostgreSQL 默认english配置会做词干提取,把“orders”变成“order”,但中文没有词干——必须用simple配置,否则中文检索直接失效。我们线上就因为这个,BM25 分数全是 0,等于白写。
六、 下一步
order-service 的 RAG 链路现在稳定在 1.2% 的答非所问率,但还不够——Rerank 模型的 650ms 延迟在高峰期撑不住。下一篇,我会拆解 如何用 JNI + ONNX Runtime 把 Rerank 延迟从 650ms 压到 80ms,以及为什么 Executors.newFixedThreadPool 在这里是性能杀手。关注我,别错过 order-service 的下一场线上事故。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)