目录递归检索:先锁高分 目录再逐层细化
30 秒导读: 别的记忆库把所有片段拍平成一个向量表,一次相似度召回就返回 topk。OpenViking 偏不——它把上下文组织成一棵目录树(
viking://路径),检索时先把 query 拆成多条检索条件,再用向量粗定位到几个高分目录,然后像人翻文件夹一样沿目录树逐层往下钻,每一层都重新打分、卡阈值、判是否收敛。这样返回的不只是"最像的那句话",而是"最像的那句话 + 它所在的整个上下文",召回更全、更准。本章只讲读路径的算法。
本章覆盖四块,由浅入深:
- 五步策略的全景(README「Directory Recursive Retrieval」+
docs/en/concepts/07-retrieval.md)。 - 意图分析:把一句 query 拆成多条带类型的检索条件(
intent_analyzer.py)。 - 递归检索核心:优先队列下钻、分数传播、rerank、阈值、层级 suffix(
hierarchical_retriever.py)。 - 检索目标解析与轨迹可观测(
retrieval_targets.py/retrieval_stats.py)。
向量库/存储引擎实现见 04 与 06;记忆写回生命周期见 05;目录树本身怎么建、L0/L1/L2 怎么分层见 02;viking:// URI 范式见 01。
1. 这是什么(零基础也能懂)
先说它要解决的痛点
一句话:单次扁平向量召回,搞不定复杂意图。
传统 RAG 的做法是:把所有片段切碎、各自算一个向量、塞进一张大表;来了 query,算一次相似度,取最像的 N 条返回。问题有两个:
- 意图混杂:用户一句"帮我写个 RFC"里,其实藏着三种需求——要技能(怎么写 RFC)、要资源(RFC 模板)、要记忆(这个用户偏好什么风格)。一条向量查询同时命中这三类,谁都不精。
- 丢上下文:命中了一个片段,但它属于哪个项目、哪个目录、上下文是什么,扁平表里看不出来。
OpenViking 的解法:沿目录树递归下钻
OpenViking 把所有上下文组织成一棵目录树,每个节点有唯一的 viking:// URI(详见 01)。检索时它不拍平,而是:
先用向量快速找到"最该进哪几个文件夹",进这些文件夹后再在里面二次检索,发现还有子文件夹就继续钻,直到钻到叶子文件(L2)。 一路上把够分的节点都 收进候选池,最后聚合排序返回。
一句话直觉:像一个熟练的资料管理员找文件——不是把全公司文件倒在地上一张张翻,而是先判断"这事儿归哪个部门",走到那个部门的柜子前,再翻具体抽屉。 这就是 README 说的"先锁高分目录,再细化内容探索"(README Directory Recursive Retrieval 一节)。
两个入口:find() 与 search()
读路径有两个门。区别在于"要不要先做意图分析":
| 特性 | find() | search() |
|---|---|---|
| 会话上下文 | 不需要 | 必需 |
| 意图分析 | 不做,直接拿 query 检索 | 做,LLM 拆成 0–5 条 TypedQuery |
| 查询条数 | 单条 | 0–5 条 |
| 延迟 | 低 | 较高 |
| 适用 | 简单直查 | 复杂任务 |
依据:docs/en/concepts/07-retrieval.md 的 find() vs search() 表。本章重点讲 search() 这条完整链路,因为它把意图分析和递归检索都用上了。
2. 顶层全景(五步策略怎么转)
README 把这套策略拆成五步。先看整条流水线,再逐块拆:
用户 query + 近期会话
│
▼
┌─────────────────────────┐
│ ① 意图分析 │ IntentAnalyzer.analyze
│ 一句话 → 0~5 条 │ → QueryPlan{ TypedQuery[] }
│ 带类型的检索条件 │
└─────────────────────────┘
│ 每条 TypedQuery 各走一遍下面的检索
▼
┌─────────────────────────┐
│ ② 向量初定位 │ 全局向量召回 level=[0,1]
│ 粗找高分「目 录」 │ → 一批候选起点目录
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ ③ 目录内二次检索 │ search_children_in_tenant
│ 进目录、搜它的孩子 │ 每层重新打分 + rerank
└─────────────────────────┘
│ 孩子里还有目录(非 L2)?
▼
┌─────────────────────────┐
│ ④ 递归下钻 │ 把够分的子目录推回优先队列
│ 优先队列按分数出队 │ 高分目录先钻,直到收敛
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ ⑤ 结果聚合 │ 去重 + 混入热度分 + 排序
│ 收成 MatchedContext[] │ + 还原 L0/L1 URI 后缀
└─────────────────────────┘
依据:README「Directory Recursive Retrieval」的五步 + docs/en/concepts/07-retrieval.md 的 Hierarchical Retrieval Flow 图。
部件与落点
| 步骤 | 干什么 | 主符号 | 文件 |
|---|---|---|---|
| ① 意图分析 | query+会话 → 多条 TypedQuery | IntentAnalyzer.analyze | openviking/retrieve/intent_analyzer.py |
| 起点目录 | 按 context_type 定默认根目录 | default_target_directories | openviking/core/retrieval_targets.py |
| ②③④ 递归 | 优先队列下钻主循环 | HierarchicalRetriever.retrieve / _recursive_search | openviking/retrieve/hierarchical_retriever.py |
| rerank | 用重排模型给候选重打分 | _rerank_scores | 同上 |
| ⑤ 聚合 | 候选转对外结果 | _convert_to_matched_contexts | 同上 |
| 可观测 | 记录每次检索指标 | RetrievalStatsCollector.record_query | openviking/retrieve/retrieval_stats.py |
3. 第一步:意图分析(把一句话拆成多条检索条件)
它要解决的小问题: 用户一句话里混了多种需求,直接拿去向量检索谁都不精。得先拆。
思路
IntentAnalyzer 干的事:把会话压缩摘要 + 最近几条消息 + 当前 query 拼成一个 prompt,交给一个专门的 query_planner 模型,让它吐出一个 JSON,里面是 0~5 条带类型的检索条件。
为什么带类型很关键——不同类型的东西,连"该长什么样"都不同:
| 类型(ContextType) | 查询风格 | 例子 |
|---|---|---|
| skill | 动词开头 | "创建 RFC 文档"、"抽取 PDF 表格" |
| resource | 名词短语 | "RFC 文档模板"、"API 使用指南" |
| memory | "用户的 XX" | "用户的代 码风格偏好" |
依据:docs/en/concepts/07-retrieval.md 的 Query Styles 表。特殊情况:纯闲聊/问好会返回 0 条(不检索);复杂任务可能同时要 skill + resource + memory 多条。
输入怎么拼
analyze 把上下文交给 _build_context_prompt 组装。三样输入各自截断/取窗:
# openviking/retrieve/intent_analyzer.py:133-156 _build_context_prompt(节选)
summary = self._truncate_text(compression_summary, self.MAX_COMPRESSION_SUMMARY_CHARS) # 压缩摘要,超长截断( 约 30000 字符)
recent = messages[-self.max_recent_messages :] if messages else [] # 只取最近 max_recent_messages(默认 5)条
recent_messages = "\n".join(f"[{m.role}]: {m.content}" for m in recent if m.content)
三个可调点:MAX_COMPRESSION_SUMMARY_CHARS = 30000(摘要上限,约 1 万 token,intent_analyzer.py:49)、max_recent_messages = 5(近期消息窗口,intent_analyzer.py:51-53),以及一个不太起眼但很实用的 target_abstract——当已知要在某个目录里搜时,把该目录的摘要塞进 prompt,让 planner 生成更贴目录的查询(analyze 形参,intent_analyzer.py:61)。
一个巧妙处:prompt 随模型而变
同一份意图分析,不同的 planner 模型要的 prompt 格式不一样。OpenViking 用一张精确的模型名 → prompt id映射表来选:
# openviking/retrieve/intent_analyzer.py:24-35
QUERY_PLANNER_PROMPT_BY_MODEL: dict[str, str] = {
"ollama/guoxuter/ov_intent_analysis_sft:v7_q8": "retrieval.ov_intent_analysis_sft_v7",
"ollama/guoxuter/ov_intent_analysis_sft:v4_q8": "retrieval.ov_intent_analysis_sft_v4",
}
def resolve_intent_analysis_prompt_id(query_planner: Any) -> str:
model = getattr(query_planner, "model", None)
if not isinstance(model, str):
return DEFAULT_INTENT_ANALYSIS_PROMPT
return QUERY_PLANNER_PROMPT_BY_MODEL.get(model.strip(), DEFAULT_INTENT_ANALYSIS_PROMPT)
没在表里的模型,一律回落到默认 prompt——为了向后兼容。这样换微调模型不用改主流程(resolve_intent_analysis_prompt_id,intent_analyzer.py:30-35)。
输出:QueryPlan
LLM 返回的 JSON 被解析成一批 TypedQuery,缺字段就给保底默认(类型解析失败 → RESOURCE,优先级默认 3):
# openviking/retrieve/intent_analyzer.py:95-109(节选)
for q in parsed.get("queries", []):
try:
context_type = ContextType(q.get("context_type", "resource"))
except ValueError:
context_type = ContextType.RESOURCE # 类型不认识就当资源
queries.append(TypedQuery(
query=q.get("query", ""),
context_type=context_type,
intent=q.get("intent", ""),
priority=q.get("priority", 3), # 缺优先级默认 3
))
解析彻底失败(parsed 为空)会直接 raise ValueError,不静默吞掉(analyze,intent_analyzer.py:90-92)。产出的 QueryPlan.queries 里,每条 TypedQuery 接下来各自独立走一遍第 4 节的递归检索。
4. 第二步起:递归检索核心(本章的算法主角)
这是最有辨识度的部分。入口是 HierarchicalRetriever.retrieve(hierarchical_retriever.py:93),它对一条 TypedQuery 完成"初定位 → 递归下钻 → 聚合"。
4.1 先理解两个模式:QUICK vs THINKING
同一个 retrieve 里藏着两条路,靠 mode 分流。默认规则很简单——配了 rerank 就走 THINKING,没配就走 QUICK:
# openviking/retrieve/hierarchical_retriever.py:117-118
if mode is None:
mode = RetrieverMode.QUICK if not self._rerank_client else RetrieverMode.THINKING
| 模式 | 做不做递归 | 打分 | 热度加权 | 何时用 |
|---|---|---|---|---|
| QUICK | 不递归,单次扁平召回 | 只用向量分 | 否 | 没配 rerank 时的快速路径 |
| THINKING | 递归下钻 | 向量分 + rerank 重排 | 是 | 配了 rerank(默认) |
QUICK 模式本质就是"退化成传统扁平召回":一次 search_in_tenant,过阈值、按 URI 去重、排序返回(retrieve,hierarchical_retriever.py:153-192)。下面重点讲 THINKING 这条完整的递归路径。
4.2 起点从哪来:向量初定位 + 默认根目录
先确定"从哪几个目录开始钻"。两个来源:
- 显式目标目录:
query.target_directories(意图分析阶段可能已给出),有就用它。 - 默认根目录:没有显式目标时,按
context_type查default_target_directories。
# openviking/retrieve/hierarchical_retriever.py:145-149
if target_dirs:
root_uris = target_dirs
else:
root_uris = default_target_directories(ctx, context_type=query.context_type)
default_target_directories 按上下文类型给不同的默认根(还会按用户/peer 身份拼路径,详见 4.7):
| context_type | 默认根目录(无 peer 时) |
|---|---|
| MEMORY | viking://user/...(用户根) |
| RESOURCE | viking://resources + 用户根 |
| SKILL | 用户 skills + viking://agent/skills |
依据:retrieval_targets.py:53-86 default_target_directories。
接着 Step 2 全局向量召回做粗定位。注意它只搜 level=[0,1],也就是只找目录节点(L0 摘要 / L1 概览),不直接捞叶子文件:
# openviking/retrieve/hierarchical_retriever.py:196-204(节选)
global_results = await vector_proxy.search_in_tenant(
...,
level=[0, 1], # 只要目录层,粗定位入口
limit=max(limit, self.GLOBAL_SEARCH_TOPK), # GLOBAL_SEARCH_TOPK = 10
)
多召几个候选(GLOBAL_SEARCH_TOPK = 10)是故意的——候 选越多,后面 rerank 的精度越好(见常量注释,hierarchical_retriever.py:51)。
query 向量只算一次,在进两条模式之前就把 dense/sparse 都嵌好,避免重复嵌入(retrieve,hierarchical_retriever.py:136-143)。
4.3 组装起点:全局命中 + rerank + 兜底根目录
Step 3 把"起点目录"凑齐。先对全局召回的目录做一次 rerank 重打分(THINKING 才做),再把它们连同兜底根目录一起塞进起点列表:
# openviking/retrieve/hierarchical_retriever.py:227-247(节选)
directory_scores = [self._finite_score(r.get("_score", 0.0)) for r in global_results]
if self._rerank_client and mode == RetrieverMode.THINKING:
directory_scores = self._rerank_scores( # 用目录的 abstract 重排
query.query,
[str(r.get("abstract", "")) for r in global_results],
directory_scores,
)
starting_points = []
for result, score in zip(global_results, directory_scores, strict=True):
starting_points.append((result["uri"], score)) # 全局命中目录 + 重排分
for uri in root_uris: # 兜底:默认根目录也进起点
if uri not in seen_starting_uris:
starting_points.append((uri, 0.0)) # 分数 0,靠后钻
兜底根目录以 0 分入队——保证即使全局召回没命中,起码还能从默认根开始钻,不至于空手而归。
4.4 递归主循环:优先队列如何"高分目录先钻"
_recursive_search(hierarchical_retriever.py:350)是算法心脏。数据结构就三样:
dir_queue:一个最大堆优先队列(用heapq+ 负分模拟),永远先弹分最高的目录。visited:去重,一个目录只钻一次。collected_by_uri:候选池,按 URI 去重、保留最高分。
怎么读这段流程: 从队列弹出最高分目录 → 并发搜它的孩子 → 孩子重新打分过阈值收进池 → 孩子里还是目录的推回队列 → 判收敛。
┌──────────── dir_queue(优先队列,-score 排序)────────────┐
│ │
弹出一批最高分目录(最多 4 个并发) │
│ │
▼ │
并发 search_children_in_tenant(每个目录搜孩子) │
│ │
▼ │
每个孩子:rerank 打分 → 分数传播 → 过阈值? │
│是 │
├──► 收进 collected_by_uri(按 URI 去重,留最高分) │
│ │
└──► 孩子还是目录(level != 2)? ──是──► 推回队列 ─────────┘
│
▼
收敛判定:topk 连续 3 轮不变 / 池子连续 3 轮不涨 → break
出队与并发的代码:
# openviking/retrieve/hierarchical_retriever.py:426-442(节选)
while dir_queue:
batch = []
while dir_queue and len(batch) < parallelism: # parallelism = 4
temp_score, current_uri = heapq.heappop(dir_queue)
if current_uri in visited:
continue
visited.add(current_uri)
batch.append((current_uri, -temp_score))
batch_results = await asyncio.gather( # 一批目录并发搜孩子
*(search_children(current_uri) for current_uri, _ in batch)
)
并发上限 MAX_PARALLEL_CHILD_SEARCHES = 4,是为了限制对远端向量库的扇出(fan-out)压力(常量注释,hierarchical_retriever.py:52)。内嵌的 search_children 只是对 search_children_in_tenant 的薄封装,一次多要点孩子好挑:limit=max(limit * 2, 20)(hierarchical_retriever.py:413-422)。
4.5 命中与停止的三把尺子
一个孩子到底收不收、要不要继续钻、整个循环啥时候停——由三个机制决定。这是本章最该记住的部分。
尺子一:分数传播(score propagation)。 孩子的最终分不是它自己的向量分,而是自己的分和父目录分的加权融合:
# openviking/retrieve/hierarchical_retriever.py:460-462
final_score = (
alpha * score + (1 - alpha) * current_score if current_score else score
)
alpha 就是 retrieval.score_propagation_alpha。它控制"孩子自己的分"占多重:
| alpha 取值 | 含义 |
|---|---|
1.0(默认) | 完全用孩子自己的分,忽略父目录分 |
0.5 | 父子各半,父目录高分会"抬"孩子 |
0.0 | 完全继承父目录分 |
依据:docs/en/concepts/07-retrieval.md Key Parameters 表把默认标为 1.0;alpha 取自 self.score_propagation_alpha(hierarchical_retriever.py:75、407)。注意 if current_score else score 这个分支:父分为 0(比如兜底根目录)时直接用孩子分,不做无意义的融合。
尺子二:阈值门(threshold)。 分数过不了阈值的孩子直接丢。阈值和比较符都可配:
# openviking/retrieve/hierarchical_retriever.py:315-319 _passes_threshold
@staticmethod
def _passes_threshold(score, threshold, score_gte):
if score_gte:
return score >= threshold # 闭区间
return score > threshold # 开区间(默认)
阈值本身有一套回落:传入值 → 实例阈值(来自 rerank 配置)→ 最后 0.0 兜底,保证永远是个有限数:
# openviking/retrieve/hierarchical_retriever.py:303-305 _resolve_threshold
def _resolve_threshold(self, threshold):
resolved = threshold if threshold is not None else self.threshold
return resolved if resolved is not None else 0.0
尺子三:只钻目录,不钻文件。 收进候选池是一回事,要不要继续往下钻是另一回事。只有非 L2 节点(目录)才推回队列;L2 是叶子文件,是终点:
# openviking/retrieve/hierarchical_retriever.py:483-485
# Only recurse into directories (L0/L1). L2 files are terminal hits.
if uri not in visited and r.get("level", 2) != 2:
heapq.heappush(dir_queue, (-final_score, uri))
收敛判定:什么时候停。 每钻完一批就检查两个"没变":
- topk 连续
MAX_CONVERGENCE_ROUNDS(=3)轮不变且已凑满 limit → 停(结果稳了)。 - 候选池大小连续 3 轮不涨 → 停(钻不出新东西了)。
- 只要有变化,两个计数器都清零、更新基线。
# openviking/retrieve/hierarchical_retriever.py:496-510(节选)
if current_topk_uris == prev_topk_uris and len(current_topk_uris) >= limit:
convergence_rounds += 1
if convergence_rounds >= self.MAX_CONVERGENCE_ROUNDS: # 3
break
elif current_pool_size == prev_pool_size:
stagnant_rounds += 1
if stagnant_rounds >= self.MAX_CONVERGENCE_ROUNDS:
break
else:
convergence_rounds = 0
stagnant_rounds = 0
prev_topk_uris = current_topk_uris
prev_pool_size = current_pool_size
这套"双不变即停"避免了在大目录树上无谓地钻到底——结果稳定就收手。
4.6 rerank:每一层都可以重排
_rerank_scores(hierarchical_retriever.py:321)在两处被调:起点目录评估(4.3)、递归时每层孩子评估(hierarchical_retriever.py:453-456)。它拿"query × 每个候选的 abstract"过重排模型,给出更贴语义的分。
亮点是容错——rerank 是外部调用,随时可能挂,所以任何异常或返回不合法(长度对不上)都回落到原向量分,绝不让检索因为 rerank 崩掉:
# openviking/retrieve/hierarchical_retriever.py:331-343(节选)
try:
scores = self._rerank_client.rerank_batch(query, documents)
except Exception as e:
logger.warning("... Rerank failed, fallback to vector scores: %s", e)
return fallback_scores # 挂了 → 用向量分
if not scores or len(scores) != len(documents):
logger.warning("... Invalid rerank result, fallback to vector scores")
return fallback_scores # 结果不合法 → 用向量分
触发条件:配了 rerank 的 AK/SK + THINKING 模式(依据 docs/en/concepts/07-retrieval.md Rerank Strategy)。
4.7 结果聚合:混热度、还原 URI 后缀
递归收上来的候选是内部字典,_convert_to_matched_contexts(hierarchical_retriever.py:519)把它们转成对外的 MatchedContext。两件不显然的事:
一、语义分再混一层"热度分"。 光靠语义相似还不够——一条常被用、刚更新过的记忆,应该更容易被捞出来。所以最终分是语义分和热度分的加权:
# openviking/retrieve/hierarchical_retriever.py:551-555(节选)
h_score = hotness_score(
active_count=c.get("active_count", 0), # 被激活/使用的次数
updated_at=updated_at_val, # 最近更新时间
)
final_score = (1 - alpha) * semantic_score + alpha * h_score # alpha = hotness_alpha
hotness_alpha(retrieval.hotness_alpha)为 0 时关掉热度加权;只有走过递归的 THINKING 路径才 apply_hotness=True,QUICK 路径不加(retrieve,hierarchical_retriever.py:191、277)。热度分的具体算法属于记忆生命周期,见 05。混完还会按新分重排一次,让热度真能改变名次(hierarchical_retriever.py:577-578)。
二、给目录 URI 还原可读后缀。 内部存储里 L0/L1 目录节点的 URI 是"裸目录名",但返回给用户时要还原成能直接打开的文件路径。_append_level_suffix 按层级补后缀:
# openviking/retrieve/hierarchical_retriever.py:53
LEVEL_URI_SUFFIX = {0: ".abstract.md", 1: ".overview.md"}
| level | 含义 | 还原后 URI 长啥样 |
|---|---|---|
| 0 | L0 摘要 | viking://.../项目/.abstract.md |
| 1 | L1 概览 | viking://.../项目/.overview.md |
| 2 | L2 文件 | 保持原样(本身就是文件) |
_append_level_suffix(hierarchical_retriever.py:581-593)还做了幂等处理:已经带后缀的、已经是 .abstract.md/.overview.md 结尾的,不重复加。L0/L1/L2 分层的来龙去脉见 02。
一个诚实的留白: 类里定义了
DIRECTORY_DOMINANCE_RATIO = 1.2(注释说"目录分需超过最大子分",hierarchical_retriever.py:50),但在本文件的读路径里没看到它被引用——可能是预留或在别处使用,这里不臆断其行为。
5. 检索目标解析:递归从哪几个根开始
前面 4.2 提到起点来自 default_target_directories。这一步看着琐碎,其实是权限和隔离的闸门——它决定一次检索允许碰哪些目录。retrieval_targets.py 负责把用户给的 target_uri(可能是单个、列表、或空)规范化成一组允许检索的根目录。
几条关键规则(依据 resolve_retrieval_targets / default_target_directories,retrieval_targets.py:30-86):
- ROOT 角色返回空——不限定目录(全局)(
retrieval_targets.py:59-60)。 - 按 context_type 给默认根:memory 给用户的 memories、resource 额外带
viking://resources、skill 带 agent 公共 skills(retrieval_targets.py:63-86)。 - peer 隔离:actor-peer 想访问别的 peer 的上下文会抛
PermissionDeniedError(_resolve_peer_target,retrieval_targets.py:190-191)。 - 只有 memories/resources 可被搜:peer 路径下访问其它段会抛
InvalidArgumentError(retrieval_targets.py:200-201)。
这一层把"检索能看见什么"和身份/命名空间绑死,viking:// 命名空间与身份规则详见 01。
6. 检索轨迹可观测:每次检索都留痕
README 的第 4 点"Visualized Retrieval Trajectory"强调:每次检索的目录浏览和文件定位轨迹都完整保留,让人能看清"为什么召回了这个",从而反向优化检索逻辑。
轨迹落在两处:
一、结构化日志(逐层轨迹)。 _recursive_search 一路 logger.info 记录进了哪个 URI、logger.debug 记录每个候选的分数和是否过阈值:
# openviking/retrieve/hierarchical_retriever.py:434
logger.info(f"[RecursiveSearch] Entering URI: {current_uri}")
按时间读这些日志,就是一条完整的"翻文件夹"轨迹。
二、聚合指标(健康度)。 retrieve 每跑完一条查询,就把结果数、分数、延迟、是否用了 rerank 记进全局收集器:
# openviking/retrieve/hierarchical_retriever.py:289-295
get_stats_collector().record_query(
context_type=context_type or "unknown",
result_count=len(final),
scores=[m.score for m in final],
latency_ms=elapsed_ms,
rerank_used=rerank_used,
)
RetrievalStatsCollector 是个线程安全单例(带锁),累加出一批只增计数器,并派生出可读的健康指标:
| 指标 | 含义 | 来源 |
|---|---|---|
zero_result_rate | 空结果查询占比(召回质量预警) | RetrievalStats.zero_result_rate |
avg_results_per_query | 每查平均结果数 | avg_results_per_query |
avg_score / max_score / min_score | 分数分布 | RetrievalStats |
rerank_used / rerank_fallback | rerank 用了多少次 / 回落多少次 | record_query |
avg_latency_ms / max_latency_ms | 延迟 | record_query |
依据:retrieval_stats.py:35-75(派生属性 + to_dict)、103-138(record_query 累加逻辑)。rerank_fallback 计数直接对应 4.6 里 rerank 挂掉回落的次数——"重排失败率"变成一个可监控的健康信号。收集器还会顺手把指标转发给 RetrievalStatsDataSource,失败也静默不影响主流程(retrieval_stats.py:140-151)。
7. 巧妙之处(值得带走的技术)
| 妙在哪 | 一句话 | 依据 |
|---|---|---|
| 目录递归代替扁平召回 | 先粗定位目录再逐层钻,返回"片段+完整上下文" | HierarchicalRetriever.retrieve / _recursive_search |
| 分数传播 | 孩子分与父目录分加权,alpha 一个参数在"纯内容"和"继承目录权重"间滑动 | hierarchical_retriever.py:460-462 |
| 双不变收敛 | topk 3 轮不变 或 池子 3 轮不涨即停,避免钻到底 | hierarchical_retriever.py:496-510 |
| 只钻目录不钻文件 | L2 是终点,天然限制递归深度 | hierarchical_retriever.py:483-485 |
| rerank 全程容错 | 异常/结果不合法一律回落向量分,检索永不因重排崩 | _rerank_scores |
| 并发有上限 | 每批最多 4 个目录并发,护住远端向量库 | MAX_PARALLEL_CHILD_SEARCHES |
| query 只嵌一次 | dense/sparse 在 分流前算好,省重复嵌入 | hierarchical_retriever.py:136-143 |
| 语义分混热度 | 常用+新更新的记忆更易被捞,还按新分重排 | _convert_to_matched_contexts |
8. 边界与局限(诚实)
- QUICK 模式退化成扁平召回:没配 rerank 时不做递归、不加热度,就是传统一次向量召回(
retrieve,hierarchical_retriever.py:153-192)。递归的全部好处依赖 rerank 存在。 - 意图分析强依赖 LLM:
analyze解析不出 JSON 会直接raise ValueError(intent_analyzer.py:90-92);planner 模型的质量直接决定拆分好坏。 - 收敛是启发式:
MAX_CONVERGENCE_ROUNDS = 3是经验值。目录树很深且分数抖动时,可能提前收手漏掉深层结果,或多钻几轮。 DIRECTORY_DOMINANCE_RATIO在读路径未见引用:定义了但本文件没用到,其实际作用不在本章可核实范围内。- rerank 后端有限:文档只列了 Volcengine 的
doubao-seed-rerank(docs/en/concepts/07-retrieval.mdBackend Support)。
9. 横向对比(同组其它章)
- 目录树怎么被建出来、L0/L1/L2 摘要/概览/正文怎么 分层写入 → 02 写入路径(本章检索的是它建的树)。
viking://URI 范式与三类上下文(memory/resource/skill)→ 01(本章context_type分流的源头)。- 递归里
search_in_tenant/search_children_in_tenant底下的向量库门面 → 04 存储层;再底层的 ANN 索引 → 06 底层引擎。 hotness_score与记忆自迭代回写 → 05 会话记忆自迭代。
10. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 意图分析入口 | openviking/retrieve/intent_analyzer.py | IntentAnalyzer.analyze |
| 意图 prompt 组装 | openviking/retrieve/intent_analyzer.py | IntentAnalyzer._build_context_prompt |
| 模型→prompt 映射 | openviking/retrieve/intent_analyzer.py | QUERY_PLANNER_PROMPT_BY_MODEL / resolve_intent_analysis_prompt_id |
| 递归检索总入口 | openviking/retrieve/hierarchical_retriever.py | HierarchicalRetriever.retrieve |
| 优先队列递归主循环 | openviking/retrieve/hierarchical_retriever.py | HierarchicalRetriever._recursive_search |
| 搜子节点封装 | openviking/retrieve/hierarchical_retriever.py | search_children(内嵌)/ search_children_in_tenant |
| rerank 打分+容错 | openviking/retrieve/hierarchical_retriever.py | HierarchicalRetriever._rerank_scores |
| 阈值判定 | openviking/retrieve/hierarchical_retriever.py | _passes_threshold / _resolve_threshold |
| 结果聚合+热度混分 | openviking/retrieve/hierarchical_retriever.py | _convert_to_matched_contexts |
| L0/L1 URI 后缀还原 | openviking/retrieve/hierarchical_retriever.py | _append_level_suffix / LEVEL_URI_SUFFIX |
| 关键常量 | openviking/retrieve/hierarchical_retriever.py | MAX_CONVERGENCE_ROUNDS / GLOBAL_SEARCH_TOPK / MAX_PARALLEL_CHILD_SEARCHES |
| 检索目标/权限解析 | openviking/core/retrieval_targets.py | resolve_retrieval_targets / default_target_directories |
| 检索指标累加 | openviking/retrieve/retrieval_stats.py | RetrievalStatsCollector.record_query / RetrievalStats |
引用均 as-of
sourceCommit b0533b55;行号可能随上游漂移,失效时用符号名grep定位。