Skip to main content

单次问答背后的全链路

上一篇讲了初始化——用三个 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 / chunkIdHit@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 顺序对应)
retrievedContextDocIdschunk 维度的业务 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 模式维护成本太高,每次生产代码改动都要同步一份到旁路。当前的做法是足够近似——改写、意图、检索主体逻辑完全一致,并且没有污染主流程,算是较优的解决方案。

Part 2:评测侧——Runner 的双接口聚合

1. 一条 query 的完整旅程

解锁付费内容,👉 戳