跳到主要内容

DeepSearch:拆子问题 + 反思补查的核心循环

这是整个项目工程含量最高、最值得读的一支。DeepSearch 适合「给个主题写报告/综述」这类宽问题(它的 __description__ 原话:"suitable for handling general and simple queries, such as given a topic and then writing a report",deep_search.py:68-70)。

3.1 它要解决的小问题

宽问题(「写一份关于 X 的报告」)一次检索覆盖不全。两个子难点:

  • 覆盖面: 得从多个角度查,而不是只查原问题。
  • 何时停: 查到什么程度算「够了」?

DeepSearch 的回答:拆角度(子问题) 解决覆盖面,反思(reflection)生成补查问题 解决「还缺什么」,最大轮数 兜底解决「何时停」。

3.2 思路/直觉(先于代码)

整个 async_retrieve 是一个循环。怎么读下面这张图: 从上到下是一轮迭代内的步骤,右侧虚线是「下一轮」的回流。

原问题


① 拆子问题(SUB_QUERY_PROMPT)──▶ [子问题 q1, q2, q3, q4]


┌─────────── 每一轮迭代(最多 max_iter 轮)───────────┐
│ ② 对每个子问题并行检索向量库 │
│ ③ 每块命中让 LLM 判 YES/NO(rerank,只留 YES) │
│ ④ 跨子问题去重,累加进总结果 │
│ ⑤ 反思:看「原问题+已查到的」还缺什么(REFLECT_PROMPT)│
│ └─ 返回空列表 → 停;否则 gap query 成为下一轮子问题 ┄┄┘
└──────────────────────────────────────────────────────┘


⑥ 汇总所有命中片段(SUMMARY_PROMPT)──▶ 最终答案

3.3 原理演示(示意,非源码)

下面这段把核心循环的骨架演出来,帮你建立直觉:

# 示意,非源码:DeepSearch 迭代检索的骨架
sub_queries = llm_decompose(original_query) # ① 拆角度
all_hits, all_subs = [], list(sub_queries)
next_round = sub_queries

for it in range(max_iter):
# ② 每个子问题并行查库,③ LLM 逐块 rerank
hits = parallel_search_and_rerank(next_round)
all_hits = dedup(all_hits + hits) # ④ 去重累加

if it == max_iter - 1:
break # 轮数兜底
# ⑤ 反思:还缺什么?
gap = llm_reflect(original_query, all_subs, all_hits)
if not gap:
break # 觉得够了,停
next_round = gap # 缺口问题进入下一轮
all_subs += gap

answer = llm_summarize(original_query, all_subs, all_hits) # ⑥

重点看两个「停」条件:反思返回空列表轮数耗尽

3.4 真实实现:逐步对照源码

① 拆子问题。 _generate_sub_queries(deep_search.py:111-118)把原问题塞进 SUB_QUERY_PROMPT,要求 LLM「拆成至多 4 个子问题,返回 python list」。返回值经 llm.literal_eval 解析成真正的 list:

response_content = self.llm.remove_think(chat_response.content)
return self.llm.literal_eval(response_content), chat_response.total_tokens

literal_eval 会容错地从 ```python ``` 代码块或杂乱文本里抠出 list(llm/base.py:67-112)——LLM 经常不老实只回纯 list,这层解析很关键。

② 并行检索。 每轮把当前所有子问题并发查库,用 asyncio.gather(deep_search.py:234-244):

search_tasks = [
self._search_chunks_from_vectordb(query, sub_gap_queries)
for query in sub_gap_queries
]
search_results = await asyncio.gather(*search_tasks)

③ LLM 当 reranker。 这是 DeepSearch 一个很「重」的设计:检索回来的每一块都单独发一次 LLM,问「这块对任一子问题有用吗?只回 YES/NO」(_search_chunks_from_vectordb,deep_search.py:145-162)。判定逻辑很严:

if "YES" in response_content and "NO" not in response_content:
all_retrieved_results.append(retrieved_result)

注意必须含 YES 且不含 NO 才收——避免 LLM 回「NO, but...」被误判。代价是每块一次 LLM 调用,token 消耗大(见 ch.06 边界)。

④ 去重。 deduplicate_resultstext 原文去重(vector_db/base.py:58-77),跨子问题/跨轮次的重复命中只留一份。

⑤ 反思生成 gap query。 _generate_gap_queries(deep_search.py:173-185)把「原问题 + 所有历史子问题 + 已命中片段」塞进 REFLECT_PROMPT,要 LLM 给「至多 3 个还需要查的问题,够了就返回空列表」。prompt 里有个有意思的偏置(deep_search.py:44):

If the original query is to write a report, then you prefer to generate some further queries, instead return an empty list.

即「如果是写报告,倾向于多查几轮」——让报告类问题更舍得迭代。

⑥ 汇总。 query(deep_search.py:271-313)在 retrieve 拿到全部片段后,用 SUMMARY_PROMPT 让 LLM 写最终答案。汇总时优先用 metadata["wider_text"](句窗的更宽上下文,见 ch.05)而非原始小块:

if self.text_window_splitter and "wider_text" in chunk.metadata:
chunk_texts.append(chunk.metadata["wider_text"])
else:
chunk_texts.append(chunk.text)

3.5 关键细节 / 坑

  • 同步外壳包异步核心。 retrieve 是同步方法,内部 asyncio.run(self.async_retrieve(...))(deep_search.py:204)。真正的并行只发生在「同一轮内多个子问题的检索」,轮与轮之间是串行(必须等反思结果才能开下一轮)。
  • max_iter 的最后一轮不反思。 循环里 if iter == max_iter - 1: break(deep_search.py:249-251)在反思之前就 break,省掉一次没用的 LLM 调用。
  • 内网搜索是占位。 search_res_from_internet = [] # TODO(deep_search.py:231)始终为空——尽管 README 提到「可整合在线内容」,这条路径在本 commit 还没接(见 ch.06)。