单次问答背后的全链路
上一篇讲了初始化——用三个 Python 脚本把 Ragent 从空白搭到了可评测状态:4 个知识库、115 篇已分块的文档、30 个意图节点。评估集也准备好了,20 条 query 等着跑。
接下来的问题是:怎么把这 20 条 query 逐条喂进 Ragent,把检索结果、生成答案、性能数据一次性收齐?
拿评估集里的一条样本来说——“预算 3000 左右,有没有拍照比较好的手机推荐?”。跑完之后,我需要知道四件事:召回了哪几篇文档(算 Hit@K)、命中了哪个意图叶子(算意图准确率)、模型回了什么(算 faithfulness、answer_correctness 等)、用户等了多久才看到第一个字(算 TTFT P95)。
问题在于,Ragent 的生产接口 /rag/v3/chat 是 SSE 流式输出,一个字一个字蹦出来——拿得到答案和首字耗时,但拿不到中间产物。召回了哪些 chunk、分到了哪个意图,SSE 流里没有这些数据。
一个接口不够用,runner 的核心设计就是一条 query 跑两个接口。
为什么一个接口不够用
先看评测需要哪些数据,以及生产 SSE 接口能不能给:
| 评测需要的数据 | 用来算什么指标 | 生产 SSE 能给吗 |
|---|---|---|
| 召回了哪些 docId / chunkId | Hit@K / Recall@K / MRR | ❌ 不暴露中间产物 |
| 意图分类结果 | 意图 Top-1 准确率 | ❌ |
| chunk 文本内容 | RAGAS faithfulness / context_recall | ❌ |
| 模型完整回复 | faithfulness / answer_correctness | ✅ 拼接 delta 即可 |
| 首字到达时间 | TTFT P95 | ✅ 需要自己打点 |
| 总耗时 | 性能基线 | ✅ |
六项里有三项拿不到。所以需要一个旁路接口来补齐检索证据,跟 SSE 接口配合,一次性收齐所有数据。
Part 1:被测侧——评测旁路接口 /rag/eval
1. 只跑检索,不跑 LLM
EvalController 是专门为评测写的旁路接口,核心思路很简单:复用生产链路的检索能力,但跳过 LLM 生成。
@RestController
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "app.eval", name = "enabled", havingValue = "true")
public class EvalController {
private final QueryRewriteService queryRewriteService;
private final IntentResolver intentResolver;
private final RetrievalEngine retrievalEngine;
@GetMapping("/rag/eval")
public Result<EvalResponse> chat(@RequestParam String question) {
long start = System.currentTimeMillis();
RewriteResult rewriteResult = queryRewriteService.rewriteWithSplit(question, List.of());
List<SubQuestionIntent> subIntents = intentResolver.resolve(rewriteResult);
RetrievalContext rc = retrievalEngine.retrieve(subIntents, searchProperties.getDefaultTopK());
return Results.success(buildResponse(rc, subIntents, System.currentTimeMillis() - start));
}
}
三行核心调用——改写拆分 → 意图识别 → 检索引擎——跟生产链路用的是同一套 Service。区别在于拿到检索结果就打包返回了,不调大模型、不走 SSE。
@ConditionalOnProperty 是开关控制:配置 app.eval.enabled=true 才注册这个 Bean,生产环境不配就不存在,零开销。
返回的 EvalResponse 包含评测需要的全部检索证据:
| 字段 | 说明 |
|---|---|
retrievedDocIds | 召回的业务文档 ID(已去重,按首次出现顺序) |
retrievedChunkIds | 召回的 chunk 主键(已去重) |
retrievedContexts | 召回的 chunk 文本(与 chunkIds 顺序对应) |
retrievedContextDocIds | chunk 维度的业务 docId(与 contexts 一一对应,保留 null,不去重) |
intentLeafIds | 每个子问题 top-1 的意图叶子节点 ID |
hasKb / hasMcp | 是否走了 KB 检索 / MCP 工具 |
latencyMs | 接口耗时 |
2. chunkId → docId 两跳映射
评估集里的 expected_doc_ids 用的是业务码(如 FAQ_VAC_001),但向量库检索出来的 chunk 只有 ragent 内部的雪花 ID。从 chunk 到业务码要跳两次数据库:
chunkId
→ t_knowledge_chunk.doc_id (第一跳:chunk 对应哪个文档的内部 ID)
→ t_knowledge_document.doc_name → 剥 .md 后缀
→ 业务码(如 FAQ_VAC_001) (第二跳:内部 ID 对应哪个业务码)
代码里分三步批量查询:先拿 chunkId 查到内部 docId,再拿内部 docId 查到 docName,最后 stripExtension() 剥后缀。两次数据库查都是批量 selectByIds,不会逐条查。
这里有两个维度的 docId 列表,用途不同。用一个具体例子来说明——假设一条 query 检索回来 5 个 chunk,来自 3 篇文档:
chunk[0] ← 来自 PROD_PHONE_001
chunk[1] ← 来自 PROD_PHONE_001(同一篇文档的另一段)
chunk[2] ← 来自 POLICY_RETURN_002
chunk[3] ← 来自 PROD_PHONE_003
chunk[4] ← 映射失败,查不到对应文档
两个列表分别长这样:
| 字段 | 值 | 长度 |
|---|---|---|
retrievedContextDocIds | [PROD_PHONE_001, PROD_PHONE_001, POLICY_RETURN_002, PROD_PHONE_003, null] | 5(跟 chunk 数一样) |
retrievedDocIds | [PROD_PHONE_001, POLICY_RETURN_002, PROD_PHONE_003] | 3(去重 + 去 null) |
retrievedDocIds 给 Hit@K / Recall@K 这类文档级指标用。这些指标回答的问题是:期望文档有没有被召回?只要 PROD_PHONE_001 出现在列表里就算命中,不管它贡献了 1 个 chunk 还是 5 个 chunk。所以去重成 3 个文档 ID 就够了。
retrievedContextDocIds 给 RAGAS 的 context_precision 这类 chunk 级指标用。这个指标回答的是另一个问题:召回的每一段文本(chunk)对回答有没有帮助?它会逐个 chunk 评判,而且排在前面的 chunk 权重更高。chunk[0] 和 chunk[1] 虽然都来自 PROD_PHONE_001,但一个可能讲的是屏幕参数,另一个讲的是价格——对不同问题的帮助完全不同,必须分开评判。所以这个列表要跟 retrievedContexts 严格一一对应:5 个 chunk 就是 5 个元素,不去重、不合并。
3. 旁路的代价:漂移风险
需要坦诚交代一个设计妥协:/rag/eval 和 /rag/v3/chat 走的是不同代码路径。
/rag/eval 直接组合了三个 Service(改写、意图、检索),而生产链路在 RAGChatService.streamChat() 里可能有额外的后处理(当前是没有的)——比如跨通道的结果合并、基于分数的二次过滤。这些后处理不会反映在旁路结果里。
这是有意识的权衡:完全复刻生产链路的 shadow 模式维护成本太高,每次生产代码改动都要同步一份到旁路。当前的做法是足够近似——改写、意图、检索主体逻辑完全一致,并且没有污染主流程,算是较优的解决方案。