跳到主要内容

生成答案与引用:证据组装、带引证问答、附加物

30 秒导读: 上一章 混合检索与重排 把问题变成了一堆排好序的文档片段。这一章讲这些片段如何变成一个带引用的答案:先拼成一段证据文本,喂给 LLM 一边流式吐答案、一边并行抽出"哪几句话支持了答案",再把这些句子回锚到原文做高亮,顺带算个可信度分、画个思维导图。

本章聚焦默认(非 agent)的问答链。可插拔的 ReAct / ReWOO 推理留给 05 可插拔推理。想先看整体地图,回 index


1. 这是什么(零基础也能懂)

一句话定义: 把检索到的文档片段,变成一段有出处、可核对的自然语言答案。

它解决什么问题。 普通的 RAG 只是"把文档塞给模型,让它回答"。但用户会追问两件事:

  • 这个答案从哪来的?哪段原文支持它?
  • 这个答案可信吗?模型是不是在编?

Kotaemon 的问答链就是围绕这两个追问设计的。它不只给答案,还给出引用高亮(答案的每句话对应到原文哪一段)、可信度分数、以及低相关警告

用起来什么样。 用户在聊天框问一句,右侧信息栏会陆续出现:

[聊天区] Fixed-size chunking 把文档切成固定大小的块,计算高效【1】,
但可能割裂语义相关内容【1】【2】。

[信息栏] Answer confidence: 0.87
▸ Content from paper.pdf (Page 3) ← 命中的原文,相关句被高亮
▸ Content from paper.pdf (Page 5)
Mindmap [Expand] [Export]

一句话直觉。 把它想成一个认真的助教:不光答题,还在答案边上用荧光笔划出课本里的依据,并在角落写一句"我大概有多确定"。


2. 顶层全景(它大概怎么转)

整条链的入口是 FullQAPipeline(继承 BaseReasoning),真源码在 libs/ktem/ktem/reasoning/simple.py:86。它的 stream() 方法(simple.py:281)是主控制流:

用户消息 message


① retrieve() 遍历 retrievers,取回文档;按 metadata.type 分出 text / plot
│ simple.py:108

② PrepareEvidencePipeline 把文档列表拼成一段带 HTML 标记的 evidence 文本
│ format_context.py:15 顺带定出 evidence_mode(纯文本/表格/图片)

③ answering_pipeline.stream() 证据+问题喂 LLM,边流式吐答案,边并行抽引用
│ citation_qa.py:190 (另有并行线程算 relevance score / mindmap)

④ show_citations_and_addons() 引用回锚到原文→高亮;算 qa_score;低相关警告;思维导图
simple.py:223

各部件的职责:

部件干什么在哪
FullQAPipeline问答总编排:检索→组证据→答题→展示引用libs/ktem/ktem/reasoning/simple.py:86
AddQueryContextPipeline用对话历史改写查询(当前默认关闭)simple.py:42
PrepareEvidencePipeline把文档拼成证据文本 + 定证据模式 + 裁长度libs/kotaemon/kotaemon/indices/qa/format_context.py:15
AnswerWithContextPipeline默认"高亮"引用模式的答题器libs/kotaemon/kotaemon/indices/qa/citation_qa.py:83
AnswerWithInlineCitation"内联"引用模式的答题器libs/kotaemon/kotaemon/indices/qa/citation_qa_inline.py:86
CitationPipeline / CiteEvidence用 function-calling 结构化抽引用片段libs/kotaemon/kotaemon/indices/qa/citation.py:22

一句话读图:证据在②被"拼装+裁剪",答案在③被"生成+同时抽引用",可信度与高亮在④被"回锚+展示"。 检索本身是上一章的事,这里只消费它的输出。


3. 核心原理(逐个机制,由浅入深)

3.1 retrieve:取回文档,并把"图"和"文"分开

retrieve()(simple.py:108)做三件事:遍历所有 retriever、按 metadata.type 分流、跨 retriever 去重。

思路。 一个库可能挂多个 retriever(不同索引)。有的片段是普通文本,有的是绘图数据(type == "plot")。文本要送去答题,绘图数据只用来在界面上画图,不能当证据喂给 LLM。所以要先分流。

真实实现里,循环遍历 self.retrievers,对每个 retriever 的返回按 type 分桶,文本片段再用 doc_id 去重:

# 真源码节选 simple.py:137-148
for doc in retriever_docs:
if doc.metadata.get("type", "") == "plot":
retriever_docs_plot.append(doc)
else:
retriever_docs_text.append(doc)

for doc in retriever_docs_text:
if doc.doc_id not in doc_ids: # 跨 retriever 去重
docs.append(doc)
doc_ids.append(doc.doc_id)

返回两样东西:docs(纯文本片段列表,后续用于答题与引用)和 info(给界面的 Document,channel"info" / "plot",simple.py:150-162)。infostream() 里被立刻 yield(simple.py:293),所以界面能先看到"检索到了哪些文档",再等答案生成

一个当前状态的小坑: retrieve() 顶部有一大段被注释掉的 add_query_context 逻辑(simple.py:112-120),现在硬编码 query = message(simple.py:121-125)。也就是说默认不做历史改写查询——AddQueryContextPipeline 虽然还在(见 3.5),但不在检索路径里被调用。

3.2 PrepareEvidencePipeline:把文档拼成一段"带标记"的证据

它要解决的小问题。 LLM 只能读一段文本。检索给的是结构化的文档对象(有正文、有表格、有图片、有 metadata),得压平成一段字符串,同时保留"这是表格/这是图/来自哪个文件"这些线索

思路。 按每个片段的 metadata.type 走不同分支,拼成一段夹带 HTML 小标签的证据文本;同时记录出现过哪些"证据模式"。真源码在 format_context.py:28run()

四种证据模式是常量(format_context.py:9-12):

常量触发条件拼进证据的内容
EVIDENCE_MODE_TEXT0默认Content from {source}: … 正文
EVIDENCE_MODE_TABLE1type == "table"Table from {source} + 表格原文(最多 5 张)
EVIDENCE_MODE_CHATBOT2type == "chatbot"Excel 每行一个预设问答场景
EVIDENCE_MODE_FIGURE3type == "image"Figure from {source} + <img> 占位 + 图片进 images 列表

拼的时候有两个去重细节:表格片段拼前先查 if retrieved_content not in evidence(format_context.py:61),纯文本同样查(format_context.py:93),避免重复内容占满上下文。

多模式如何收敛成一个。 一次检索可能同时命中文本和图。最终 evidence_mode 按优先级归一(format_context.py:101-105):图 > 表 > 文。这个模式决定 3.3 里用哪个 prompt 模板。

最后一步:裁长度。 全部拼完后,用 TokenSplittermax_context_length(默认 32000,format_context.py:25)切一刀,只保留第一块(format_context.py:109-112)。超出上下文窗口的证据被直接丢弃——简单粗暴但可靠。

返回值是打包成 Document.content 的三元组 (evidence_mode, evidence, images)(format_context.py:114),在 stream() 里被解构(simple.py:295)。

3.3 AnswerWithContextPipeline:边流式答题,边并行抽引用

这是默认答题器(citation_qa.py:83),对应界面上的 "citation: highlight" 模式。它的 stream()(citation_qa.py:190)是本章最核心的一段。

第一步:按证据模式选 prompt。 get_prompt()(citation_qa.py:121)根据 evidence_mode 从四个模板里挑一个。这个默认模板族(citation_qa.py:40-80)共同的骨架是"用下面的上下文回答问题,不知道就说不知道,用 {lang} 回答":

模板常量用于特点
DEFAULT_QA_TEXT_PROMPT纯文本要求"详细+清晰解释"
DEFAULT_QA_TABLE_PROMPT表格明示上下文含"文本/表格/图"
DEFAULT_QA_CHATBOT_PROMPT预设场景"挑最合适的场景,原样输出答案文本"
DEFAULT_QA_FIGURE_PROMPT图片(多模态开时)走多模态消息

{context}{question}{lang} 三个占位符由 PromptTemplate.populate() 填(citation_qa.py:135)。用户能在设置里替换 qa_prompt(simple.py:453),覆盖默认文本模板。

第二步:并行起两个后台线程。 在开始流式生成之前,若开了引用/思维导图,就各起一个 threading.Thread(citation_qa.py:222-229):

# 真源码节选 citation_qa.py:210-229
def citation_call():
nonlocal citation
citation = self.citation_pipeline(context=evidence, question=question)

def mindmap_call():
nonlocal mindmap
mindmap = self.create_mindmap_pipeline(context=evidence, question=question)

if evidence:
if self.enable_citation:
citation_thread = threading.Thread(target=citation_call)
citation_thread.start() # 主线程去流式答题,引用抽取并行进行

这是关键设计:抽引用是另一次独立的 LLM 调用(见 3.4),它和主答题同时跑,不阻塞用户看到答案流式出现。

第三步:组装消息并流式生成。 先按 n_last_interactions 拼历史对话(citation_qa.py:238-240),再决定是否走多模态。图片模式且开了多模态时,把 prompt 和图片打包成 content 列表(citation_qa.py:242-257);否则就是单条 HumanMessage

然后优先尝试流式,不支持才回退到一次性调用(citation_qa.py:262-272)。每个流式 token 通过 yield Document(channel="chat", …) 推给界面。

第四步:算 qa_score。 流式过程中把每个 token 的 logprobs 累积起来,结束后算几何平均的指数:

# 真源码节选 citation_qa.py:274-277
if logprobs:
qa_score = np.exp(np.average(logprobs)) # 平均对数概率还原成 [0,1] 的"信心"
else:
qa_score = None

直觉: logprob 是模型对自己每个 token 的把握。平均后取指数,得到一个 0~1 的"答案信心分"。模型越犹豫(logprob 越负),分越低。它不是"答案正确性",只是"模型有多确定"。

第五步:等后台线程,打包返回。 两个后台线程各 join(timeout=CITATION_TIMEOUT)(超时 5 秒,citation_qa.py:35),超时就放弃引用。最后把答案文本和 citation/mindmap/qa_score/citation_viz 装进 Document.metadata 返回(citation_qa.py:284-292)。

3.4 CitationPipeline:用 function-calling 抽出"支持答案的原句"

它要解决的小问题。 怎么让模型结构化地吐出"我引用了原文的哪几句话"?自由文本很难解析。答案是:用工具调用(function calling)强制模型返回一个 JSON 数组

核心是一个 Pydantic 模型 CiteEvidence(citation.py:10):

# 真源码节选 citation.py:10-19
class CiteEvidence(BaseModel):
"""List of evidences (maximum 5) to support the answer."""
evidences: List[str] = Field(
...,
description=(
"Each source should be a direct quote from the context, "
"as a substring of the original content (max 15 words)."
),
)

prepare_llm()(citation.py:31)把这个 schema 转成 OpenAI 风格的 function 定义,并用 tool_choice: "required" 强制模型必须调用它(citation.py:38-42)。系统提示直白:"You are a world class algorithm to answer questions with correct and exact citations"(citation.py:45-48)。

解析要兼容多家格式。 不同厂商把工具参数放在不同字段:OpenAI/Cohere 在 first_func["function"]["arguments"],Anthropic 在 first_func["args"],代码两种都试(citation.py:78-83),再按字符串/对象分别 parse_raw / parse_obj(citation.py:87-90)。整段裹在 try/except 里,任何失败都返回 None——引用抽取永远不能拖垮主答案

抽完只是"一串引文字符串",还没锚定到原文。 回锚发生在展示阶段,见 3.6。

3.5 AddQueryContextPipeline:用历史改写查询(当前已停用)

设计意图。 用户问"它去年表现如何?"时,"它"指代前文。直接拿这句去检索会检不准。AddQueryContextPipeline(simple.py:42)让 LLM 结合最近几轮对话,把模糊问题改写成一个自包含的检索查询

它的提示词还带了几个 few-shot 示例(simple.py:63-68),并约定两个特判返回值(simple.py:59-61):

LLM 返回含义run() 的动作
"1"问题已含足够信息返回原问题(simple.py:80-81)
"0"无需检索(如"你好")返回空 Document(simple.py:77-78)
其它改写后的查询返回改写结果(simple.py:83)

注意:它现在不在主路径上。 如 3.1 所述,retrieve() 里调用它的代码被注释掉了(simple.py:112-120)。它仍在 get_pipeline() 里被配置好 llm(simple.py:383-386),但实际检索不经过它。本章如实记录:这是一个已实现、当前默认停用的组件

3.6 内联引用:另一条不用 function-calling 的路

除了"高亮"模式,还有"内联"模式 AnswerWithInlineCitation(citation_qa_inline.py:86)。用户在设置里选 "citation: inline" 时启用(simple.py:361-364)。

它和默认模式最大的不同:引用不靠第二次 LLM 调用,而是让答题的那一次 LLM 直接按固定格式吐出引用。 提示词 DEFAULT_QA_CITATION_PROMPT(citation_qa_inline.py:16)要求模型先输出一段 CITATION LIST(每条给 START_PHRASE / END_PHRASE 标出原文片段的首尾),再输出 FINAL ANSWER,答案里用 【number】 标注引用。

流式解析的巧思。 因为答案和引用混在同一个流里,stream()(citation_qa_inline.py:193)要一边流一边切:只有当输出里出现了标记 START_ANSWER = "FINAL ANSWER" 后,才开始把 token 当"正式答案"推给界面(citation_qa_inline.py:262-276)。它还处理了一个"小模型把 CITATION LIST 重复输出"的边界情况——一旦在答案段又冒出 START_CITATION 就 break(citation_qa_inline.py:280-281)。

