docs: 添加 Knowledge Core 与 Retrieval Core PR 审阅问题与修复复盘文档
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。 > 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。 详细记录了 10 个关键问题的分析与解决方案: - P-01:folder 路径逃逸问题及安全校验 - P-02 与 P-03:伪 upsert 和索引部分提交问题 - P-04:失效向量残留清理机制 - P-05:PATCH tags 语义错误修复 - P-06:候选池、Metadata Filter 与分页优化 - P-07:rebuild 请求语义与失败恢复 - P-08:index_meta 事务一致性保证 - P-09:POST 同路径静默覆盖防护 - P-10:删除操作部分提交问题 包含完整的测试策略和可复用的工程经验总结。
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
# Knowledge Core 与 Retrieval Core:PR 审阅问题与修复复盘
|
||||
|
||||
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
|
||||
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。
|
||||
|
||||
## 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-01:folder 路径逃逸
|
||||
|
||||
### 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
|
||||
HTTP:500
|
||||
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-05:PATCH 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-07:rebuild 请求语义与失败恢复
|
||||
|
||||
### 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-09:POST 同路径静默覆盖
|
||||
|
||||
### 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/Knowledge与Retrieval-Core开发说明.md 模块开发说明
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user