工程知识库问答里,“答案对不对”和“答案能不能查回原文”同样重要。一条没有来源的回答,用户无法核对,也不敢据此做决策。本文讲 RAG 系统怎么实现引用溯源,让回答里的每个关键结论都能定位到具体文档和条款。
摘要:本文围绕 RAG 系统的引用溯源展开,说明为什么工程知识库问答必须让每个结论都能查回原文。文章从入库切分时写入溯源元数据讲起,介绍检索阶段如何随片段返回文档名、条款号、页码与版本,生成阶段如何要求模型标注引用编号并做引用对齐校验,最后给出文档换版、切片失效等场景下的处理办法,形成“结论—片段—原文”的可回溯链路。
一、为什么答案必须带出处
在工程场景中,回答往往关系到规范适用、技术做法甚至责任判断,空口给出一个结论风险很高。
带出处有几个实际作用:
所以“回答必带出处”不只是产品要求,也是工程类问答可信的底线。
二、出处信息从哪来
引用不是在生成阶段才凭空造的,而是在文档入库切分时就埋好。
每个切片在写入时,除了向量和正文,还要带上完整的溯源元数据:
| doc_id | 来源文档唯一标识 |
| doc_title | 文档名称、编号 |
| section_path | 所属章节 / 条款号 |
| page_no | 页码(扫描件、PDF 尤为重要) |
| version | 文档版本 |
| chunk_id | 切片标识 |
切片粒度也会影响引用质量。工程规范天然按“章、节、条”组织,按条款切分能让一个切片对应一条相对完整的规定,引用时就能精确到“第 X 条”,而不是模糊地指向一整页。
从文档入库到回答展示,引用溯源的整体链路可以概括为下面几步:
#mermaid-svg-KWdPIP9gfkQdB1Bp{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KWdPIP9gfkQdB1Bp .error-icon{fill:#552222;}#mermaid-svg-KWdPIP9gfkQdB1Bp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KWdPIP9gfkQdB1Bp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .marker.cross{stroke:#333333;}#mermaid-svg-KWdPIP9gfkQdB1Bp svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KWdPIP9gfkQdB1Bp p{margin:0;}#mermaid-svg-KWdPIP9gfkQdB1Bp .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .cluster-label text{fill:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .cluster-label span{color:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .cluster-label span p{background-color:transparent;}#mermaid-svg-KWdPIP9gfkQdB1Bp .label text,#mermaid-svg-KWdPIP9gfkQdB1Bp span{fill:#333;color:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .node rect,#mermaid-svg-KWdPIP9gfkQdB1Bp .node circle,#mermaid-svg-KWdPIP9gfkQdB1Bp .node ellipse,#mermaid-svg-KWdPIP9gfkQdB1Bp .node polygon,#mermaid-svg-KWdPIP9gfkQdB1Bp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .rough-node .label text,#mermaid-svg-KWdPIP9gfkQdB1Bp .node .label text,#mermaid-svg-KWdPIP9gfkQdB1Bp .image-shape .label,#mermaid-svg-KWdPIP9gfkQdB1Bp .icon-shape .label{text-anchor:middle;}#mermaid-svg-KWdPIP9gfkQdB1Bp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .rough-node .label,#mermaid-svg-KWdPIP9gfkQdB1Bp .node .label,#mermaid-svg-KWdPIP9gfkQdB1Bp .image-shape .label,#mermaid-svg-KWdPIP9gfkQdB1Bp .icon-shape .label{text-align:center;}#mermaid-svg-KWdPIP9gfkQdB1Bp .node.clickable{cursor:pointer;}#mermaid-svg-KWdPIP9gfkQdB1Bp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .arrowheadPath{fill:#333333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KWdPIP9gfkQdB1Bp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KWdPIP9gfkQdB1Bp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KWdPIP9gfkQdB1Bp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KWdPIP9gfkQdB1Bp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .cluster text{fill:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp .cluster span{color:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KWdPIP9gfkQdB1Bp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KWdPIP9gfkQdB1Bp rect.text{fill:none;stroke-width:0;}#mermaid-svg-KWdPIP9gfkQdB1Bp .icon-shape,#mermaid-svg-KWdPIP9gfkQdB1Bp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KWdPIP9gfkQdB1Bp .icon-shape p,#mermaid-svg-KWdPIP9gfkQdB1Bp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KWdPIP9gfkQdB1Bp .icon-shape .label rect,#mermaid-svg-KWdPIP9gfkQdB1Bp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KWdPIP9gfkQdB1Bp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KWdPIP9gfkQdB1Bp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KWdPIP9gfkQdB1Bp :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
通过
不通过
文档入库切分
写入溯源元数据
检索返回片段与元数据
生成时标注引用编号
引用对齐校验
展示可点击引用角标
补来源或删除断言
点开查看原文片段
三、引用片段的定位方法
检索阶段,系统返回的不只是相似文本,还应该连同每个切片的元数据一起返回。这样生成回答时,每一段候选证据都自带“身份证”。
检索结果大致是这样的结构:
{
"content": "……(该条款正文)……",
"cite": {
"doc_title": "混凝土结构工程施工规范",
"section": "第8.3.2条",
"page_no": 47,
"version": "2024版",
"chunk_id": "c_8831"
},
"score": 0.86
}
回答中引用哪几条,通常取检索得分高、且确实被用来支撑结论的片段。为了让引用更精确,还可以在片段内部做二次定位:如果一个切片较长,定位到其中与问题最相关的那几句,避免引用范围过大。
四、生成阶段的引用对齐
把证据交给大模型时,要明确要求它在用到某条证据的句子后标注对应编号,并只基于给定材料作答。一个简化的提示组织方式:
以下是检索到的资料,每条用 [编号] 标识。
请只依据资料回答,在引用事实的句末标注对应编号。
资料中没有的内容,不要编造,直接说明无法确认。
[1] ……(出处:规范 第8.3.2条)
[2] ……(出处:企业制度 第3章)
这样模型会输出类似“……应按某要求处理 [1];企业内部还需走某流程 [2]”的结果,编号再映射回真实文档。
不过模型偶尔会出现“张冠李戴”——句子和引用编号对不上。更稳妥的做法是再加一道引用对齐校验:
- 检查每个引用编号是否真实存在,防止编造不存在的来源;
- 抽查被引用的片段是否真的支撑那句话,而不是仅仅语义相关;
- 对没有任何引用支撑的事实性陈述,要么补来源,要么删除。
这一步可以结合规则校验与模型校验,把“无来源的断言”拦下来。
校验环节的判断逻辑可以这样组织:
#mermaid-svg-Jk9cHnnv7pp4t72p{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Jk9cHnnv7pp4t72p .error-icon{fill:#552222;}#mermaid-svg-Jk9cHnnv7pp4t72p .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Jk9cHnnv7pp4t72p .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Jk9cHnnv7pp4t72p .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Jk9cHnnv7pp4t72p .marker.cross{stroke:#333333;}#mermaid-svg-Jk9cHnnv7pp4t72p svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Jk9cHnnv7pp4t72p p{margin:0;}#mermaid-svg-Jk9cHnnv7pp4t72p .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p .cluster-label text{fill:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p .cluster-label span{color:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p .cluster-label span p{background-color:transparent;}#mermaid-svg-Jk9cHnnv7pp4t72p .label text,#mermaid-svg-Jk9cHnnv7pp4t72p span{fill:#333;color:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p .node rect,#mermaid-svg-Jk9cHnnv7pp4t72p .node circle,#mermaid-svg-Jk9cHnnv7pp4t72p .node ellipse,#mermaid-svg-Jk9cHnnv7pp4t72p .node polygon,#mermaid-svg-Jk9cHnnv7pp4t72p .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Jk9cHnnv7pp4t72p .rough-node .label text,#mermaid-svg-Jk9cHnnv7pp4t72p .node .label text,#mermaid-svg-Jk9cHnnv7pp4t72p .image-shape .label,#mermaid-svg-Jk9cHnnv7pp4t72p .icon-shape .label{text-anchor:middle;}#mermaid-svg-Jk9cHnnv7pp4t72p .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Jk9cHnnv7pp4t72p .rough-node .label,#mermaid-svg-Jk9cHnnv7pp4t72p .node .label,#mermaid-svg-Jk9cHnnv7pp4t72p .image-shape .label,#mermaid-svg-Jk9cHnnv7pp4t72p .icon-shape .label{text-align:center;}#mermaid-svg-Jk9cHnnv7pp4t72p .node.clickable{cursor:pointer;}#mermaid-svg-Jk9cHnnv7pp4t72p .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Jk9cHnnv7pp4t72p .arrowheadPath{fill:#333333;}#mermaid-svg-Jk9cHnnv7pp4t72p .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Jk9cHnnv7pp4t72p .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Jk9cHnnv7pp4t72p .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jk9cHnnv7pp4t72p .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Jk9cHnnv7pp4t72p .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jk9cHnnv7pp4t72p .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Jk9cHnnv7pp4t72p .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Jk9cHnnv7pp4t72p .cluster text{fill:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p .cluster span{color:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Jk9cHnnv7pp4t72p .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Jk9cHnnv7pp4t72p rect.text{fill:none;stroke-width:0;}#mermaid-svg-Jk9cHnnv7pp4t72p .icon-shape,#mermaid-svg-Jk9cHnnv7pp4t72p .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jk9cHnnv7pp4t72p .icon-shape p,#mermaid-svg-Jk9cHnnv7pp4t72p .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Jk9cHnnv7pp4t72p .icon-shape .label rect,#mermaid-svg-Jk9cHnnv7pp4t72p .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jk9cHnnv7pp4t72p .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Jk9cHnnv7pp4t72p .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Jk9cHnnv7pp4t72p :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
否
是
是
否
模型输出回答
引用编号是否存在
判定为编造来源,拦截
片段是否支撑该句
是否存在无引用的事实陈述
补来源或删除该陈述
通过校验,输出结果
下面用一段 Python 代码把这两步落地:先用正则提取回答里的 [n] 编号,与检索结果编号集合比对;再对每个「句子—引用」配对调用模型做支撑度抽查。
import re
import json
from openai import OpenAI
client = OpenAI()
# 检索阶段返回的候选证据,key 为引用编号
retrieved = {
1: {"content": "混凝土浇筑应连续进行,间歇时间不得超过规范规定。",
"cite": {"doc_title": "混凝土结构工程施工规范", "section": "第8.3.2条"}},
2: {"content": "重大工序变更需经项目技术负责人审批后方可实施。",
"cite": {"doc_title": "企业施工管理制度", "section": "第3章"}},
}
CITE_PATTERN = re.compile(r"\\[(\\d+)\\]")
def split_sentences(answer: str):
"""按中文句末标点切句,保留引用编号"""
parts = re.split(r"(?<=[。!?;])", answer)
return [p.strip() for p in parts if p.strip()]
def check_citation_exists(answer: str, retrieved: dict):
"""规则校验:回答中的编号必须真实存在于检索结果"""
used = {int(n) for n in CITE_PATTERN.findall(answer)}
missing = used – set(retrieved.keys())
return used, missing
def check_support(sentence: str, evidence: str) –> bool:
"""模型校验:判断证据片段是否支撑该句"""
prompt = (
"判断【证据】是否足以支撑【结论】。只回答 是 或 否。\\n"
f"【结论】{sentence}\\n【证据】{evidence}"
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return resp.choices[0].message.content.strip().startswith("是")
def validate(answer: str, retrieved: dict):
used, missing = check_citation_exists(answer, retrieved)
if missing:
return {"pass": False, "reason": f"引用了不存在的编号 {sorted(missing)}"}
for sentence in split_sentences(answer):
nums = [int(n) for n in CITE_PATTERN.findall(sentence)]
if not nums:
# 无引用的事实性陈述,交由上层决定补来源或删除
return {"pass": False, "reason": f"无引用支撑的陈述:{sentence}"}
for n in nums:
if not check_support(sentence, retrieved[n]["content"]):
return {"pass": False,
"reason": f"片段 [{n}] 未支撑该句:{sentence}"}
return {"pass": True, "reason": "全部引用编号存在且支撑度校验通过"}
# 情况一:编号编造,被拦截
bad_answer = "混凝土应连续浇筑 [1];变更需报总部备案 [3]。"
print(json.dumps(validate(bad_answer, retrieved), ensure_ascii=False))
# 运行结果:{"pass": false, "reason": "引用了不存在的编号 [3]"}
# 情况二:编号存在但片段不支撑,被拦截
weak_answer = "混凝土应连续浇筑 [1];变更需报总部备案 [2]。"
print(json.dumps(validate(weak_answer, retrieved), ensure_ascii=False))
# 运行结果:{"pass": false, "reason": "片段 [2] 未支撑该句:变更需报总部备案 [2]。"}
# 情况三:编号存在且支撑,通过
good_answer = "混凝土浇筑应连续进行 [1];重大工序变更需经技术负责人审批 [2]。"
print(json.dumps(validate(good_answer, retrieved), ensure_ascii=False))
# 运行结果:{"pass": true, "reason": "全部引用编号存在且支撑度校验通过"}
这段代码把校验拆成两层:编号存在性用正则加集合差集,成本低、能挡住编造来源;支撑度抽查才调用模型,只对真正被引用的句子付费,避免全量调用带来的开销。实际工程中还可以把 check_support 的结果缓存起来,同一「句子—片段」组合不重复判定。
五、溯源失效的处理
引用要可靠,还得考虑来源本身会变化。
文档可能被删除、作废或换版,导致引用指向的内容发生变化。应对办法:
- 回答中保留文档版本号,引用的是当时检索到的版本,避免新版改了内容却对不上;
- 引用指向的切片被删除时,标记为“该来源已失效”,而不是展示一个打不开的链接;
- 关键结论如果只来自单一来源、且来源已更新,应重新检索再回答;
- 当所有证据都不足以支撑结论时,明确提示“未在资料中找到依据,请人工核实”,而不是硬凑一个引用。
在展示层,引用最好做成可点击的角标或脚注,点开能看到原文片段、文档名、条款和页码,形成“结论—片段—原文”的完整链路。
引用溯源的本质,是给 AI 给出的每一个结论加上可回溯的锚点。把元数据、片段定位、引用对齐和失效处理这几环做扎实,知识库的回答才真正具备工程场景所需的可信度。
本文为技术科普,不构成任何商业建议。
网硕互联帮助中心







评论前必须登录!
注册