Home Projects Blog Resume Contact 中文
Back to list
2026年7月5日 1,818 words 4 min read

01-请求入口与 Retrieval Trace:先把证据链照亮

做 RAG 项目时,很容易一上来就想调 chunk 大小、换 embedding 模型、加 rerank、改 prompt。NexaRAG 的施工路线里,第一步却是 Retrieval Trace。

#NexaRAG#Retrieval Trace#证据链#可观测性

01-请求入口和 Retrieval Trace:先把证据链照亮

做 RAG 项目时,很容易一上来就想调 chunk 大小、换 embedding 模型、加 rerank、改 prompt。NexaRAG 的施工路线里,第一步却是 Retrieval Trace。

这个选择很有代表性。RAG 的问题经常会在证据层提前暴露。trace 能沿着链路回答:用户原问题是什么,改写后变成什么,工具是否被调用,BM25 召回了什么,向量检索召回了什么,融合后谁排在前面,rerank 是否改变顺序,最终进入 prompt 的证据是哪几段,答案是否引用这些证据。

这篇的核心判断是:

RAG 优化的第一步,是让证据链可观察。

一次请求从哪里进入

NexaRAG 当前的聊天入口在 api/chat_router/router.py。这个文件只处理 HTTP 层的事情:校验问题、接收图片、选择普通响应或流式响应,然后把请求交给 ChatService

典型的文本接口很薄:

@router.post("")
async def chat(req: ChatRequest, request: Request):
    chat_service = get_chat(request)
    return await chat_service.chat(
        validate_question(req.question),
        session_id=req.session_id,
        user_id=req.user_id,
    )

这里刻意收敛 RAG 细节。API 层只负责把用户请求送进应用编排,BM25、向量库和 citation 留给后面的服务层处理。

真正的聊天编排在 core/chat/service.pyChatService 做的事情比“调用模型”多:它要恢复或创建会话,构造长期记忆上下文,压缩历史消息,创建 AgentInput,运行 Agent 节点链路,最后写回会话、记录 trace、整理响应。

它的核心流程可以压成这样:

chat()
  -> _agent_request()
       -> ConversationRegistry.get_or_create()
       -> MemoryContextBuilder.build()
       -> build_agent_chat_history()
       -> AgentInput(...)
  -> agent_group.run(...)
  -> _finalize_turn()
       -> finalize_trace()
       -> conversation.memory.add_message()
       -> record_agent_output()
  -> response_from_output()

这条链路说明一件事:NexaRAG 的 RAG 请求比“query -> retrieve -> answer”更长。它带着会话、用户、图片、记忆、工具调用、生成结果和验证结果一起流动。

Agent 节点把请求拆成多个阶段

默认 Agent 编排在 agent/groups/customer_service_group.py。构建函数 build_customer_service_group() 会把多个节点串起来:

VisionNode
  -> IntentNode
  -> QueryRewriteNode
  -> ToolReasoningNode
  -> AnswerGenerationNode
  -> VerificationNode

每个节点只处理一段职责。

VisionNode 处理图片。
IntentNode 判断意图。
QueryRewriteNode 改写检索问题。
ToolReasoningNode 负责工具调用和知识库检索。
AnswerGenerationNode 基于上下文生成答案。
VerificationNode 检查答案是否被证据支持。

和 Retrieval Trace 最直接相关的是 ToolReasoningNode。它会调用 search_knowledgesearch_product_knowledgesearch_troubleshoot_docs 这些工具。工具实现放在 tools/_retrieval/builder.py,最终会进入知识库检索,并把检索过程包装成可回传的 trace。

这个设计有一个重要好处:trace 会跟着 Agent state 一起流动。后面生成答案、校验答案、前端展示诊断信息时,都可以继续使用同一份证据链。

Trace 记录证据过程

普通日志更多记录程序是否报错。Retrieval Trace 记录的是证据怎么来的。

NexaRAG 的 trace 基础结构在 rag/_trace/records.py 里。create_retrieval_trace() 会准备一份统一骨架。

后续章节会对字段进行逐个讲解引用

按当前代码,完整字段结构可以写成这样:

