跳到主要内容

搜索引擎层:两阶段检索、引擎池与过滤

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:594)

┌─────────────────┴──────────────────┐
│ ① 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:88 类文档串)。预览阶段广撒网、成本低;过滤后全文阶段窄而精、成本可控。

真实实现:run() 的六步

真正的编排在 _execute_search(search_engine_base.py:661 起,是 run() 内的闭包)。核心六步逐段看:

第 1 步 — 抓预览。 失败(空)直接短路返回:

# search_engine_base.py:666 _execute_search 内
previews = self._get_previews(query)
if not previews:
return [] # 一条预览都没有,后面全免了

_get_previews 是抽象方法(search_engine_base.py:1317),每个引擎必须自己实现——这是"吸收源差异"的地方。

第 2 步 — 科学引擎的 DOI 富化。 只对 is_scientific 的引擎做,且必须在预览过滤器之前:

# search_engine_base.py:682
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:674-681 那段注释的自述)。

第 3 步 — 预览过滤器。 一串 BaseFilter,逐个作用在预览上:

# search_engine_base.py:697
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:700
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:716
if self.search_snippets_only:
results = filtered_items # 省钱模式:摘要就够了
else:
results = self._get_full_content(filtered_items)

_get_full_content 的默认实现(search_engine_base.py:1113)只是从预览里剥出 _full_result 字段;需要爬网页/下 PDF 的引擎会覆盖它(见 §6)。

第 6 步 — 内容过滤 + 记账。 内容过滤器作用在全文上(:722),然后统计条数、记录限流成功样本(:725-739),finally 里写 metrics(SearchTracker.record_search,:799)。

一句话记住: _get_previews 抓预览、_get_full_content 抓全文,中间夹三种过滤;search_snippets_only 是"要不要进阶段二"的总开关。


4. 核心机制二:引擎分类标志位——一堆布尔量在管什么

BaseSearchEngine 顶上定义了一排类属性布尔量(search_engine_base.py:113-192)。它们不是装饰,而是驱动装配和行为的开关,子类按自己的性质覆盖。

4.1 分类标志(告诉系统"我是什么源")

标志含义谁在用它
is_public是否查公网egress 出站管制的引擎放行判定(见 06)
is_generic是否通用网页搜索(vs 专用)UI 归类;注意不代表排序好
is_scientific是否学术源(arXiv/PubMed…)触发 §3 的 DOI→OpenAlex 富化
is_local是否搜本地私有文档egress 判定;本地库不算公网
is_news / is_code / is_books新闻 / 代码 / 图书专用UI 分组(见 §5.4)

search_engine_base.py:115 起把 is_public 默认设为 False——默认从安全侧倒向"当作非公网",子类显式声明才是公网。

4.2 行为标志(告诉系统"怎么处理我的结果")

is_lexicalneeds_llm_relevance_filter 是最容易混的一对,基类注释专门澄清(search_engine_base.py:142-154):

  • is_lexical(词法/关键词检索):像 arXiv、PubMed、Wikipedia、Mojeek 这类只按关键词命中、没有 ML 排序的引擎。这是个信息标志,可驱动多种行为(查询优化、去重、UI 提示)。
  • needs_llm_relevance_filter(要不要自动开 LLM 过滤):行为标志。为 True 时,工厂会给实例设 enable_llm_relevance_filter=True,于是 §3 第 4 步真的会跑。

两者常一起出现(词法引擎排序差、需要 LLM 补救),但可独立设置——一个排序还行但结果很杂的非词法引擎也可以单独开。

还有两个调 LLM 过滤性能的旋钮(search_engine_base.py:168-169):

属性默认作用
relevance_filter_batch_size5预览分批送 LLM,每批多少条;小批对弱模型更稳。None/0 = 不分批
relevance_filter_max_parallel_batches10并发跑几批

具体例子: arXiv 引擎(engines/search_engine_arxiv.py:15-22)把这几个标志一次性摆明:

# search_engine_arxiv.py:15
is_public = True # 查公网
is_generic = False # 专用,不是通用搜索
is_scientific = True # → 触发 DOI 富化
is_lexical = True # 关键词命中,排序弱
needs_llm_relevance_filter = True # → 所以自动开 LLM 过滤补救

5. 核心机制三:引擎怎么被选中和装配

30 多个引擎不是一股脑全加载。系统按"注册表定义实现 → 设置提供参数 → 工厂按名装配"三步走。

5.1 注册表:引擎名 → 实现类

ENGINE_REGISTRY(engine_registry.py:27)是一张硬编码字典,把引擎名映射到"哪个模块、哪个类",这是**"谁实现了这个引擎"的唯一真源**,永远不从设置数据库读:

# engine_registry.py:63 EngineEntry 是个 frozen dataclass
"searxng": EngineEntry(
module_path=".engines.search_engine_searxng",
class_name="SearXNGSearchEngine",
full_search_module=".engines.full_search", # 全文抓取用哪个包装器
full_search_class="FullSearchResults",
),

注意 full_search_module/class 只有支持"全文抓取"的引擎才填(SearXNG、Brave、Google PSE、Mojeek、SerpAPI 等)。

5.2 配置:把设置和注册表拼成完整 config

search_config(search_engines_config.py:94)负责组装每个引擎的完整配置字典。它做三件事:

  1. 从设置(快照/DB)读出 search.engine.web 下的用户配置,展开成每引擎的嵌套字典(_extract_per_engine_config,:60)。
  2. 把注册表的 module_path/class_name 注入进去(:126-136)——设置只管"用不用、什么参数",实现类由注册表钉死。
  3. 把运行时注册的东西也当引擎加进来:LangChain 检索器(:141)、Library RAG 聚合库(:172)、每个文档集合 collection_<id>(:194)。

get_available_engines(search_engines_config.py:275)是另一个入口:它在上面基础上再筛一遍"真正可用的"——要求 use_in_auto_search=True 且(如需 API key)key 确实配了。LangGraph 智能体策略(03)就靠它拿可选工具集。

5.3 工厂:按名装配一个实例

create_search_engine(search_engine_factory.py:13)是总装线。关键几步:

create_search_engine(engine_name, llm, settings_snapshot, ...)

├─ 是注册的检索器? → 走 egress 判定 → 返回 RetrieverSearchEngine (:44)
├─ engine_name == "none"? → 报错(历史上会静默走真网络) (:127)
├─ 名字不在 config? → 试"显示标签"回退,还不行就 FAIL CLOSED 报错 (:138,:183)
├─ egress PEP: evaluate_engine 判定该引擎在当前 scope 下能不能跑 (:229) → 06
├─ 反射读子类 __init__ 签名,只喂它认识的参数(过滤掉多余 kwarg) (:309)
├─ 实例化 → 盖上 _engine_name(供运行时 egress 复核) (:357,:362)
└─ 决定要不要开 LLM 相关性过滤(优先级见下) (:385)

"要不要开 LLM 相关性过滤"的优先级(search_engine_factory.py:385-442)值得记:

每引擎设置 (search.engine.web.<name>.default_params.enable_llm_relevance_filter)
↓ 没设才看
引擎类属性 needs_llm_relevance_filter == True → 自动开
↓ 都没有才看
全局 search.skip_relevance_filter → 只对"未分类"引擎生效

装配尾声,若调用方要全文且引擎支持,会再包一层全文包装器(_create_full_search_wrapper,:472)。

5.4 分组:UI 选择器里的分band

engine_groups.py 是纯展示层的单一真源:classify_engine_group(:66)按"集合 > 学术/本地/图书/代码/新闻 > 按是否需要 API key 二分"把引擎归到某个 band(SEARCH_ENGINE_GROUPS,:24)。这块只影响下拉菜单排版,不影响检索逻辑,了解即可。

注意历史包袱: 工厂里 auto/meta/parallel/parallel_scientific 这些"元引擎"已被移除(search_engine_factory.py:190-195 的报错文案自述)——现在"同时用多个引擎"由默认的 langgraph-agent 策略动态选,不再有一个"并行元引擎"实体。


6. 两个真实引擎:预览与全文怎么落地

抽象讲完,看两个具体引擎怎么把 _get_previews/_get_full_content 落到实处——一个词法学术源、一个通用网页元搜索。

6.1 arXiv:预览=摘要,全文=下 PDF

预览阶段(search_engine_arxiv.py:124 _get_previews)调 arxiv 包,把每篇论文压成一个预览字典,snippet 就是摘要截断:

# search_engine_arxiv.py:146 (节选)
preview = {
"id": paper.entry_id,
"title": paper.title,
"link": paper.entry_id,
"snippet": paper.summary[:SNIPPET_LENGTH_SHORT] + "...", # 摘要当预览
"authors": [a.name for a in paper.authors[:3]],
}

它还顺手把完整 paper 对象缓存进 self._papers(:141),这样全文阶段不用二次请求。

全文阶段(search_engine_arxiv.py:187 _get_full_content)才是"贵"的部分:补全所有作者/分类/DOI,并在 include_full_text 开且没超 max_full_text 上限时真的下载 PDF 并抽文本(pypdf 失败退 pdfplumber,:271-312)。默认只下 1 篇(max_full_text),这就是两阶段省钱哲学的体现——不是留下的每篇都抓 PDF,而是留下的头几篇。

6.2 SearXNG:爬 HTML 拿预览,委托 FullSearchResults 拿全文

SearXNG 是自托管的元搜索。它标 is_public=Trueis_generic=True(search_engine_searxng.py:33,35)。

预览阶段(:432 _get_previews:220 _get_search_results):它请求 SearXNG 实例、用 BeautifulSoup 解析返回的 HTML(不是 JSON),逐条抽标题/URL/摘要。有个精巧的健壮性处理 _is_valid_search_result(:70):当后端引擎失败或被限流,SearXNG 会返回指向自己的错误/统计页,这个方法把"相对 URL"和"指回本实例的 URL"(如 /stats?engine=)都判为无效,避免把错误页当搜索结果——还顺便从 /stats?engine= 里解析出是哪个后端引擎挂了并告警(:389-396)。

全文阶段(:475 _get_full_content):它不自己爬,而是委托给 FullSearchResults:

# search_engine_searxng.py:496
return self.full_search._get_full_content(relevant_items)

6.3 FullSearchResults:通用全文抓取器

full_search.py:24FullSearchResults 是可复用的"给一批链接抓正文"的组件。它的 _get_full_content(:178)对每个链接做双重校验再抓:

# full_search.py:193 (节选)
if not validate_url(link): # ① SSRF 校验:只看 IP 类别
continue
if evaluate_url_fn is not None and egress_ctx is not None:
url_decision = evaluate_url_fn(link, egress_ctx) # ② egress scope 校验
if not url_decision.allowed:
continue # 公网链接在 PRIVATE_ONLY 下被拦

SSRF 只管"这个 IP 是不是内网"(防打内网),egress scope 是另一条轴(公网/私网策略,细节见 06)。过了两关的 URL 才交给 batch_fetch_and_extract(:217)——它优先用专用下载器(arXiv/PubMed),回退到 HTML 爬取。抓不到内容不报错,只把 full_content 置空(:230-232),让检索照常返回。


7. 核心机制四:跨引擎过滤 vs 引擎内 LLM 相关性过滤

LDR 有两个用 LLM 判相关性的过滤器,位置和目的不同,别混:

引擎内相关性过滤跨引擎过滤
在哪跑单个引擎 run() 内、预览之后策略层,多引擎结果合并之后
干什么从本引擎预览里挑相关的对所有引擎的合并结果统一重排/重编号
代码relevance_filter.py:160cross_engine_filter.py:96
输出格式纯文本索引列表(regex 解析)JSON 索引数组

7.1 引擎内:纯文本索引,躲开结构化输出的坑

filter_previews_for_relevance(relevance_filter.py:160)的设计很务实:不用结构化输出,让 LLM 直接吐 0, 2, 5 这种纯文本,再用正则抠整数(_INT_RE,:55)。文件头注释解释了原因——结构化输出在各家 provider 上毛病一堆(qwen 会进"散文模式"、function_calling 有延迟、schema 扯皮),纯文本 + 正则最稳。

几个关键的健壮性设计:

  • 空响应 = 有效判断("全都不相关"),不二次猜测,但会告警(:342-347)。
  • 异常 ≠ 拒绝全部:网络/解析失败视为"过滤器不可用",回退到原始预览的截断切片(:300-305),避免下游被灌满,也避免因一次故障返回零结果。
  • 分批 + 并行:大预览列表切成 batch_size 批并行送(:255 起用 ThreadPoolExecutor)。这里有个刻意的选择——不用 with 语句管理线程池,因为 __exit__shutdown(wait=True) 卡在卡死的 Ollama 请求上;改成手动 shutdown(wait=False) 让超时的批被丢弃而不是拖死整条流水线(:246-294 注释)。整体有个 300 秒墙钟超时(_FILTER_WALL_TIMEOUT_S,:70)。
  • 部分成功是常态:有的批成功、有的超时,成功的保留、失败的贡献空;只有每一批都失败才走截断回退(:300)。

聚合时按原始顺序合并、用 seen 集合去重(:319-331),日志里还区分 KEPT / REMOVED / SKIPPED——"skipped"专指所在批超时/报错、评委根本没看过它,和"removed"(评委看了判不相关)是两回事(:357-380)。

7.2 跨引擎:合并后重排 + 重编号

CrossEngineFilter.filter_results(cross_engine_filter.py:96)在策略层跑,处理"多个引擎的结果拼在一起后怎么办"。它接受两个开关:

  • reorder:是否按相关性重新排序;
  • reindex:是否重新分配引用序号(_prepare_and_return,:74,把 result["index"] 重写成连续编号)。

去重复用同一套逻辑 _valid_unique_indices(:81):跳过非整数、跳过越界、按首次出现顺序去重。几个防翻车的保护:

  • 结果太少(≤10 条)直接不过滤(:125)——省一次 LLM 调用。
  • 只把前 max_context_items(默认 30)条喂给 LLM 看(:60-69,:132),控制 prompt 大小。
  • 过滤后空了,回退返回前 10 条原始结果(:188-197),不让"过滤器把一切删光"变成灾难。
  • 异常兜底返回截断的原始结果(:247)。

策略侧的接线在 source_based_strategy.py:112 构造 CrossEngineFilter,并在累积结果后调用它,传 start_index=len(self.all_links_of_system)(:415-430)——这样跨多轮检索的引用序号能连续递增,报告里的引用编号才不会重号(引用合成见 04)。


8. 核心机制五:自适应限流——把等待时间"学"出来

它要解决的小问题

不同搜索源的限流阈值你事先不知道,写死一个固定 sleep 要么太慢(浪费)要么太快(被 429)。LDR 的做法是在线学习每个引擎该等多久

怎么接进 run()

限流藏在 run() 的 tenacity 重试壳里(search_engine_base.py:645-651):

# search_engine_base.py:645
@retry(
stop=stop_after_attempt(3), # 最多试 3 次
wait=AdaptiveWait(lambda: self._get_adaptive_wait()), # 等多久?问 tracker
retry=retry_if_exception_type((RateLimitError,)), # 只对"被限流"重试
after=self._record_retry_outcome, # 每次之后记录结果
reraise=True,
)
def _run_with_retry(): ...

AdaptiveWait(search_engine_base.py:78)是个自定义 tenacity 等待策略,它不返回固定值,而是每次回调 _get_adaptive_wait()(:456)去问 tracker。注意:只有 RateLimitError 会触发重试,别的异常不重试直接吞掉返回空(:759-772)。引擎子类负责把 429/"too many requests"这类信号翻译成 RateLimitError(基类给了 _is_rate_limit_error/_raise_if_rate_limit 工具,:994/:1060;arXiv 在 :176-183 就是这么干的)。

tracker 怎么学

AdaptiveRateLimitTracker(rate_limiting/tracker.py:48)对每个引擎维护一个等待时间估计。两个核心方法:

get_wait_time(:233)——探索 vs 利用。 大部分时候用学到的 base_wait 加抖动;以 exploration_rate(默认 0.1)的概率探索——故意试一个更快的等待,看限流是不是放松了(:285-293):

# tracker.py:285
if random.random() < self.exploration_rate:
wait_time = base_wait * random.uniform(0.5, 0.9) # 探索:试快一点
else:
wait_time = base_wait * random.uniform(0.9, 1.1) # 利用:用学到的值

record_outcome_update_estimate(:324,:392)——学。 累积最近若干次尝试(滑动窗口 deque),用指数移动平均learning_rate 更新 base:成功就往成功等待的中位数收敛,全失败就把 base 抬高(封顶 10 秒防失控,:414-449):

# tracker.py:440
new_base = (1 - learning_rate) * old_base + learning_rate * new_base
new_base = min(new_base, 10.0) # 绝对封顶 10s

三种 profile(conservative/balanced/aggressive)只是缩放探索率和学习率(_apply_profile,:119)。学到的估计可持久化到用户加密库(:478-520),但programmatic 模式和 CI 模式下只走内存、不落库(:463,多处 is_ci_environment() 短路),这也是 programmatic_mode 要一路透传的原因(基类 _configure_programmatic_mode,:408,会据此绑定不同的 tracker)。

精华: 限流不是配置死值,而是一个"探索-利用"的在线学习器——它用少量探索去试探 API 有没有放松,用移动平均记住每个引擎的甜点位,还封顶防跑飞。


9. 巧妙之处(可借鉴)

妙在哪依据
两阶段把"判断相关"和"抓全文"解耦,贵操作只对精选做search_engine_base.py:661-720 _execute_search
DOI 富化刻意前置到过滤之前,否则质量过滤字段是空的会退化search_engine_base.py:674-695
LLM 过滤用纯文本+正则而非结构化输出,绕开各家 provider 的坑relevance_filter.py:29-55
线程池故意不用 with,手动 shutdown(wait=False) 让卡死的批被丢弃而非拖死流水线relevance_filter.py:246-294
过滤器异常回退到截断切片而非返回空,故障不等于"全不相关"relevance_filter.py:300-305
SearXNG 把"指回自己实例的 URL"判为无效,识破后端限流伪装成的错误页search_engine_searxng.py:70-90
限流用探索-利用在线学习等待时间,而非写死 sleeprate_limiting/tracker.py:233-299
未知引擎名 FAIL CLOSED 直接报错,不静默走真网络search_engine_factory.py:127-136,183-195

10. 边界与局限(诚实说)

  • _get_previews 是纯抽象方法,任何新引擎不实现它就无法实例化(search_engine_base.py:1317);而 _get_full_content 有默认实现,不覆盖就只做字段剥离、不抓全文(:1113)。
  • 相关性过滤强依赖 LLM 质量:弱模型可能把全部结果判为不相关,系统只告警不纠正(relevance_filter.py:342-347)。
  • 限流学习是每进程、内存态的:get_tracker() 每次返回新实例(tracker.py:833),多用户 Flask 下不跨请求缓存;持久化也仅在有用户上下文时发生。
  • egress 复核有已知盲区:运行时 _verify_egress_scope 只在快照换新对象或 scope/primary 就地被改时重验(靠 identity + policy_key memo,search_engine_base.py:479-547);工厂 PEP 才是主执法点。判定细节与这层的完整语义见 06
  • HTML 解析是脆的:SearXNG 靠 CSS 选择器逐级回退抽结果(search_engine_searxng.py:304-316),上游改版可能导致抽取失败退化成空结果。

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

主题文件路径关键符号
两阶段 run() 骨架web_search_engines/search_engine_base.pyBaseSearchEngine.run_execute_search(闭包)
预览/全文契约web_search_engines/search_engine_base.py_get_previews(抽象)、_get_full_content_extract_full_result
分类/行为标志web_search_engines/search_engine_base.pyis_publicis_scientificis_lexicalneeds_llm_relevance_filter
自适应等待策略web_search_engines/search_engine_base.pyAdaptiveWait_get_adaptive_wait_record_retry_outcome
引擎内 LLM 相关性过滤web_search_engines/relevance_filter.pyfilter_previews_for_relevance_invoke_text_INT_RE
跨引擎重排/重编号advanced_search_system/filters/cross_engine_filter.pyCrossEngineFilter.filter_results_valid_unique_indices_prepare_and_return
期刊声誉过滤advanced_search_system/filters/journal_reputation_filter.pyJournalReputationFiltercreate_default
工厂装配web_search_engines/search_engine_factory.pycreate_search_engine_create_full_search_wrapper
引擎名→实现类web_search_engines/engine_registry.pyENGINE_REGISTRYEngineEntryget_engine_entry
引擎配置组装web_search_engines/search_engines_config.pysearch_configget_available_engines
UI 分组web_search_engines/engine_groups.pySEARCH_ENGINE_GROUPSclassify_engine_group
检索器注册web_search_engines/retriever_registry.pyRetrieverRegistryregisterget_metadata
arXiv 引擎示例web_search_engines/engines/search_engine_arxiv.pyArXivSearchEngine._get_previews_get_full_content
SearXNG 引擎示例web_search_engines/engines/search_engine_searxng.pySearXNGSearchEngine._get_search_results_is_valid_search_result
通用全文抓取web_search_engines/engines/full_search.pyFullSearchResults._get_full_contentcheck_urls
DOI→OpenAlex 富化utilities/openalex_enrichment.pyenrich_results_with_source_ids
自适应限流web_search_engines/rate_limiting/tracker.pyAdaptiveRateLimitTracker.get_wait_time_update_estimaterecord_outcomeget_tracker