Feat/knowledge retrieval core #1

Closed
yxx wants to merge 0 commits from feat/knowledge-retrieval-core into main
Collaborator

概述

实现 AI 笔记的 Knowledge Core 与 Retrieval Core 后端能力:笔记(Note)的创建 / 读取 / 更新 / 删除,Markdown 到 Block 的切分与稳定 ID,SQLite + FTS5 全文索引,sqlite-vec 向量检索,以及 FTS / Vector / Hybrid 三模式混合检索(RRF 融合 + Reranker),返回可定位的 Citation。

核心能力

  • 笔记Note CRUD(/api/notes),Markdown 文件为持久化载体(Vault),SQLite / FTS5 / 向量为可重建索引。
  • Block 切分:标题行独立成块、正文按空行分段,记录 start_offset / end_offset 用于 Citation 定位;note_id / block_id 由内容稳定派生。
  • FTS5unicode61 + 自定义 CJK 分词(单字 + 双字 bigram)。
  • 向量HashEmbeddingProvider(dim=128,确定性特征哈希 + L2 归一化)与 SqliteVecStore(sqlite-vec vec0 虚拟表)。
  • 混合检索:FTS / Vector / Hybrid 三模式,RRF(k=60)融合 + min-max 归一化 + LexicalReranker
  • Citation:每个结果返回 cit_ 前缀引用,含 note_id / block_id / file_path / heading_path / offset。
  • 索引/api/index/rebuild 全量重建,/api/index/status 查询状态。

分层

Router → Service → Repository(仅 SQLite)+ Retrieval Infra(Embedding / VectorStore / Reranker),引擎只依赖抽象接口,不拼接 vec0 内部 SQL。

两轮审阅修复