retrieval_trace  # 一次请求的完整证据链诊断对象
  original_query  # 用户进入系统时的原始问题
  rewritten_query  # QueryRewriteNode 改写后的检索问题
  intent  # 意图识别结果,通常包含 intent、confidence 等信息
  needs_retrieval  # 当前问题是否需要检索知识库
  tool_calls[]  # Agent 本轮调用过的检索工具列表
    name  # 工具名称,比如 search_knowledge
    args  # 工具调用参数,比如 query、top_k、retrieval_mode
    fallback  # 这次工具调用是否来自兜底检索逻辑
  retrieval  # 检索阶段的完整诊断信息
    retrieval_mode  # 请求指定的检索模式,如 auto、bm25、vector、hybrid
    effective_mode  # 实际生效的检索模式,比如 vector 关闭后落到 bm25
    filters  # 本次检索使用的 metadata filter
    fallback_used  # 检索阶段是否使用了 fallback
    vector_enabled  # 当前是否启用向量检索通道
    bm25[]  # BM25 通道召回的候选列表
      rank  # 当前候选在该列表中的排序
      source  # 候选所属文档来源
      chunk_id  # 候选对应的 chunk 编号
      title_path  # 候选所在文档章节路径
      block_type  # 候选主要 block 类型
      block_types  # 候选包含的 block 类型集合
      chunk_strategy_version  # 生成该 chunk 的切分策略版本
      text_preview  # 候选正文预览
      metadata  # 候选原始 metadata
      retrieval_source  # 候选来源通道标记
      bm25_score  # BM25 原始或归一化分数
      vector_score  # 向量检索分数
      rrf_score  # RRF 融合分数
      rerank_score  # rerank 后的分数
      bm25_rank  # BM25 通道中的名次
      vector_rank  # 向量通道中的名次
      rerank_rank  # rerank 后的名次
    vector[]  # 向量检索通道召回的候选列表
      rank  # 当前候选在该列表中的排序
      source  # 候选所属文档来源
      chunk_id  # 候选对应的 chunk 编号
      title_path  # 候选所在文档章节路径
      block_type  # 候选主要 block 类型
      block_types  # 候选包含的 block 类型集合
      chunk_strategy_version  # 生成该 chunk 的切分策略版本
      text_preview  # 候选正文预览
      metadata  # 候选原始 metadata
      retrieval_source  # 候选来源通道标记
      bm25_score  # BM25 原始或归一化分数
      vector_score  # 向量检索分数
      rrf_score  # RRF 融合分数
      rerank_score  # rerank 后的分数
      bm25_rank  # BM25 通道中的名次
      vector_rank  # 向量通道中的名次
      rerank_rank  # rerank 后的名次
    fused[]  # BM25 和向量检索经过 RRF 融合后的候选列表
      rank  # 当前候选在融合列表中的排序
      source  # 候选所属文档来源
      chunk_id  # 候选对应的 chunk 编号
      title_path  # 候选所在文档章节路径
      block_type  # 候选主要 block 类型
      block_types  # 候选包含的 block 类型集合
      chunk_strategy_version  # 生成该 chunk 的切分策略版本
      text_preview  # 候选正文预览
      metadata  # 候选原始 metadata
      retrieval_source  # 候选来源通道标记
      bm25_score  # BM25 原始或归一化分数
      vector_score  # 向量检索分数
      rrf_score  # RRF 融合分数
      rerank_score  # rerank 后的分数
      bm25_rank  # BM25 通道中的名次
      vector_rank  # 向量通道中的名次
      rerank_rank  # rerank 后的名次
    reranked[]  # rerank 真正生效后的候选列表
      rank  # 当前候选在 reranked 列表中的排序
      source  # 候选所属文档来源
      chunk_id  # 候选对应的 chunk 编号
      title_path  # 候选所在文档章节路径
      block_type  # 候选主要 block 类型
      block_types  # 候选包含的 block 类型集合
      chunk_strategy_version  # 生成该 chunk 的切分策略版本
      text_preview  # 候选正文预览
      metadata  # 候选原始 metadata
      retrieval_source  # 候选来源通道标记
      bm25_score  # BM25 原始或归一化分数
      vector_score  # 向量检索分数
      rrf_score  # RRF 融合分数
      rerank_score  # rerank 后的分数
      bm25_rank  # BM25 通道中的名次
      vector_rank  # 向量通道中的名次
      rerank_rank  # rerank 后的名次
    rerank  # rerank 阶段的诊断信息
      enabled  # 本次是否启用 rerank
      provider  # rerank provider,比如 noop、keyword_overlap、dashscope
      fallback_used  # rerank 失败时是否回退到 fused 结果
      input_count  # 进入 rerank 的候选数量
      output_count  # rerank 输出的候选数量
      rank_changes[]  # rerank 前后名次变化列表
        chunk_id  # 发生排序变化的 chunk 编号
        original_rank  # rerank 前的名次
        rerank_rank  # rerank 后的名次
        original_score  # rerank 前的分数
        rerank_score  # rerank 后的分数
      errors[]  # rerank 阶段错误信息
    context_builder  # ContextBuilder 构造 final context 的诊断信息
      input_count  # 进入 ContextBuilder 的候选数量
      dedup  # 去重阶段诊断信息
        before_count  # 去重前候选数量
        after_count  # 去重后候选数量
        removed_chunk_ids[]  # 去重移除的 chunk 编号列表
      merge  # 相邻 chunk 合并阶段诊断信息
        before_count  # 合并前候选数量
        after_count  # 合并后候选数量
        merged_groups[][]  # 被合并的相邻 chunk id 分组
      budget  # 上下文预算控制信息
        max_evidence_count  # 最大 evidence 数量
        max_context_chars  # final context 最大字符数
        max_chunk_chars  # 单个 chunk 最大字符数
        used_chars  # 已使用字符数
        truncated_evidence_ids[]  # 被截断的 evidence 编号列表
        context_truncated  # final context 是否发生整体截断
      evidence_count  # 最终生成的 evidence 数量
    final_context[]  # 检索阶段写入的最终 evidence 列表
      evidence_id  # evidence 编号,比如 E1
      chunk_id  # evidence 对应的 chunk 编号
      doc_id  # evidence 所属文档编号
      source  # evidence 所属文档来源
      title  # evidence 所属文档标题
      title_path[]  # evidence 所在章节路径
      version  # evidence 所属文档版本
      content  # evidence 正文内容
      score  # evidence 继承的候选分数
      metadata  # evidence 原始 metadata
      truncated  # evidence 内容是否被截断
    errors[]  # 检索阶段错误信息
  final_context[]  # 顶层最终 evidence 列表,便于后续生成和校验直接读取
    evidence_id  # evidence 编号,比如 E1
    chunk_id  # evidence 对应的 chunk 编号
    doc_id  # evidence 所属文档编号
    source  # evidence 所属文档来源
    title  # evidence 所属文档标题
    title_path[]  # evidence 所在章节路径
    version  # evidence 所属文档版本
    content  # evidence 正文内容
    score  # evidence 继承的候选分数
    metadata  # evidence 原始 metadata
    truncated  # evidence 内容是否被截断
  answer_policy  # 答案策略判断结果
    requires_citation  # 本轮回答是否要求引用 evidence
    refused  # 本轮是否触发拒答
    asked_followup  # 本轮是否触发追问
    reason  # 策略原因,比如 no_evidence、ambiguous_product
    missing_aspects[]  # 当前证据缺少的回答要素
  refusal  # 拒答相关信息
    refused  # 本轮是否拒答
    reason  # 拒答原因
    missing_aspects[]  # 拒答时缺少的关键信息
  answer  # 最终答案预览文本
  verification  # 答案校验结果
    supported  # verifier 判断答案是否被证据支持
    confidence  # verifier 给出的置信度
    unsupported_claims[]  # 证据支持不足的声明列表
    conflicting_claims[]  # 冲突声明列表
    numeric_warnings[]  # 数字、型号、日期等高风险事实告警
    citation_check  # 引用检查结果
      has_citations  # 答案是否包含 evidence 引用
      cited_evidence_ids[]  # 答案实际引用的 evidence 编号列表
      unknown_evidence_ids[]  # 当前 evidence list 之外的引用编号
      citation_coverage  # 关键事实句的引用覆盖率
      warnings[]  # 引用检查告警列表
    action  # verifier 建议动作,比如 allow、warn、refuse
    verifier_error  # verifier 执行过程中的错误信息
    reason  # 校验结论原因
    suggestion  # verifier 给出的修正建议
    pass  # 兼容前端和测试的通过标记
    score  # 0 到 5 的压缩评分
  errors[]  # 全局错误信息
  timing_ms  # 各阶段耗时统计

