跳到主要内容

从检索到回答:证据打包、带源提示与事后核查

30 秒导读: 检索(见 04)给了你一堆命中的文本块 query_results。本章讲最后一公里:怎么把这堆块按 token 预算打包成证据、塞进 prompt 模板做带源推理,再用一组确定性的小工具逐条核查模型的回答——数字对不对、来自哪一页、有多少词能在原文里找到、是不是其实"没找到"。llmware 的取向很鲜明:核查不再叫一个大模型来判对错,而是用可复现的字符串/数字匹配


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

一句话定义: 这是 RAG(检索增强生成)流水线的生成 + 验证段——把检索到的资料喂给语言模型生成答案,然后当场验证这个答案有没有偏离资料

它解决什么问题。 检索能找到相关段落,但模型读完段落后仍可能编:把 $1,000,000 说成 $100,000、引用一段原文里根本没有的话。企业场景(合同、财报、法规)里,一个编错的数字就是事故。所以光"检索 + 生成"不够,还要能回答:

  • 这个答案里的每个数字,原文里出现过吗?在第几页?
  • 这段回答的用词,有多少能在证据里对上?对不上的比例高,就是危险信号。
  • 模型到底是答出来了,还是其实没找到(该老实说"Not Found")却硬编了一段?

给谁用。 做文档问答、合同审阅、尽调、客服知识库的工程师——凡是"答错要负责任"的场景。

用起来什么样。 整段流程就是"加源 → 提问 → 核查"三步:

# 示意,非源码 —— 完整可运行例子见 prompts.py 类文档字符串
prompter = Prompt().load_model("llmware/bling-1b-0.1") # 载入一个小生成模型
prompter.add_source_document(folder, "employment_agreement.pdf") # ① 打包证据
result = prompter.prompt_with_source("What is the base salary amount?") # ② 带源提问
# result[0]["llm_response"] -> " $1,000,000.00"

facts = prompter.evidence_check_numbers(result) # ③ 核查:数字在原文出现过吗
stats = prompter.evidence_comparison_stats(result) # ③ 核查:用词有多少能对上
# facts[0]["fact_check"] -> [{'fact':'1000000.00','status':'Confirmed','page_num':4,...}]
# stats[0]["comparison_stats"]-> {'percent_display':'100.0%','verified_token_match_ratio':1.0,...}

一句话直觉。 把它想成带脚注的考试:模型是考生,证据是开卷资料,evidence_check_* 是判卷老师——但这个老师不靠"感觉",而是拿着原文一个数字一个数字地核、一个词一个词地对。

本节不出现底层细节。记住一句:llmware 的 RAG 不止"生成",还内建"当场对账"。


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

怎么读这张图: 从左到右是一次问答的生命周期。上半行是"打包 → 提问 → 生成",下半行是"事后核查",两段都锚定在同一份 evidence(证据文本)+ evidence_metadata(每块的字符区间/页码/来源)上——正是这份共享的元数据让核查能把答案精确定位回原文。

检索结果 qr 最终产物
(04 章产出) response_dict 列表
│ ▲
▼ │
┌───────────────┐ 写入 ┌────────────────────┐ │
│ ① 打包证据 │────────>│ Prompt.source_ │ │
│ Sources. │ 按token │ materials[] │ │
│ package_source │ 预算切批 │ 每批: text+metadata │ │
└───────────────┘ │ +biblio+stats │ │
util.py:1990 └─────────┬──────────┘ │
│ 取 batch[0]["text"] │
▼ 作为 context │
┌────────────────────┐ register │
│ ② 带源提问 │ │
│ prompt_with_source ├───────────┤
│ →prompt_main→模型 │ llm_response
└─────────┬──────────┘ +evidence │
│ output_dict │
▼ │
┌─────────────────────────────────────────────┐ │
│ ③ 事后核查(QualityCheck,全部确定性) │────┘
│ evidence_check_numbers → 数字逐个回原文找 │
│ evidence_check_sources → 定位最像的原文片段 │
│ evidence_comparison_stats→ 词级重叠率 │
│ classify_not_found → 是不是"没找到" │
└───────────────────┬─────────────────────────┘
│ 可选:人审

HumanInTheLoop(导出CSV/评分/改写)
prompts.py:1916

部件一句话职责:

部件干什么在哪
Sources.package_source把检索块按 token 预算切成一批批证据,保留每块页码/来源util.py:1990
Prompt.source_materials有状态的证据货架,累积历次加进来的批次prompts.py:196
add_source_* 系列各种入口:查询结果、整库、Wikipedia、本地文档… 统一都调 package_sourceprompts.py:374/391/411/488
prompt_with_source取一批证据当 context,喂给模型,注册进历史prompts.py:563
prompt_main真正调模型的地方,组装出带 evidence/evidence_metadataoutput_dictprompts.py:773
QualityCheck三个确定性核查器 + "没找到"分类prompts.py:1264
HumanInTheLoop把交互导出给人复核、收评分与人工改写prompts.py:1916

主线走一遍(高层): qr(检索结果)→ package_source 按 token 预算切成 source_materials 里的多批 → prompt_with_source 默认取第 0 批当 contextprompt_main 套模板、调模型、产出带证据的 output_dictevidence_check_*output_dict 里的 llm_responseevidence 逐条核对 → 需要时交 HumanInTheLoop 给人。这条闭环就是本章要拆开的全部。


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

3.1 证据打包:把检索块塞进"上下文预算"

它要解决的小问题。 检索可能返回 50 个文本块,但模型的上下文窗口装不下这么多;而且一旦切开,你还得记住"第 37 块来自合同第 4 页"——否则事后没法把答案定位回原文。

思路。 Sources.package_source 做两件事:贪心地把块塞进一个"批"直到快满,满了就开新批;同时为每块记一份元数据(在批内的字符起止、页码、来源文件、doc_idblock_id)。这份元数据是后面所有核查的地基。

关键设计:上下文预算故意保守。 Prompt 默认 context_window_size = 1000(prompts.py:129),注释直说这是"假设模型至少 2048 窗口、50% 进 / 50% 出"的保守估计;若载入了真实模型,会改用模型的 max_input_len(prompts.py:135)。保守是为了给小模型留出准确率余量。

图示——贪心装批:

块1(300tok) 块2(400tok) 块3(500tok) 块4(200tok) ... 预算=1000tok
└──── 批0 ────┘ │
300+400=700 <1000 ✔ │ 700+500=1200 >1000 ✘ → 收尾批0,块3另起批1

批0=[块1,块2] 批1=[块3,...]
每批附:text(拼好的大字符串) + metadata(每块的字符区间+页码) + biblio(用了哪些文件哪几页) + batch_stats(tokens/chars/samples)

原理演示(把核心贪心逻辑抽出来):

# 示意,非源码 —— 对应 util.py:2058 起的主循环
current_batch, token_counter, batch_meta = "", 0, []
for chunk in samples: # samples 已去重
t = token_count(chunk["text"])
if token_counter + t < CONTEXT_WINDOW: # 还装得下 → 继续拼
current_batch += chunk["text"] + "\n"
batch_meta.append({"evidence_start_char": ..., "page_num": chunk["page_num"], ...})
token_counter += t
else: # 装不下 → 收尾这批,开新批
source_materials.append({"text": current_batch, "metadata": batch_meta, ...})
current_batch, token_counter, batch_meta = chunk["text"], t, [新块的meta]
# 重点看:token 预算是"下一块加进来会不会超",超了才切

真实实现。 预算判断在 util.py:2089(if (t + token_counter) < self.context_window_size:);溢出时收尾旧批、重置新批在 util.py:2124 起;每批产出的字典结构(batch_id/text/metadata/biblio/batch_stats)在 util.py:2160-2164

两个易错的坑(都在源码里处理了):

  • 单块就超预算怎么办? 先探测,若某块本身 token 数 > 窗口,chunk_large_sample(util.py:2218)把它按 token 硬切成多份再进主循环(util.py:2050-2056)。
  • aggregate_source=True 的续批语义。 若货架上已有"半满"的最后一批,package_source 会把它弹出来继续填(util.py:2024-2042),而不是每次 add_source_* 都开新批——所以你可以连着加好几个源,它们会尽量拼进同一批上下文。

3.2 一个入口收编所有源:add_source_* 家族

它要解决的小问题。 证据的来源五花八门:上一步的查询结果、整个小型 library、一篇 Wikipedia、一个本地 PDF、一支股票的实时财报……但下游的打包与核查希望格式统一

思路。 每个 add_source_* 只负责"把自己那种源变成 [{text, file_source, page_num, ...}] 的列表",然后一律交给同一个 Sources(self).package_source(...)。差异被收敛在入口,主干只认一种形状。

入口源从哪来位置
add_source_query_results直接传入上一次查询的 query_resultsprompts.py:374
add_source_new_query现场对 library 跑一次 Query(...).query(...)prompts.py:358
add_source_library整个小型 library 全量取出prompts.py:391
add_source_wikipedia解析若干篇 Wikipedia 文章prompts.py:411
add_source_document现场解析一个本地文件(任意格式)prompts.py:488
add_source_yahoo_finance抓某 ticker 的实时财报字段prompts.py:434

真实实现——统一收口。add_source_query_results(prompts.py:374)几乎只有一行核心:sources = Sources(self).package_source(query_results, aggregate_source=True);add_source_document(prompts.py:488)先 Parser().parse_one(...)、可选按 query 做内存内过滤(Utilities().fast_search_dicts),再走同一句 package_source所有入口的终点都是 §3.1 的打包器。

顺手的检视工具。 review_sources_summary(prompts.py:529)遍历 source_materials,把每批的 batch_statsbiblio 汇总出来——让你在提问前先看清"我到底攒了多少 token、覆盖了哪些文件哪几页"。

3.3 带源推理:把证据注入模板,再调模型

它要解决的小问题。 有了证据批,怎么变成一句真正发给模型的话?而且要能约束模型"只用给定文本作答"。

思路。 prompt_with_source(prompts.py:563)默认只用第 0 批证据(first_source_only=True),把 source_materials[0]["text"]context 传给 prompt_main;若你想遍历所有批(长文档问答),传 first_source_only=Falsesource_id_list=[0,1,5],它会对每批各调一次模型并汇总。

先做一道安全检查。 调模型前先 verify_source_materials_attached()(prompts.py:547):如果一个证据都没挂,就发 warning 并退化成无 context 推理——提醒你"这时候的回答不可信"。

模板怎么把 context 包进去。 真正的拼装在模型层的 build_core_prompt(models.py:2487),按 prompt_card 的 run_order 依次拼 blurb / $context / $query / instruction。以 just_the_facts 这张卡为例(model_configs.py:4104):

组件内容
blurb1Please read the following text:
$context←你的证据批文本注入这里
blurb2Please answer the question:
$query←你的问题
instructionIn providing the answer, please only use facts contained in the text.

那句 instruction 就是"闭卷、只许用原文"的显式约束——这是 RAG 抑制幻觉的第一道防线,但只是"请求",真正的兜底靠 §3.4 的事后核查。

产出:一个自带证据的 output_dict prompt_main(prompts.py:773)调完模型后,把回答连同证据打成字典(prompts.py:868),关键是这两项:

  • "evidence": context —— 这次用的整段证据文本原文;
  • "evidence_metadata": [...] —— 每块证据的字符区间与来源(prompts.py:884)。

正是 evidence + evidence_metadata 这对字段,让下一步的核查能"拿着答案回原文里找"。 prompt_with_source 还会把该批的 metadatabiblio 补回 response_dict(prompts.py:611-615),并逐条 register_llm_inference 进历史(prompts.py:662)。

3.4 事后核查:三个确定性小工具

这是 llmware 最有辨识度的一段。核查不再叫大模型来判对错,而是用可复现的字符串 / 数字匹配。三个工具分工明确:

方法(Prompt 层)它回答什么问题核查器(QualityCheck)结果键
evidence_check_numbers回答里的数字在原文出现过吗?第几页?fact_checker_numbersfact_check
evidence_check_sources最像这段回答的原文片段是哪几段?source_reviewersource_review
evidence_comparison_stats回答的用词有多大比例能在原文对上?token_comparisoncomparison_stats

位置:三个 Prompt 层入口在 prompts.py:1120 / 1141 / 1162,底层核查器在 QualityCheck(prompts.py:1264)。三者都接受"单个 dict 或 dict 列表",逐个补上结果键后返回。


(a) 数字核查 fact_checker_numbers(prompts.py:1363)

思路: 回答里的钱和数字最要命。先从 llm_response抽出所有数字(顺手剥掉 $ , % ( ) 。等符号,% 会被换算成小数),再到 evidence 里逐个找数值相等的 token;找到就标 Confirmed 并附上一段前后各 10 词的上下文窗口和页码,找不到标 Not Confirmed

# 示意,非源码 —— 对应 prompts.py:1439 起的匹配循环
for n in ai_numbers: # 回答里抽出的每个数字
for i, tok in enumerate(evidence_tokens):
if isfloat(tok) and float(tok) == n: # 数值相等即命中
window = " ... " + " ".join(evidence_tokens[i-10:i+10]) + " ... "
fact_check.append({"fact": tok, "status": "Confirmed",
"text": window, "page_num": page_num, "source": source_fn})
break
else:
fact_check.append({"fact": str(n), "status": "Not Confirmed", "text": ""})
# 重点看:比的是 float 数值相等,不是字符串——"1,000,000" 和 "1000000" 算同一个

妙在: 它匹配的是数值而非字面串,所以千分位逗号、货币符号不影响;且每个确认命中都带回页码 + 原文片段,可以直接做成脚注给用户看。

(b) 来源定位 source_reviewer(prompts.py:1593)

思路:CorpTokenizer 把回答和每块证据都切成词,算回答的词在该块里命中的比例 match_score = match / ai_token_len;超过阈值的块留下,按分数排序,取前 3 个最像的,并从命中词的中位位置向两侧各截 10 词做片段。

三个阈值把"像不像"量化(prompts.py:1603-1605):

阈值含义
min_th0.25命中比例 > 25%(或绝对命中 > min_match_count)才算候选来源
min_match_count3绝对命中词数的兜底门槛
conclusive_th0.75命中 > 75% 视为"确凿来源",直接停止再找

每条结果带 match_score / source / page_num / doc_id / block_id,等于给回答自动配了引用

(c) 词级对账 token_comparison(prompts.py:1707)

思路: 给一个"回答整体有多少词能在证据里找到"的总分——这是判断幻觉最直接的量化指标。逐个 token 比对(数字额外做数值相等、还会把证据里的"英文数词"用 replace_word_numbers 展开成数值),得到:

# comparison_stats 的真实结构(prompts.py:1829)
{"percent_display": "100.0%", # 人看的百分比
"confirmed_words": [...], # 命中的词
"unconfirmed_words": [...], # 没命中的词 ← 重点审这些
"verified_token_match_ratio": 1.0, # 0~1 的匹配率
"key_point_list": [...]} # 按"要点"拆的逐点匹配

关键细节: 匹配是精确匹配(注释明说移除了 lemmatizer 和近似匹配,prompts.py:1788),换来结果可复现、可解释——同样输入永远同样结果,不像"再问一次模型"那样飘。

3.5 "没找到"分类:RAG 里最容易被忽视的正确行为

它要解决的小问题。 好的 RAG 系统必须敢说"资料里没有答案"。但模型常常不肯认输、硬编一段。怎么自动识别"其实是没找到"?

思路——三个可叠加的测试(classify_not_found_response,prompts.py:1183):

① parse_llm_response —— 回答清洗后为空,或以 "not found" 开头 → 判定"没找到"
② evidence_match —— verified_token_match_ratio < 0.25 → 判定"没找到"
③ ask_the_model(实验)—— 再叫一个模型给自己的回答判"是不是没找到"(默认不用,慎用)

为什么默认信①②不信③。 测试 ② classify_not_found_evidence_match(prompts.py:1858)复用 §3.4(c) 的匹配率:低于 0.25 就说明回答的词绝大多数在证据里根本没有——极可能是编的。这是纯确定性的。而 ③ 是"再叫模型判"的实验性做法,文档字符串自己都写了"有的模型很准、有的很差,请小心使用"(prompts.py:1889)。多个测试若结论不一致,not_found_classification 直接标 undetermined(prompts.py:1236),把判断权交回给人——而不是假装确定。

这正体现了 llmware 的整体取向:能用确定性小检查解决的,就不叫大模型;大模型不可靠的地方,宁可标"待定"也不硬猜。

3.6 人在环:核查之后的最后一道

它要解决的小问题。 自动核查能标红可疑答案,但高风险场景(合同、合规)最终得有人拍板、能纠正、能留痕。

三件事(HumanInTheLoop,prompts.py:1916):

  • 导出复核。 send_to_human_for_review(prompts.py:1240)→ export_current_interaction_to_csv(prompts.py:1964),把当前交互(问题/回答/证据/核查结果)导成 CSV 给人看。
  • 打分。 apply_user_ratings(prompts.py:1247)写入 human_rating / human_feedback / human_assessed_accuracy
  • 改写留痕。 apply_user_corrections(prompts.py:1255)→ update_llm_response_record(prompts.py:2006):人工改答案时,把原值存进 change_log 再覆盖(prompts.py:2013-2031),保留可回溯的修改历史。

这些字段早在 Prompt.__init__llm_state_vars(prompts.py:172-180)里就为"证据 / 核查 / 人审"预留好了位置——证据、事实核查、人工反馈从一开始就是一等公民,不是事后补丁。


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

  • 用确定性检查代替"LLM 判 LLM"。 数字核查比数值、来源定位比词重叠率、都可复现可解释(prompts.py:1363 / 1593 / 1707)。把"验证"从概率问题降维成字符串/数字问题——便宜、稳定、可审计。这是本章最该带走的一条。
  • 证据元数据贯穿始终。 package_source 为每块证据记 evidence_start_char/stop_char + page_num + doc_id + block_id(util.py:2095-2103),prompt_main 原样带进 output_dict(prompts.py:884),核查器再据此把答案精确定位回某文件某页(prompts.py:1512-1523)。一份元数据,复用到底。
  • 贪心 token 预算 + 保守窗口。 默认窗口只取 1000(prompts.py:129),宁可切更多批也要给小模型留准确率余量;续批语义(util.py:2024)让连续加源能拼进同一上下文,不浪费预算。
  • "没找到"是一等结果。 多测试投票、不一致就标 undetermined(prompts.py:1236)——诚实优先于"看起来确定"。
  • 人审改写强制留痕。 change_log 先存原值再改(prompts.py:2013),纠错可回溯。

5. 边界与局限(诚实)

  • 数字核查只认"数字"。 fact_checker_numbers 抽的是 isfloat 能识别的数值(prompts.py:1418);日期、单位、实体名不在其列。注释也标了 "looks for numbers only right now"(prompts.py:1372)。
  • 词级对账是精确匹配,没有语义。 token_comparison 明确移除了近似匹配(prompts.py:1788):同义改写、换个说法的正确答案会被算成"未确认"、匹配率偏低。它防的是"照抄式幻觉",防不了"语义等价但换词"的漏判。
  • 默认只用第 0 批证据。 prompt_with_sourcefirst_source_only=True(prompts.py:604)意味着若答案落在第 1 批之后,得显式传 first_source_only=Falsesource_id_list,否则会漏。
  • ask_the_model 分类不稳。 实验性、模型间差异大(prompts.py:1889),默认关闭,别在生产里盲信。
  • select_among_multiple_responses 尚未完成。 多批回答聚合成"最佳答案"的那步返回空字典(prompts.py:1098 起,return aggregated_response_dictprompts.py:1118),目前是占位。

6. 横向对比与前情


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

按符号名 grep 比行号更抗漂移;下表符号均在 081bc08 核实。

主题文件符号
证据打包主逻辑(token 预算切批)llmware/util.pySources.package_source
超长单块二次切分llmware/util.pySources.chunk_large_sample
token 计数 / tokenizer 回退llmware/util.pySources.token_counter / resolve_tokenizer
证据货架(有状态)llmware/prompts.pyPrompt.source_materials(__init__)
各类加源入口llmware/prompts.pyadd_source_query_results / add_source_library / add_source_wikipedia / add_source_document
加源后的汇总检视llmware/prompts.pyPrompt.review_sources_summary
挂源校验llmware/prompts.pyPrompt.verify_source_materials_attached
带源提问(取批→调模型→注册)llmware/prompts.pyPrompt.prompt_with_source
主推理(组装带证据的 output_dict)llmware/prompts.pyPrompt.prompt_main
数字事实核查llmware/prompts.pyPrompt.evidence_check_numbers / QualityCheck.fact_checker_numbers
来源片段定位llmware/prompts.pyPrompt.evidence_check_sources / QualityCheck.source_reviewer
词级匹配率llmware/prompts.pyPrompt.evidence_comparison_stats / QualityCheck.token_comparison
"没找到"分类llmware/prompts.pyPrompt.classify_not_found_response / QualityCheck.classify_not_found_evidence_match
人在环(导出/评分/改写留痕)llmware/prompts.pyHumanInTheLoop.export_current_interaction_to_csv / update_llm_response_record
证据/核查/人审的状态字段llmware/prompts.pyPrompt.llm_state_vars
prompt 模板拼装(context 注入)llmware/models.pyPromptCatalog.build_core_prompt
闭卷 prompt 卡示例llmware/model_configs.pyjust_the_facts / number_or_none prompt_card