数据截至 (上游 commit a53b13c7022b)
04 · 搜索与来源:把四家抹平成一种形状
这一章讲数据整流:四个搜索后端 API 长得完全不同,项目怎么把它们统一成一种结果形状,再去重、按 token 预算截断、可选抓全文。这是让上层节点「不关心用哪家搜索」的关键。
4.1 统一的结果形状
不管用哪个后端,web_research 拿到的都是同一种字典(utils.py 各搜索函数的返回):
# 示意,非源码——统一后的形状
{
"results": [
{
"title": "标题",
"url": "来源链接",
"content": "摘要/片段",
"raw_content": "全文(或 None)",
},
...
]
}
四个后端各自负责把自家字段翻译到这四个键上:
| 后端 | 需要 key | 原字段 → 统一字段 | 位置 |
|---|---|---|---|
| DuckDuckGo | 无(免费) | href/title/body → url/title/content | utils.py:166 |
| SearXNG | 自建实例 URL | link/title/snippet → url/title/content | utils.py:223 |
| Tavily | API key | 本身就接近统一形状,直接返回 | utils.py:278 |
| Perplexity | API key | 一段答复 + 一串 citations → 拆成多条 | utils.py:310 |
DuckDuckGo 和 SearXNG 还做了完整性校验:三个字段缺任何一个就跳过该条并打印告警(utils.py:198-200/258-260),避免把残缺来源塞进下游。
4.2 Perplexity 的特别处理
Perplexity 的 sonar-pro 返回的是一段综合答复 + 一串引用链接,不是逐条结果。项目把它拆开(utils.py:363-387):第一条 citation 挂上完整答复文本(raw_content 给全文),其余 citation 只作为引用链接、raw_content 设为 None、content 写「See above for full content」。这样它就能塞进统一形状里,同时避免把整段答复复制很多份。
4.3 去重 + 格式化 + token 预算
拿到统一结果后,deduplicate_and_format_sources(utils.py:55-120)做三件事:
一、按 URL 去重。 用字典以 url 为键,重复 URL 只留第一条(utils.py:95-98)。
二、拼成结构化文本。 每条来源拍成 Source: / URL: / Most relevant content: 三段(utils.py:102-107),方便 LLM 阅读。
三、按 token 预算截断全文。 当 fetch_full_page 开时,把 raw_content 截到预算内:
# utils.py:109-118 —— 真实源码(节选)
char_limit = max_tokens_per_source * CHARS_PER_TOKEN # 1000 * 4 = 4000 字符
raw_content = source.get("raw_content", "")
if raw_content is None:
raw_content = ""
if len(raw_content) > char_limit:
raw_content = raw_content[:char_limit] + "... [truncated]"
这里用了一个极粗的 token 估算:CHARS_PER_TOKEN = 4(utils.py:14),即「4 字符 ≈ 1 token」,配 MAX_TOKENS_PER_SOURCE = 1000(graph.py:41)得出每条来源上限约 4000 字符。不精确,但足够防止把整篇长网页灌爆本地模型的上下文。
4.4 抓全文:HTML → Markdown
若 fetch_full_page 开,DuckDuckGo/SearXNG 会对每条 URL 调 fetch_raw_content(utils.py:141-162):用 httpx 带 10 秒超时拉网页,再用 markdownify 把 HTML 转成 Markdown。任何异常都吞掉、返回 None(utils.py:160-162),所以单个网页抓失败不会拖垮整轮。
一个配置矛盾值得留意:
configuration.py:37-41里fetch_full_page默认True,但README.md:80说它默认false。以代码为准,默认是True。
4.5 两种「来源」文本,别混淆
项目里有两个生成来源文本的函数,用途不同:
| 函数 | 输出 | 给谁看 | 位置 |
|---|---|---|---|
deduplicate_and_format_sources | 详细的 Source:/URL:/content 块 | 喂给 LLM 做摘要(进 web_research_results) | utils.py:55 |
format_sources | 简洁的 * title : url 列表 | 进 sources_gathered,最终报告的引用清单 | utils.py:123 |
前者是「给模型读的料」,后者是「给人看的引用」。finalize_summary 去重的正是后者产出的那些 * title : url 行(见 02 章)。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| DuckDuckGo 后端 | src/ollama_deep_researcher/utils.py | duckduckgo_search |
| SearXNG 后端 | src/ollama_deep_researcher/utils.py | searxng_search |
| Tavily 后端 | src/ollama_deep_researcher/utils.py | tavily_search |
| Perplexity 后端 + 引用拆分 | src/ollama_deep_researcher/utils.py | perplexity_search |
| 去重+格式化+token 截断 | src/ollama_deep_researcher/utils.py | deduplicate_and_format_sources |
| 抓全文转 Markdown | src/ollama_deep_researcher/utils.py | fetch_raw_content |
| 人读引用清单 | src/ollama_deep_researcher/utils.py | format_sources |