这里有两个细节值得单独记一下。第一,bm25vectorfusedreranked 里的候选项来自 normalize_candidate(),实际只会保留前 MAX_TRACE_ITEMS=10 条;某些分数字段只在对应通道产生时出现。第二,正常检索下 final_context 是 evidence 结构;兜底场景里可能只有 indextext_preview,用于保留最低限度的上下文线索。

这些字段看起来很多,但它们都服务于同一个目标:当回答出问题时,可以定位是哪一层出了问题。

比如用户问“小米 15 Pro 电池容量是多少”,最后答案不对。trace 可以把问题拆成几种情况:

trace 现象可能问题
BM25 和 vector 均未召回正确文档文档未入库、query 表达偏离、filter 过窄
正确文档在 fused 里,但未进 rerankedrerank 或排序策略有问题
正确证据在 reranked 里,但未进 final context上下文预算、去重或截断出了问题
final context 有正确证据,但答案缺少引用生成 prompt 或引用约束需要加强
答案引用了未知的 [E99]citation check 或输出约束需要加强

有了 trace,这些问题就能从“RAG 效果差”拆成具体层次,排查也更有方向。

检索工具怎样把证据变成 trace

tools/_retrieval/builder.py 是请求链路里很关键的一层。它会在格式化检索结果时尽量保留结构化 trace