第一轮(8771745

  • 路径逃逸:folder 清洗 + Vault 边界校验(拒绝 .. / 绝对路径 / 盘符)。
  • 部分提交:创建 / 更新失败时回滚文件。
  • 失效向量:替换时清理旧 block 向量。
  • 搜索分页:total 反映真实命中数,候选池覆盖 offset+limit

第二轮(6cf531f

  • 原子性:元数据 + 向量单事务提交,避免 PATCH 半提交。
  • 向量写入:upsert 改 delete-then-insert 幂等,支持共享连接。
  • 过滤漏召回:FTS 取全量命中 + 过滤时 oversample(×4)。
  • PATCH tags 语义:None 保留 / [] 清空 / 非空替换。
  • rebuild:拒绝未实现的增量 scope / note_ids,扫描先行 + 失败回滚旧索引。

测试

backend/tests/test_retrieval.py 共 24 个用例(单元 + 端到端 + 两轮回归),全量 python -m pytest -q39 passed。测试通过 conftest.py 隔离数据目录,不污染真实索引。

待后续

  • 增量重建(scope=notes/vectorsnote_ids)目前明确返回 400 拒绝,待异步任务队列落地。
  • 笔记移动(/notes/{id}/move)仍为 501 占位,ID 随路径变化,后续保留原 ID。

🤖 Generated with Claude Code

## 概述 实现 AI 笔记的 Knowledge Core 与 Retrieval Core 后端能力:笔记(Note)的创建 / 读取 / 更新 / 删除,Markdown 到 Block 的切分与稳定 ID,SQLite + FTS5 全文索引,sqlite-vec 向量检索,以及 FTS / Vector / Hybrid 三模式混合检索(RRF 融合 + Reranker),返回可定位的 Citation。 ## 核心能力 - **笔记**:`Note` CRUD(`/api/notes`),Markdown 文件为持久化载体(Vault),SQLite / FTS5 / 向量为可重建索引。 - **Block 切分**:标题行独立成块、正文按空行分段,记录 `start_offset` / `end_offset` 用于 Citation 定位;`note_id` / `block_id` 由内容稳定派生。 - **FTS5**:`unicode61` + 自定义 CJK 分词(单字 + 双字 bigram)。 - **向量**:`HashEmbeddingProvider`(dim=128,确定性特征哈希 + L2 归一化)与 `SqliteVecStore`(sqlite-vec vec0 虚拟表)。 - **混合检索**:FTS / Vector / Hybrid 三模式,RRF(k=60)融合 + min-max 归一化 + `LexicalReranker`。 - **Citation**:每个结果返回 `cit_` 前缀引用,含 `note_id` / `block_id` / `file_path` / `heading_path` / offset。 - **索引**:`/api/index/rebuild` 全量重建,`/api/index/status` 查询状态。 ## 分层 `Router → Service → Repository(仅 SQLite)+ Retrieval Infra(Embedding / VectorStore / Reranker)`,引擎只依赖抽象接口,不拼接 vec0 内部 SQL。 ## 两轮审阅修复 ### 第一轮(`8771745`) - 路径逃逸:`folder` 清洗 + Vault 边界校验(拒绝 `..` / 绝对路径 / 盘符)。 - 部分提交:创建 / 更新失败时回滚文件。 - 失效向量:替换时清理旧 block 向量。 - 搜索分页:`total` 反映真实命中数,候选池覆盖 `offset+limit`。 ### 第二轮(`6cf531f`) - 原子性:元数据 + 向量单事务提交,避免 PATCH 半提交。 - 向量写入:`upsert` 改 delete-then-insert 幂等,支持共享连接。 - 过滤漏召回:FTS 取全量命中 + 过滤时 oversample(×4)。 - PATCH tags 语义:`None` 保留 / `[]` 清空 / 非空替换。 - rebuild:拒绝未实现的增量 `scope` / `note_ids`,扫描先行 + 失败回滚旧索引。 ## 测试 `backend/tests/test_retrieval.py` 共 24 个用例(单元 + 端到端 + 两轮回归),全量 `python -m pytest -q` → **39 passed**。测试通过 `conftest.py` 隔离数据目录,不污染真实索引。 ## 待后续 - 增量重建(`scope=notes/vectors`、`note_ids`)目前明确返回 400 拒绝,待异步任务队列落地。 - 笔记移动(`/notes/{id}/move`)仍为 501 占位,ID 随路径变化,后续保留原 ID。 🤖 Generated with [Claude Code](https://claude.com/claude-code)
yxx added 6 commits 2026-08-27 20:53:10 +08:00
Owner

审阅结论

当前版本暂不建议合并。

Knowledge Core 与 Retrieval Core 的整体分层比较清晰,Markdown Parser、SQLite/FTS5、sqlite-vec、RRF、Reranker、Citation 等链路已经跑通,分支自带的 26 个测试也全部通过。

不过进一步检查和边界测试发现,目前存在路径逃逸、笔记更新部分提交、失效向量残留、搜索分页不完整等问题。其中前两个可能造成 Vault 外文件被修改或接口报错后数据仍被部分更新,建议修复后再合并。


必须修复

1. [P1] folder 可以绕过 Vault 根目录

位置:

  • backend/app/services/note_service.py:35
  • backend/app/services/note_service.py:43

_rel_path() 只去除了 folder 两端的 /,没有拒绝 ..、Windows 绝对路径或盘符路径。随后 _abs_path() 直接执行:

_vault() / rel_path

因此 API 调用方可以构造类似

```json
{
  "title": "escaped",
  "folder": "../outside",
  "markdown": "# outside"
}

实测该请求会在 Vault 目录之外创建:

../outside/escaped.md

由于删除笔记时也使用数据库中的 file_path,对应文件之后还可以通过 DELETE 接口被删除。

建议:

  1. 禁止绝对路径、盘符路径和任何 .. 路径段;
  2. 对 Vault 和目标路径执行 resolve()
  3. 写入、读取和删除前确认目标路径仍位于 vault.resolve() 下;
  4. 把路径校验收敛到一个统一函数,避免读写路径采用不同规则。

2. [P1] VectorStore 的 upsert 实际是普通 INSERT,正常 PATCH 会失败

位置:

  • backend/app/retrieval/vectorstore.py:41
  • backend/app/retrieval/vectorstore.py:48
  • backend/app/services/note_service.py:64

SqliteVecStore.upsert() 当前执行:

INSERT INTO vec_blocks (block_id, embedding) VALUES (?, ?)

block_id 是根据 note、heading_path 和 content 稳定生成的。仅修改标题或标签时,原有 Block 的 ID 不会变化,再次插入向量会触发:

sqlite3.OperationalError:
UNIQUE constraint failed on vec_blocks primary key

实测流程:

  1. 创建一篇带标签的笔记;
  2. 只修改标题;
  3. PATCH 抛出上述异常。

更严重的是,repository.replace_note_metadata() 已在向量写入前独立提交。最终表现为:

  • PATCH 返回 500;
  • SQLite 中标题已经更新;
  • 原标签已经被清空;
  • 向量更新失败。

也就是接口虽然失败,部分状态却已经改变。

建议:

  1. 实现真正的 upsert,例如先删除对应 ID 后插入,或者使用 sqlite-vec 支持的替换方式;
  2. 更新前获取旧 Block ID,并统一处理需要保留、更新和删除的向量;
  3. 为文件、元数据和向量更新设计失败恢复机制;
  4. 至少保证向量写入失败时,不会留下已提交的新元数据;
  5. 增加“仅改标题”“仅改标签”“正文部分不变”的测试。

应当修复

3. [P2] 修改正文后旧向量不会被删除

位置:

  • backend/app/services/note_service.py:64
  • backend/app/repository.py:75

更新笔记时,replace_note_metadata() 会删除旧的 blocks 和 FTS 记录,但 index_note() 只插入新向量,没有删除旧 Block 对应的向量。

实测一次全文更新后:

blocks     = 1
vec_blocks = 2

旧向量对应的 Block 已不存在,因此 get_block_hits() 会将其丢弃。但这些失效向量仍参与 sqlite-vec 的 Top-K 检索。反复编辑后,旧向量可能占满候选池,使有效 Block 无法进入前 50,最终造成向量检索漏召回甚至返回空结果。

建议在替换 Block 前记录旧 block_id,然后:

  • 删除已经失效的向量;
  • 更新仍然存在的向量;
  • 插入新向量。

全量重建之外,也应有测试验证:

vec_blocks 中的 ID 集合 == blocks 中的 ID 集合

4. [P2] 固定 50 条候选导致分页和 Metadata Filter 不完整

位置:

  • backend/app/retrieval/engine.py:28
  • backend/app/retrieval/engine.py:50
  • backend/app/retrieval/engine.py:77
  • backend/app/retrieval/engine.py:98

FTS 和 Vector 都先固定截取:

CANDIDATE_POOL = 50

之后才执行 Metadata Filter 和分页。这会带来两个问题。

分页失效

实测创建 60 个匹配 Block 后请求:

{
  "query": "共同查询词",
  "mode": "fts",
  "limit": 10,
  "offset": 50
}

返回:

{
  "total": 50,
  "items": [],
  "offset": 50
}

实际上数据库中有 60 个匹配项,第六页应该返回 10 条。

Metadata Filter 漏召回

folder、tag、note_id 和时间过滤都在召回 50 条之后执行。如果满足过滤条件的结果排名在第 51 位以后,接口会错误地返回空结果。

建议:

  1. FTS 模式尽可能将 metadata 条件下推到 SQL;
  2. 候选池至少考虑 offset + limit
  3. Hybrid/Vector 模式使用可配置的 oversampling;
  4. 过滤后候选不足时继续扩大召回范围;
  5. 明确 page.total 表示完整命中数量还是候选池数量,目前该值容易误导前端。

5. [P2] PATCH 省略 tags 时会清空标签,显式传空数组又无法清除 frontmatter 标签

位置:

  • backend/app/services/note_service.py:108
  • backend/app/services/note_service.py:119
  • backend/app/knowledge/parser.py:58

NoteUpdateRequest.tags 是可选字段,按照 PATCH 语义,未提供时应该保留现有标签。但当前代码直接把 tags=None 传给 parse_note()

tags=tags

解析器又使用了真值判断:

resolved_tags = list(tags) if tags else _parse_tags(frontmatter.get("tags"))

因此会出现两种错误:

  1. PATCH 未提供 tags 时,原有 API 标签会被 frontmatter 标签或空数组替换;
  2. PATCH 显式提供 tags=[] 时,因为空列表为假,反而无法清除 frontmatter 中的标签。

前面的标题更新复现中,原标签 ["keep"] 在 PATCH 失败后已经变成了 []

建议:

resolved_tags = list(tags) if tags is not None else _parse_tags(...)

同时在 update_note() 中区分:

  • tags is None:保留 record.tags
  • tags == []:明确清空标签;
  • 提供非空数组:替换标签。

需要补充以上三种 PATCH 测试。


6. [P2] rebuild 接口忽略 scopenote_ids,并且失败时会留下半成品索引

位置:

  • backend/app/services/index_service.py:40
  • backend/app/services/index_service.py:42

IndexRebuildRequest 对外提供了:

scope = all / notes / vectors
note_ids
force

但当前所有请求都会执行:

repository.clear_all()
await vector_store.clear()

然后重新扫描整个 Vault。也就是说:

  • scope="vectors" 仍会清空 notes 和 FTS;
  • 指定 note_ids 不会限制重建范围;
  • force 没有实际作用;
  • 接口返回的 scope 与实际执行行为不一致。

另外,旧索引在扫描和 embedding 之前就被清空。如果中途遇到文件编码错误、SQLite 错误或 embedding 异常,系统会留下空索引或部分索引。

如果 MVP 暂时只支持全量重建,建议:

  1. 对非 scope="all" 或非空 note_ids 返回明确的 400/501;
  2. 不要接受参数后静默忽略;
  3. 先构建临时索引,成功后再切换;
  4. 或至少在失败时恢复旧索引,并保存 failed Job 状态;
  5. 增加重建失败和参数语义测试。

测试情况

分支原有测试:

26 passed in 25.91s

额外完成了以下验证:

  • 仅修改标题时复现 vec_blocks 主键冲突;
  • 确认接口失败后 SQLite 元数据仍被部分更新;
  • 确认 PATCH 省略 tags 会清空原标签;
  • 确认 folder="../outside" 可以写出 Vault;
  • 确认修改正文后旧向量仍留在 vec_blocks;
  • 确认超过 50 条结果后 offset 分页失效;
  • git diff --check 通过。

建议补充的测试

建议至少增加以下回归测试:

  1. 拒绝 ..、绝对路径和 Windows 盘符路径;
  2. PATCH 仅修改 title;
  3. PATCH 仅修改 tags;
  4. PATCH 未提供 tags 时保留原标签;
  5. PATCH tags=[] 时正确清空标签;
  6. 修改部分正文时稳定 Block 不触发向量主键冲突;
  7. 更新后不存在孤立的 vec_blocks 记录;
  8. 搜索结果超过 50 条时分页正确;
  9. metadata 目标结果位于候选池之外时仍可召回;
  10. rebuild 正确处理或拒绝 scope、note_ids;
  11. rebuild 中途失败时不会破坏现有可用索引。

整体架构和第一条检索链路已经建立起来,但上述问题涉及文件系统边界、数据一致性和搜索接口正确性,建议修复后重新审阅。

## 审阅结论 当前版本暂不建议合并。 Knowledge Core 与 Retrieval Core 的整体分层比较清晰,Markdown Parser、SQLite/FTS5、sqlite-vec、RRF、Reranker、Citation 等链路已经跑通,分支自带的 26 个测试也全部通过。 不过进一步检查和边界测试发现,目前存在路径逃逸、笔记更新部分提交、失效向量残留、搜索分页不完整等问题。其中前两个可能造成 Vault 外文件被修改或接口报错后数据仍被部分更新,建议修复后再合并。 --- ## 必须修复 ### 1. [P1] `folder` 可以绕过 Vault 根目录 位置: - `backend/app/services/note_service.py:35` - `backend/app/services/note_service.py:43` `_rel_path()` 只去除了 folder 两端的 `/`,没有拒绝 `..`、Windows 绝对路径或盘符路径。随后 `_abs_path()` 直接执行: ```python _vault() / rel_path 因此 API 调用方可以构造类似: ```json { "title": "escaped", "folder": "../outside", "markdown": "# outside" } ``` 实测该请求会在 Vault 目录之外创建: ```text ../outside/escaped.md ``` 由于删除笔记时也使用数据库中的 `file_path`,对应文件之后还可以通过 DELETE 接口被删除。 建议: 1. 禁止绝对路径、盘符路径和任何 `..` 路径段; 2. 对 Vault 和目标路径执行 `resolve()`; 3. 写入、读取和删除前确认目标路径仍位于 `vault.resolve()` 下; 4. 把路径校验收敛到一个统一函数,避免读写路径采用不同规则。 --- ### 2. [P1] VectorStore 的 `upsert` 实际是普通 INSERT,正常 PATCH 会失败 位置: - `backend/app/retrieval/vectorstore.py:41` - `backend/app/retrieval/vectorstore.py:48` - `backend/app/services/note_service.py:64` `SqliteVecStore.upsert()` 当前执行: ```python INSERT INTO vec_blocks (block_id, embedding) VALUES (?, ?) ``` 但 `block_id` 是根据 note、heading_path 和 content 稳定生成的。仅修改标题或标签时,原有 Block 的 ID 不会变化,再次插入向量会触发: ```text sqlite3.OperationalError: UNIQUE constraint failed on vec_blocks primary key ``` 实测流程: 1. 创建一篇带标签的笔记; 2. 只修改标题; 3. PATCH 抛出上述异常。 更严重的是,`repository.replace_note_metadata()` 已在向量写入前独立提交。最终表现为: - PATCH 返回 500; - SQLite 中标题已经更新; - 原标签已经被清空; - 向量更新失败。 也就是接口虽然失败,部分状态却已经改变。 建议: 1. 实现真正的 upsert,例如先删除对应 ID 后插入,或者使用 sqlite-vec 支持的替换方式; 2. 更新前获取旧 Block ID,并统一处理需要保留、更新和删除的向量; 3. 为文件、元数据和向量更新设计失败恢复机制; 4. 至少保证向量写入失败时,不会留下已提交的新元数据; 5. 增加“仅改标题”“仅改标签”“正文部分不变”的测试。 --- ## 应当修复 ### 3. [P2] 修改正文后旧向量不会被删除 位置: - `backend/app/services/note_service.py:64` - `backend/app/repository.py:75` 更新笔记时,`replace_note_metadata()` 会删除旧的 blocks 和 FTS 记录,但 `index_note()` 只插入新向量,没有删除旧 Block 对应的向量。 实测一次全文更新后: ```text blocks = 1 vec_blocks = 2 ``` 旧向量对应的 Block 已不存在,因此 `get_block_hits()` 会将其丢弃。但这些失效向量仍参与 sqlite-vec 的 Top-K 检索。反复编辑后,旧向量可能占满候选池,使有效 Block 无法进入前 50,最终造成向量检索漏召回甚至返回空结果。 建议在替换 Block 前记录旧 `block_id`,然后: - 删除已经失效的向量; - 更新仍然存在的向量; - 插入新向量。 全量重建之外,也应有测试验证: ```text vec_blocks 中的 ID 集合 == blocks 中的 ID 集合 ``` --- ### 4. [P2] 固定 50 条候选导致分页和 Metadata Filter 不完整 位置: - `backend/app/retrieval/engine.py:28` - `backend/app/retrieval/engine.py:50` - `backend/app/retrieval/engine.py:77` - `backend/app/retrieval/engine.py:98` FTS 和 Vector 都先固定截取: ```python CANDIDATE_POOL = 50 ``` 之后才执行 Metadata Filter 和分页。这会带来两个问题。 #### 分页失效 实测创建 60 个匹配 Block 后请求: ```json { "query": "共同查询词", "mode": "fts", "limit": 10, "offset": 50 } ``` 返回: ```json { "total": 50, "items": [], "offset": 50 } ``` 实际上数据库中有 60 个匹配项,第六页应该返回 10 条。 #### Metadata Filter 漏召回 folder、tag、note_id 和时间过滤都在召回 50 条之后执行。如果满足过滤条件的结果排名在第 51 位以后,接口会错误地返回空结果。 建议: 1. FTS 模式尽可能将 metadata 条件下推到 SQL; 2. 候选池至少考虑 `offset + limit`; 3. Hybrid/Vector 模式使用可配置的 oversampling; 4. 过滤后候选不足时继续扩大召回范围; 5. 明确 `page.total` 表示完整命中数量还是候选池数量,目前该值容易误导前端。 --- ### 5. [P2] PATCH 省略 tags 时会清空标签,显式传空数组又无法清除 frontmatter 标签 位置: - `backend/app/services/note_service.py:108` - `backend/app/services/note_service.py:119` - `backend/app/knowledge/parser.py:58` `NoteUpdateRequest.tags` 是可选字段,按照 PATCH 语义,未提供时应该保留现有标签。但当前代码直接把 `tags=None` 传给 `parse_note()`: ```python tags=tags ``` 解析器又使用了真值判断: ```python resolved_tags = list(tags) if tags else _parse_tags(frontmatter.get("tags")) ``` 因此会出现两种错误: 1. PATCH 未提供 tags 时,原有 API 标签会被 frontmatter 标签或空数组替换; 2. PATCH 显式提供 `tags=[]` 时,因为空列表为假,反而无法清除 frontmatter 中的标签。 前面的标题更新复现中,原标签 `["keep"]` 在 PATCH 失败后已经变成了 `[]`。 建议: ```python resolved_tags = list(tags) if tags is not None else _parse_tags(...) ``` 同时在 `update_note()` 中区分: - `tags is None`:保留 `record.tags`; - `tags == []`:明确清空标签; - 提供非空数组:替换标签。 需要补充以上三种 PATCH 测试。 --- ### 6. [P2] rebuild 接口忽略 `scope` 和 `note_ids`,并且失败时会留下半成品索引 位置: - `backend/app/services/index_service.py:40` - `backend/app/services/index_service.py:42` `IndexRebuildRequest` 对外提供了: ```text scope = all / notes / vectors note_ids force ``` 但当前所有请求都会执行: ```python repository.clear_all() await vector_store.clear() ``` 然后重新扫描整个 Vault。也就是说: - `scope="vectors"` 仍会清空 notes 和 FTS; - 指定 `note_ids` 不会限制重建范围; - `force` 没有实际作用; - 接口返回的 scope 与实际执行行为不一致。 另外,旧索引在扫描和 embedding 之前就被清空。如果中途遇到文件编码错误、SQLite 错误或 embedding 异常,系统会留下空索引或部分索引。 如果 MVP 暂时只支持全量重建,建议: 1. 对非 `scope="all"` 或非空 `note_ids` 返回明确的 400/501; 2. 不要接受参数后静默忽略; 3. 先构建临时索引,成功后再切换; 4. 或至少在失败时恢复旧索引,并保存 failed Job 状态; 5. 增加重建失败和参数语义测试。 --- ## 测试情况 分支原有测试: ```text 26 passed in 25.91s ``` 额外完成了以下验证: - 仅修改标题时复现 vec_blocks 主键冲突; - 确认接口失败后 SQLite 元数据仍被部分更新; - 确认 PATCH 省略 tags 会清空原标签; - 确认 `folder="../outside"` 可以写出 Vault; - 确认修改正文后旧向量仍留在 vec_blocks; - 确认超过 50 条结果后 offset 分页失效; - `git diff --check` 通过。 --- ## 建议补充的测试 建议至少增加以下回归测试: 1. 拒绝 `..`、绝对路径和 Windows 盘符路径; 2. PATCH 仅修改 title; 3. PATCH 仅修改 tags; 4. PATCH 未提供 tags 时保留原标签; 5. PATCH `tags=[]` 时正确清空标签; 6. 修改部分正文时稳定 Block 不触发向量主键冲突; 7. 更新后不存在孤立的 vec_blocks 记录; 8. 搜索结果超过 50 条时分页正确; 9. metadata 目标结果位于候选池之外时仍可召回; 10. rebuild 正确处理或拒绝 scope、note_ids; 11. rebuild 中途失败时不会破坏现有可用索引。 整体架构和第一条检索链路已经建立起来,但上述问题涉及文件系统边界、数据一致性和搜索接口正确性,建议修复后重新审阅。 ```
Kronecker closed this pull request 2026-08-27 21:00:21 +08:00
Kronecker reopened this pull request 2026-08-27 23:04:57 +08:00
Kronecker closed this pull request 2026-08-27 23:05:52 +08:00
yxx requested review from Kronecker 2026-08-27 23:07:20 +08:00
Kronecker reopened this pull request 2026-08-27 23:07:45 +08:00
Owner

第二轮审阅结论

上一轮提出的问题大部分已经修复:

  • 路径逃逸已修复;
  • 稳定 Block 的向量主键冲突已修复;
  • 失效向量可以正确清理;
  • PATCH tags 语义已修复;
  • 常规分页及 Metadata Filter 漏召回已改善;
  • rebuild 已明确拒绝未支持的 scope,并增加失败恢复;
  • 当前 39 个测试全部通过。

本次复核仍发现以下问题。考虑到主体功能已经可用,可以先合并,后续通过独立提交修复。

1. index_meta 写入失败会导致文件与索引不一致

backend/app/services/note_service.py:125-128

Block、FTS 和向量事务提交后,set_index_meta() 又使用单独事务。如果该操作失败,update_note() 会恢复旧 Markdown,但数据库中的 Block、FTS 和向量已经变成新内容。

实测结果:

Markdown 文件:old body
数据库 Block:new searchable body

建议将 index_meta 写入前面的同一事务,或者不要让它的失败触发 Markdown 回滚。

2. POST 同目录、同标题会静默覆盖原笔记

backend/app/services/note_service.py:131-145

笔记路径完全由 folder + title 生成,创建前未检查目标文件或 note_id 是否已经存在。连续创建同目录、同标题笔记时,两次返回相同 note_id,第二次正文会直接覆盖第一篇。

如果第二次创建在索引阶段失败,当前异常处理还会删除该路径,使原有 Markdown 一并丢失。

建议创建前检测冲突并返回 409 Conflict。如果产品设计允许覆盖,则需要保存旧文件,并在失败时恢复旧内容。

3. 删除操作仍存在部分提交

backend/app/services/note_service.py:189-195

删除流程依次执行:

  1. 删除 notes、blocks 和 FTS;
  2. 删除向量;
  3. 删除 Markdown。

这三个操作没有统一的失败恢复。实测向量删除失败时:

notes:已删除
vec_blocks:仍存在
Markdown:仍存在
接口:返回失败

建议让 SQLite 元数据和向量共用一个事务;文件删除则采用可恢复方式,例如先移动到临时位置,数据库提交成功后再最终删除。

4. FTS 分页仍会在第 1000 条截断

backend/app/retrieval/engine.py:30-35

SearchRequest.offset 没有 1000 的限制,但 FTS 固定最多获取 1000 条,并把截断后的数量作为 page.total

实测数据库存在 1010 个匹配 Block 时,请求:

{
  "query": "共同词",
  "mode": "fts",
  "limit": 10,
  "offset": 1000
}

返回:

{
  "total": 1000,
  "items": []
}

建议在 SQL 层分别执行 COUNT 和 LIMIT/OFFSET。如果暂时只支持前 1000 条,则需要在接口模型中限制 offset,并明确 total 的含义。

建议

本次可以先合并,以上问题作为合并后的 follow-up 修复,并补充对应回归测试。

## 第二轮审阅结论 上一轮提出的问题大部分已经修复: - 路径逃逸已修复; - 稳定 Block 的向量主键冲突已修复; - 失效向量可以正确清理; - PATCH tags 语义已修复; - 常规分页及 Metadata Filter 漏召回已改善; - rebuild 已明确拒绝未支持的 scope,并增加失败恢复; - 当前 39 个测试全部通过。 本次复核仍发现以下问题。考虑到主体功能已经可用,可以先合并,后续通过独立提交修复。 ### 1. index_meta 写入失败会导致文件与索引不一致 `backend/app/services/note_service.py:125-128` Block、FTS 和向量事务提交后,`set_index_meta()` 又使用单独事务。如果该操作失败,`update_note()` 会恢复旧 Markdown,但数据库中的 Block、FTS 和向量已经变成新内容。 实测结果: ```text Markdown 文件:old body 数据库 Block:new searchable body ``` 建议将 `index_meta` 写入前面的同一事务,或者不要让它的失败触发 Markdown 回滚。 ### 2. POST 同目录、同标题会静默覆盖原笔记 `backend/app/services/note_service.py:131-145` 笔记路径完全由 `folder + title` 生成,创建前未检查目标文件或 `note_id` 是否已经存在。连续创建同目录、同标题笔记时,两次返回相同 `note_id`,第二次正文会直接覆盖第一篇。 如果第二次创建在索引阶段失败,当前异常处理还会删除该路径,使原有 Markdown 一并丢失。 建议创建前检测冲突并返回 `409 Conflict`。如果产品设计允许覆盖,则需要保存旧文件,并在失败时恢复旧内容。 ### 3. 删除操作仍存在部分提交 `backend/app/services/note_service.py:189-195` 删除流程依次执行: 1. 删除 notes、blocks 和 FTS; 2. 删除向量; 3. 删除 Markdown。 这三个操作没有统一的失败恢复。实测向量删除失败时: ```text notes:已删除 vec_blocks:仍存在 Markdown:仍存在 接口:返回失败 ``` 建议让 SQLite 元数据和向量共用一个事务;文件删除则采用可恢复方式,例如先移动到临时位置,数据库提交成功后再最终删除。 ### 4. FTS 分页仍会在第 1000 条截断 `backend/app/retrieval/engine.py:30-35` `SearchRequest.offset` 没有 1000 的限制,但 FTS 固定最多获取 1000 条,并把截断后的数量作为 `page.total`。 实测数据库存在 1010 个匹配 Block 时,请求: ```json { "query": "共同词", "mode": "fts", "limit": 10, "offset": 1000 } ``` 返回: ```json { "total": 1000, "items": [] } ``` 建议在 SQL 层分别执行 COUNT 和 LIMIT/OFFSET。如果暂时只支持前 1000 条,则需要在接口模型中限制 offset,并明确 total 的含义。 ## 建议 本次可以先合并,以上问题作为合并后的 follow-up 修复,并补充对应回归测试。
Kronecker closed this pull request 2026-08-27 23:22:27 +08:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Kronecker/NotesAgentic#1