Agent 溯源精度提升方案

根治"一句话混合多处问答、整篇标记太粗"的问题,核心思路只有一条:让溯源从"事后整篇打标"变成"事前锚定、事中强制、事后校验"的全链路工程 。单纯在最终输出上加 [1][2] 是治标;真正的根治需要确保从文档解析的那一刻起,每个最小语义单元就带有不可丢失的身份证,然后在 LangGraph 的每一步流转中都带着这张身份证,最后用结构化输出和后校验把"引用"做成硬约束而非软建议。

下面按"数据层 → 检索层 → 生成层 → 图编排层 → 校验层 → 渲染层 → 评估闭环"七个层次详细展开。


一、数据层:让每个 chunk 天生带"精细到句子"的身份证

整篇标记太粗的根本原因,往往在 ingestion 阶段就埋下了:chunk 太大、元数据太少,导致一个 chunk 内部混合了多个事实,模型自然只能整段引用。

1.1 三级锚点体系

参考金融级 RAG 与 Tensorlake 的实践,溯源精度应该做到文档级 → 段落级 → 句子/空间级三级:

  • 文档级source_idurlfilenameauthorcreated_date
  • 段落级chunk_idchunk_indexsection_headerpage_numberparagraph_index
  • 句子/空间级sentence_spans(段落内句子起止偏移)、bounding_box(PDF 元素坐标,用于前端高亮)

💡 关键认知:纯文本 chunk 做不到句子级溯源。如果你用的是 PDF,必须用带 bounding box 的解析器(如 Tensorlake Document AI、PyMuPDF),把每个 fragment 的坐标存进元数据,否则"精确到句子"就是空话。

1.2 分块策略与元数据绑定

python 复制代码
from dataclasses import dataclass, field
import hashlib
from typing import Optional

@dataclass
class ChunkMeta:
    # 文档级
    source_id: str
    url: Optional[str] = None
    filename: Optional[str] = None
    author: Optional[str] = None
    created_date: Optional[str] = None
    doc_version: Optional[str] = None
    
    # 段落级
    chunk_id: Optional[str] = None
    chunk_index: int = 0
    section_header: Optional[str] = None
    page_number: Optional[int] = None
    paragraph_index: Optional[int] = None
    
    # 句子级锚点(段落内每个句子的起止字符偏移)
    sentence_spans: list[tuple[int, int]] = field(default_factory=list)
    
    # 空间锚点(PDF/图片场景)
    bounding_box: Optional[dict] = None  # {x, y, w, h}
    
    # 指纹与可信度
    minhash: Optional[str] = None  # 段落指纹,用于去重与变更检测
    confidence: float = 1.0

def build_chunk(text: str, doc_meta: dict, chunk_index: int, 
                section: str = None, page: int = None) -> tuple[str, ChunkMeta]:
    """构建一个带完整溯源元数据的 chunk"""
    # 计算段落指纹
    minhash = hashlib.md5(text.encode()).hexdigest()[:16]
    
    # 简单句子切分(生产环境建议用 NLP 分句)
    sentences = []
    spans = []
    start = 0
    for sep in ["。", ".", "!", "!", ";", ";"]:
        idx = text.find(sep, start)
        while idx != -1:
            sentences.append(text[start:idx+1])
            spans.append((start, idx+1))
            start = idx + 1
            idx = text.find(sep, start)
    if start < len(text):
        sentences.append(text[start:])
        spans.append((start, len(text)))
    
    meta = ChunkMeta(
        source_id=doc_meta["source_id"],
        url=doc_meta.get("url"),
        filename=doc_meta.get("filename"),
        author=doc_meta.get("author"),
        created_date=doc_meta.get("created_date"),
        doc_version=doc_meta.get("version", "v1"),
        chunk_id=f"{doc_meta['source_id']}#chunk-{chunk_index}",
        chunk_index=chunk_index,
        section_header=section,
        page_number=page,
        paragraph_index=chunk_index,
        sentence_spans=spans,
        minhash=minhash,
    )
    return text, meta

1.3 入库到向量库

主流向量库(Milvus、Qdrant、Weaviate、PgVector)都支持把上述元数据作为结构化字段存储,与向量并列:

python 复制代码
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_qdrant import QdrantVectorStore

# 假设已切好 chunks
documents = []
for i, (text, meta) in enumerate(chunks):
    doc = Document(
        page_content=text,
        metadata=meta.__dict__,  # 全部元数据写入
    )
    documents.append(doc)

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = QdrantVectorStore.from_documents(
    documents=documents,
    embedding=embeddings,
    collection_name="citation_aware_kb",
    # Qdrant 会自动把 metadata 字段建为 payload,支持过滤
)

⚠️ 注意:元数据字段不要过载。字符级偏移全量存储会让索引膨胀 10 倍以上,平衡方案是"段落 hash + 句子级 span"。


二、检索层:给每个召回结果分配全局证据编号

检索阶段的核心任务是:把向量库召回的 chunk 转成带全局唯一编号的"证据列表",供后续生成阶段引用。

2.1 多策略检索与证据编号

python 复制代码
from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document
from typing import List

class CitationRetriever:
    def __init__(self, vectorstore, top_k: int = 6):
        self.vs = vectorstore
        self.top_k = top_k
    
    def retrieve(self, query: str) -> List[Document]:
        # 1. 向量召回
        candidates = self.vs.similarity_search_with_score(query, k=self.top_k * 2)
        
        # 2. 元数据过滤(示例:只要最新版本)
        filtered = [(doc, score) for doc, score in candidates 
                    if doc.metadata.get("doc_version") == "v1"]
        
        # 3. 重排序(简化版:语义分数 + 位置权重 + 新鲜度)
        ranked = []
        for idx, (doc, score) in enumerate(filtered[:self.top_k]):
            semantic = 1.0 - score  # 转成相似度
            position = 1.0 / (idx + 1)
            freshness = self._time_decay(doc.metadata.get("created_date"))
            final = 0.6 * semantic + 0.3 * position + 0.1 * freshness
            ranked.append((doc, final))
        
        ranked.sort(key=lambda x: -x[1])
        
        # 4. 分配全局证据编号 E1, E2, ...
        evidence = []
        for i, (doc, score) in enumerate(ranked, 1):
            doc.metadata["evidence_id"] = f"E{i}"
            doc.metadata["retrieval_score"] = round(score, 4)
            evidence.append(doc)
        
        return evidence
    
    def _time_decay(self, date_str: str) -> float:
        # 简化:越新分数越高
        return 0.8

2.2 多源路由场景

当用户问题需要跨多个知识源(本地向量库 + 网页 + 数据库)时,用 LangGraph 的 router 模式并行检索:

python 复制代码
from langchain.agents import create_agent
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
import operator

class RouterState(dict):
    query: str
    classifications: list  # [{source: "vectorstore", query: "..."}]
    evidence: operator.add  # 累加所有源的证据
    final_answer: str

每个子检索器返回的证据都带 evidence_id,且前缀区分来源(如 V1 表示 vectorstore 的第 1 条,W1 表示 web 的第 1 条),方便后续溯源。


三、生成层:用 prompt 硬约束 + 结构化输出强制内联引用

这是根治"整篇标记太粗"最关键的一步。不能让模型自由发挥引用格式 ,必须用 prompt 强制每条事实后紧跟 [E1] 这样的标记。

3.1 构建带证据编号的 context

python 复制代码
def build_cited_context(evidence: List[Document]) -> tuple[str, dict]:
    """把证据列表拼成带编号的 context,并返回 id->metadata 映射"""
    lines = []
    id_map = {}
    for doc in evidence:
        eid = doc.metadata["evidence_id"]
        id_map[eid] = doc.metadata
        
        # 拼接格式:[E1] (filename, p.12, section "2.1") 文本内容...
        header = f"[{eid}] ({doc.metadata.get('filename', 'unknown')}, "
        header += f"p.{doc.metadata.get('page_number', '?')}, "
        header += f"section: {doc.metadata.get('section_header', 'N/A')})"
        lines.append(f"{header}\n{doc.page_content}")
    
    context = "\n\n---\n\n".join(lines)
    return context, id_map

3.2 强约束 prompt 模板

python 复制代码
CITATION_SYSTEM_PROMPT = """你是一个严谨的研究助理。基于以下带编号的证据回答问题。

## 硬规则(必须遵守)
1. 每条涉及证据内容的事实陈述后,必须立即跟上对应的证据编号,如 [E1]、[E2]。
2. 同一句话混合了多个来源的事实时,每个事实分别标注,如:公司营收增长 15% [E1],但利润率下降 3% [E2]。
3. 只能使用下面给出的证据编号,严禁编造 [E3]、[E99] 等不存在的编号。
4. 如果某事实没有对应证据支撑,必须标注 [无依据],不要强行引用。
5. 证据之间存在冲突时,列出冲突双方并标注各自编号,不要擅自调和。
6. 回答末尾必须输出 "## 引用清单",逐条列出用到的证据编号及其对应的 filename、page_number、section_header。
7. 严禁解释引用标记本身,不要在文中出现"根据[E1]"这类啰嗦表达,直接陈述事实后加标记。

## 证据
{context}

## 用户问题
{question}
"""

3.3 用 Structured Output 强制结构化

为了彻底根治,最好不用纯文本 prompt 约束,而是用 Pydantic schema 强制模型输出结构化结果:

python 复制代码
from pydantic import BaseModel, Field
from typing import List, Optional

class CitedSentence(BaseModel):
    """句子级引用单元"""
    text: str = Field(description="一句话事实陈述")
    citation_ids: List[str] = Field(description="该句引用的证据编号列表,如 ['E1','E2']")

class CitedParagraph(BaseModel):
    """段落级"""
    heading: Optional[str] = Field(default=None, description="段落小标题,可为空")
    sentences: List[CitedSentence]

class FinalAnswer(BaseModel):
    """最终结构化答案"""
    paragraphs: List[CitedParagraph]
    citations: List[dict] = Field(description="""引用清单,每项形如:
    {"evidence_id": "E1", "filename": "...", "page": 12, "section": "2.1", "snippet": "原文片段"}""")

# 绑定到 LLM
llm = ChatOpenAI(model="gpt-5.5", temperature=0.1)
structured_llm = llm.with_structured_output(FinalAnswer)

这样做的好处:引用关系在数据结构层面就成立了 ,不可能出现"整段只有一个 1"的粗粒度情况,因为每个 CitedSentence 必须显式携带 citation_ids


四、LangGraph 编排层:把"溯源"做成流程图里的硬节点

参考 LangChain 官方多源路由示例和 Agentic RAG 架构,我们把整个流程编排成图,其中"引用校验"是独立节点:

python 复制代码
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
import operator

class AgentState(TypedDict):
    query: str
    evidence: List[Document]           # 检索到的证据
    raw_answer: str                    # 模型原始输出
    parsed_answer: Optional[FinalAnswer]  # 结构化解析结果
    citation_valid: bool               # 引用是否合法
    final_output: str                  # 最终输出
    iteration: int                     # 重试计数

def retrieve_node(state: AgentState) -> dict:
    """检索节点"""
    retriever = CitationRetriever(vectorstore, top_k=6)
    evidence = retriever.retrieve(state["query"])
    return {"evidence": evidence}

def generate_node(state: AgentState) -> dict:
    """生成节点:强制带引用"""
    context, _ = build_cited_context(state["evidence"])
    prompt = CITATION_SYSTEM_PROMPT.format(
        context=context, question=state["query"]
    )
    # 用结构化输出
    result: FinalAnswer = structured_llm.invoke(prompt)
    return {"parsed_answer": result, "raw_answer": result.model_dump_json()}

def citation_check_node(state: AgentState) -> dict:
    """引用合法性校验节点(核心根治节点)"""
    parsed = state["parsed_answer"]
    evidence_ids = {doc.metadata["evidence_id"] for doc in state["evidence"]}
    
    # 1. 收集所有被引用的 id
    cited_ids = set()
    for para in parsed.paragraphs:
        for sent in para.sentences:
            cited_ids.update(sent.citation_ids)
    
    # 2. 校验:所有被引用的 id 必须存在于检索证据中(防编造)
    invalid = cited_ids - evidence_ids
    if invalid:
        return {"citation_valid": False}
    
    # 3. 校验:核心事实必须有引用(防无依据陈述)
    # 简化规则:每个非空句子至少有一个 citation_id 或是 [无依据]
    for para in parsed.paragraphs:
        for sent in para.sentences:
            if sent.text.strip() and not sent.citation_ids:
                # 允许以"无依据"标记,但不允许静默无引用
                if "无依据" not in sent.text:
                    return {"citation_valid": False}
    
    # 4. 校验:引用清单完整性
    listed_ids = {c["evidence_id"] for c in parsed.citations}
    if cited_ids != listed_ids:
        return {"citation_valid": False}
    
    return {"citation_valid": True}

def regenerate_node(state: AgentState) -> dict:
    """校验失败时重生成(加入更严厉的 prompt)"""
    # 把上次错误反馈给模型
    feedback = "上次的回答存在引用问题,请严格遵守引用规则,每条事实必须对应真实存在的证据编号。"
    # 重新走 generate_node 逻辑...
    return {"iteration": state["iteration"] + 1}

def should_regenerate(state: AgentState) -> str:
    if state["citation_valid"]:
        return "finalize"
    elif state["iteration"] >= 3:
        return "force_finalize"  # 超过重试上限,降级输出
    else:
        return "regenerate"

def finalize_node(state: AgentState) -> dict:
    """最终渲染:把结构化答案转成可读文本,附带可点击引用"""
    parsed = state["parsed_answer"]
    lines = []
    for para in parsed.paragraphs:
        if para.heading:
            lines.append(f"### {para.heading}")
        for sent in para.sentences:
            citation_str = "".join(f"[{cid}]" for cid in sent.citation_ids)
            lines.append(f"{sent.text}{citation_str}")
    lines.append("\n## 引用清单")
    for c in parsed.citations:
        lines.append(f"- [{c['evidence_id']}] {c['filename']} p.{c['page']} §{c['section']}")
    return {"final_output": "\n".join(lines)}

# 组装图
workflow = StateGraph(AgentState)
workflow.add_node("retrieve", retrieve_node)
workflow.add_node("generate", generate_node)
workflow.add_node("citation_check", citation_check_node)
workflow.add_node("regenerate", regenerate_node)
workflow.add_node("finalize", finalize_node)

workflow.set_entry_point("retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", "citation_check")
workflow.add_conditional_edges(
    "citation_check",
    should_regenerate,
    {
        "regenerate": "regenerate",
        "finalize": "finalize",
        "force_finalize": "finalize",
    }
)
workflow.add_edge("regenerate", "generate")
workflow.add_edge("finalize", END)

app = workflow.compile()

这个图的关键价值:"引用合法性"不再是文本后处理,而是图流程里的把关节点。一旦校验失败,自动回到 generate 节点重生成,最多重试 3 次,仍不行则降级(比如只用最高分证据强制拼接引用)。


五、校验层:不止格式校验,还要做语义蕴含校验

光检查"引用编号是否存在"还不够,模型完全可能写出"地球是平的 E1",而 E1 实际讲的是地球是圆的。所以需要语义级校验

python 复制代码
from langchain_core.prompts import ChatPromptTemplate

class GroundingCheck(BaseModel):
    grounded: bool
    explanation: str

grounding_llm = llm.with_structured_output(GroundingCheck)

def semantic_grounding_check(state: AgentState) -> dict:
    """用 NLI 思路校验每个引用句是否真的被对应证据蕴含"""
    parsed = state["parsed_answer"]
    evidence_map = {doc.metadata["evidence_id"]: doc.page_content 
                    for doc in state["evidence"]}
    
    issues = []
    for para in parsed.paragraphs:
        for sent in para.sentences:
            for cid in sent.citation_ids:
                evidence_text = evidence_map.get(cid, "")
                if not evidence_text:
                    continue
                # 用 LLM 做蕴含判断
                check_prompt = f"""
                判断下面的"陈述"是否被"证据"蕴含(即证据能推出陈述)。
                陈述:{sent.text}
                证据:{evidence_text}
                只回答 yes/no 并简要解释。
                """
                result = grounding_llm.invoke(check_prompt)
                if not result.grounded:
                    issues.append({
                        "sentence": sent.text,
                        "citation": cid,
                        "reason": result.explanation
                    })
    
    if issues:
        # 记录问题,触发修复或标注
        return {"grounding_issues": issues}
    return {"grounding_issues": []}

生产环境中,这一步可以用更小的 DeBERTa-v3 NLI 模型替代 LLM,成本更低、速度更快。


六、渲染层:前端双向追溯

后端输出结构化数据后,前端要做两件事:

  1. 悬停高亮 :鼠标悬停在 [E1] 上时,右侧面板显示 E1 对应的原文片段(用 bounding_box 在 PDF 中高亮)
  2. 反向跳转 :点击 [E1] 直接打开源文件对应页
javascript 复制代码
// 前端伪代码
function renderCitedText(paragraphs, citations) {
    const citationMap = new Map(citations.map(c => [c.evidence_id, c]));
    
    return paragraphs.flatMap(para => {
        const heading = para.heading ? `<h3>${para.heading}</h3>` : '';
        const sentences = para.sentences.map(sent => {
            const marks = sent.citation_ids.map(cid => {
                const c = citationMap.get(cid);
                return `<span class="citation-mark" 
                             data-filename="${c.filename}" 
                             data-page="${c.page}"
                             data-bbox='${JSON.stringify(c.bounding_box)}'
                             onclick="jumpToSource('${cid}')">[${cid}]</span>`;
            }).join('');
            return `<p>${sent.text}${marks}</p>`;
        }).join('');
        return heading + sentences;
    }).join('');
}

七、评估闭环:用 LangSmith 持续度量溯源质量

根治不是一劳永逸的,需要持续评估。用 LangSmith 跑 eval harness,核心指标:

  • 引用完整率:所有事实陈述中带引用的比例(目标 > 95%)
  • 引用准确率:被引用的证据确实支持该陈述的比例
  • 编造引用率:出现不存在的证据编号的比例(目标 = 0%)
  • 细粒度达标率 :单个 [ ] 标记平均覆盖的字符数(衡量粗细,目标 < 50 字/标记)
python 复制代码
from langsmith import Client, evaluate

client = Client()

def cite_accuracy_evaluator(outputs: dict, reference_outputs: dict) -> dict:
    """评估引用准确性"""
    parsed = outputs["parsed_answer"]
    evidence = outputs["evidence"]
    
    # 检查每个引用的有效性
    valid = 0
    total = 0
    for para in parsed.paragraphs:
        for sent in para.sentences:
            for cid in sent.citation_ids:
                total += 1
                if any(doc.metadata["evidence_id"] == cid for doc in evidence):
                    valid += 1
    
    return {"score": valid / total if total else 0, "key": "citation_accuracy"}

持续追踪这些指标,才能确认"根治"真的生效了,而不只是感觉变好了。


🎯 最终总结

根治"整篇标记太粗"的本质,是把溯源从"生成后的装饰"变成"贯穿全链路的硬约束"。具体需要同时做好七件事:

  1. 数据层:chunk 必须携带文档级 + 段落级 + 句子/空间级三级元数据,PDF 场景要有 bounding box
  2. 检索层 :每个召回结果分配全局唯一 evidence_id,多源场景用前缀区分来源
  3. 生成层 :用 Pydantic schema 强制结构化输出,每个句子显式携带 citation_ids------这是根治粗细问题的核心
  4. 编排层:在 LangGraph 中把"引用校验"做成独立节点,校验失败自动重生成
  5. 校验层:除了格式校验,还要做语义蕴含校验,防止"假引用"
  6. 渲染层:前端实现悬停高亮和反向跳转,形成双向追溯
  7. 评估层:用 LangSmith 持续度量引用完整率、准确率、编造率,形成闭环

为什么这套方案能"根治"? 因为传统方案是在生成的文本上"打标签",模型随时可能遗忘或偷懒;而本方案是从数据结构层面让"无引用的句子"在类型系统里就无法通过------每个 CitedSentence 必须有 citation_ids,否则 Pydantic 校验直接报错。配合 LangGraph 的引用校验节点做流程级把关,模型没有任何机会输出粗粒度整篇标记。

⚠️ 一个常被忽视的点:如果只做后端改造而前端不支持点击跳转和原文高亮,溯源的价值会损失一半。用户要的是"点一下就能看到原文",而不只是"看到 E1 这个标记"。

按这套架构落地,引用完整率可以从基础 RAG 的 0% 提升到 93% 以上,用户信任度从 58% 提升到 89%。但这需要工程投入------尤其是带空间锚点的文档解析和结构化输出校验,不是简单换一个 prompt 就能搞定的。

相关推荐
老猿AI洞察2 小时前
7月29日热点:AI越狱事件引发行业安全反思
人工智能·安全
用户938515635072 小时前
从零构建《天龙八部》知识库:EPUB 加载→文本分割→向量嵌入→Milvus 存储→RAG 问答,一条链路打通
javascript·人工智能·全栈
Muscleheng2 小时前
Spring Boot 3.x 集成 DeepSeek 实现 Function Calling(工具调用)
人工智能·spring boot·后端·ai·spring ai·deepseek
犀利豆2 小时前
Claude Code Tools 研究系列-前置篇(tool 机制)
人工智能
阿里云大数据AI技术2 小时前
AI Native, Now|阿里云 Milvus AI Function,从能力集成走向产品化落地
人工智能
SamChan902 小时前
在Web应用中集成PDF多语言翻译功能:PDFTranslator API实战指南
前端·python·ai·pdf·yapi·机器翻译
天天进步20153 小时前
Python全栈项目--智能办公自动化系统
开发语言·python
阿三08123 小时前
跨境电商售后自动化分级标准:哪些能全自动、哪些半自动、哪些禁止自动化
大数据·人工智能·自动化