当知识库支持 search_with_trace() 时,工具会拿到类似这样的 payload:

{
  results: [...],
  trace: {...}
}

然后 _contextualize_payload() 会把候选结果交给 ContextBuilder().build(results)。如果构造出了上下文,它会把 evidence list 写回 trace:

final_context = [evidence.to_dict() for evidence in context_result.evidences]
trace["final_context"] = final_context
retrieval["final_context"] = final_context
retrieval["context_builder"] = context_result.trace

这一步很重要。RAG 在“检索到了结果”之后,还要把候选结果整理成最终上下文,也就是后面生成答案时真正看到的证据。

trace 同时记录召回结果和 final context,才能覆盖一类常见问题:正确证据确实召回了,但未进入 prompt。

ChatService 在最后补齐诊断信息

Agent 运行结束以后,core/chat/diagnostics.py 会通过 finalize_trace() 把最终结果补进 trace:

trace["rewritten_query"] = output.rewritten_query or trace.get("rewritten_query", "")
trace["intent"] = output.intent or trace.get("intent", {})
trace["tool_calls"] = trace.get("tool_calls") or output.used_tools
trace["answer"] = text_preview(output.answer, 800)
trace["verification"] = output.verification or trace.get("verification", {})

也就是说,trace 同时记录检索阶段、答案和验证结果。这样前端拿到响应以后,可以把“证据是怎么来的”和“答案是否通过验证”放在一起看。

record_agent_output() 还会把 assistant_messageverification_resultretrieval_trace 写进会话记录。这让 trace 既是一次响应里的字段,也可以成为后续复盘和排查的材料。

前端诊断面板的价值

NexaRAG 前端诊断相关代码在 frontend/src/features/diagnostics/。其中 DiagnosticsPanel.tsx 负责总入口,RetrievalTraceSection.tsx 展示检索链路,VerificationDiagnostics.tsx 展示答案校验,其他组件负责候选列表、证据列表、策略信息、图片诊断等。

这类 UI 的价值很实际。RAG 的很多质量问题需要写知识库的人、调 prompt 的人、做评测的人一起看清楚:系统到底拿到了什么证据。

比如:

  • 如果 metadata 缺少产品名,诊断面板里会看到 filters 生效困难。
  • 如果 query rewrite 加了错误限定,rewritten query 和召回结果会一起偏掉。
  • 如果相邻 chunk 太碎,final context 里可能出现一堆重复片段。
  • 如果答案没按证据引用,verification 和 citation check 会暴露问题。

前端展示 trace 的意义,就是把“答案错了”变成“证据链哪里断了”。

Trace 会影响后续每一步改造

这也是系列第一篇正文先讲 trace 的原因。

后面做文档入库和 metadata,要看正确文档是否有稳定身份。
做结构化 chunking,要看正确 chunk 是否更容易进入候选池。
做混合检索,要看 BM25、vector、fused 三层分别召回了什么。
做 rerank,要看 rerank 前后排名是否变化。
做上下文工程,要看 final context 是否更短、更清楚、更可引用。
做引用拒答,要看 answer policy、citation check、verification 是否一致。
做 Golden Set,要通过 trace 判断失败发生在召回、重排、上下文还是生成。

有了 trace,每次修改都能留下证据。

小结

Retrieval Trace 是 NexaRAG 施工线的第一步,因为它让 RAG 链路从黑盒变成可检查的过程。

请求从 api/chat_router/router.py 进入,core/chat/service.py 负责应用编排,agent/groups/customer_service_group.py 串起多个节点,tools/_retrieval/builder.py 把检索结果和上下文构建写进 trace,core/chat/diagnostics.py 在最终响应前补齐答案和 verification,前端 features/diagnostics 再把这条证据链展示出来。

下一篇继续往前看证据链的源头:知识进入系统之前,文档身份、chunk 身份和 metadata 怎样先建立起来。

参考材料:

  • NexaRAG api/chat_router/router.py
  • NexaRAG core/chat/service.py
  • NexaRAG core/chat/diagnostics.py
  • NexaRAG agent/groups/customer_service_group.py
  • NexaRAG tools/_retrieval/builder.py
  • NexaRAG rag/_trace/records.py
  • NexaRAG frontend/src/features/diagnostics/DiagnosticsPanel.tsx
  • 原 RAG 系列 05-一次RAG请求的完整链路-从用户问题到最终答案.md