fix(retrieval): 保证删除一致性并完善 FTS 分页

This commit is contained in:
2026-08-27 23:28:37 +08:00
parent af8ccb0f18
commit 32f249c43a
4 changed files with 197 additions and 11 deletions
+83 -4
View File
@@ -120,20 +120,24 @@ def replace_note_metadata(
return old_block_ids return old_block_ids
def delete_note(note_id: str) -> list[str]: def delete_note(
note_id: str, *, conn: sqlite3.Connection | None = None
) -> list[str]:
"""删除笔记及其 Block、FTS5 索引;返回被删除的 block_id 供向量层清理。""" """删除笔记及其 Block、FTS5 索引;返回被删除的 block_id 供向量层清理。"""
conn = connect() owns = conn is None
conn = conn or connect()
try: try:
block_ids = [ block_ids = [
row["block_id"] row["block_id"]
for row in conn.execute("SELECT block_id FROM blocks WHERE note_id = ?", (note_id,)) for row in conn.execute("SELECT block_id FROM blocks WHERE note_id = ?", (note_id,))
] ]
with transaction(conn): with transaction(conn) if owns else nullcontext():
conn.execute("DELETE FROM blocks_fts WHERE note_id = ?", (note_id,)) conn.execute("DELETE FROM blocks_fts WHERE note_id = ?", (note_id,))
conn.execute("DELETE FROM notes WHERE note_id = ?", (note_id,)) # blocks 级联删除 conn.execute("DELETE FROM notes WHERE note_id = ?", (note_id,)) # blocks 级联删除
return block_ids return block_ids
finally: finally:
conn.close() if owns:
conn.close()
def get_note_record(note_id: str) -> NoteRecord | None: def get_note_record(note_id: str) -> NoteRecord | None:
@@ -215,6 +219,81 @@ def fts_search(match: str, limit: int = 100) -> list[FtsHit]:
conn.close() conn.close()
def fts_search_page(
*,
match: str,
limit: int,
offset: int,
folders: list[str],
note_ids: list[str],
tags: list[str],
created_from: datetime | None,
created_to: datetime | None,
updated_from: datetime | None,
updated_to: datetime | None,
) -> tuple[list[FtsHit], int]:
"""执行带元数据过滤的 FTS 精确分页,并返回过滤后的完整命中数。"""
where = ["blocks_fts MATCH ?"]
params: list[object] = [match]
def add_in(column: str, values: list[str]) -> None:
if not values:
return
placeholders = ",".join("?" * len(values))
where.append(f"{column} IN ({placeholders})")
params.extend(values)
add_in("n.folder", folders)
add_in("n.note_id", note_ids)
if tags:
placeholders = ",".join("?" * len(tags))
where.append(
f"EXISTS (SELECT 1 FROM json_each(n.tags) AS tag WHERE tag.value IN ({placeholders}))"
)
params.extend(tags)
for column, lower, upper in (
("n.created_at", created_from, created_to),
("n.updated_at", updated_from, updated_to),
):
if lower is not None:
where.append(f"julianday({column}) >= julianday(?)")
params.append(_iso(lower))
if upper is not None:
where.append(f"julianday({column}) <= julianday(?)")
params.append(_iso(upper))
from_sql = """
FROM blocks_fts
JOIN blocks AS b ON b.block_id = blocks_fts.block_id
JOIN notes AS n ON n.note_id = b.note_id
"""
where_sql = " AND ".join(where)
conn = connect()
try:
total = conn.execute(
f"SELECT COUNT(*) {from_sql} WHERE {where_sql}", params
).fetchone()[0]
rows = conn.execute(
f"""
SELECT blocks_fts.block_id, blocks_fts.note_id, bm25(blocks_fts) AS rank
{from_sql}
WHERE {where_sql}
ORDER BY rank
LIMIT ? OFFSET ?
""",
[*params, limit, offset],
).fetchall()
return (
[FtsHit(block_id=row["block_id"], note_id=row["note_id"], bm25=row["rank"])
for row in rows],
total,
)
finally:
conn.close()
def get_block_hits(block_ids: list[str]) -> list[BlockHit]: def get_block_hits(block_ids: list[str]) -> list[BlockHit]:
if not block_ids: if not block_ids:
return [] return []
+41 -4
View File
@@ -31,8 +31,6 @@ CANDIDATE_POOL = 50
MAX_CANDIDATE_POOL = 200 MAX_CANDIDATE_POOL = 200
# 带 metadata 过滤时放大召回倍数,缓解「先截断候选池再过滤」造成的漏召回 # 带 metadata 过滤时放大召回倍数,缓解「先截断候选池再过滤」造成的漏召回
OVERSCAN_FACTOR = 4 OVERSCAN_FACTOR = 4
# FTS 一次性取全量命中上限:保证 fts 模式 total 准确、过滤不漏召回;超出则截断
FTS_FETCH_LIMIT = 1000
class RetrievalEngine: class RetrievalEngine:
@@ -47,6 +45,9 @@ class RetrievalEngine:
self.vector_store = vector_store self.vector_store = vector_store
async def search(self, request: SearchRequest) -> SearchResponse: async def search(self, request: SearchRequest) -> SearchResponse:
if request.mode == SearchMode.fts:
return self._search_fts(request)
has_filters = bool( has_filters = bool(
request.folders or request.note_ids or request.tags request.folders or request.note_ids or request.tags
or request.created_from or request.created_to or request.created_from or request.created_to
@@ -67,8 +68,7 @@ class RetrievalEngine:
if request.mode in (SearchMode.fts, SearchMode.hybrid): if request.mode in (SearchMode.fts, SearchMode.hybrid):
match = match_query(request.query) match = match_query(request.query)
if match: if match:
fts_limit = FTS_FETCH_LIMIT if request.mode == SearchMode.fts else recall fts_hits = repository.fts_search(match, recall)
fts_hits = repository.fts_search(match, fts_limit)
fts_ranked = [h.block_id for h in fts_hits] fts_ranked = [h.block_id for h in fts_hits]
# bm25 越小越相关,取反后统一为「越大越相关」 # bm25 越小越相关,取反后统一为「越大越相关」
fts_scores = {h.block_id: -h.bm25 for h in fts_hits} fts_scores = {h.block_id: -h.bm25 for h in fts_hits}
@@ -125,6 +125,43 @@ class RetrievalEngine:
page=PageMeta(total=total, limit=request.limit, offset=request.offset), page=PageMeta(total=total, limit=request.limit, offset=request.offset),
) )
def _search_fts(self, request: SearchRequest) -> SearchResponse:
"""FTS 专用路径:过滤、COUNT 与分页全部在 SQLite 中完成。"""
match = match_query(request.query)
if not match:
return self._empty(request)
fts_hits, total = repository.fts_search_page(
match=match,
limit=request.limit,
offset=request.offset,
folders=request.folders,
note_ids=request.note_ids,
tags=request.tags,
created_from=request.created_from,
created_to=request.created_to,
updated_from=request.updated_from,
updated_to=request.updated_to,
)
if not fts_hits:
return SearchResponse(
query=request.query,
mode=request.mode,
page=PageMeta(total=total, limit=request.limit, offset=request.offset),
)
hits = {h.block_id: h for h in repository.get_block_hits([hit.block_id for hit in fts_hits])}
ordered = normalize_scores(
[(hit.block_id, -hit.bm25) for hit in fts_hits if hit.block_id in hits]
)
items = [self._build_result(hits[block_id], request, score) for block_id, score in ordered]
return SearchResponse(
query=request.query,
mode=request.mode,
items=items,
page=PageMeta(total=total, limit=request.limit, offset=request.offset),
)
def _matches(self, hit: BlockHit, request: SearchRequest) -> bool: def _matches(self, hit: BlockHit, request: SearchRequest) -> bool:
if request.folders and hit.folder not in request.folders: if request.folders and hit.folder not in request.folders:
return False return False
+25 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from uuid import uuid4
from app import repository from app import repository
from app.config import get_settings from app.config import get_settings
@@ -216,9 +217,30 @@ async def delete_note(note_id: str) -> bool:
record = repository.get_note_record(note_id) record = repository.get_note_record(note_id)
if record is None: if record is None:
return False return False
block_ids = repository.delete_note(note_id)
await vector_store.delete(block_ids) path = _abs_path(record.file_path)
_delete_markdown(record.file_path) tombstone = path.with_name(f".{path.name}.{uuid4().hex}.deleting") if path.exists() else None
if tombstone is not None:
path.replace(tombstone)
conn = connect()
try:
with transaction(conn):
block_ids = repository.delete_note(note_id, conn=conn)
await vector_store.delete(block_ids, conn=conn)
except BaseException:
if tombstone is not None and tombstone.exists():
tombstone.replace(path)
raise
finally:
conn.close()
if tombstone is not None:
# 数据库已提交后,tombstone 即不再属于 Vault;清理失败不应把成功删除报告为失败。
try:
tombstone.unlink(missing_ok=True)
except OSError:
pass
return True return True
+48
View File
@@ -324,6 +324,35 @@ def test_create_note_rejects_existing_path_without_overwrite(vault) -> None:
assert got is not None and got.markdown == "原始正文" assert got is not None and got.markdown == "原始正文"
def test_delete_note_rolls_back_when_vector_delete_fails(vault, monkeypatch) -> None:
"""向量删除失败时,笔记数据库记录和 Markdown 都恢复到删除前。"""
from app.database.db import connect
from app.services import note_service
note = asyncio.run(
note_service.create_note(title="删除回滚", markdown="待保留正文", folder="测试", tags=[])
)
path = vault / note.file_path
async def _boom(_ids, **_kwargs):
raise RuntimeError("vector delete failed")
monkeypatch.setattr(note_service.vector_store, "delete", _boom)
with pytest.raises(RuntimeError):
asyncio.run(note_service.delete_note(note.note_id))
got = asyncio.run(note_service.get_note(note.note_id))
assert got is not None and got.markdown == "待保留正文"
assert path.exists()
conn = connect()
try:
assert conn.execute(
"SELECT COUNT(*) FROM vec_blocks WHERE block_id = ?", (note.blocks[0].block_id,)
).fetchone()[0] == 1
finally:
conn.close()
def test_update_removes_stale_vectors(vault) -> None: def test_update_removes_stale_vectors(vault) -> None:
from app.database.db import connect from app.database.db import connect
from app.services import note_service from app.services import note_service
@@ -366,6 +395,25 @@ def test_search_pagination_total_reflects_all_matches(vault) -> None:
assert page2.items # 跨过旧候选池边界仍能取到结果 assert page2.items # 跨过旧候选池边界仍能取到结果
def test_fts_pagination_is_not_truncated_at_one_thousand(vault) -> None:
"""FTS total 与分页由数据库计算,不在第 1000 个候选处截断。"""
from app.retrieval.engine import engine
from app.services import note_service
markdown = "\n\n".join(f"共同词 p{i}" for i in range(1010))
asyncio.run(
note_service.create_note(title="千条分页", markdown=markdown, folder="", tags=[])
)
response = asyncio.run(
engine.search(
SearchRequest(query="共同词", mode=SearchMode.fts, limit=10, offset=1000)
)
)
assert response.page.total == 1010
assert len(response.items) == 10
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 审阅回归:PATCH tags 语义 / 向量-块一致性 / 过滤漏召回 / rebuild 语义与回滚 # 审阅回归:PATCH tags 语义 / 向量-块一致性 / 过滤漏召回 / rebuild 语义与回滚
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #