跳到主要内容

检索层:查询改写、按源路由的 Dispatcher、多检索器与向量库

30 秒导读: DocsGPT 的 RAG 检索,本质是"把一句用户问题,变成一批带引用标签的文档片段"。它做了三件不显然的事:① 用聊天历史把问题改写成独立查询;② 一个 Dispatcher 把不同"源"(知识库)按各自配置路由到不同检索器(经典向量、混合、图),再在一份共享 token 预算下合并;③ 无论内部多复杂,只要所有源都是普通 classic 源,输出与改造前的单检索器逐字节一致——这是贯穿全章的设计红线。

本章覆盖检索的读路径(query→docs)。摄取侧(怎么把文档切块入库、怎么抽取知识图谱)在第 6 章。图检索这里只讲"怎么读图",不讲"怎么建图"。


1. 这是什么(零基础也能懂)

一句话定义: 检索层是 RAG(Retrieval-Augmented Generation,检索增强生成)里的"检索"那一半——在 LLM 回答之前,先从用户的知识库里捞出最相关的几段原文,塞进 prompt,让模型有据可依。

解决什么问题: LLM 不知道你私有文档里写了什么。RAG 的套路是:把文档切成小片段("chunk")、算成向量存进向量库;提问时把问题也算成向量,找最近邻的几个片段,连同问题一起交给模型。检索层负责的就是"从问题到片段"这一步。

在 DocsGPT 里,它要额外扛住三个现实复杂度:

现实复杂度检索层的应对
用户在多轮对话里问"那它呢?"——问题本身不完整用聊天历史改写成独立查询再检索
一次提问可能横跨多个知识库,每个库想用不同检索策略Dispatcher 按源路由到不同检索器
塞进 prompt 的原文不能无限长(有 token 上限)所有检索器共享一份 token 预算,先到先得

用起来什么样: 上层(agent 的工具循环,见第 2 章)并不直接碰向量库,而是拿到一个检索器对象,调 .search("用户的问题"),拿回一个 list[dict],每个 dict 长这样:

# 示意,非源码 —— 一次 search() 的返回元素
{
"text": "……被检索到的原文片段……",
"title": "quickstart", # 用于展示
"source": "<source_id 或路径>", # 用于引用/去重
"filename": "quickstart.md", # 拼进 prompt 头部
}

一句话直觉: 把检索层想成一个图书管理员:你随口问一句(可能还带着上文),他先把你的话补全成一个明确的检索请求,再决定去哪几个书架、用什么方式找,最后在"你桌子只放得下这么多书"的限制下,把最相关的几页递给你。


2. 顶层全景(它大概怎么转)

2.1 部件与职责

检索层是一组小类,各司其职:

部件干什么文件
BaseRetriever抽象基类,只定义 search() 一个方法application/retriever/base.py
RetrieverCreator工厂:按 key(classic/hybrid/graphrag)造检索器application/retriever/retriever_creator.py
ClassicRAG主力检索器:查询改写 + 向量搜索 + token 预算application/retriever/classic_rag.py
Dispatcher按源路由到多个检索器,合并结果application/retriever/dispatcher.py
HybridRetrieverClassicRAG 子类,向量+关键词 RRF 融合application/retriever/hybrid_rag.py
GraphRAGRetriever组合 ClassicRAG,图上跑 Personalized PageRankapplication/retriever/graph_rag.py
PreScreenStage后处理:LLM 逐批筛掉不相关候选application/retriever/stages/prescreen.py
VectorCreator / BaseVectorStore向量库工厂与抽象(faiss/pgvector/…)application/vectorstore/

2.2 主线走一遍(高层)

从"上层要检索"到"拿回片段",数据这样流:

上层(StreamProcessor / InternalSearchTool)
│ 给 source + 可选的 per-source 列表 sources

build_dispatcher(...) ← kill-switch:关掉就退回旧的单个 ClassicRAG


┌─────────────── Dispatcher ───────────────┐
│ _build_groups: 按 retriever key 把源分组 │
│ · 所有 classic 源 → 合成 1 个组(关键!) │
│ · 每个非 classic key → 各自一组 │
│ _budget_for_group: 把 token 预算切给各组 │
└───────┬───────────────┬───────────────────┘
▼ ▼
ClassicRAG HybridRetriever / GraphRAGRetriever
(向量搜索) (RRF 融合 / 图 PPR)
│ │
▼ ▼
[候选片段] [候选片段]
│ │
▼ ▼
可选 PreScreenStage(LLM 逐批筛)
│ │
└──────┬────────┘

合并:共享预算内先到先得 → list[dict]

这张图怎么读: 从上到下是一次检索的时间顺序。最该记住的是中间那一步——"所有 classic 源合成 1 个组":这保证了当你没用任何高级配置时,Dispatcher 退化成"就是一个 ClassicRAG",行为和从前一模一样。这条"逐字节一致(byte-identical parity)"红线,是理解整章设计取舍的钥匙。


3. 核心原理(逐个机制,由浅入深)

3.1 抽象与工厂:一个 search() 撑起所有检索器

它要解决的小问题: 上层不想知道"这次是向量搜索还是图搜索",只想调一个统一方法。

思路: 基类窄到极致——只有一个抽象方法 search:

# application/retriever/base.py:4
class BaseRetriever(ABC):
@abstractmethod
def search(self, *args, **kwargs):
pass

所有检索器都实现 search(),于是上层可以无差别地对待它们。造哪一个,交给工厂 RetrieverCreator:

# application/retriever/retriever_creator.py:7
retrievers = {
"classic": ClassicRAG,
"default": ClassicRAG, # 缺省即经典
"hybrid": HybridRetriever,
"graphrag": GraphRAGRetriever,
}

create_retriever(type, ...)(retriever_creator.py:14)把传入的 type 转小写、查表、实例化;查不到就抛 ValueError。还有一个 register(key, cls)(retriever_creator.py:22)让新检索器可以注册进来——和后面向量库、chunker 的工厂是同一套"注册表"模式。

注意 classicdefault 指向同一个类。 这不是冗余:default 是"用户没指定时"的兜底,classic 是"显式选经典"。Dispatcher 分组时会把两者归一成 classic(见 3.3)。

3.2 ClassicRAG:主力检索器的三段式

ClassicRAG(classic_rag.py:11)是整章的地基——HybridRetriever 继承它、GraphRAGRetriever 组合它。它一次 search() 干三件事,依次讲。

3.2.1 第一段:带聊天历史的查询改写

要解决的小问题: 多轮对话里,用户会说"那它的价格呢?"——"它"指谁,只有看上文才知道。直接拿这句去向量库搜,几乎搜不到东西。

思路: 先用一次小的 LLM 调用,把"原问题 + 聊天历史"改写成一个独立、自足的检索查询。

触发条件很克制——只有真需要时才花这次 LLM 调用:

# application/retriever/classic_rag.py:113 _rephrase_query
if (
not self.original_question
or not self.chat_history
or self.chat_history == []
or self.chunks == 0 # 压根不检索
or not self.vectorstores # 没有源
):
return self.original_question # 直接返回,不调 LLM

任何一个条件不满足(比如没有历史),就原样返回,零额外开销。真要改写时,它拼一个 system prompt("给定以下对话历史……把问题改写成独立检索查询"),调 self.llm.gen(...),失败则回退到原问题(classic_rag.py:135-145)。

一个巧妙的成本归因细节: 改写用的 LLM 是个"侧信道",构造后被打上标签:

# application/retriever/classic_rag.py:68
self.llm._token_usage_source = "rag_condense" # 成本记账时归到这个来源
self.llm._request_id = request_id # 关联回发起的请求

于是查询改写烧掉的 token,在账单里能和主回答分开看。(prescreen 阶段同理,标 rag_prescreen。)

惰性缓存: 改写结果不总是立刻要用。构造函数有个 defer_rephrase 开关:

# application/retriever/classic_rag.py:85
if defer_rephrase:
self.question = self.original_question # 先不改写
else:
self.question = self._rephrase_query() # 老路径:立即改写
self._rephrased_question = self.question

真正取用时走 _get_rephrased_question()(classic_rag.py:93),它只在 _rephrased_question is None 时才计算、然后缓存。这样一个"配置了 rephrase_query=False"的源可以完全跳过这次 LLM 侧调用——Dispatcher 正是靠这个开关来实现"按源决定要不要改写"(见 3.3)。默认路径(defer_rephrase=False)则照旧立即改写,行为不变。

3.2.2 第二段:向量搜索取候选

拿到查询后,对每个源开一个向量库、搜候选。这一步被抽成一个可覆写的钩子:

# application/retriever/classic_rag.py:147 _fetch_candidates
k = min(max(src_k * 2, 20), 500) # 多取一些候选,再夹到 [20,500]
search_kwargs = {"k": k}
if score_threshold is not None:
search_kwargs["score_threshold"] = score_threshold
return docsearch.search(question, **search_kwargs)

为什么单独抽出来? 因为子类要改的只有这一步HybridRetriever 覆写它做 RRF 融合(3.4),而外层的"每源解析 + 预算循环"完全继承下来。这是典型的"模板方法"——把稳定骨架留在基类,把易变的一步开个口子。

score_threshold(相关度阈值)是"能用就用":pgvector/mongodb 会遵守它,faiss 这类不支持的存储会在自己的 search() 里把它丢掉而不是报错(见 §4 的 faiss 例子)。

3.2.3 第三段:per-source override + 共享 token 预算

要解决的小问题: 塞进 prompt 的原文有 token 上限;多个源要公平分享这个上限,谁也别把别人饿死。

核心在 _get_data()(classic_rag.py:162)的循环里。 先算预算:

# application/retriever/classic_rag.py:171
chunks_per_source = max(1, self.chunks // len(self.vectorstores))
token_budget = max(int(self.doc_token_limit * 0.9), 100) # 留 10% 余量
cumulative_tokens = 0

然后逐源取候选、逐片段累加,一旦累计 token 撞到预算就停:

# application/retriever/classic_rag.py:215 (简化摘录)
for doc in docs_temp:
if cumulative_tokens >= token_budget:
break
...
doc_tokens = num_tokens_from_string(doc_text_with_header)
if cumulative_tokens + doc_tokens < token_budget:
all_docs.append({"text": page_content, **labels})
cumulative_tokens += doc_tokens

这就是"先到先得的共享预算":排在前面的源和片段先占额度,占满即止。

per-source override 是这一段的精华。 每个源可以带一份 RetrievalConfig(由 Dispatcher 塞进 self.per_source_retrieval)。循环里对每个源先查有没有 override:

# application/retriever/classic_rag.py:180 (简化)
src_cfg = self.per_source_retrieval.get(vectorstore_id)
if src_cfg is not None:
src_k = max(1, int(src_cfg.chunks)) # 这个源要几块
score_threshold = src_cfg.score_threshold # 这个源的阈值
question = (self._get_rephrased_question() # 这个源要不要改写
if src_cfg.rephrase_query else self.original_question)
else:
src_k = chunks_per_source
score_threshold = None
question = self._get_rephrased_question() # 无 override → 默认改写

看清那个"无 override"分支: 它用的是惰性缓存的改写结果。在非 deferred 的老路径里,缓存构造时已填好,所以这一分支逐字节复现旧行为——这正是 parity 红线在代码级的体现。有 override 时,才按该源的 rephrase_query 决定走改写还是原问题。

RetrievalConfig 的字段(application/storage/db/source_config.py:93):

字段默认含义
retriever"classic"用哪个检索器(RetrieverCreator 的 key)
chunks2最终 top-k
score_thresholdNone相关度阈值(部分存储遵守)
rephrase_queryTrue是否做查询改写侧调用
prescreenNone是否开 LLM 预筛(见 3.5)
exposure"prefetch"预取 vs 作为 agent 工具按需检索

3.2.4 组装引用标签:labels_from_metadata

每个片段要带上给人看的标签(标题/来源/文件名),这由一个共享函数完成:

# application/retriever/labels.py:9 labels_from_metadata
title = metadata.get("title", metadata.get("post_title", text)) # 缺则用正文
...
source = metadata.get("source") or fallback_source # 缺则用 source_id
return {"title": title, "source": source, "filename": filename}

为什么单独抽出来? 注释点破了:ClassicRAG 和 GraphRAG 都调它,好让不同检索器产出的引用标签保持一致——图检索和向量检索捞到同一份文档时,引用长得一样。

3.3 Dispatcher:按源路由 + parity 红线

要解决的小问题: 一次提问横跨多个源,每个源可能想用不同检索器、不同参数;但又不能因此破坏"老用户什么都没配"时的既有行为。

思路: 引入一个也实现 search()Dispatcher(dispatcher.py:51),它本身是个 BaseRetriever,对上层透明。内部把源按检索器 key 分组,每组造一个检索器,在共享预算下合并。

3.3.1 分组:所有 classic 源必须合成一个组

# application/retriever/dispatcher.py:108 _build_groups(简化)
for entry in self._sources:
retrieval = self._coerce_retrieval(entry.get("retrieval"))
key = (retrieval.retriever or "classic").lower()
if key in _CLASSIC_KEYS: # {"classic","default"}
key = "classic" # 归一
group = grouped.setdefault(key, {"retriever": key, "doc_ids": [], "retrievals": {}})
group["doc_ids"].append(doc_id)
if self._is_override(retrieval): # 只有真的改了参数才记 override
group["retrievals"][doc_id] = retrieval

关键点: classicdefault 被归一到同一个 "classic" 组,于是所有普通源汇进唯一一个 ClassicRAG 实例——就像改造前那样。文件顶部的注释把这条设计目标写死了:

dispatcher.py:6 —— "Parity guarantee: when every source is classic/default … all sources flow into ONE ClassicRAG instance built exactly as today, so the output — including token-budget behaviour — is byte-identical to the pre-dispatch path."

3.3.2 _is_override:什么才算"改了配置"

只有当源真的偏离默认,才把它记成 override(否则它继续走全局 ClassicRAG 路径,保持 parity):

# application/retriever/dispatcher.py:171 _is_override
return (
retrieval.chunks != _DEFAULT_RETRIEVAL.chunks
or retrieval.score_threshold != _DEFAULT_RETRIEVAL.score_threshold
or retrieval.rephrase_query != _DEFAULT_RETRIEVAL.rephrase_query
or retrieval.prescreen is not None
)

注释点明:只比较 ClassicRAG 读路径真正会用到的那几个旋钮。一个停在默认值的源 = 零额外 LLM 调用、逐字节一致。

3.3.3 预算切分:一组时给满,多组时均分

# application/retriever/dispatcher.py:199 _budget_for_group
if n_groups <= 1:
return self.doc_token_limit # 单组 → 满预算 = 复现旧行为
base = self.doc_token_limit // n_groups
remainder = self.doc_token_limit % n_groups
return base + (1 if group_idx < remainder else 0) # 均分,余数给靠前的组

单组给满——这样"全 classic"时那唯一的 ClassicRAG 拿到完整 doc_token_limit,预算行为和旧代码分毫不差。多组才平均切,且总和不超上限,谁也饿不死谁。

3.3.4 search:快路径 vs 合并路径

# application/retriever/dispatcher.py:271 search(结构)
if n_groups == 1:
# 快路径 / 精确 parity:就是底层检索器 + 满预算,没有任何合并记账
retriever = self._build_group_retriever(groups[0], self.doc_token_limit)
docs = retriever.search(query) if query else retriever.search()
return self._run_stages(docs, context, self._group_stages(groups[0]))

# 多组:各组在共享 cap 下先到先得地 merge
for idx, group in enumerate(groups):
budget = self._budget_for_group(n_groups, idx)
...

单组直接短路,连合并记账都不做——这是 parity 最彻底的保证。多组时,每组各自检索、跑后处理 stage,再把结果按 num_tokens_from_string 逐条累加进 merged,撞到 cap(预算的 90%)就停。

一个安全细节: 多组循环里某组失败时,日志只打异常类型名而非消息:

# application/retriever/dispatcher.py:296
logger.error("Group '%s' search failed: %s", group["retriever"], type(exc).__name__)

注释说明原因:向量库连接错误的原始消息里可能带着含凭据的 DSN,不能进日志。

3.3.5 建组检索器:defer_rephrase 与 candidate_k 的接线

_build_group_retriever(dispatcher.py:213)是把"组"翻译成"检索器实例"的地方,两个接线值得记:

  • 拔高 top-k 给 prescreen 用: 若组里有源开了 prescreen,要先多取候选再筛。于是 kwargs["chunks"] = max(self.chunks, candidate_k),candidate_k 来自 max_candidate_k(group["retrievals"])
  • 延迟改写: 只要组里有 per-source override 且是可延迟的 key,就 kwargs["defer_rephrase"] = True,让 rephrase_query=False 的源能跳过改写调用。

之后把 group["retrievals"] 通过 setattr(retriever, "per_source_retrieval", ...) 塞进检索器,3.2.3 的循环就能读到它。

3.3.6 build_dispatcher:kill-switch

Dispatcher 不是硬接线的,外面套了一层工厂:

# application/retriever/dispatcher.py:321 build_dispatcher
if not getattr(settings, "PER_SOURCE_RETRIEVAL_ENABLED", True):
return create_classic() # 关掉开关 → 退回旧的单个 ClassicRAG
return Dispatcher(**kwargs)

PER_SOURCE_RETRIEVAL_ENABLED(application/core/settings.py:106,默认 True)是一个总闸:出问题时一键关掉整套 per-source 机制,退回上线前的单检索器。上层(stream_processor.py:1032internal_search.py:59)统一通过它来建检索器,并传一个 _legacy_classic 闭包作为兜底。

3.4 HybridRetriever:向量 + 关键词的 RRF 融合

要解决的小问题: 纯向量搜索擅长"语义相近",但会漏掉"精确关键词/罕见术语";纯关键词搜索反过来。想两者兼得。

思路: 各搜一份排名列表,用 RRF(Reciprocal Rank Fusion,倒数排名融合) 合并——不看分数绝对值,只看"在各自列表里排第几"。

HybridRetriever(hybrid_rag.py:48)继承 ClassicRAG,只覆写 _fetch_candidates:

# application/retriever/hybrid_rag.py:51
def _fetch_candidates(self, docsearch, question, src_k, score_threshold):
candidate_k = min(max(src_k * 2, 20), 500)
vector_hits = docsearch.search(question, k=candidate_k)
keyword_hits = docsearch.keyword_search(question, k=candidate_k)
return reciprocal_rank_fusion(vector_hits, keyword_hits)

融合公式:每个文档在每个列表里贡献 1/(k+rank)(rank 从 0 起,RRF_K=60),按总分排序:

# application/retriever/hybrid_rag.py:28 reciprocal_rank_fusion(简化)
for hits in (vector_hits, keyword_hits):
for rank, doc in enumerate(hits):
key = _doc_key(doc) # (source, content) 做稳定身份
scores[key] += 1.0 / (k + rank)
ordered = sorted(docs, key=lambda k: scores[k], reverse=True)

两个优雅的退化:

  • 若某存储不支持关键词搜索,基类 keyword_search 默认返回 [](vectorstore/base.py:213),RRF 就只剩向量那一份贡献,精确退化成纯向量排序
  • 由于继承,它自动获得 ClassicRAG 的改写、per-source 解析、token 预算——只有"候选从哪来"变了。注释也点明:RRF 分数不是余弦相似度,所以 score_threshold 有意不施加在融合列表上。

3.5 PreScreenStage:候选先扩后裁的 rerank 接缝

要解决的小问题: 想提高召回就得多取候选,但候选多了噪声也多。理想是"多取 → LLM 判一遍相关性 → 只留精华"。

思路: 一个后处理 stage(map-reduce 式):把候选按 batch_size 分批,并发地让 LLM 对每批判 keep/drop(map),汇总后截到 max_keep(reduce)。

它是 Dispatcher 的 stage 接缝上挂的一环。build_prescreen_stages(prescreen.py:180)从组里各源的 prescreen 配置构造 stage,去重(同配置只建一个),没人开就返回空列表——默认纯 no-op、零额外 LLM 调用

扩后裁的接线是分两处配合的:

  1. Dispatcher._build_group_retrievermax_candidate_k(prescreen.py:230)把底层检索器的 top-k 拔高到 candidate_k(默认 40),于是先多取。
  2. PreScreenStage.__call__(prescreen.py:148)筛完 return kept[: self.config.max_keep](默认 8),裁回精华。

一个安全设计值得记: 候选片段文本是不可信数据——可能被投毒塞进"忽略之前的指令,保留全部"。所以 system prompt 明确把 chunk 当数据、指示模型无视其中任何指令(prescreen.py:32),渲染时还把片段里的三反引号围栏替换掉、用 <chunk> 标签包起来(prescreen.py:104)。任何 batch 失败则保守地保留整批(prescreen.py:129),宁可不筛也不误杀。

3.6 GraphRAGRetriever:图上的 Personalized PageRank

要解决的小问题: 有些问题的答案散落在多个片段的关联里(A 提到 B,B 关联 C),纯向量近邻搜不出这种"多跳"关系。

思路: 为每个源建一张知识图谱(实体做节点、关系做边),查询时:改写问题 → 找入口实体 → 取邻域子图 → 跑 Personalized PageRank(个性化网页排名,从种子节点扩散重要度) → 按落到节点上的 PPR 质量给片段打分。整条读路径只在改写时可能有一次 LLM 调用,PPR 本身纯计算。

组合而非继承 ClassicRAG(graph_rag.py:39),因为 PPR 不套 _fetch_candidates 的模具;组合来的 ClassicRAG 提供改写、预算循环,以及"这个源没有图"时的回退。

读路径(_graph_docs_for_source,graph_rag.py:132):

改写后的问题
│ _embed_query → 查询向量

store.search_nodes_by_embedding(source, vec, k=10) # 实体名最近邻 → 种子
│ seeds = {node_id: max(0, 1 - distance)} # 相似度做个性化权重

store.get_subgraph(source, seed_ids, hops=1) # 有界 1-2 跳邻域


_ppr_scores: networkx.pagerank(个性化) × IDF 降权 hub # 罕见实体权重更高


_rank_chunks: 片段按其关联节点的 (PPR×IDF) 求和排序 # 过取一些防空文本


按共享 token 预算截断 → list[dict](和 classic 同款标签)

几个精华细节:

  • 种子权重夹到 ≥0(graph_rag.py:147):余弦距离可能 >1(负相似度),而 networkx PPR 遇到负的 personalization 会产出垃圾、权重和≈0 时还会 ZeroDivisionError;全零则通过 None 守卫塌缩成均匀 PPR。
  • IDF 给 hub 降权(graph_rag.py:34 _idf):PPR 之后,每个节点质量乘 1/log(2+doc_freq)——高频"枢纽"实体贡献被压低,让具体实体更突出。
  • 三层回退:图库不可用、某源没图(count_nodes==0)、或图检索抛异常,任一都退回组合的 ClassicRAG 对该源做普通向量检索(graph_rag.py:192 _get_data / _classic_for_source)。图检索是增强,坏了不影响可用性。

图的读接口在 GraphStore(application/graphrag/store.py:66)。 本章只关心读:search_nodes_by_embedding(store.py:542,pgvector 余弦最近邻找种子)、get_subgraph(store.py:577,按跳数有界扩展邻域,MAX_SUBGRAPH_NODES/EDGES 封顶防 hub 爆炸)、get_chunk_ids_for_nodes / get_chunk_texts(把节点映射回原文)。图是 pgvector 专属、与向量表同库,建图/抽取(extraction.py)属摄取侧,留到第 6 章


4. 向量库抽象:一个接口,多种后端

检索器不直接依赖任何具体向量库,而是经 VectorCreator 工厂拿一个 BaseVectorStore

工厂(vector_creator.py:9)是同款注册表:

# application/vectorstore/vector_creator.py:10
vectorstores = {
"faiss": FaissStore, "elasticsearch": ElasticsearchStore,
"mongodb": MongoDBVectorStore, "qdrant": QdrantStore,
"milvus": MilvusStore, "pgvector": PGVectorStore,
}

create_vectorstore(type, ...)(vector_creator.py:19)按 settings.VECTOR_STORE 选后端。

后端key备注
FAISSfaiss本地文件索引,默认、最简
pgvectorpgvectorPostgres 扩展;GraphRAG 也依赖它
Qdrantqdrant专用向量库
Milvusmilvus专用向量库
MongoDBmongodbAtlas 向量搜索
Elasticsearchelasticsearch兼作关键词搜索

诚实标注: 克隆里有 application/vectorstore/lancedb.py 文件,但它没有被注册进 VectorCreator.vectorstores(vector_creator.py:10 只列了上面 6 个)。所以当前读路径不会经工厂拿到 LanceDB 实现;它要么是历史遗留、要么走别处。以工厂注册表为准。

抽象基类(vectorstore/base.py:204 BaseVectorStore)定义 search(抽象)、add_texts(抽象)、以及一个有默认实现keyword_search:

# application/vectorstore/base.py:213
def keyword_search(self, question, k=10):
# 默认返回空,让 hybrid 在不支持关键词的存储上退化成纯向量
return []

精读一个实现:FaissStore(vectorstore/faiss.py:43)。它从存储加载 index.faiss + index.pkl,search 直接转发给 langchain 的 FAISS:

# application/vectorstore/faiss.py:86
def search(self, *args, **kwargs):
# FAISS 没有相关度阈值旋钮,丢掉它以免崩掉这次前向
kwargs.pop("score_threshold", None)
return self.docsearch.similarity_search(*args, **kwargs)

这就是 3.2.2 里说的"score_threshold 能用就用":faiss 主动 pop 掉,所以 per-source 阈值在 faiss 上被安全忽略而非报错。加载时还用 get_vectorstore(faiss.py:13)校验路径不逃出 indexes 目录,防路径穿越。


5. 巧妙之处(可带走的技术)

  • "byte-identical parity"作为改造纪律。 引入 per-source 路由这种大改时,用"所有默认源塌缩成唯一一个旧检索器 + 单组给满预算 + 单组短路不记账"三招,保证未启用新特性的用户逐字节不受影响。设计目标直接写进代码注释(dispatcher.py:6),而非口头约定。(dispatcher.py:171 _is_overridedispatcher.py:199 _budget_for_groupdispatcher.py:278)
  • 模板方法开一个口子。 ClassicRAG 把稳定的"每源解析+预算"留在 _get_data,只把易变的"候选从哪来"抽成 _fetch_candidates,于是 Hybrid 只覆写一行逻辑就换了检索策略。(classic_rag.py:147hybrid_rag.py:51)
  • 惰性改写 = 按源省钱。 defer_rephrase + _get_rephrased_question 缓存,让"这个源不需要改写"能真正跳过 LLM 调用,而默认路径行为不变。(classic_rag.py:85-97)
  • 成本按来源打标。 改写和预筛各自给 LLM 打 rag_condense / rag_prescreen 标签,账单可拆分。(classic_rag.py:68prescreen.py:99)
  • 把不可信数据当不可信数据。 预筛把候选文本围栏化 + 指令化免疫注入;Dispatcher 错误日志只打异常类型名以免泄露带凭据的 DSN。(prescreen.py:32dispatcher.py:296)
  • 优雅退化贯穿始终。 关键词不支持→纯向量;某源无图→ClassicRAG;预筛某批失败→保留整批;总闸 PER_SOURCE_RETRIEVAL_ENABLED→退回旧检索器。坏一环不塌全局。

6. 边界与局限

  • GraphRAG 强绑 pgvector。 图表与向量表同库,GraphStore 只走 psycopg/pgvector(store.py:66);其它后端下图检索不可用,只会回退到普通向量检索。
  • LanceDB 未接线。 文件在,但没进工厂注册表(见 §4),当前读路径拿不到它。
  • prescreen / rephrase 是查询期 LLM 成本。 默认关闭/克制触发正是因为它们烧钱烧延迟;开 prescreen 会为每批候选各发一次 LLM 调用(上限 8 并发,prescreen.py:29)。
  • RRF 不用分数只用排名。 好处是跨异构列表可比,代价是丢掉了相似度绝对信息,score_threshold 在 hybrid 上不生效(hybrid_rag.py:56 注释)。
  • 多组预算是"先到先得"而非"按相关度全局排序"。 合并时按组顺序累加到 cap 即停(dispatcher.py:308),靠前的组可能占掉更多额度;没有跨组的统一 rerank(那需另配 stage)。

7. 横向对比(本组其它章)


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

主题文件路径符号名
检索器抽象基类application/retriever/base.pyBaseRetriever
检索器工厂/注册表application/retriever/retriever_creator.pyRetrieverCreator.create_retriever / register
主力检索器application/retriever/classic_rag.pyClassicRAG
带历史的查询改写application/retriever/classic_rag.py_rephrase_query
改写的惰性缓存application/retriever/classic_rag.py_get_rephrased_question
向量搜索候选钩子application/retriever/classic_rag.py_fetch_candidates
per-source override + token 预算application/retriever/classic_rag.py_get_data
引用标签组装(共享)application/retriever/labels.pylabels_from_metadata
按源路由application/retriever/dispatcher.pyDispatcher
按 retriever key 分组application/retriever/dispatcher.py_build_groups
override 判定application/retriever/dispatcher.py_is_override
预算切分application/retriever/dispatcher.py_budget_for_group
合并/快路径application/retriever/dispatcher.pyDispatcher.search
kill-switch 工厂application/retriever/dispatcher.pybuild_dispatcher
混合检索(RRF)application/retriever/hybrid_rag.pyHybridRetriever / reciprocal_rank_fusion
图检索(PPR)application/retriever/graph_rag.pyGraphRAGRetriever / _ppr_scores / _graph_docs_for_source
图读接口application/graphrag/store.pyGraphStore.search_nodes_by_embedding / get_subgraph
LLM 预筛 stageapplication/retriever/stages/prescreen.pyPreScreenStage / build_prescreen_stages / max_candidate_k
per-source 配置模型application/storage/db/source_config.pyRetrievalConfig / PreScreenConfig
向量库抽象application/vectorstore/base.pyBaseVectorStore / keyword_search
向量库工厂application/vectorstore/vector_creator.pyVectorCreator.create_vectorstore
FAISS 实现application/vectorstore/faiss.pyFaissStore.search
per-source 总闸application/core/settings.pyPER_SOURCE_RETRIEVAL_ENABLED