feat/knowledge-retrieval-core
main
实现 AI 笔记的 Knowledge Core 与 Retrieval Core 后端能力:笔记(Note)的创建 / 读取 / 更新 / 删除,Markdown 到 Block 的切分与稳定 ID,SQLite + FTS5 全文索引,sqlite-vec 向量检索,以及 FTS / Vector / Hybrid 三模式混合检索(RRF 融合 + Reranker),返回可定位的 Citation。
Note
/api/notes
start_offset
end_offset
note_id
block_id
unicode61
HashEmbeddingProvider
SqliteVecStore
LexicalReranker
cit_
file_path
heading_path
/api/index/rebuild
/api/index/status
Router → Service → Repository(仅 SQLite)+ Retrieval Infra(Embedding / VectorStore / Reranker),引擎只依赖抽象接口,不拼接 vec0 内部 SQL。
Router → Service → Repository(仅 SQLite)+ Retrieval Infra(Embedding / VectorStore / Reranker)
8771745
folder
..
total
offset+limit
6cf531f
upsert
None
[]
scope
note_ids
backend/tests/test_retrieval.py 共 24 个用例(单元 + 端到端 + 两轮回归),全量 python -m pytest -q → 39 passed。测试通过 conftest.py 隔离数据目录,不污染真实索引。
backend/tests/test_retrieval.py
python -m pytest -q
conftest.py
scope=notes/vectors
/notes/{id}/move
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
当前版本暂不建议合并。
Knowledge Core 与 Retrieval Core 的整体分层比较清晰,Markdown Parser、SQLite/FTS5、sqlite-vec、RRF、Reranker、Citation 等链路已经跑通,分支自带的 26 个测试也全部通过。
不过进一步检查和边界测试发现,目前存在路径逃逸、笔记更新部分提交、失效向量残留、搜索分页不完整等问题。其中前两个可能造成 Vault 外文件被修改或接口报错后数据仍被部分更新,建议修复后再合并。
位置:
backend/app/services/note_service.py:35
backend/app/services/note_service.py:43
_rel_path() 只去除了 folder 两端的 /,没有拒绝 ..、Windows 绝对路径或盘符路径。随后 _abs_path() 直接执行:
_rel_path()
/
_abs_path()
_vault() / rel_path 因此 API 调用方可以构造类似: ```json { "title": "escaped", "folder": "../outside", "markdown": "# outside" }
实测该请求会在 Vault 目录之外创建:
../outside/escaped.md
由于删除笔记时也使用数据库中的 file_path,对应文件之后还可以通过 DELETE 接口被删除。
建议:
resolve()
vault.resolve()
backend/app/retrieval/vectorstore.py:41
backend/app/retrieval/vectorstore.py:48
backend/app/services/note_service.py:64
SqliteVecStore.upsert() 当前执行:
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
实测流程:
更严重的是,repository.replace_note_metadata() 已在向量写入前独立提交。最终表现为:
repository.replace_note_metadata()
也就是接口虽然失败,部分状态却已经改变。
backend/app/repository.py:75
更新笔记时,replace_note_metadata() 会删除旧的 blocks 和 FTS 记录,但 index_note() 只插入新向量,没有删除旧 Block 对应的向量。
replace_note_metadata()
index_note()
实测一次全文更新后:
blocks = 1 vec_blocks = 2
旧向量对应的 Block 已不存在,因此 get_block_hits() 会将其丢弃。但这些失效向量仍参与 sqlite-vec 的 Top-K 检索。反复编辑后,旧向量可能占满候选池,使有效 Block 无法进入前 50,最终造成向量检索漏召回甚至返回空结果。
get_block_hits()
建议在替换 Block 前记录旧 block_id,然后:
全量重建之外,也应有测试验证:
vec_blocks 中的 ID 集合 == blocks 中的 ID 集合
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 条。
folder、tag、note_id 和时间过滤都在召回 50 条之后执行。如果满足过滤条件的结果排名在第 51 位以后,接口会错误地返回空结果。
offset + limit
page.total
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():
NoteUpdateRequest.tags
tags=None
parse_note()
tags=tags
解析器又使用了真值判断:
resolved_tags = list(tags) if tags else _parse_tags(frontmatter.get("tags"))
因此会出现两种错误:
tags=[]
前面的标题更新复现中,原标签 ["keep"] 在 PATCH 失败后已经变成了 []。
["keep"]
resolved_tags = list(tags) if tags is not None else _parse_tags(...)
同时在 update_note() 中区分:
update_note()
tags is None
record.tags
tags == []
需要补充以上三种 PATCH 测试。
backend/app/services/index_service.py:40
backend/app/services/index_service.py:42
IndexRebuildRequest 对外提供了:
IndexRebuildRequest
scope = all / notes / vectors note_ids force
但当前所有请求都会执行:
repository.clear_all() await vector_store.clear()
然后重新扫描整个 Vault。也就是说:
scope="vectors"
force
另外,旧索引在扫描和 embedding 之前就被清空。如果中途遇到文件编码错误、SQLite 错误或 embedding 异常,系统会留下空索引或部分索引。
如果 MVP 暂时只支持全量重建,建议:
scope="all"
分支原有测试:
26 passed in 25.91s
额外完成了以下验证:
folder="../outside"
git diff --check
建议至少增加以下回归测试:
整体架构和第一条检索链路已经建立起来,但上述问题涉及文件系统边界、数据一致性和搜索接口正确性,建议修复后重新审阅。
上一轮提出的问题大部分已经修复:
本次复核仍发现以下问题。考虑到主体功能已经可用,可以先合并,后续通过独立提交修复。
backend/app/services/note_service.py:125-128
Block、FTS 和向量事务提交后,set_index_meta() 又使用单独事务。如果该操作失败,update_note() 会恢复旧 Markdown,但数据库中的 Block、FTS 和向量已经变成新内容。
set_index_meta()
实测结果:
Markdown 文件:old body 数据库 Block:new searchable body
建议将 index_meta 写入前面的同一事务,或者不要让它的失败触发 Markdown 回滚。
index_meta
backend/app/services/note_service.py:131-145
笔记路径完全由 folder + title 生成,创建前未检查目标文件或 note_id 是否已经存在。连续创建同目录、同标题笔记时,两次返回相同 note_id,第二次正文会直接覆盖第一篇。
folder + title
如果第二次创建在索引阶段失败,当前异常处理还会删除该路径,使原有 Markdown 一并丢失。
建议创建前检测冲突并返回 409 Conflict。如果产品设计允许覆盖,则需要保存旧文件,并在失败时恢复旧内容。
409 Conflict
backend/app/services/note_service.py:189-195
删除流程依次执行:
这三个操作没有统一的失败恢复。实测向量删除失败时:
notes:已删除 vec_blocks:仍存在 Markdown:仍存在 接口:返回失败
建议让 SQLite 元数据和向量共用一个事务;文件删除则采用可恢复方式,例如先移动到临时位置,数据库提交成功后再最终删除。
backend/app/retrieval/engine.py:30-35
SearchRequest.offset 没有 1000 的限制,但 FTS 固定最多获取 1000 条,并把截断后的数量作为 page.total。
SearchRequest.offset
实测数据库存在 1010 个匹配 Block 时,请求:
{ "query": "共同词", "mode": "fts", "limit": 10, "offset": 1000 }
{ "total": 1000, "items": [] }
建议在 SQL 层分别执行 COUNT 和 LIMIT/OFFSET。如果暂时只支持前 1000 条,则需要在接口模型中限制 offset,并明确 total 的含义。
本次可以先合并,以上问题作为合并后的 follow-up 修复,并补充对应回归测试。
No dependencies set.
The note is not visible to the blocked user.
概述
实现 AI 笔记的 Knowledge Core 与 Retrieval Core 后端能力:笔记(Note)的创建 / 读取 / 更新 / 删除,Markdown 到 Block 的切分与稳定 ID,SQLite + FTS5 全文索引,sqlite-vec 向量检索,以及 FTS / Vector / Hybrid 三模式混合检索(RRF 融合 + Reranker),返回可定位的 Citation。
核心能力
NoteCRUD(/api/notes),Markdown 文件为持久化载体(Vault),SQLite / FTS5 / 向量为可重建索引。start_offset/end_offset用于 Citation 定位;note_id/block_id由内容稳定派生。unicode61+ 自定义 CJK 分词(单字 + 双字 bigram)。HashEmbeddingProvider(dim=128,确定性特征哈希 + L2 归一化)与SqliteVecStore(sqlite-vec vec0 虚拟表)。LexicalReranker。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 边界校验(拒绝../ 绝对路径 / 盘符)。total反映真实命中数,候选池覆盖offset+limit。第二轮(
6cf531f)upsert改 delete-then-insert 幂等,支持共享连接。None保留 /[]清空 / 非空替换。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
审阅结论
当前版本暂不建议合并。
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:35backend/app/services/note_service.py:43_rel_path()只去除了 folder 两端的/,没有拒绝..、Windows 绝对路径或盘符路径。随后_abs_path()直接执行:实测该请求会在 Vault 目录之外创建:
由于删除笔记时也使用数据库中的
file_path,对应文件之后还可以通过 DELETE 接口被删除。建议:
..路径段;resolve();vault.resolve()下;2. [P1] VectorStore 的
upsert实际是普通 INSERT,正常 PATCH 会失败位置:
backend/app/retrieval/vectorstore.py:41backend/app/retrieval/vectorstore.py:48backend/app/services/note_service.py:64SqliteVecStore.upsert()当前执行:但
block_id是根据 note、heading_path 和 content 稳定生成的。仅修改标题或标签时,原有 Block 的 ID 不会变化,再次插入向量会触发:实测流程:
更严重的是,
repository.replace_note_metadata()已在向量写入前独立提交。最终表现为:也就是接口虽然失败,部分状态却已经改变。
建议:
应当修复
3. [P2] 修改正文后旧向量不会被删除
位置:
backend/app/services/note_service.py:64backend/app/repository.py:75更新笔记时,
replace_note_metadata()会删除旧的 blocks 和 FTS 记录,但index_note()只插入新向量,没有删除旧 Block 对应的向量。实测一次全文更新后:
旧向量对应的 Block 已不存在,因此
get_block_hits()会将其丢弃。但这些失效向量仍参与 sqlite-vec 的 Top-K 检索。反复编辑后,旧向量可能占满候选池,使有效 Block 无法进入前 50,最终造成向量检索漏召回甚至返回空结果。建议在替换 Block 前记录旧
block_id,然后:全量重建之外,也应有测试验证:
4. [P2] 固定 50 条候选导致分页和 Metadata Filter 不完整
位置:
backend/app/retrieval/engine.py:28backend/app/retrieval/engine.py:50backend/app/retrieval/engine.py:77backend/app/retrieval/engine.py:98FTS 和 Vector 都先固定截取:
之后才执行 Metadata Filter 和分页。这会带来两个问题。
分页失效
实测创建 60 个匹配 Block 后请求:
返回:
实际上数据库中有 60 个匹配项,第六页应该返回 10 条。
Metadata Filter 漏召回
folder、tag、note_id 和时间过滤都在召回 50 条之后执行。如果满足过滤条件的结果排名在第 51 位以后,接口会错误地返回空结果。
建议:
offset + limit;page.total表示完整命中数量还是候选池数量,目前该值容易误导前端。5. [P2] PATCH 省略 tags 时会清空标签,显式传空数组又无法清除 frontmatter 标签
位置:
backend/app/services/note_service.py:108backend/app/services/note_service.py:119backend/app/knowledge/parser.py:58NoteUpdateRequest.tags是可选字段,按照 PATCH 语义,未提供时应该保留现有标签。但当前代码直接把tags=None传给parse_note():解析器又使用了真值判断:
因此会出现两种错误:
tags=[]时,因为空列表为假,反而无法清除 frontmatter 中的标签。前面的标题更新复现中,原标签
["keep"]在 PATCH 失败后已经变成了[]。建议:
同时在
update_note()中区分:tags is None:保留record.tags;tags == []:明确清空标签;需要补充以上三种 PATCH 测试。
6. [P2] rebuild 接口忽略
scope和note_ids,并且失败时会留下半成品索引位置:
backend/app/services/index_service.py:40backend/app/services/index_service.py:42IndexRebuildRequest对外提供了:但当前所有请求都会执行:
然后重新扫描整个 Vault。也就是说:
scope="vectors"仍会清空 notes 和 FTS;note_ids不会限制重建范围;force没有实际作用;另外,旧索引在扫描和 embedding 之前就被清空。如果中途遇到文件编码错误、SQLite 错误或 embedding 异常,系统会留下空索引或部分索引。
如果 MVP 暂时只支持全量重建,建议:
scope="all"或非空note_ids返回明确的 400/501;测试情况
分支原有测试:
额外完成了以下验证:
folder="../outside"可以写出 Vault;git diff --check通过。建议补充的测试
建议至少增加以下回归测试:
..、绝对路径和 Windows 盘符路径;tags=[]时正确清空标签;整体架构和第一条检索链路已经建立起来,但上述问题涉及文件系统边界、数据一致性和搜索接口正确性,建议修复后重新审阅。
第二轮审阅结论
上一轮提出的问题大部分已经修复:
本次复核仍发现以下问题。考虑到主体功能已经可用,可以先合并,后续通过独立提交修复。
1. index_meta 写入失败会导致文件与索引不一致
backend/app/services/note_service.py:125-128Block、FTS 和向量事务提交后,
set_index_meta()又使用单独事务。如果该操作失败,update_note()会恢复旧 Markdown,但数据库中的 Block、FTS 和向量已经变成新内容。实测结果:
建议将
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删除流程依次执行:
这三个操作没有统一的失败恢复。实测向量删除失败时:
建议让 SQLite 元数据和向量共用一个事务;文件删除则采用可恢复方式,例如先移动到临时位置,数据库提交成功后再最终删除。
4. FTS 分页仍会在第 1000 条截断
backend/app/retrieval/engine.py:30-35SearchRequest.offset没有 1000 的限制,但 FTS 固定最多获取 1000 条,并把截断后的数量作为page.total。实测数据库存在 1010 个匹配 Block 时,请求:
返回:
建议在 SQL 层分别执行 COUNT 和 LIMIT/OFFSET。如果暂时只支持前 1000 条,则需要在接口模型中限制 offset,并明确 total 的含义。
建议
本次可以先合并,以上问题作为合并后的 follow-up 修复,并补充对应回归测试。
Pull request closed