解析引用。 answer_to_citations()(citation_qa_inline.py:103)逐行扫描,用正则 CITATION_PATTERN(citation_qa_inline.py:72)抓引用编号,收集 start_phrase/end_phrase,组装成 InlineEvidence 列表(dataclass,citation_qa_inline.py:77)。

渲染成可点链接。 replace_citation_with_link()(citation_qa_inline.py:152)把 【1】 替换成 <a class='citation' id='mark-1'> 链接,还能把合并引用 【1,2】 拆成 【1】【2】(citation_qa_inline.py:161-167)。


4. 深入实现:引用如何回锚到原文并高亮

这是"带引证问答"的最后一公里——把抽出来的引文,精确定位回原文的字符区间,做成高亮。入口是 prepare_citations()(citation_qa.py:322),由 show_citations_and_addons() 调用(simple.py:225)。

4.1 模糊匹配:引文对不上原文一字不差怎么办

模型抽的引文,常常和原文有细微出入(空格、大小写、断句)。所以匹配用 difflib.SequenceMatcher模糊子串匹配,而非精确查找。

高亮模式find_text()(utils.py:4):把引文按行拆开,对原文逐段找匹配块,只有匹配长度超过阈值(max(len(sentence)*0.25, min_length),utils.py:23)才算数,最后把所有命中合并成一个区间。

内联模式find_start_end_phrase()(utils.py:44):分别定位 start_phraseend_phrase 在原文的位置,取二者跨度作为高亮区间;还做了两道防护——若结束短语出现在开始短语之前就只保留开始(utils.py:66-68),且区间过长时截断到 max_excerpt_length=300(utils.py:74-76)。

4.2 从字符区间到界面高亮

match_evidence_with_context() 返回 spans——每个 doc_id 对应一串 {start, end} 区间(高亮模式 citation_qa.py:296;内联模式 citation_qa_inline.py:322)。prepare_citations() 拿着 spans:

  • 命中的文档 → 把原文按区间切开,命中段用 Render.highlight() 包成高亮 HTML,拼回完整正文,放进 with_citation(citation_qa.py:332-377)。区间排序后还做了防重叠处理(citation_qa.py:343-348)。
  • 没命中的文档 → 进 without_citation,并按 llm_trulens_score 降序排,分高的默认展开(citation_qa.py:381-402)。

两个列表都是 channel="info"Document,回到 show_citations_and_addons() 里被 yield 到信息栏(simple.py:272-274)。

4.3 附加物:警告、置信分、思维导图、嵌入可视化

show_citations_and_addons()(simple.py:223)把可信度信号和附加视图一起吐给界面:

  • 低相关警告。 若文档带 llm_trulens_score 且最高分低于 CONTEXT_RELEVANT_WARNING_SCORE(默认 0.3,citation_qa.py:36),就 yield 一条 "WARNING! Context relevance score is low"(simple.py:251-258)。这个分数来自上一章的 LLM 重排
  • 答案置信分。 把 3.3 算的 qa_score 四舍五入后显示 "Answer confidence: X"(simple.py:260-270)。
  • 思维导图。 prepare_mindmap()(simple.py:166)把 mindmap 文本包成 markmap 的 <script> 模板,渲染成可展开/导出的折叠块。
  • 嵌入可视化。 prepare_citation_viz()(simple.py:206)在文档多于一个时,用 CreateCitationVizPipeline 把上下文和问题的嵌入用 UMAP 降到二维画散点(libs/ktem/ktem/utils/visualize_cited.py:27),作为 channel="plot" 输出。

顺带一提,stream() 在展示引用前还会处理推理模型的 <think> 标签:replace_think_tag_with_details()(utils.py:85)把它转成可折叠的 <details>(simple.py:319-323)。


5. 巧妙之处(可借鉴的技术)

  • 答题与抽引用并行,用线程 + 超时兜底。 抽引用是独立的第二次 LLM 调用,却和主答题同时跑,join(timeout=5s) 保证再慢也不拖累主答案(citation_qa.py:222-282)。用户体验上"答案先出、引用后补"。
  • tool_choice: "required" 强制结构化引用。 不靠解析自由文本,而用 function calling 逼模型返回 CiteEvidence 数组,并对 OpenAI/Anthropic 两种参数格式都做兼容(citation.py:38-42citation.py:78-83)。
  • 引用抽取全程 try/except 吞异常。 引用失败只返回 None(citation.py:91-93),绝不让"锦上添花"的功能拖垮主答案。
  • 模糊匹配回锚,容忍模型引文与原文的细微出入。SequenceMatcher + 长度阈值,而非精确子串,现实中模型很少一字不差地复述(utils.py:4utils.py:44)。
  • 两套引用哲学并存。 "高亮"=多一次 LLM 调用换结构化引文;"内联"=省一次调用,让答题 LLM 自己按格式吐。前者稳、后者省 token,USE_LOW_LLM_REQUESTS 时默认干脆关掉引用(simple.py:420-433)。
  • 证据裁剪只留第一块。 超上下文窗口时不做复杂取舍,TokenSplitter 切完取 [0](format_context.py:109-112)——简单、可预测。

