数据截至 (上游 commit c80d325ec761)
搜索引擎层:两阶段检索、引擎池与过滤
30 秒导读: 研究策略里那句
self.search.run(q)(见 01)看着只是"搜一下",背后其实是一整套机制:LDR 有 30 多个搜索源(arXiv、PubMed、SearXNG、Tavily、GitHub、本地文档库……),接口、返回格式、限流规则各不相同。本章讲清 LDR 怎么把它们统一成同一个run()契约,以及这个run()内部的两阶段检索(先便宜后贵)、引擎装配(工厂+注册表)、结果过滤(三层去噪)、自适应限流(学等待时间)。
1. 这节讲什么(先建立直觉)
一句话: 这一层是 LDR 的"搜索适配器 + 检索流水线"。它把任何一个搜索源都包成一个 BaseSearchEngine 子类,对外只暴露一个方法 run(query) -> List[dict];方法内部统一走"两阶段检索 + 过滤 + 限流"。
它要解决的真实痛点: 想象你要接 30 个搜索 API。有的返回 JSON,有的要爬 HTML;有的自带好排序(Google),有的只按关键词命中(arXiv);有的免费,有的按次收费还限流。如果每个策略都去直接调这些 API,代码会炸成一团。LDR 的做法是:所有差异都收进子类,策略层只面对一个干净的 run()。
本章边界(不越界):
| 不讲什么 | 去哪看 |
|---|---|
| 策略层的研究主循环、怎么决定搜什么 | 01-research-engine-and-strategies.md |
| LangGraph 智能体怎么把引擎当工具动态挑选 | 03-langgraph-agent-strategy.md |
| egress 出站管制策略的判定细节(scope、PDP) | 06-security-egress-library-news.md |
本章只讲:run() 内部发生了什么、引擎怎么被分类和装配、结果怎么被过滤、限流怎么自适应。egress 校验在本章只作为"流水线里的一站"点到为止,判定逻辑留给 06。
2. 顶层全景:一次 run(query) 的旅程
先看大盘。一个策略拿到某个引擎实例后调 engine.run("量子纠错 2024 进展"),内部像这样流动(从上到下是时间顺序,命中失败即降级):
engine.run(query) ← 唯一对外入口 (search_engine_base.py:612)
│
┌─────────────────┴──────────────────┐
│ ① egress 出站校验 (放行才继续) │ _verify_egress_scope → 细节见 06
└─────────────────┬──────────────────┘
│
┌─────────────────┴──────────────────┐
│ ② tenacity 重试壳 (限流才重试) │ @retry + AdaptiveWait
└─────────────────┬──────────────────┘
│ 每次尝试跑一遍 _execute_search:
▼
┌────────────────────────────────────────────────────┐
│ 阶段一 · 便宜 │
│ ③ _get_previews(query) 抓一批"预览"(标题+摘要) │ ← 子类必须实现
│ ④ (科学引擎) DOI → OpenAlex 源 ID 富化 │
│ ⑤ 预览过滤器 preview_filters (如期刊声誉) │
│ ⑥ LLM 相关性过滤 _filter_for_relevance (可选) │
└───────────────────────────┬────────────────────────┘
│ 只有留下来的项才进阶段二
▼
┌────────────────────────────────────────────────────┐
│ 阶段二 · 贵 │
│ ⑦ _get_full_content(kept) 抓全文(爬网页/下 PDF) │ ← 子类可覆盖
│ ⑧ 内容过滤器 content_filters │
└───────────────────────────┬────────────────────────┘
▼
⑨ 记录 metrics + 学限流等待时间 → 返回 List[dict]
部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
BaseSearchEngine | 抽象基类,定义 run() 骨架与两阶段契约 | web_search_engines/search_engine_base.py |
各 *SearchEngine 子类 | 实现 _get_previews / _get_full_content,吸收源差异 | web_search_engines/engines/*.py |
create_search_engine | 工厂:按引擎名+设置装配出实例 | web_search_engines/search_engine_factory.py |
ENGINE_REGISTRY | 注册表:引擎名 → 实现类的硬编码映射 | web_search_engines/engine_registry.py |
filter_previews_for_relevance | 引擎内 LLM 相关性过滤 | web_search_engines/relevance_filter.py |
CrossEngineFilter | 策略层:合并多引擎结果后统一重排/重编号 | advanced_search_system/filters/cross_engine_filter.py |
AdaptiveRateLimitTracker | 记录成功/退避,学每个引擎的等待时间 | web_search_engines/rate_limiting/tracker.py |
3. 核心机制一:为什么要"两阶段"
它要解决的小问题
搜索结果的"全文"很贵:要爬网页、下 PDF、抽正文,一条可能几百毫秒到几秒。但一次搜索原始命中可能有 15~25 条,其中大半跟你的问题根本不相关。如果对每一条都抓全文,你在为一堆马上要被扔掉的结果付费(时间+带宽+可能的 LLM 判断成本)。
思路
把"判断相关"和"抓全文"分开,中间插一道过滤:
抓一批便宜的预览(标题+摘要) → 过滤掉不相关的 → 只对剩下的抓贵的全文
↑ 便宜、可以多抓 ↑ 贵、只对精选做
这就是 BaseSearchEngine 名字里那句 "two-phase retrieval capability"(search_engine_base.py:95 类文档串)。预览阶段广撒网、成本低;过滤后全文阶段窄而精、成本可控。
真实实现:run() 的六步
真正的编排在 _execute_search(search_engine_base.py:679 起,是 run() 内的闭包)。核心六步逐段看:
第 1 步 — 抓预览。 失败(空)直接短路返回:
# search_engine_base.py:674 _execute_search 内
previews = self._get_previews(query)
if not previews:
return [] # 一条预览都没有,后面全免了
_get_previews 是抽象方法(search_engine_base.py:1329),每个引擎必须自己实现——这是"吸收源差异"的地方。
第 2 步 — 科学引擎的 DOI 富化。 只对 is_scientific 的引擎做,且必须在预览过滤器之前:
# search_engine_base.py:690
if getattr(self, "is_scientific", False):
previews = enrich_results_with_source_ids(previews, email=email)
它拿结果里的 DOI 去 OpenAlex 批量换回期刊/会议的 openalex_source_id(utilities/openalex_enrichment.py:51 enrich_results_with_source_ids)。为什么必须先做: 期刊声誉过滤器(下一步)靠这个 openalex_source_id 查期刊质量;旧流水线把富化放在过滤之后,导致过滤时字段还是空的,只能退化成脆弱的"按名字匹配"(见 search_engine_base.py:682-689 那段注释的自述)。
第 3 步 — 预览过滤器。 一串 BaseFilter,逐个作用在预览上:
# search_engine_base.py:705
for preview_filter in self._preview_filters:
previews = preview_filter.filter_results(previews, query)
学术引擎默认往这里塞一个 JournalReputationFilter(期刊声誉过滤,给期刊 1-10 打分、predatory 期刊直接删,见 filters/journal_reputation_filter.py 头部的分层说明)。它放在 LLM 相关性之前,因为"查期刊质量"是即时的数据查表,没必要让不合格期刊白白消耗后面昂贵的 LLM 调用。
第 4 步 — LLM 相关性过滤(可选)。 只有引擎被标记要过滤、且有 LLM 时才跑:
# search_engine_base.py:708
enable_llm_filter = getattr(self, "enable_llm_relevance_filter", False)
if enable_llm_filter and self.llm:
filtered_items = self._filter_for_relevance(previews, query)
else:
filtered_items = previews
细节见 §7 的相关性过滤。
第 5 步 — 抓全文(阶段二真正的"贵")。 有个关键开关 search_snippets_only:开着就跳过全文,只返回摘要:
# search_engine_base.py:724
if self.search_snippets_only:
results = filtered_items # 省钱模式:摘要就够了
else:
results = self._get_full_content(filtered_items)
_get_full_content 的默认实现(search_engine_base.py:1131)只是从预览里剥出 _full_result 字段;需要爬网页/下 PDF 的引擎会覆盖它(见 §6)。
第 6 步 — 内容过滤 + 记账。 内容过滤器作用在全文上(:730),然后统计条数、记录限流成功样本(:733-747),finally 里写 metrics(SearchTracker.record_search,:807)。
一句话记住:
_get_previews抓预览、_get_full_content抓全文,中间夹三种过滤;search_snippets_only是"要不要进阶段二"的总开关。
4. 核心机制二:引擎分类标志位——一堆布尔量在管什么
BaseSearchEngine 顶上定义了一排类属性布尔量(search_engine_base.py:120-199)。它们不是装饰,而是驱动装配和行为的开关,子类按自己的性质覆盖。