CitedRAG 对话页:回答的每个结论后面带 [1][2] 引用编号,下方展开证据

证据优先的 RAG 工作台

CitedRAG

面向论文与技术文档的知识问答工作台,支持文档导入、证据检索与带来源和页码引用的回答。

  • Python
  • FastAPI
  • LangGraph
  • React
03 / 05 · DOCUMENTATION

编排与引用约束

用状态和预算约束研究过程,再对引用结构与逐句证据支持分别校验。

图中的职责

工作流状态不仅保存聊天消息,还保存原始问题、研究任务、证据池、按片段 ID 索引的证据、预算进度、警告与错误。把结构化任务单独保存,可以避免后续节点反复从自然语言计划里猜测“哪些步骤已经完成”。

Supervisor 根据当前状态选择 direct、planner、researcher、writer 或结束。简单问题可跳过模型路由直接检索;直接路径证据不足时可以升级到规划。planner 生成结构化任务后,后续路由以任务状态为准,避免模型跳过未完成研究或反复规划。

citedrag/backend/src/agent/graph.pyL41-74 @f821925
def build_graph() -> StateGraph[AgentState]:
    """构建 Supervisor 委派制多 Agent 协作图。

    返回未编译的 StateGraph,调用方可根据需要:
    - .compile() 获取 CompiledStateGraph
    - 注入 checkpointer(M3 多轮会话持久化用)
    - 设置 interrupt_before(调试用)

    Returns:
        已添加所有节点和边的 StateGraph builder
    """
    builder = StateGraph(AgentState)

    # 添加所有节点
    builder.add_node("supervisor", supervisor_node)
    builder.add_node("direct", direct_node)
    builder.add_node("planner", planner_node)
    builder.add_node("researcher", researcher_node)
    builder.add_node("writer", writer_node)

    # START → supervisor(入口)
    builder.add_edge(START, "supervisor")

    # 所有 worker 节点 → 条件边 → supervisor(继续)或 END(终止)
    # supervisor 通过 Command(goto=...) 动态路由到各 worker
    # path_map: should_continue 返回 Literal["supervisor","END"],需显式映射到节点/哨兵
    for worker in ["direct", "planner", "researcher", "writer"]:
        builder.add_conditional_edges(
            worker,
            should_continue,
            path_map={"supervisor": "supervisor", "END": END},
        )

    return builder
Supervisor 用 Command 动态委派;每个工作节点通过条件边继续或结束。

researcher 将多个待执行任务放入线程池,各任务保留独立证据与工具预算,完成后按计划顺序合并。达到 supervisor 迭代上限后不再委派新任务;模型调用和工具调用另有预算,不能只靠提示词要求“不要调用太多次”。

节点主要职责输出或终止依据
supervisor按现有状态与预算选择下一步委派、升级、结束与审批
direct直接检索并尝试有据回答可回答结果或证据缺口
planner将复杂问题转成结构化子任务每项任务的问题、状态与身份
researcher检索各子任务并累积证据任务结果、证据 ID、错误和轨迹
writer从证据池选择内容并综合最终答案、引用与诊断

简单问题的启发式直接路径可以省去一次模型路由。进入研究后,未完成任务、已有最终答案和迭代上限优先决定合法下一步,避免模型通过一句委派文本绕过任务状态。

并行研究怎样合并

每个研究任务有自己的证据缓冲、调用预算和结果记录,多任务可以在线程池并行运行。主节点按计划顺序收集 outcome,再按片段 ID 去重合并证据;任务完成顺序不同,不应使最终证据身份随机变化。

共享状态只保留每个任务必要的最终助手说明,不把整份内部 ReAct 对话无条件复制回主会话。这样既能追踪任务是否完成,也减少中间过程挤占最终写作窗口。

线程任务的取消属于协作式停止:尚未开始的 future 可以取消,正在执行的工作还依赖取消信号和各调用的退出点。它不是可以随时强制终止任意第三方模型请求的进程隔离机制。

高成本计划先审批

当任务数量或预计调用成本达到配置阈值,图在 researcher 执行前暂停。审批记录包含原始问题、任务与成本估计,用户可以修改任务、批准或取消;恢复时使用检查点里的计划,不重新计算另一份计划。

演示用包含“深入”“比较”等词的问题触发同样的审批界面;批准后执行本地检索,取消后不写入完成答案。这个触发规则和成本估计只用于演示,真实项目由 supervisor 配置和运行状态决定。

审批面向尚未执行的研究成本,并不撤销此前已经完成的直接检索或规划调用。用户修改计划后,研究应执行批准的那份任务,而不是继续使用界面修改前的隐藏副本。

预算也分层:supervisor 迭代限制委派次数,researcher 步数与工具预算限制子任务工作,运行超时限制整体占用。达到某层上限时,应保留原因与已有证据,不能靠换一个节点继续无界运行。

引用结构不等于支持结论

结构校验先检查 [N] 是否有效、是否越界,再只保留答案实际使用的证据,并按首次出现顺序连续编号:

citedrag/backend/src/agent/workers.pyL294-336 @f821925
def enforce_citations(
    answer: str, citations: list[dict[str, Any]]
) -> tuple[str, list[dict[str, Any]]]:
    """Reject invalid citations and keep only citations actually used by an answer.

    Marker parsing is evaluation-only code, so an unexpected validation failure must
    fail closed to the ordinary insufficient-evidence response rather than break an
    answer request.
    """
    try:
        parsed = parse_citation_markers(answer)
        if parsed.malformed:
            return INSUFFICIENT_EVIDENCE_ANSWER, []

        markers = parsed.markers
        if any(number > len(citations) for number in (marker.number for marker in markers)):
            return INSUFFICIENT_EVIDENCE_ANSWER, []
        if not markers:
            if answer.strip() == INSUFFICIENT_EVIDENCE_ANSWER:
                return answer, []
            return INSUFFICIENT_EVIDENCE_ANSWER, []

        # Repeated markers map to the same citation; numbering follows first
        # occurrence so the public contract remains contiguous and unambiguous.
        renumbering: dict[int, int] = {}
        normalized_parts: list[str] = []
        used_citations: list[dict[str, Any]] = []
        previous_end = 0
        for marker in markers:
            new_number = renumbering.get(marker.number)
            if new_number is None:
                new_number = len(used_citations) + 1
                renumbering[marker.number] = new_number
                used_citations.append(citations[marker.number - 1])
            normalized_parts.append(answer[previous_end:marker.start])
            normalized_parts.append(f"[{new_number}]")
            previous_end = marker.end
        normalized_parts.append(answer[previous_end:])
        return "".join(normalized_parts), used_citations
    except Exception:  # noqa: BLE001 - validation must fail closed in the request path.
        return INSUFFICIENT_EVIDENCE_ANSWER, []

无引用、格式异常或编号越界时失败关闭;有合法编号仍需继续做结论支持校验。

启用支持校验时,随后由模型判断每句结论能否获得证据支持。未通过的结论被移除;校验器失败或没有留下有据内容时返回拒答,同时保留诊断信息。来源命中只能证明检索到了某篇文档,不能证明答案忠实于它。

三种失败的区别

情况举例检查层
引用无效只有两条证据却写 [3]编号与结构校验
结论不受支持证据介绍一种方法,回答却给出资料没有的性能数字模型支持校验
资料未覆盖问题所有候选都与所问属性无关证据判断与拒答

结构归一化按首次使用顺序重编号。例如答案先用 [4] 再用 [2],公开结果可以规范为 [1]、[2],引用列表同步只保留实际使用的两项。重复引用继续指向同一证据,不会生成新的来源身份。

支持校验按配置启用,依赖模型判断;它增加约束,却不等于形式化证明。校验器可能结合其他已检索片段判断支持,因此仍要核对具体编号对应的原文是否清晰,不能只凭出现合法引用就停止审查。

流式输出也必须先校验

直接逐 token 输出会让用户先看到未经验证的结论。_ValidatedStreamer 先积累完整句,再校验并移除不受支持部分,之后才发送增量:

citedrag/backend/src/agent/workers.pyL703-730 @f821925
def _flush(self, force: bool) -> None:
    prefix, remainder = _split_complete_sentences(self._buffer, force)
    self._buffer = remainder
    if not prefix.strip():
        return
    if any(
        marker.number > len(self._citations)
        for marker in parse_citation_markers(prefix).markers
    ):
        self._dropped += max(1, len(_claims_for_validation(prefix, True)))
        return
    verdicts = verify_cited_claims(
        prefix,
        self._chunks,
        self._llm_config,
        self._policy,
        include_uncited=True,
    )
    if verdicts is None:
        self._validation_failed = True
        return
    self._dropped += sum(1 for supported in verdicts.values() if not supported)
    supported = _drop_unsupported_claims(prefix, verdicts, include_uncited=True)
    if not supported.strip():
        return
    streamed = self._renumber(supported)
    self._stream_callback(streamed)
    self._streamed.append(streamed)
引用编号增量归一化,使可见流与最终保存的回答保持一致;这会引入句子缓冲和校验延迟。

用户看到的流因此不是模型原始 token 的无条件转发。运行时先缓冲句子或文本批次,校验后才发送,最后保存的回答应与已经显示的有据内容保持一致。首段显示可能更慢,但避免先传播无依据结论、完成时再突然删掉整段。

拒答与降级如何保留信息

没有证据、证据不足、检索失败和校验不可用应通过不同诊断原因表达。前两者提示补充资料或缩小问题,后两者提示依赖或执行异常;都返回空泛的“无法回答”,会使用户无法判断下一步。

部分句子有据时可保留已通过部分,并说明移除的结论;全部无法支持或校验无法完成时失败关闭。拒答策略与状态完成要分开:一轮可以正常完成并给出拒答,也可以因依赖错误以失败结束。

演示直接摘录命中原文并附编号,没有执行这套模型语义校验。前端的证据、委派路径和运行状态可以操作,真实语义保证需要模型服务和后端测试共同验证。