6. 边界与局限

  • 历史改写查询默认停用。 AddQueryContextPipeline 已实现但被注释出主路径(simple.py:112-125),多轮对话里的指代问题不会被自动改写。
  • 证据超长直接截断。 只保留裁剪后的第一块,靠后的检索片段可能被丢弃(format_context.py:109-112);排序质量因此更依赖上一章的重排。
  • qa_score 只反映"模型的确定度",不是正确性。 它是 token logprob 的平均指数(citation_qa.py:274-277);模型可能自信地答错。
  • 引用有超时上限。 5 秒内没抽完就放弃(citation_qa.py:35citation_qa.py:280),慢模型可能干脆没有高亮。
  • 引用依赖模糊匹配,可能漏锚或错锚。 模型引文与原文差异过大时 find_text 会匹配失败,该文档落入"未命中"列表(citation_qa.py:329)。
  • 表格证据最多 5 张。 超出的表格被忽略(format_context.py:57)。
  • 本章不覆盖 agent 推理。 ReAct / ReWOO 的多步推理是另一条链,见 05;FullDecomposeQAPipeline(把问题拆成子问题逐个答,simple.py:487)是本链的一个子类扩展,原理与本章一致。

7. 横向对比

同 shelf 的其它 RAG/agent 项目在"答案可信度"上取舍不同。Kotaemon 的特色是把引用做成第一优先级:独立线程抽引用、模糊回锚高亮、logprob 置信分、UMAP 嵌入可视化,四件事叠加成一套"可核对"体验。许多轻量 RAG 只做到"把文档拼进 prompt"就结束,不回锚、不给置信信号。

在本库内部,这一章是检索链的下游终点:上游是 03 混合检索与重排(它产出的 llm_trulens_score 直接被本章的低相关警告复用),平行的另一条推理路径是 05 可插拔推理。组件如何组合、BaseComponent/Node/channel 的机制见 01 组件模型


8. 代码地图(导航索引)

主题文件路径符号名
问答总编排 / 主控制流libs/ktem/ktem/reasoning/simple.pyFullQAPipeline.stream
取回并分流文档libs/ktem/ktem/reasoning/simple.pyFullQAPipeline.retrieve
展示引用与附加物libs/ktem/ktem/reasoning/simple.pyFullQAPipeline.show_citations_and_addons
思维导图 / 嵌入可视化libs/ktem/ktem/reasoning/simple.pyprepare_mindmap / prepare_citation_viz
历史改写查询(停用)libs/ktem/ktem/reasoning/simple.pyAddQueryContextPipeline
子问题分解链libs/ktem/ktem/reasoning/simple.pyFullDecomposeQAPipeline
证据拼装 + 证据模式 + 裁长libs/kotaemon/kotaemon/indices/qa/format_context.pyPrepareEvidencePipeline.run
证据模式常量libs/kotaemon/kotaemon/indices/qa/format_context.pyEVIDENCE_MODE_TEXT/TABLE/CHATBOT/FIGURE
默认答题器(高亮引用)libs/kotaemon/kotaemon/indices/qa/citation_qa.pyAnswerWithContextPipeline.stream
prompt 模板族libs/kotaemon/kotaemon/indices/qa/citation_qa.pyDEFAULT_QA_TEXT_PROMPT
qa_score / 低相关阈值libs/kotaemon/kotaemon/indices/qa/citation_qa.pyqa_score / CONTEXT_RELEVANT_WARNING_SCORE
引用回锚 + 高亮渲染libs/kotaemon/kotaemon/indices/qa/citation_qa.pyprepare_citations / match_evidence_with_context
结构化引用抽取libs/kotaemon/kotaemon/indices/qa/citation.pyCitationPipeline / CiteEvidence
内联引用答题器libs/kotaemon/kotaemon/indices/qa/citation_qa_inline.pyAnswerWithInlineCitation
内联引用解析 / 转链接libs/kotaemon/kotaemon/indices/qa/citation_qa_inline.pyanswer_to_citations / replace_citation_with_link
模糊匹配回锚libs/kotaemon/kotaemon/indices/qa/utils.pyfind_text / find_start_end_phrase