数据截至 (上游 commit c80d325ec761)
引用合成与报告生成(quick vs detailed)
30 秒导读: 搜索引擎捞回一堆网页片段后,这一环负责两件事——先把片段喂给 LLM,产出带
[1] [2]编号引用、且不敢编造来源的文字;再把这些文字拼成用户能读的产物。产物分两档:轻量的 quick summary(一段综述)和重量的 detailed report(带目录、逐小节深挖的长文)。本章讲透"编号怎么保持连续""怎么防幻觉""两档产物怎么拼",不重复策略循环(01)和搜索引擎(02)。
1. 这是什么(零基础也能懂)
一句话定义: 把"一堆搜索结果 + 一个问题"变成"一段有理有据、每个论断后面挂着 [3] 这种编号、结尾附来源清单"的可信文本,再装配成报告。
它解决什么问题。 LLM 天生爱"一本正经地编"——你让它"带引用回答",它会顺手编出一个看着像真的、其实不存在的来源。深度研究工具最怕这个:引用一旦是假的,整份报告就不可信。这一环的核心任务,就是用工程手段把"编造引用"这条路堵死。
三个绕不开的小问题:
| 小问题 | 白话 |
|---|---|
| 编号怎么来 | 第 5 篇搜索结果,凭什么在正文里叫 [5]、在来源清单里也叫 [5]? |
| 编号怎么跨轮不乱 | 一份 detailed report 有十几个小节,每节都各搜各的,编号凭什么不从 [1] 重头再来、彼此打架? |
| 怎么不让模型编 | 搜索一无所获时,怎么保证模型不退回"我记得好像是……"然后编个假链接? |
一句话直觉: 把整个系统里所有见过的源(网页)想象成一本共享的花名册(all_links_of_system),每条源进册子时就分到一个永久工号(index 字段)。正文里的 [n]、结尾清单里的 [n]、导出的 PDF 里的 [n]——引的都是同一个工号。编号连续、不重号、跨小节可追,全靠这本花名册只有一份。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是一次合成的数据流。左半("引用侧")把搜索结果变成带编号的可信文本;右半("报告侧")把文本拼成产物并导出。中间那本"共享花名册"是编号连续的关键。
┌──────────────────────────────────────────┐
│ 共享花名册 all_links_of_system │
│ [每条源一个永久 index 工号] │
└───────▲───────────────────────┬────────────┘
│ 写入 index │ 读取全部源
搜索结果 ─────────────────┤ │
(List[Dict]) │ ▼
│ ┌───────┴────────┐ ┌──────────────┐
▼ │ 引用处理器 │ │ 来源清单装配 │
┌─────────┐ │ CitationHandler │ │ format_links │
│ 无源? │──是──▶│ → 按 type 选实现│ │ _to_markdown │
│ 防幻觉门 │ │ → 调 LLM 带编号 │ └──────┬───────┘
└────┬────┘ └───────┬──────────┘ │
│否 / 拒答 │ {content, documents} │
└───────────────────▶│ ▼
▼ ┌──────────────┐
┌─────────────────┐ │ 两种产物 │
│ 带 [n] 的合成文本 │──────────▶│ quick 综述 │
└─────────────────┘ │ detailed 报告 │
└──────┬───────┘
▼
┌──────────────┐
│ 导出 exporters│
│ PDF/ODT/LaTeX │
└──────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
CitationHandler | 统一入口,按 handler_type 把活派给具体实现 | citation_handler.py:10 |
BaseCitationHandler | 公共底座:建文档、编号、格式化、无源防幻觉门 | citation_handlers/base_citation_handler.py:15 |
StandardCitationHandler | 默认实现:详细分析 + 可选事实核查 | citation_handlers/standard_citation_handler.py:11 |
ForcedAnswerCitationHandler | 逼模型给确定答案(BrowseComp 基准用) | citation_handlers/forced_answer_citation_handler.py:14 |
PrecisionExtractionHandler | 正则 + LLM 精确抠答案(SimpleQA 用) | citation_handlers/precision_extraction_handler.py:21 |
format_links_to_markdown | 把花名册渲染成结尾"来源"段 | utilities/search_utilities.py:217 |
IntegratedReportGenerator | detailed 报告装配器,逐小节调 analyze_topic | report_generator.py:33 |
CitationFormatter | 把正文 [n] 变成超链接 [[n]](url) | text_optimization/citation_formatter.py:54 |
ExporterRegistry / 各 exporter | 把最终 Markdown 导成 PDF/ODT/LaTeX… | exporters/registry.py:15 |
3. 核心机制一:引用处理器体系(选谁 + 防编造)
3.1 统一入口按 handler_type 选实现
要解决的小问题: 不同任务对"回答风格"要求不同——普通研究要详实、基准测试要一个准确答案。但调用方不想关心这些差异,只想说一句"帮我合成"。
思路: CitationHandler 是一层薄壳(facade)。它自己不做合成,只在构造时读 citation.handler_type 设置,import 并实例化真正干活的子类,然后把调用透传下去。
真实实现——按类型分派,认不出就退回 standard:
# citation_handler.py:38 _create_handler(handler_type)
if handler_type == "standard":
return StandardCitationHandler(...)
if handler_type in ["forced", "forced_answer", "browsecomp"]:
return ForcedAnswerCitationHandler(...) # 基准:逼出确定答案
if handler_type in ["precision", "precision_extraction", "simpleqa"]:
return PrecisionExtractionHandler(...) # SimpleQA:正则精抠
logger.warning(...) # 未知类型
return StandardCitationHandler(...) # 兜底退回默认
对外只暴露两个动词:analyze_initial(第一轮)和 analyze_followup(带着"既有知识"的后续轮),都只是 return self._handler.xxx(...) 的转发(citation_handler.py:92、:98)。
三种实现的取舍:
| 实现 | 面向 | 关键手法 | 拿不准时 |
|---|---|---|---|
| Standard | 普通研究(默认) | 详实分析,可选跨源事实核查 | 老实说"信息不足" |
| ForcedAnswer | BrowseComp 基准 | 提示词硬命令"绝不说无法确定" | 猜一个最可能的(_extract_direct_answer) |
| Precision | SimpleQA 基准 | 正则先抓姓名/年份/数字,再让 LLM 定夺 | 挑出现最多的候选 |
精华: ForcedAnswer 有句直白的设计哲学——
"A wrong answer is better than no answer for this task."(forced_answer_citation_handler.py:119)。这是为跑分特化的行为,和默认 Standard "无源就拒答"的谨慎正好相反。选错 handler 会让一个求稳的产品变成满嘴跑火车,所以它藏在设置里、默认关。
3.2 无源不调 LLM —— 防幻觉的硬门
要解决的小问题: 这是全章最重要的一条。只要 sources 段是空的,还去提示 LLM"请带 [1] [2] 引用回答",模型就会退回训练记忆、编出假引用。
思路: 不给模型这个机会。合成前先看有没有文档;没有就直接返回一段固定的"无源"说明,根本不发 LLM 请求。
真实实现——底座提供统一的拒答出口:
# base_citation_handler.py:180 _no_sources_response(question)
logger.warning(f"[{...}] No sources available ... skipping LLM call "
f"to avoid fabricated citations")
content = ("No sources were found for this question. ... No answer was "
"generated because, without sources, it would have to rely on "
"the language model's built-in knowledge and could contain "
"fabricated citations.")
return {"content": content, "documents": []}
两个入口的门槛略有不同(这是细节,但很关键):
| 方法 | 触发拒答的条件 | 依据 |
|---|---|---|
analyze_initial | 没有任何文档 | standard_citation_handler.py:18 |
analyze_followup | 没有文档且没有"既有知识" | standard_citation_handler.py:55 |
后续轮之所以放宽:既有知识里已经带着上一轮的合法引用,即便本轮没搜到新东西,让模型基于旧引用继续答仍是可信的;只有当"新源和旧知识都空"时才拒答。
注意: 只有 Standard 装了这道门。ForcedAnswer 和 Precision 故意不装——它们的任务就是"哪怕没料也得挤个答案出来",所以直接走合成(
forced_answer_citation_handler.py:21建完文档就格式化,不检查空)。这再次说明基准 handler 不适合当日常默认。
3.3 精确抽取:先正则再 LLM(Precision)
要解决的小问题: SimpleQA 这类问题只要一个短答案(一个全名、一个年份、一场比分)。让 LLM 自由发挥容易带出多余信息或半截名字。
思路: 先用 _identify_question_type(precision_extraction_handler.py:143)按问句关键词判定类型(full_name / temporal / dimension / score …),LLM 答完后再用对应的正则 + 二次 LLM 抠把答案收紧。例如全名类会找出所有姓名变体、按最后一个词分组、每组取最长的当"完整名"(_extract_full_name,:224)。
这套逻辑是本章里**唯一大量用非 LLM 手段(正则、频次统计)**兜底的地方;它服务的是"要精确短答"的窄场景,不是通用报告。
4. 核心机制二:编号如何跨轮连续
4.1 编号的唯一真源:共享 dict 的 index 字段
要解决的小问题: 正文写 [5],结尾清单也得是同一个 [5] 指向同一个 URL;而且一份长报告里第 3 小节的 [5] 不能和第 1 小节的 [5] 撞车。
思路(呼应 01,此处从引用侧讲透): 每条搜索结果就是一个 dict。引用处理器在建 LangChain 文档时,直接往这个 dict 上写 index 字段。因为策略层把同一批 dict 对象同时放进了共享的 all_links_of_system,写进去的 index 就自动传播到了花名册——不需要另外同步。
真实实现——只在缺 index 时才写,且带偏移量:
# base_citation_handler.py:139 _create_documents(search_results, nr_of_links=0)
for i, result in enumerate(search_results):
if "index" not in result: # 已有就不覆盖
result["index"] = str(i + nr_of_links + 1) # 关键:加偏移
doc_index = int(result.get("index", i + nr_of_links + 1))
documents.append(Document(page_content=content,
metadata={"source": ..., "index": doc_index}))
_format_sources 随后就照着这个 index 把每段源文本标上 [index] 交给 LLM(base_citation_handler.py:172)。LLM 在正文里引用的编号,和喂进去的这个编号一致。
4.2 nr_of_links:让编号接着上一轮往下走
关键在 nr_of_links 这个偏移量。 假设上一轮已经收了 8 条源(花名册长度 = 8),这一轮新搜到 3 条:新的编号必须是 [9] [10] [11],不能又从 [1] 开始。策略层就把"本轮开始前的花名册长度"当偏移传进来。
真实实现——策略在合成前记下长度,合成后扩展花名册:
# advanced_search_system/strategies/source_based_strategy.py
total_citation_count_before_this_search = len(self.all_links_of_system) # :178
...
self.all_links_of_system.extend(final_filtered_results) # :452 先入册
final_citation_result = self.citation_handler.analyze_followup( # :477
query, final_filtered_results, previous_knowledge="",
nr_of_links=total_citation_count_before_this_search, # :481 传偏移
)
整条编号传播链(一次 analyze_topic 内):
本轮开始
│ 记下 len(all_links_of_system) = N (偏移量 nr_of_links)
▼
新结果入册 all_links_of_system.extend(...) (同一批 dict 对象)
▼
analyze_followup(..., nr_of_links=N)
▼
_create_documents: result["index"] = i + N + 1 (写到 dict 上)
▼
因为是同一批 dict → 花名册里这些条目也就有了 index
▼
LLM 拿 [N+1..] 编号写正文 → 结尾清单读花名册同样的 index
跨多次 analyze_topic(detailed report 每个小节一次)时,花名册只有一份、只增不减,偏移量每次接着上次,于是全篇编号严格递增、永不重号。
精华: 这里最巧的是"零拷贝同步"——index 不是算两遍再对齐,而是靠"正文文档"和"花名册条目"指向同一个 dict 对象,写一次两边都有。源码注释点破了这层(
source_based_strategy.py:483-486:"these are the same dict objects ... indices propagate automatically")。
5. 核心机制三:两种产物(quick vs detailed)
5.1 分叉点:mode 决定走哪条路
研究服务按 mode 分叉(web/services/research_service.py:1135、:2080):
| 产物 | 是什么 | 怎么来 |
|---|---|---|
| quick summary | 一段综述文字 + 来源 | 直接拿研究阶段的 current_knowledge,格式化引用即可 |
| detailed report | 带目录、多小节的长文 | 交给 IntegratedReportGenerator,逐小节再各自研究一遍 |
quick 便宜:研究循环产出的合成文本本身就带编号,套一层引用超链接、拼上来源段就完事。detailed 贵:它把报告拆成小节,每个小节都当成一次独立的深度研究重新跑。
5.2 detailed:逐小节多次调 analyze_topic
思路: IntegratedReportGenerator.generate_report(report_generator.py:88)分三步——① 让 LLM 定目录结构;② 逐小节研究 + 生成;③ 拼最终报告。
第 ② 步是重头戏:_research_and_generate_sections(report_generator.py:298)遍历每个小节,为每节造一个研究 query,再调一次 search_system.analyze_topic(subsection_query)(report_generator.py:491)。也就是说,一份 N 小节的报告要跑 N 次完整检索—合成。
关键设计:传入既有的 search_system 以复用花名册。 报告生成器不新建搜索系统,而是接住研究阶段那一个(research_service.py:2181 传 search_system=search_system),这样所有小节共享同一本 all_links_of_system,§4 的编号连续性才跨小节成立。
5.3 累积上下文防止小节间重复
要解决的小问题: N 个小节各查各的,很容易车轱辘话——每节都从头讲一遍背景。
思路: 每写完一节就把内容存进 accumulated_findings,写下一节前,把最近几节的内容塞进 prompt,并硬加一句"这些已经写过,别重复"。
真实实现——两个常量卡住上下文规模:
# report_generator.py:16
DEFAULT_MAX_CONTEXT_SECTIONS = 3 # 只回看最近 3 节
DEFAULT_MAX_CONTEXT_CHARS = 4000 # 上下文最多 4000 字符(照顾小模型)
_build_previous_context(report_generator.py:261)取最近 max_context_sections 节拼起来,超长就按句子边界截断(_truncate_at_sentence_boundary,:224),外面裹上 === CONTENT ALREADY WRITTEN (DO NOT REPEAT) === 的分隔块。两个常量都可被 report.max_context_* 设置覆盖(:70、:75)。
5.4 Sources 段的装配
第 ③ 步 _format_final_report(report_generator.py:542)拼目录 + 各节正文,末尾读整本花名册渲染来源:
# report_generator.py:580
utilities = importlib.import_module("local_deep_research.utilities")
formatted_all_links = utilities.search_utilities.format_links_to_markdown(
all_links=self.search_system.all_links_of_system) # 读整本花名册
...
final_report_content += "\n\n## Sources\n\n" + formatted_all_links # :602
边界(存储不变量): 内存返回的 detailed 报告带
## Sources尾巴,给 MCP / 程序化 API 用;但存库时会被format_document_split剥掉,report_content列只存"纯答案"——来源另存research_resources表,显示时再由report_assembly_service.assemble_full_report(web/services/report_assembly_service.py:31)重新拼回。源码把这个不变量反复标注,防止有人把拼好的整块写回去(report_generator.py:586-601)。
6. 最终来源清单与导出
6.1 从结果里抽链接
extract_links_from_search_results(utilities/search_utilities.py:146)把搜索结果 dict 收敛成 {title, url, index, ...},并保留一大票引用相关字段(doi、authors、published、journal 等,:179-205),让它们能一路带到数据库,不在这步丢掉。
6.2 渲染来源段(去重 + 规范化 URL)
format_links_to_markdown(utilities/search_utilities.py:217)是来源段的渲染器,两个要点:
- 按规范化 URL 去重:
canonical_url_key折叠尾斜杠、utm 参数、fragment、端口、大小写,同一来源即便被多次引用也只列一次,但保留它的多个编号。 - 按首次出现顺序输出,每条形如
[1, 7] 标题 (source nr: 1, 7)加URL:行(:275)。
渲染样例(示意):
[1, 4] Attention Is All You Need [Q1] (source nr: 1, 4)
URL: https://arxiv.org/abs/1706.03762
[2] OpenAI Blog (source nr: 2)
URL: https://openai.com/research
6.3 正文 [n] 变超链接
正文里的裸 [n] 由 CitationFormatter 转成可点的 [[n]](url)。format_document_split(text_optimization/citation_formatter.py:141)先找到 ## Sources 边界,把答案和来源切开,只对答案部分套超链接;若正文没有来源段,则走 apply_inline_hyperlinks(:191)用结构化源列表兜底。样式由 report.citation_format 设置选(number / domain / source-tagged 等模式,CitationMode,:32;工厂在 research_service.py:224)。
6.4 导出成文件
导出层是注册表 + 抽象基类的经典搭配:
BaseExporter(exporters/base.py:37)定义三个属性(format_name/file_extension/mimetype)加一个export(markdown_content, options),输入统一是 Markdown 字符串,输出ExportResult(content: bytes, filename, mimetype)。ExporterRegistry(exporters/registry.py:15)用@register装饰器登记各 exporter,get_exporter("pdf")按名取单例(:55)。- 具体格式:PDF、ODT、LaTeX、Quarto、RIS 等(
exporters/__init__.py里逐个 import 触发注册)。基类还管 50MB 上限和安全文件名(base.py:63、:118)。
也就是说:引用合成产出 Markdown → 导出层把这份 Markdown 转成任意格式。引用编号和来源段在 Markdown 阶段就已定型,导出只是换壳。
7. 边界与局限
- 基准 handler 会主动编答案。 ForcedAnswer / Precision 为跑分特化,不装"无源拒答"门,拿不准就猜。当日常默认会把可信度换成命中率——它们默认关着是对的。
- detailed 报告很贵。 N 个小节 = N 次完整检索—合成;小节多、每节 iteration 深时,LLM 调用与耗时线性放大(
report_generator.py:298的循环)。 - 去重只按 URL,不按内容。 同一文章的两个不同 URL(镜像站、不同参数无法规范化时)会被当两条源、占两个编号(
format_links_to_markdown的 canonical 只处理 URL 形态,不比对正文)。 - 上下文回看只有 3 节 / 4000 字符。 跨度大的报告里,第 10 节看不到第 1 节的细节,防重复是"近距离"的(
report_generator.py:16-21)。 - 编号连续依赖"同一个 search_system"。 若 detailed 路径没把研究阶段的 search_system 传进报告生成器,花名册就会断开、编号从头再来——所以
research_service.py:2181显式传入。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 引用统一入口 / 按类型分派 | citation_handler.py | CitationHandler、_create_handler |
| 引用底座:建文档 + 编号 | citation_handlers/base_citation_handler.py | BaseCitationHandler、_create_documents、_format_sources |
| 无源防幻觉门 | citation_handlers/base_citation_handler.py | _no_sources_response |
| 默认实现 + 空源守卫 | citation_handlers/standard_citation_handler.py | StandardCitationHandler、analyze_initial、analyze_followup |
| 逼出确定答案(基准) | citation_handlers/forced_answer_citation_handler.py | ForcedAnswerCitationHandler、_needs_answer_extraction、_extract_direct_answer |
| 精确抽取(SimpleQA) | citation_handlers/precision_extraction_handler.py | PrecisionExtractionHandler、_identify_question_type、_apply_precision_extraction |
| 编号偏移传播 | advanced_search_system/strategies/source_based_strategy.py | all_links_of_system、analyze_followup(nr_of_links=...) |
| detailed 报告装配 | report_generator.py | IntegratedReportGenerator、generate_report、get_report_generator |
| 逐小节研究 + 防重复上下文 | report_generator.py | _research_and_generate_sections、_build_previous_context、DEFAULT_MAX_CONTEXT_SECTIONS/CHARS |
| 最终报告 + Sources 尾 | report_generator.py | _format_final_report |
| 抽链接 / 渲染来源段 | utilities/search_utilities.py | extract_links_from_search_results、format_links_to_markdown |
| 正文 [n] 超链接 | text_optimization/citation_formatter.py | CitationFormatter、format_document_split、apply_inline_hyperlinks、CitationMode |
| 存储侧重装来源 | web/services/report_assembly_service.py | assemble_full_report、_build_sources_markdown |
| quick/detailed 分叉 + 引用格式工厂 | web/services/research_service.py | get_citation_formatter(:177)、mode 分支(:1088/:2080) |
| 导出注册表 + 基类 | exporters/registry.py、exporters/base.py | ExporterRegistry、BaseExporter、ExportResult |
相邻章节: 引擎主线与策略骨架看 01;搜索引擎两阶段检索看 02;LangGraph 智能体策略看 03;运行时底座看 05。