数据截至 (上游 commit 081bc084a5bc)
从检索到回答:证据打包、带源提示与事后核查
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_source | prompts.py:374/391/411/488 |
prompt_with_source | 取一批证据当 context,喂给模型,注册进历史 | prompts.py:563 |
prompt_main | 真正调模型的地方,组装出带 evidence/evidence_metadata 的 output_dict | prompts.py:773 |
QualityCheck | 三个确定性核查器 + "没找到"分类 | prompts.py:1264 |
HumanInTheLoop | 把交互导出给人复核、收评分与人工改写 | prompts.py:1916 |
主线走一遍(高层): qr(检索结果)→ package_source 按 token 预算切成 source_materials 里的多批 → prompt_with_source 默认取第 0 批当 context → prompt_main 套 模板、调模型、产出带证据的 output_dict → evidence_check_* 拿 output_dict 里的 llm_response 和 evidence 逐条核对 → 需要时交 HumanInTheLoop 给人。这条闭环就是本章要拆开的全部。
3. 核心原理(逐个机制,由浅入深)
3.1 证据打包:把检索块塞进"上下文预算"
它要解决的小问题。 检索可能返回 50 个文本块,但模型的上下文窗口装不下这么多;而且一旦切开,你还得记住"第 37 块来自合同第 4 页"——否则事后没法把答案定位回原文。
思路。 Sources.package_source 做两件事:贪心地把块塞进一个"批"直到快满,满了就开新批;同时为每块记一份元数据(在批内的字符起止、页码、来源文件、doc_id、block_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_results | prompts.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_stats 和 biblio 汇总出来——让你在提问前先看清"我到底攒了多少 token、覆盖了哪些文件哪几页"。