Files
llm-wiki/workbench/docs/current-kb-retrieval-design.md
2026-07-12 21:26:08 +08:00

588 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 当前知识库自动检索增强设计
> 状态:**已完成** · 实施于 2026-05-28 · 对应 ADR-19 · 阶段 3.5 收尾补强
>
> 本文档是 codex 实施手册。先读 §0 决策摘要,再按 §9 顺序动手。
> 任何与 [PRODUCT.md](../PRODUCT.md) 冲突的描述以 PRODUCT.md 为准;本文档需要破例的地方在 §12 明确写出并对应到 ADR-19。
## 0. 决策摘要(codex 起手必读)
| 决策 | 选择 | 理由速记 |
|---|---|---|
| 架构路线 | **双轨**:新工具 `query_knowledge_base` + `/api/prompt` 预调用同一份代码 | 工具路径符合 ADR-7;预调用兜底弱模型不调工具的情况 |
| 触发策略 | **黑名单**:默认触发,特定场景跳过 | 漏触发的代价是当前 bug;误触发的代价只是浪费 token |
| 多轮检索 | **每轮 user turn 都判断 + 检索** | 用户切话题时能立刻拿到新页面,本地 grep 成本可接受 |
| 失败降级 | **静默降级裸 prompt + SSE error 事件 + 日志** | 用户体验不中断,问题可溯源 |
| 检索 cache | **复用 [pages.ts](../server/src/pages.ts) 的 `getCachedPages`** | 避免两套 cache 失效时机错位 |
| `@` 格式契约 | **解析消息中的 `[[wiki/...]]`**(前端 [ChatPanel.tsx:218-230](../web/src/components/ChatPanel.tsx) 已插入此格式) | 不发明新语法 |
| 上下文上限 | **按 main 模型 contextWindow 的 20% 动态算**;模型未知时 fallback 4000 字符 | 兼顾 glm4k 级)与 Claude200k 级) |
| 验收硬标志 | **日志 + 字符串命中**双重客观可查 | 防止"看起来对实际没注入"的 hallucination 蒙混过关 |
## 1. 问题
阶段 3.5 已实现非 wiki 目录初始化 + 批量消化,但用户进入新知识库直接问"这是我自媒体创作的文章,总结一下",主模型反问用户提供文章内容。
已确认事实:
- 批量消化结果已写入 `wiki/synthesis/sessions/`
- `/api/refs` 能列出这些新页面([pages.ts](../server/src/pages.ts) PAGE_DIRS 含 `synthesis`
- 失败对话的会话日志显示模型未调用任何 KB 工具
- `/api/prompt`[index.ts:745-832](../server/src/index.ts))当前是裸 `session.prompt(message)`,无任何检索 / 注入
- 现有 Extension 工具 `current_knowledge_base` / `list_knowledge_base_pages`[extensions/knowledge-base.ts](../server/src/extensions/knowledge-base.ts))描述写得够强,但 agent 实际不调
**根因**:主对话缺少一层"系统兜底检索"。ADR-7 设计的"靠 Extension 工具让 agent 自觉调用"在弱模型场景下不可靠。
## 2. 设计目标
- 用户在当前库提问,系统默认把当前库作为上下文来源
- `@` 显式引用优先级高于自动检索
- 回答末尾列参考页面;空库 / 无命中时明确说明
- 不把整个库塞进每次对话;不引入新依赖
- 不改变 `@` 引用 / `/` 命令 / 子代理批量消化的现有心智
## 3. 核心思路:双轨
```text
用户提问
后端解析消息中的 [[wiki/...]] → 显式引用列表 R_explicit
shouldUseKnowledgeBase(message) 判断是否触发自动检索(黑名单)
若触发 → 调用同一份 searchKnowledgeBase(kbPath, query, R_explicit)
拼成隐藏上下文 + 原始消息 → session.prompt(wrapped)
模型输出(包含末尾"参考页面")
```
同一份 `searchKnowledgeBase` 同时暴露为:
- **新工具** `query_knowledge_base(query)`:让强模型 / 未来强 agent 显式调用,符合 ADR-7
- **后端预调用**`/api/prompt` 在判断需要时主动调,兜底弱模型不调工具的失败模式
这条路径只覆盖**主对话**。子代理批量消化([digest/batch.ts](../server/src/digest/))走的是裸 prompt + only read tool,本设计不涉及它。
## 4. 不采用的方案
### 4.1 纯改 prompt / 加强 system prompt
不稳定。同一模型不同问法、不同 provider 表现差异巨大。
### 4.2 每次塞整库
KB 大了就慢、贵、乱,污染普通聊天。
### 4.3 把批量消化结果写进聊天历史
消化结果属于知识库,不属于某一次对话。塞进历史会让切会话 / 切库 / 长期使用语义错乱。
### 4.4 立即引入向量库
本地文本检索足够覆盖当前阶段。未来 KB 规模 > 100 篇再升级。
### 4.5 纯工具路径(无后端预调用)
ADR-7 的精神路线,但已被你这次"反问要文章"的对话验证为不稳。本设计保留工具(双轨的一条腿),但不依赖它。
## 5. 解法方案
### 5.1 检索函数 `searchKnowledgeBase`
签名(伪代码,仅说明契约):
```text
searchKnowledgeBase(kbPath, query, options) → SearchResult[]
options:
explicitRefs: string[] // 来自 parseExplicitPageRefs,必含
maxPages: number // 默认 6
snippetMaxChars: number // 默认 600
totalBudgetChars: number // 由 /api/prompt 按模型 contextWindow 算后传入
SearchResult:
path: string // 相对 KB 根,如 wiki/synthesis/sessions/xxx.md
title: string
snippet: string
mtime: number
hitReason: 'explicit' | 'title' | 'filename' | 'body' | 'recent_synthesis' | 'kb_meta'
score: number
```
**实现要点**
1. **复用 [pages.ts](../server/src/pages.ts)**:调 `getCachedPages(kbPath)` 拿候选,不另建 cache。score 函数在 pages.ts 现有 title 2 / name 1 / path 0.5 基础上扩展(见下)。
2. **检索范围**
- `wiki/` 下所有 markdown(已被 pages.ts 覆盖)
- 额外加 KB 元信息文件:`purpose.md` / `index.md` / `overview.md`(如果存在)—— 这些是"总结这个库"类问题的最强答案源
- 现阶段**不读** `raw/` 下的原始素材(批量消化后用户应基于消化页问答)
3. **排序规则**(高到低):
- hitReason='explicit'(来自 `[[wiki/...]]` 显式引用,固定排首位)
- hitReason='title'(标题命中)
- hitReason='kb_meta'purpose/index/overview 在泛化问题下加权)
- hitReason='recent_synthesis'`wiki/synthesis/sessions/` 在"这些文章 / 刚刚消化 / 总结"类问题下加权,按 mtime 降序)
- hitReason='filename'
- hitReason='body'
4. **片段提取**:命中 body 时,从命中位置前后各取 ~300 字符;标题/文件名命中时取页面开头 ~600 字符
5. **总预算控制**:累加每个 snippet 字符数,达到 `totalBudgetChars` 立即停止;至少保证 explicitRefs 全部入选(哪怕超 budget 也强保留显式引用的截断版本)
6. **空 KB 返回 `[]`,不抛错**
7. **cache 隔离**pages.ts 的 cache 按 kbPath 隔离,retrieval 直接复用,无需自己维护
### 5.2 `parseExplicitPageRefs`
签名:
```text
parseExplicitPageRefs(message) → string[] // 相对路径数组,如 ['wiki/synthesis/sessions/x.md']
```
**实现要点**
- 正则匹配 `\[\[(wiki\/[^\]\n]+\.md)\]\]`
- 去重,保持出现顺序
- 不验证文件是否存在(验证交给 searchKnowledgeBase
### 5.3 `shouldUseKnowledgeBase`(黑名单策略)
签名:
```text
shouldUseKnowledgeBase(message, kbSelected) → boolean
```
**实现要点**(按顺序判断,命中即返回):
1. 没选中 KB → `false`
2. 消息 trim 后以 `/` 开头(命令)→ `false`
3. 消息已含 `[[wiki/...]]`(显式引用)→ `true`(短路,必须检索)
4. 消息 trim 后字符数 < 3 → `false`
5. 命中**寒暄白名单**(精确匹配,忽略标点)→ `false`
- 集合:`你好` / `hi` / `hello` / `在吗` / `谢谢` / `thanks` / `thx` / `ok` / `好的`
6. 命中**元问询白名单**(含其一即跳过)→ `false`
- 关键词:`当前模型` / `模型是什么` / `怎么设置` / `怎么用` / `界面` / `快捷键` / `登录` / `API key`
7. 其余 → `true`(默认触发)
**关键**:本策略激进地默认触发。误触发的代价只是 token,漏触发的代价是当前观察到的 bug。
### 5.4 `buildKnowledgeContextPrompt`
签名:
```text
buildKnowledgeContextPrompt(originalMessage, kb, results) → wrappedMessage
```
**输出格式**results 非空时):
```text
{用户原始消息原样保留在最前}
---
[系统检索上下文 / 用户不可见]
当前知识库:
- 名称: {kb.name}
- 路径: {kb.path}
系统已从当前知识库检索到以下页面(按相关度排序):
[1] {results[0].title}
路径: {results[0].path}
{results[0].snippet}
[2] ...
回答约束:
- 必须基于以上页面内容回答;不得编造未在页面中出现的事实
- 如果以上页面不足以回答,明确说明"当前知识库未找到相关内容",禁止反问用户提供文章
- 回答末尾必须列出"参考页面",格式为 `- 《标题》:路径`
- 参考页面只能从上述检索结果中选取,禁止编造路径
```
**results 为空时**:仍包装一段简短上下文:
```text
{用户原始消息}
---
[系统检索上下文]
当前知识库:{kb.name}
系统已尝试检索,但未在当前知识库找到与该问题相关的页面。
请明确告诉用户"当前知识库未找到相关内容",禁止反问用户提供文章,也禁止编造来源。
```
**关键**:空结果也包装,因为弱模型一旦完全没拿到上下文就会回到"反问要文章"的失败模式。强制告诉它"找过了,没有"。
### 5.5 主对话 `/api/prompt` 改造
[index.ts:745-832](../server/src/index.ts) 当前逻辑:
```text
session.prompt(message)
```
改造后:
```text
1. 解析: explicitRefs = parseExplicitPageRefs(message)
2. 判断: shouldUse = shouldUseKnowledgeBase(message, kbSelected)
3. 若 !shouldUse → 直接 session.prompt(message)(与今日行为一致)
4. 若 shouldUse:
a. 算 totalBudgetChars = floor(activeModel.contextWindow * 0.2) || 4000
b. SSE 推送 knowledge_search_start
c. try {
results = searchKnowledgeBase(kbPath, message, { explicitRefs, totalBudgetChars })
wrapped = buildKnowledgeContextPrompt(message, kb, results)
SSE 推送 knowledge_search_done { count: results.length, paths: results.map(r => r.path) }
写日志(见 §5.7)
session.prompt(wrapped)
} catch (err) {
SSE 推送 knowledge_search_error { message: err.message }
写日志
session.prompt(message) // 静默降级
}
```
**每轮 user turn 独立判断 + 检索**。不缓存判断结果。
### 5.6 失败降级
任何一环抛异常(IO 失败、parseExplicitPageRefs throw、buildKnowledgeContextPrompt 字符数爆掉):
- 不中断 prompt
- 降级为裸 `session.prompt(message)`
- SSE 推 `knowledge_search_error` 事件,前端在状态栏轻量显示"知识库检索失败,已按普通对话处理"
- 日志(见 §5.7)写完整 error stack
### 5.7 检索日志
写入 `~/.llm-wiki-agent/logs/retrieval/<YYYY-MM-DD>.jsonl`,每行:
```json
{
"ts": 1735000000000,
"sessionId": "abc12345",
"kbPath": "~/my-kb",
"messagePreview": "这是我自媒体创作的文章...",
"triggered": true,
"explicitRefs": [],
"results": [{"path": "wiki/synthesis/sessions/x.md", "hitReason": "recent_synthesis", "score": 3.2}],
"wrappedCharCount": 4823,
"error": null
}
```
**作用**:验收脚本、用户反馈复现、长期效果分析都依赖这份日志。
### 5.8 新工具 `query_knowledge_base`
在 [extensions/knowledge-base.ts](../server/src/extensions/knowledge-base.ts) 注册第三个工具:
```text
pi.registerTool({
name: 'query_knowledge_base',
parameters: { query: string },
execute: ({ query }) => {
results = searchKnowledgeBase(currentKbPath, query, { explicitRefs: [], totalBudgetChars: 4000 })
return formatted text for agent
}
})
```
**作用**:让强模型在主对话流中也能继续追问 KB;未来若改为"工具优先 + 注入降级"也只是改 `/api/prompt` 的判断条件,retrieval 函数不动。
工具描述要点:"Search the user's current knowledge base for pages relevant to a query. Use this when the user asks a follow-up question that may require pulling additional context from the knowledge base beyond what's already in the conversation."
### 5.9 前端反馈
复用现有 tool 状态显示样式。新增三个 SSE 事件渲染:
- `knowledge_search_start` → 显示"正在检索当前知识库…"
- `knowledge_search_done { count, paths }` → 显示"已检索到 N 个相关页面"
- `knowledge_search_empty`count=0 时)→ 显示"当前知识库未找到相关内容"
- `knowledge_search_error` → 显示"知识库检索失败,已按普通对话处理"
不新增大面板。
## 6. API 与模块边界
### 6.1 新增文件
- `server/src/retrieval.ts`:导出 `searchKnowledgeBase` / `parseExplicitPageRefs` / `shouldUseKnowledgeBase` / `buildKnowledgeContextPrompt`
- 单测 `server/src/retrieval.test.ts`:触发策略、`[[...]]` 解析、空 KB、cache 隔离、budget 控制
### 6.2 复用与改动
- **复用** [pages.ts](../server/src/pages.ts) 的 `getCachedPages` —— retrieval 在其上加片段提取与扩展 score
- **改动** [index.ts](../server/src/index.ts) 的 `/api/prompt`:按 §5.5 改造,新增 SSE 事件类型
- **改动** [extensions/knowledge-base.ts](../server/src/extensions/knowledge-base.ts):新增 `query_knowledge_base` 工具
- **不动**:digest/ 目录全部(子代理批量消化不受影响)
### 6.3 不新增对外 HTTP API
第一版只在 `/api/prompt` 内部使用。
## 7. 验收标准
### 7.1 客观可查标志(**所有验收的硬底线**)
每条主对话验收必须同时满足:
1. **日志可查**`~/.llm-wiki-agent/logs/retrieval/*.jsonl` 中存在对应记录,`triggered` / `results` / `wrappedCharCount` 字段符合预期
2. **字符串命中**:模型回答中至少出现一次检索结果的页面标题或独特短语(grep 可验)
无法同时通过这两条的验收一律不算过。
### 7.2 检索函数 `searchKnowledgeBase`
1. 对"总结这些文章"类问题,能命中 `wiki/synthesis/sessions/` 下最新页面
2. 对具体关键词(如 `OpenClaw`),能命中标题或正文相关页面
3. 返回结果含 path / title / snippet / mtime / hitReason / score
4. 默认 maxPages=6,可配置
5. 单页 snippet 不超过 snippetMaxChars
6. 总字符数不超过 totalBudgetChars
7. explicitRefs 即使超 budget 也强保留(可截断片段)
8. 空 KB 返回 `[]`,不抛错
9. 同一 query 在 cache 未失效时多次调用,结果一致
10. 批量消化新文件后,调用 pages.ts cache 失效路径,下一次检索能拿到新页
### 7.3 `parseExplicitPageRefs`
1. `这是什么 [[wiki/synthesis/sessions/x.md]]``['wiki/synthesis/sessions/x.md']`
2. `[[wiki/a.md]] 和 [[wiki/b.md]]``['wiki/a.md', 'wiki/b.md']`
3. `[[wiki/a.md]] [[wiki/a.md]]``['wiki/a.md']`(去重)
4. `普通文本无引用``[]`
5. `[[notwiki.md]]``[]`(必须以 wiki/ 开头)
### 7.4 `shouldUseKnowledgeBase`(黑名单)
1. 未选 KB → false
2. `/sediment` / `/pdf` → false
3. `你好` / `谢谢` / `ok` → false
4. `当前模型是什么` → false
5. `[[wiki/x.md]] 说了啥` → true(含显式引用,短路)
6. `总结一下` → true
7. `这是我自媒体创作的文章,总结一下` → true(本 bug 的原始 case
8. `OpenClaw 相关页面讲了什么` → true
9. 长度 < 3 → false(如 `嗯`
### 7.5 `/api/prompt` 端到端
1. 触发路径:SSE 顺序为 `knowledge_search_start → knowledge_search_done → text_delta* → done`
2. 不触发路径:SSE 不出现任何 knowledge_search_* 事件
3. 触发但检索抛错:SSE 出现 `knowledge_search_error`,但 `text_delta` 仍正常流出(裸 prompt 降级)
4. 注入字符数符合模型 contextWindow * 20% 上限
5. 模型回答末尾出现"参考页面"列表,所有路径都在 `results.paths` 中(禁止幻觉路径)
6. 多轮:第 1 轮触发后第 2 轮再问新话题,第 2 轮独立判断 + 独立检索
### 7.6 `@` 显式引用
1. `@A` 选中后输入框变成 `[[wiki/.../A.md]]`,发送后 A 在 results 首位(hitReason='explicit'
2. `[[wiki/.../A.md]] [[wiki/.../B.md]] 对比` → results 前两个是 A 和 B
3. `[[wiki/.../A.md]] 再结合全库看看` → results[0]=A,后续是自动检索补充
4. `[[wiki/不存在.md]] 说了啥` → results 中无该路径,buildKnowledgeContextPrompt 在末尾加一行"用户引用了 wiki/不存在.md,但该页面不存在"
### 7.7 切库隔离
1. A 库批量消化后,问"总结" → results 全部属于 A 库
2. 切到 B 库(B 库为空)→ 问"总结" → results 为空 / 模型说"未找到"
3. 切回 A 库 → 问"总结" → 仍能命中 A 库的页面
### 7.8 失败降级
1. mock fs read 失败 → SSE 推 error 事件、回答仍能流式输出、日志写 error stack
2. 注入字符数超 contextWindow → 自动截断到 budget 内,不抛错
3. shouldUseKnowledgeBase 抛错(不应发生,但兜底)→ 走裸 prompt
## 8. 端到端验收剧本
### 场景 1:批量消化后总结(本 bug 原始 case)
1. 选择普通目录 → 初始化为 KB → 批量消化 7 篇文章
2. 进入该 KB
3. 问:"这是我自媒体创作的文章,总结一下。"
**必须结果**
- 不反问用户要文章
- 回答覆盖多篇文章主题
- 末尾"参考页面"列出 ≥ 3 篇 sessions/ 下的页面
- 日志 jsonl 中 `triggered=true``results.length ≥ 3`
- 模型回答中 grep 到至少 1 个 results.paths 中页面的标题
### 场景 2:具体主题检索
1. 同一库问:"OpenClaw 相关的文章讲了什么?"
**必须结果**
- results 中至少 1 篇含 `OpenClaw`
- 回答基于这些页面,无幻觉
### 场景 3:切库隔离
1. 切到另一个 KB(已知不含相关内容)
2. 问:"这些文章总结一下。"
**必须结果**
- results 为空 or 全部属于新 KB
- 模型明确说"当前知识库未找到相关内容"
- 不出现上一 KB 的页面路径
### 场景 4`@` 显式引用
1. 输入 `@某篇文章 总结它的传播亮点`(前端会替换为 `[[wiki/.../某篇.md]] 总结它的传播亮点`
**必须结果**
- results[0] 是该指定页面
- 自动检索补充 ≤ 3 篇
- 回答主要基于指定页面
### 场景 5:普通聊天不检索
1. 问:"你好。"
2. 问:"当前模型是什么?"
3. 输入 `/sediment`
**必须结果**
- 三次都不触发 knowledge_search_* 事件
- 日志 jsonl 中 `triggered=false`
### 场景 6:多轮切话题
1. 第 1 轮:"总结一下这批文章"
2. 第 2 轮(在同一对话):"其中讲 AI 的那篇具体说了什么?"
**必须结果**
- 两轮独立检索,各自有日志记录
- 第 2 轮 results 与第 1 轮不必相同(query 变了)
### 场景 7:失败降级
1.`chmod 000` 临时让 KB 某文件不可读
2. 问:"总结一下"
**必须结果**
- SSE 出现 `knowledge_search_error`
-`text_delta` 仍正常输出
- 日志 jsonl 中 `error` 字段非空
- 前端状态栏短暂显示"知识库检索失败,已按普通对话处理"
## 9. 实施顺序(codex 起手必读)
| 步骤 | 任务 | 验收 |
|---|---|---|
| 1 | 新建 `server/src/retrieval.ts` + 单测;实现 `parseExplicitPageRefs` / `shouldUseKnowledgeBase` | §7.3 §7.4 全过 |
| 2 | 在 retrieval.ts 实现 `searchKnowledgeBase`(复用 pages.ts `getCachedPages` | §7.2 全过 |
| 3 | 实现 `buildKnowledgeContextPrompt` + 日志写入 | 单测覆盖空 results / 含 explicit / 含 error 三种形态 |
| 4 | 改造 [index.ts](../server/src/index.ts) `/api/prompt`:增加判断 + 调用 + SSE 新事件 + 失败降级 | §7.5 §7.8 全过 |
| 5 | 在 [extensions/knowledge-base.ts](../server/src/extensions/knowledge-base.ts) 注册 `query_knowledge_base` 工具 | 单测:工具签名正确、execute 返回格式符合 §5.8 |
| 6 | 前端新增 3 个 SSE 事件渲染(knowledge_search_start/done/error | 手动验:剧本 1 能看到检索状态 |
| 7 | 跑完 §8 全部 7 个端到端剧本 | 所有剧本通过 §7.1 双重客观标志 |
每步动手前先在对话里说"准备改 X,影响 Y",作者确认后再动。
## 10. 风险与边界
### 10.1 模型不读注入内容
弱模型(glm 等)可能拿到注入仍反问。
- 缓解:§5.4 wrappedMessage 末尾加强约束句("如未基于以下页面回答而再次反问用户提供文章,视为错误")
- 监控:日志可统计"注入了但模型反问"的发生率
### 10.2 cache 失效窗口
批量消化刚跑完、pages.ts cache 还指向旧 fingerprint。
- 缓解:pages.ts 已用 mtime+size fingerprint,每次 retrieval 都会触发重新扫描
- 验收:场景 1 必须在批量消化结束后立即问(不等手动刷新)
### 10.3 切库 cache 串库
pages.ts cache 按 kbPath 隔离,retrieval 复用,不会串库。
- 验收:场景 3 必须通过
### 10.4 检索误触发
黑名单策略激进。
- 缓解:寒暄白名单 + 元问询白名单 + `/` 命令短路 + 长度 < 3 短路
- 监控:日志可统计触发率,过高时回头收紧
### 10.5 来源不真实
模型可能编造路径作为来源。
- 缓解:§5.4 prompt 中强约束"参考页面只能从上述检索结果中选取"
- 验收:§7.5.5 grep 检验回答中所有路径都在 results.paths 中
### 10.6 与 `@` 心智冲突
用户老心智:"想引用必须 @"。新行为:"不 @ 系统也可能引用"。
- 缓解:§5.9 前端轻量状态显示让用户知道何时检索了
- 长期:如果用户反馈混乱,提供"关闭自动检索"开关(本轮不做)
### 10.7 中文路径 / emoji
PRODUCT.md §6.6 UTF-8 铁律。
- retrieval 所有字符串处理用 raw UTF-8,不 normalize
- cache key 用原始 absolute path
### 10.8 多轮成本
每轮都检索 = 每轮多 100-300ms IO。
- 本地 grep + cache 命中下可接受
- 监控:日志记 retrieval 耗时,> 1s 时考虑优化
## 11. 本轮不做
- 不引入向量数据库
- 不引入新 npm 包
- 不做复杂搜索 UI
- 不把整个知识库塞进每次 prompt
- 不把批量消化结果写进聊天历史
- 不改变 `@` / `/` / 子代理批量消化的现有心智
- 不动 digest/ 目录任何代码
- 不做"关闭自动检索"开关(等用户反馈再说)
## 12. ADR 影响
本设计**正面破例** [PRODUCT.md ADR-7](../PRODUCT.md)"知识库上下文用 Extension 注入,不拼 prompt")。需要在 PRODUCT.md 第 7 节追加:
### ADR-19:主对话引入"系统检索 + 上下文注入"
**背景**ADR-7 设计的"靠 Extension 工具让 agent 自觉调用"在阶段 3.5 批量消化后的真实场景下不稳——弱模型不调 `list_knowledge_base_pages` / `read`,直接反问用户提供文章。
**决策**
1. 主对话 `/api/prompt` 路径破例采用"后端检索 + 拼隐藏上下文"模式
2. ADR-7 的"应用状态用 Extension 注入"原则在 `currentKnowledgeBase` 等状态查询上仍然成立,本破例只针对"问答类知识库检索"
3. 同一份 `searchKnowledgeBase` 同时暴露为新工具 `query_knowledge_base`,保留 ADR-7 路径供未来强模型使用
4. 子代理批量消化(digest/ 目录)不受影响,继续走裸 prompt + only read tool
5. 主对话流式输出过程中,每个 user turn 独立判断 + 独立检索,不跨轮缓存
6. 失败降级为裸 promptSSE 推 error 事件 + 写日志,绝不中断对话
**与既有 ADR 的关系**
- 破例 **ADR-7**:仅限主对话问答检索,状态查询工具保留
- 兼容 **ADR-3**SSE):新增 3 个轻量事件类型
- 兼容 **ADR-16**Skill 优先 / agent 元能力用 Extension):检索是 agent 工作台元能力,落 server/ 端合理
- 兼容 **ADR-18**(子代理路径):不影响 digest/ 目录
**何时重新评估**
- 主流模型工具调用稳定性显著提升 → 考虑改回纯工具路径
- 用户大量反馈"参考页面被编造" → 强化 prompt 约束 + 引入后置校验
- KB 规模 > 100 篇时检索耗时不可接受 → 引入向量检索
## 13. 完成情况
本设计已在阶段 3.5 收尾中落地。
- `/api/prompt` 已接入当前知识库自动检索;每轮用户消息独立判断,触发时推送 `knowledge_search_start` / `knowledge_search_done` / `knowledge_search_empty` / `knowledge_search_error`
- `query_knowledge_base` 工具已注册,和主对话预检索共用同一套检索函数
- 检索结果复用 `pages.ts` 的缓存与扫描路径,覆盖 `wiki/synthesis/sessions/``purpose.md``index.md``wiki/overview.md`
- `[[wiki/...]]` 显式引用会优先进入结果;缺失页面会进入包装提示,不静默吞掉
- 检索日志写入 `~/.llm-wiki-agent/logs/retrieval/<YYYY-MM-DD>.jsonl`
- 普通寒暄、`/` 命令、模型/设置类问题、导出产物指令不会触发知识库检索
**验收实况**
- `node --import tsx --test server/src/retrieval.test.ts server/src/digest/concurrency.test.ts` 通过
- `npm run --silent typecheck` 通过
- 真实接口验证通过:`这是我自媒体创作的文章,总结一下` 触发检索并返回参考页面;`你好` 不触发检索;导出 PDF 指令不触发检索