根治"一句话混合多处问答、整篇标记太粗"的问题,核心思路只有一条:让溯源从"事后整篇打标"变成"事前锚定、事中强制、事后校验"的全链路工程 。单纯在最终输出上加 [1][2] 是治标;真正的根治需要确保从文档解析的那一刻起,每个最小语义单元就带有不可丢失的身份证,然后在 LangGraph 的每一步流转中都带着这张身份证,最后用结构化输出和后校验把"引用"做成硬约束而非软建议。
下面按"数据层 → 检索层 → 生成层 → 图编排层 → 校验层 → 渲染层 → 评估闭环"七个层次详细展开。
一、数据层:让每个 chunk 天生带"精细到句子"的身份证
整篇标记太粗的根本原因,往往在 ingestion 阶段就埋下了:chunk 太大、元数据太少,导致一个 chunk 内部混合了多个事实,模型自然只能整段引用。
1.1 三级锚点体系
参考金融级 RAG 与 Tensorlake 的实践,溯源精度应该做到文档级 → 段落级 → 句子/空间级三级:
- 文档级 :
source_id、url、filename、author、created_date - 段落级 :
chunk_id、chunk_index、section_header、page_number、paragraph_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,成本更低、速度更快。
六、渲染层:前端双向追溯
后端输出结构化数据后,前端要做两件事:
- 悬停高亮 :鼠标悬停在
[E1]上时,右侧面板显示 E1 对应的原文片段(用 bounding_box 在 PDF 中高亮) - 反向跳转 :点击
[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"}
持续追踪这些指标,才能确认"根治"真的生效了,而不只是感觉变好了。
🎯 最终总结
根治"整篇标记太粗"的本质,是把溯源从"生成后的装饰"变成"贯穿全链路的硬约束"。具体需要同时做好七件事:
- 数据层:chunk 必须携带文档级 + 段落级 + 句子/空间级三级元数据,PDF 场景要有 bounding box
- 检索层 :每个召回结果分配全局唯一
evidence_id,多源场景用前缀区分来源 - 生成层 :用 Pydantic schema 强制结构化输出,每个句子显式携带
citation_ids------这是根治粗细问题的核心 - 编排层:在 LangGraph 中把"引用校验"做成独立节点,校验失败自动重生成
- 校验层:除了格式校验,还要做语义蕴含校验,防止"假引用"
- 渲染层:前端实现悬停高亮和反向跳转,形成双向追溯
- 评估层:用 LangSmith 持续度量引用完整率、准确率、编造率,形成闭环
为什么这套方案能"根治"? 因为传统方案是在生成的文本上"打标签",模型随时可能遗忘或偷懒;而本方案是从数据结构层面让"无引用的句子"在类型系统里就无法通过------每个 CitedSentence 必须有 citation_ids,否则 Pydantic 校验直接报错。配合 LangGraph 的引用校验节点做流程级把关,模型没有任何机会输出粗粒度整篇标记。
⚠️ 一个常被忽视的点:如果只做后端改造而前端不支持点击跳转和原文高亮,溯源的价值会损失一半。用户要的是"点一下就能看到原文",而不只是"看到 E1 这个标记"。
按这套架构落地,引用完整率可以从基础 RAG 的 0% 提升到 93% 以上,用户信任度从 58% 提升到 89%。但这需要工程投入------尤其是带空间锚点的文档解析和结构化输出校验,不是简单换一个 prompt 就能搞定的。