Files
NotesAgentic/docs/retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md
T

504 lines
18 KiB
Markdown
Raw 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.
# Knowledge Core 与 Retrieval CorePR 审阅问题与修复复盘
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。
> 2026-08-30 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 71 项通过,Knowledge/Retrieval 已通过 `notes.*` 与 `rag.search` Tool 接入 Agent Runtime。
## 1. 背景
Knowledge Core 与 Retrieval Core 建立了项目第一条完整的本地知识检索链路:
```text
Markdown Vault
→ Markdown Block 解析
→ SQLite 元数据 / FTS5 / sqlite-vec
→ FTS 与向量双路召回
→ RRF 融合与轻量 Reranker
→ Metadata Filter
→ Citation 与 Snippet
```
功能分支完成后进行了两轮审阅。第一轮主要检查路径安全、索引一致性、PATCH 语义、检索分页和重建行为;第二轮重点验证第一轮修复是否真正覆盖事务失败和大数据边界。
审阅不是只看现有测试是否通过,而是主动构造失败条件,例如:
- 让 embedding、向量删除或索引元信息写入主动抛出异常;
- 创建同目录、同标题的重复笔记;
- 使用 `../`、Windows 盘符等恶意 folder
- 制造超过候选池以及超过 1000 条的 FTS 命中;
- 比较 `blocks``vec_blocks` 的 ID 集合;
- 在接口报错后重新读取 Markdown、SQLite 和向量表,检查是否发生部分提交。
## 2. 审阅与修复时间线
| 阶段 | 提交 | 内容 |
| --- | --- | --- |
| 功能分支首轮修复 | `8771745` | 路径逃逸、更新部分提交、失效向量和基础分页 |
| 功能分支二次修复 | `6cf531f` | 跨表事务、Metadata Filter、PATCH tags、rebuild 回滚 |
| 合并提交 | `9348225` | 将 Knowledge/Retrieval Core 合并到 `main` |
| 合并后第一批修复 | `af8ccb0` | `index_meta` 事务一致性、重复创建冲突 |
| 合并后第二批修复 | `32f249c` | 删除一致性、FTS 精确过滤与分页 |
最终回归测试结果:
```text
43 passed
```
## 3. 问题总览
| 编号 | 问题 | 主要风险 | 最终处理 |
| --- | --- | --- | --- |
| P-01 | folder 可逃逸 Vault | 任意位置写入或删除 Markdown | 路径标准化,并校验 resolve 后仍位于 Vault |
| P-02 | VectorStore 的 upsert 实际为 INSERT | 正常 PATCH 主键冲突 | delete-then-insert,实现幂等 upsert |
| P-03 | 元数据与向量分开提交 | 接口失败但数据库已部分改变 | 共用连接和 SQLite 事务 |
| P-04 | 更新正文后遗留旧向量 | Top-K 被无效向量占据 | 比较新旧 Block ID,事务内清理失效向量 |
| P-05 | PATCH tags 语义错误 | 省略字段却清空标签,空数组无法清空 | 严格区分 `None``[]` |
| P-06 | 固定候选池破坏过滤与分页 | 合法结果漏召回,total 不准确 | 先扩充候选,最终改为 FTS SQL 侧过滤和分页 |
| P-07 | rebuild 忽略 scope/note_ids 且先清空 | 请求语义错误,失败留下半成品 | 拒绝未支持范围,扫描在前,备份与失败恢复 |
| P-08 | `index_meta` 在主事务外写入 | 文件回滚但索引已更新 | 将 `index_meta` 纳入同一事务 |
| P-09 | POST 同路径静默覆盖 | 原笔记无提示丢失 | 数据库预检 + 文件排他创建 + 409 |
| P-10 | 删除由三个独立操作组成 | notes、向量和文件互相不一致 | tombstone + SQLite 同事务 + 失败恢复 |
## 4. P-01folder 路径逃逸
### 4.1 问题原因
最初的笔记路径由下面的逻辑生成:
```python
folder_part = folder.strip().strip("/")
rel_path = f"{folder_part}/{safe_title}.md"
absolute_path = vault / rel_path
```
代码只去除了 folder 两端的斜杠,没有处理:
- `..``.` 路径段;
- Windows 反斜杠;
- `C:\...` 等盘符路径;
- NUL 字节;
- 符号链接或规范化后越过 Vault 的路径。
因此 `folder="../outside"` 会使最终路径落到 Vault 之外。
### 4.2 后果
攻击者或错误的前端输入可以在 Vault 外创建 Markdown。数据库会保存这个越界路径,后续读取和删除接口还会继续访问该文件,风险从“越界写入”扩大为“越界读取和删除”。
### 4.3 解决思路
路径安全不能只依赖字符串替换,需要同时完成两层校验:
1. 输入层拒绝明显非法的路径片段;
2. 文件系统层对最终路径执行 `resolve()`,确认它仍属于 Vault。
### 4.4 最终方案
`note_service.py` 新增 `_normalize_folder()` 和安全的 `_abs_path()`
- 同时按 `/``\` 拆分目录;
- 拒绝 `.``..`、盘符和 NUL
- 对 Vault 与目标执行 `resolve()`
- 使用 `Path.is_relative_to()` 验证目标未逃逸;
- 所有读、写、删操作统一经过 `_abs_path()`
回归测试覆盖 `../../outside``..\..\etc``C:\Windows``a/../b` 等输入。
## 5. P-02 与 P-03:伪 upsert 和索引部分提交
### 5.1 问题原因
Block ID 根据 `note_id + heading_path + content` 稳定生成。只修改标题或标签时,正文 Block ID 不会改变。
最初 `SqliteVecStore.upsert()` 名为 upsert,实际执行的却是普通 INSERT:
```sql
INSERT INTO vec_blocks (block_id, embedding) VALUES (?, ?)
```
同一个 Block 再次写入时会触发 sqlite-vec 主键冲突。同时,元数据和 FTS 已经在另一个事务中提交,向量失败无法回滚前面的修改。
### 5.2 后果
一次仅修改标题的 PATCH 就可能出现:
```text
HTTP500
Markdown:已写入
notes / blocks / FTS:已更新
vec_blocks:写入失败
```
调用方看到的是“更新失败”,系统内部却已经改变。这类部分提交比直接失败更危险,因为客户端重试可能继续扩大不一致。
### 5.3 解决思路
- upsert 必须具备幂等性;
- notes、blocks、FTS 与 vec_blocks 都在同一个 SQLite 文件内,应共用连接和事务;
- Repository 和 VectorStore 既要支持独立调用,也要支持加入调用方事务。
### 5.4 最终方案
VectorStore 的 upsert 改为:
```text
DELETE existing block_id
→ INSERT new embedding
```
`repository.replace_note_metadata()``vector_store.upsert()``vector_store.delete()` 都可接收外部连接。`index_note()` 打开一个连接和事务,把元数据、FTS、失效向量删除、新向量插入放在同一提交边界中。
## 6. P-04:失效向量残留
### 6.1 问题原因
更新正文后,Repository 会删除旧 Block 并插入新 Block,但早期实现只写入新向量,没有删除内容已经不存在的旧向量。vec0 没有到 blocks 表的外键约束,因此这些向量不会自动级联删除。
### 6.2 后果
实测一次全文更新后出现:
```text
blocks = 1
vec_blocks = 2
```
旧向量仍会参加 KNN 排名,但随后无法取得 Block 上下文。反复编辑后,失效向量可能占满 Top-K,使真正有效的结果无法进入候选集。
### 6.3 解决思路与方案
更新前取得旧 `block_id`,解析后得到新 `block_id`,计算集合差:
```text
stale = old_ids - new_ids
missing = new_ids - old_ids
```
- 删除 `stale` 对应向量;
- 只为 `missing` 写入新向量;
- 未变化 Block 沿用原向量;
- 整个过程与 Block 替换使用同一事务。
测试以集合不变量验证修复:
```text
set(vec_blocks.block_id) == set(blocks.block_id)
```
## 7. P-05PATCH tags 语义错误
### 7.1 问题原因
解析器曾使用真值判断:
```python
resolved_tags = list(tags) if tags else parse_frontmatter_tags()
```
但 PATCH 中的 `None``[]` 含义不同:
- `None`:客户端没有提交 tags,应保留原值;
- `[]`:客户端明确要求清空标签。
真值判断把两种状态混为一谈。另外,更新服务直接把 `None` 传给解析器,导致原有 API 标签被 frontmatter 或空数组替换。
### 7.2 后果
- 仅修改标题也会意外清空标签;
- 显式提交空数组时,反而可能重新读取 frontmatter 标签,无法清空。
### 7.3 最终方案
解析器改为判断 `tags is not None`。更新服务根据 PATCH 语义计算有效标签:
```python
effective_tags = record.tags if tags is None else tags
```
回归测试分别覆盖省略、替换和清空三种情况。
## 8. P-06:候选池、Metadata Filter 与分页
### 8.1 第一阶段问题
初版 FTS 和向量召回都固定只取 50 条候选,然后才执行 Metadata Filter 和分页:
```text
Top 50 → Metadata Filter → offset/limit
```
这会产生两个错误:
1. 第 51 条以后的页面永远无法访问;
2. 满足 folder/tag 条件的结果如果排在第 51 条以后,会被错误地过滤掉。
实测 60 个匹配 Block 请求 `offset=50` 时,响应为 `total=50, items=[]`
### 8.2 中间修复为什么仍不完整
第一轮修复根据 `offset + limit` 扩大候选池,并在有过滤条件时 overscan;FTS 模式则把上限扩大到 1000。
这解决了常见的小数据场景,但本质只是把硬边界从 50 移到了 1000。第二轮实测 1010 个匹配 Block,请求 `offset=1000` 时仍得到:
```json
{"total": 1000, "items": []}
```
### 8.3 最终解决思路
全文检索分页不应先把所有结果加载到 Python 再切片,应由数据库完成:
```text
FTS MATCH
+ Metadata WHERE
→ COUNT(*) 得到精确 total
→ ORDER BY bm25
→ LIMIT / OFFSET
```
### 8.4 最终方案
Repository 新增 FTS 精确分页查询:
- JOIN `blocks``notes`
- folder 和 note_id 使用 SQL `IN`
- tags 使用 `json_each()`
- 时间范围使用 `julianday()` 比较;
- 相同过滤条件分别用于 COUNT 和数据页查询;
- `RetrievalEngine` 为纯 FTS 模式使用专用查询路径。
最终测试确认 1010 个匹配 Block 在 `offset=1000, limit=10` 时返回:
```text
total = 1010
items = 10
```
向量与 Hybrid 模式仍属于 Top-K 近邻召回,其 `total` 表示候选集数量,不等价于全文数据库的全局命中数。这是检索模型本身的语义差异,需要在接口文档和前端展示中保持明确。
## 9. P-07rebuild 请求语义与失败恢复
### 9.1 问题原因
接口模型公开了 `scope=all/notes/vectors``note_ids`,但最初所有请求都会无条件清空全部索引并全量扫描 Vault。旧索引在扫描和 embedding 之前就被删除,中途失败会留下空库或半成品。
### 9.2 后果
- 调用方请求只重建向量,实际却清空 notes 和 FTS;
- 指定 note_ids 仍触发全量重建;
- 单个损坏文件或 embedding 异常即可破坏原本可用的索引。
### 9.3 最终方案
当前 MVP 明确只支持全量重建:
-`scope="all"` 或非空 `note_ids` 返回 `400 UNSUPPORTED_SCOPE`
- 清理旧索引前先把 Vault 文件全部读取到内存,读取失败不影响旧索引;
- 重建前备份 SQLite 文件;
- 中途失败时恢复备份,并记录 failed Job;
- 成功或失败后清理临时备份。
`force` 当前仍是预留字段,没有行为差异。后续实现增量重建时,应一起明确它的契约,避免再次出现“参数被接受但没有效果”。
## 10. P-08`index_meta` 位于主事务之外
### 10.1 问题如何被复审发现
第一轮修复已经把 notes、blocks、FTS 和向量放进同一事务,看起来解决了原子性问题。但 `index_meta` 仍在该事务提交后,通过新连接单独写入。
通过故障注入让 `set_index_meta()` 抛出异常后发现:
```text
主索引事务:已经提交新内容
index_meta:失败
update_note:捕获异常,恢复旧 Markdown
最终结果:Markdown 是旧内容,索引是新内容
```
这说明“把主要写操作放进事务”还不够,只要事务外仍有能够决定接口成功或失败的步骤,部分提交依然存在。
### 10.2 最终方案
`repository.set_index_meta()` 增加可选连接参数:
- 独立调用时自行开连接、开事务并提交;
- 收到外部连接时加入已有事务,不自行提交和关闭。
`index_note()` 在主事务退出前写入 `index_meta`。现在任何一步失败都会回滚全部 SQLite 修改,外层再恢复 Markdown,从而保持文件与索引一致。
## 11. P-09POST 同路径静默覆盖
### 11.1 问题原因
笔记的相对路径和 note_id 都由 `folder + title` 确定。创建接口写文件前没有检查同路径资源是否存在,Repository 又使用 `ON CONFLICT DO UPDATE`,因此第二次 POST 会被当作更新执行。
### 11.2 后果
连续创建同目录、同标题笔记时:
```text
第一次:original
第二次:replacement
note_id:相同
最终正文:replacement
```
原笔记没有任何冲突提示就被覆盖。更严重的是,早期失败回滚逻辑会在第二次索引失败时删除目标文件,连第一篇原文也一并丢失。
### 11.3 解决思路与方案
创建和更新必须具有不同语义:
- POST 只创建新资源,冲突时返回 409;
- PATCH 才允许修改已有资源。
最终实现包含双重保护:
1. 根据稳定 note_id 查询 SQLite,存在则返回 `409 RESOURCE_CONFLICT`
2. 使用 `Path.open("x")` 排他创建文件,处理数据库检查与文件写入之间的并发竞争。
只有本次请求确实创建了新文件,后续索引失败时才会删除它,因此不会误删旧文件。
## 12. P-10:删除操作部分提交
### 12.1 问题原因
早期删除流程是三个独立步骤:
```text
提交 notes / blocks / FTS 删除
→ 单独提交向量删除
→ 删除 Markdown
```
如果第二步向量删除失败,第一步无法回滚,第三步不会执行。
### 12.2 后果
故障注入后的状态为:
```text
notes:已删除
blocks / FTS:已删除
vec_blocks:仍存在
Markdown:仍存在
HTTP:失败
```
系统无法通过普通读取接口找到笔记,但文件和孤立向量仍在,调用方也无法判断是否应该重试。
### 12.3 解决思路
SQLite 内的四类数据可以使用同一事务;Markdown 文件不能加入 SQLite 事务,因此需要补偿事务。直接先删文件不利于恢复,先提交数据库又会在文件删除失败时不一致,所以采用可恢复的 tombstone:
```text
Markdown 原子 rename 为非 .md tombstone
→ 同一 SQLite 事务删除 metadata / blocks / FTS / vectors
→ 成功:删除 tombstone
→ 失败:rollback SQLite,并把 tombstone rename 回原路径
```
### 12.4 最终方案
- `repository.delete_note()``vector_store.delete()` 都支持外部连接;
- `note_service.delete_note()` 负责建立统一事务;
- 原文件先移动为同目录下带随机 UUID 的 `.deleting` 文件;
- SQLite 失败时恢复原文件;
- SQLite 成功后清理 tombstone
- tombstone 不以 `.md` 结尾,因此即使最终清理因系统原因失败,也不会被 Vault 扫描器重新索引。
回归测试通过让向量删除主动失败,确认 notes、Block、向量和 Markdown 全部恢复到删除前状态。
## 13. 测试策略
这次审阅新增的测试不只验证成功路径,还验证跨层不变量和失败恢复。
### 13.1 路径安全
- `test_create_note_rejects_path_traversal`
### 13.2 写入与回滚
- `test_update_note_rolls_back_file_on_index_error`
- `test_update_note_rolls_back_index_when_index_meta_fails`
- `test_create_note_rejects_existing_path_without_overwrite`
- `test_delete_note_rolls_back_when_vector_delete_fails`
### 13.3 向量一致性
- `test_update_removes_stale_vectors`
- `test_patch_partial_content_no_orphan_vectors`
### 13.4 PATCH 契约
- `test_patch_tags_semantics`
### 13.5 检索过滤与分页
- `test_search_pagination_total_reflects_all_matches`
- `test_fts_pagination_is_not_truncated_at_one_thousand`
- `test_fts_metadata_filter_recalls_beyond_candidate_pool`
### 13.6 rebuild
- `test_rebuild_rejects_unsupported_scope_and_note_ids`
- `test_rebuild_failure_restores_old_index`
测试运行方式:
```powershell
cd backend
uv run pytest -q
```
## 14. 可复用的工程经验
### 14.1 函数名不能替代语义验证
一个函数叫 `upsert`,不代表它真的具备幂等更新能力。审阅时应继续检查底层 SQL、唯一键行为和重复调用结果。
### 14.2 原子性要覆盖完整的成功判定路径
只把主要数据表放进事务还不够。日志、索引版本、元信息等后续步骤如果仍会让接口失败,也必须加入事务,或者被设计为不影响主操作结果。
### 14.3 文件系统与数据库需要补偿事务
SQLite 可以回滚,文件系统通常不能参与数据库事务。跨两种存储介质时,可采用临时文件、原子 rename、tombstone 和失败恢复构造可补偿流程。
### 14.4 PATCH 必须区分“未提供”和“显式为空”
`None`、空列表、空字符串和缺失字段经常具有不同业务含义。使用简单真值判断容易破坏部分更新语义。
### 14.5 固定扩大候选池不是分页方案
把上限从 50 调到 1000 只能推迟问题。只要 API 允许更大的 offset,硬截断就会再次暴露。能由数据库完成的过滤、计数和分页,应尽量下推到数据库。
### 14.6 回归测试应验证失败后的状态
只断言“抛出了异常”无法证明回滚正确。异常后还应重新读取每个存储层,检查文件、元数据、FTS 和向量是否满足不变量。
## 15. 后续可以继续改进的方向
- 为 rebuild 使用临时数据库或 SQLite Backup API,成功后原子切换,替代直接复制数据库文件;
- 明确并实现 `force``scope``note_ids` 的完整语义;
- 为 tombstone 增加启动时清理或恢复机制;
- 对并发创建、更新、删除增加集成测试;
- 明确 FTS 的精确 total 与 Vector/Hybrid 候选 total 在 API 层的差异;
- 建立更大规模的检索 Benchmark,持续观察召回率、延迟和索引体积;
- 在服务层引入结构化日志,记录回滚、tombstone 清理失败和 rebuild 恢复结果。
## 16. 相关文件
```text
backend/app/services/note_service.py Note CRUD、文件补偿与索引编排
backend/app/repository.py SQLite 元数据、FTS 查询与事务接入
backend/app/retrieval/vectorstore.py sqlite-vec 幂等写入和删除
backend/app/retrieval/engine.py FTS/Vector/Hybrid 检索编排
backend/app/services/index_service.py 全量重建与失败恢复
backend/app/knowledge/parser.py Block ID 与 tags 解析语义
backend/tests/test_retrieval.py 审阅回归测试
docs/development/Knowledge与Retrieval-Core开发说明.md 模块开发说明
```