跳到主要内容

目录递归检索:先锁高分目录再逐层细化

30 秒导读: 别的记忆库把所有片段拍平成一个向量表,一次相似度召回就返回 topk。OpenViking 偏不——它把上下文组织成一棵目录树(viking:// 路径),检索时先把 query 拆成多条检索条件,再用向量粗定位到几个高分目录,然后像人翻文件夹一样沿目录树逐层往下钻,每一层都重新打分、卡阈值、判是否收敛。这样返回的不只是"最像的那句话",而是"最像的那句话 + 它所在的整个上下文",召回更全、更准。本章只讲读路径的算法

本章覆盖四块,由浅入深:

  1. 五步策略的全景(README「Directory Recursive Retrieval」+ docs/en/concepts/07-retrieval.md)。
  2. 意图分析:把一句 query 拆成多条带类型的检索条件(intent_analyzer.py)。
  3. 递归检索核心:优先队列下钻、分数传播、rerank、阈值、层级 suffix(hierarchical_retriever.py)。
  4. 检索目标解析与轨迹可观测(retrieval_targets.py / retrieval_stats.py)。

向量库/存储引擎实现见 0406;记忆写回生命周期见 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()
会话上下文不需要必需
意图分析不做,直接拿 query 检索做,LLM 拆成 0–5 条 TypedQuery
查询条数单条0–5 条
延迟较高
适用简单直查复杂任务

依据:docs/en/concepts/07-retrieval.mdfind() 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+会话 → 多条 TypedQueryIntentAnalyzer.analyzeopenviking/retrieve/intent_analyzer.py
起点目录按 context_type 定默认根目录default_target_directoriesopenviking/core/retrieval_targets.py
②③④ 递归优先队列下钻主循环HierarchicalRetriever.retrieve / _recursive_searchopenviking/retrieve/hierarchical_retriever.py
rerank用重排模型给候选重打分_rerank_scores同上
⑤ 聚合候选转对外结果_convert_to_matched_contexts同上
可观测记录每次检索指标RetrievalStatsCollector.record_queryopenviking/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.mdQuery 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 起点从哪来:向量初定位 + 默认根目录

先确定"从哪几个目录开始钻"。两个来源:

  1. 显式目标目录:query.target_directories(意图分析阶段可能已给出),有就用它。
  2. 默认根目录:没有显式目标时,按 context_typedefault_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 时)
MEMORYviking://user/...(用户根)
RESOURCEviking://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:75407)。注意 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:191277)。热度分的具体算法属于记忆生命周期,见 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 长啥样
0L0 摘要viking://.../项目/.abstract.md
1L1 概览viking://.../项目/.overview.md
2L2 文件保持原样(本身就是文件)

_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_fallbackrerank 用了多少次 / 回落多少次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.md Backend 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.pyIntentAnalyzer.analyze
意图 prompt 组装openviking/retrieve/intent_analyzer.pyIntentAnalyzer._build_context_prompt
模型→prompt 映射openviking/retrieve/intent_analyzer.pyQUERY_PLANNER_PROMPT_BY_MODEL / resolve_intent_analysis_prompt_id
递归检索总入口openviking/retrieve/hierarchical_retriever.pyHierarchicalRetriever.retrieve
优先队列递归主循环openviking/retrieve/hierarchical_retriever.pyHierarchicalRetriever._recursive_search
搜子节点封装openviking/retrieve/hierarchical_retriever.pysearch_children(内嵌)/ search_children_in_tenant
rerank 打分+容错openviking/retrieve/hierarchical_retriever.pyHierarchicalRetriever._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.pyMAX_CONVERGENCE_ROUNDS / GLOBAL_SEARCH_TOPK / MAX_PARALLEL_CHILD_SEARCHES
检索目标/权限解析openviking/core/retrieval_targets.pyresolve_retrieval_targets / default_target_directories
检索指标累加openviking/retrieve/retrieval_stats.pyRetrievalStatsCollector.record_query / RetrievalStats

引用均 as-of sourceCommit b0533b55;行号可能随上游漂移,失效时用符号名 grep 定位。