别背“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)向量模型对代码符号几乎无泛化能力

五、 什么时候别用 / 别踩的坑(反直觉清单)

  1. 别对短文本做向量化。order-service 的工单标题平均只有 12 个字,向量模型对短文本的语义区分度极差——你把“订单关闭”和“订单取消”编码后,余弦相似度高达 0.91,等于白做。短文本直接用 BM25 + 同义词扩展。
  2. 别把 Rerank 的分数当置信度。Cross-Encoder 的输出是 logits,不是概率。0.8 分不代表 80% 置信。我们踩过坑——设置 score > 0.7 才进入 LLM,结果误杀了一半正确回答。正确做法是只做排序,不做阈值过滤。
  3. 别在事务里调 Rerank。Rerank 模型推理 50ms 左右,但如果你在 @Transactional 里调用,会持有数据库连接,导致连接池耗尽。order-service 就因为这个在高峰期 OOM 过。必须把检索和 Rerank 移到事务边界之外。
  4. 别用同一个向量模型处理所有语言。我们的文档有中英混合,text-embedding-ada-002 对中文口语泛化差,但对英文代码好。我们最终训练了一个双塔模型,中文用 bge-large-zh,英文用 e5-large,然后按 query 的语言路由。别迷信“一个模型打天下”。
  5. 别忽略 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 的下一场线上事故。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