fix(r4-52b): vault.add 按内容 SHA256 去重,重复上传覆盖旧 doc_id

缺陷 #52b(验收 §6 第 2 条 + 回归 G3):同一 PDF 重复上传 4 次 → 4 个 doc_id
× 200 chunk = 800 条,FAISS 检索 top_k 被同内容不同 doc_id 的块占满。

修复(vstore/vault.py):
- add() 先算内容哈希 SHA256(原始文件字节),查 index.json 的 content_sha
  字段判重;同内容 → 移除旧 doc 的检索行/密文/chunk 行,再落一条新 doc
  (新 doc_id,meta 以本次上传为准,created_at 刷新)
- 去重后该内容在库中恒仅 1 个 doc_id、chunk 总数不翻倍;同名不同内容
  按内容哈希不误判(G3-2);不同内容互不覆盖(G3-1)
- content_sha 落 index.json(doc_meta 新字段);rebuild_index 对历史密文
  缺 content_sha 时以密文内容补算(重建后去重依据不丢)
- 与现有删除机制一致:index.remove_item 过滤重建 faiss、chunks.remove_document
  过滤重建 chunk faiss(IndexFlatIP 不支持逐行删,现有机制即重建)

测试:vstore/tests/test_vault_dedup.py(5 用例):同内容两次 → 1 个 doc_id/
chunk 不翻倍/旧密文清理;meta 以本次为准;不同内容 → 2 个 doc_id;
同名不同内容不误判;重启(新实例 load)后仍去重。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:12:57 +08:00
co-authored by Claude Fable 5
parent 3889a2a29b
commit 60f7360cec
2 changed files with 212 additions and 11 deletions
+123
View File
@@ -0,0 +1,123 @@
"""test_vault_dedup.py —— 内容 SHA256 去重(缺陷 #52b,验收 §6 第 2 条 + 回归 G3)。
运行:cd vstore && /home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python -m pytest tests/test_vault_dedup.py -v
(或 vstore/pyproject.toml testpaths 下全量 pytest;venv = plantai-rag-new-venv)
G3 断言:
- 同内容 add 两次 → 库中该内容仅 1 个 doc_id、chunk 总数不翻倍;
- 不同内容 add → 2 个 doc_id 互不覆盖;
- 同名不同内容 → 按内容哈希不误判(保留 2 个 doc_id)。
"""
from __future__ import annotations
import hashlib
import pytest
from vstore.vault import Vault
@pytest.fixture
def vault(tmp_path, embed_fn, monkeypatch):
from vstore.settings import VstorePaths, set_paths
set_paths(VstorePaths(root=str(tmp_path), vault_path="", index_path="", chunk_path=""))
# 桩掉 chunk 批量嵌入(vault._chunked_embed 动态查找),不触网真 Ollama
dim = 1024
monkeypatch.setattr(
"vstore.embed.embed_batch_chunked",
lambda texts, *a, **k: [[1.0 / dim] * dim for _ in texts],
)
return Vault(tmp_path / "vault", tmp_path / "index")
def _doc_ids_for_content(vault, content: bytes) -> list[str]:
"""库中 content_sha == sha256(content) 的 doc 行 id 列表(磁盘事实)。"""
sha = hashlib.sha256(content).hexdigest()
import json
rows = []
try:
data = json.loads(vault.index.json_path.read_text(encoding="utf-8"))
items = data.get("items", []) if isinstance(data, dict) else []
except (ValueError, OSError):
items = []
for item in items:
if isinstance(item, dict) and str(item.get("content_sha") or "") == sha:
rows.append(item)
return [r.get("id") for r in rows]
def _make_pdf(text: str) -> bytes:
"""真实最小 PDF 字节(pymupdf 可解析、含目标文本)。"""
import pymupdf
doc = pymupdf.open()
page = doc.new_page()
page.insert_text((72, 72), text)
return doc.tobytes()
def test_same_content_added_twice_keeps_one_doc_id(vault):
"""#52b:同内容 add 两次 → 1 个 doc_id、chunk 数不翻倍、旧密文被清。"""
content = _make_pdf("同一份论文内容。")
first = vault.add(content, "paper.pdf", {"name": "论文", "category": "资料"})
assert first["content_sha"] == hashlib.sha256(content).hexdigest()
chunks_after_first = vault.chunks.count()
assert chunks_after_first >= 1
second = vault.add(content, "paper.pdf", {"name": "论文", "category": "资料"})
ids = _doc_ids_for_content(vault, content)
assert ids == [second["document_id"]], f"库中该内容 doc_id 应为仅新 doc:{ids}"
assert second["document_id"] != first["document_id"]
# 旧密文被清理(覆盖语义不留两份)
assert not (vault.vault_dir / f"{first['document_id']}.enc").exists()
assert (vault.vault_dir / f"{second['document_id']}.enc").exists()
# chunk 总数不翻倍(旧 doc_id 的 chunk 已清理)
assert vault.chunks.count() == chunks_after_first
# chunk 行全部属于新 doc_id(旧 doc_id 不再出现在检索结果)
assert {r["doc_id"] for r in vault.chunks.rows()} == {second["document_id"]}
def test_duplicate_reuses_latest_meta(vault):
"""#52b:覆盖语义——重复上传 meta 以本次为准(created_at 刷新、name 覆盖)。"""
content = "内容甲。".encode("utf-8")
first = vault.add(content, "a.txt", {"name": "旧名"})
second = vault.add(content, "a.txt", {"name": "新名"})
rows = vault.list_rows()
assert len(rows) == 1
assert rows[0]["name"] == "新名"
assert rows[0]["id"] == second["document_id"]
assert first["document_id"] not in [r["id"] for r in rows]
def test_different_content_keeps_two_doc_ids(vault):
"""G3-1:不同内容上传 → 2 个 doc_id 互不覆盖。"""
a = vault.add("内容甲。".encode("utf-8"), "a.txt", {"name": "甲"})
b = vault.add("内容乙。".encode("utf-8"), "b.txt", {"name": "乙"})
assert a["document_id"] != b["document_id"]
assert len(vault.list_rows()) == 2
assert set(_doc_ids_for_content(vault, "内容甲。".encode("utf-8"))) == {a["document_id"]}
assert set(_doc_ids_for_content(vault, "内容乙。".encode("utf-8"))) == {b["document_id"]}
def test_same_name_different_content_not_deduped(vault):
"""G3-2:同名不同内容 → 按 SHA256 不误判(保留 2 个 doc_id)。"""
a = vault.add(_make_pdf("内容甲。"), "same.pdf", {"name": "同名"})
b = vault.add(_make_pdf("内容乙。"), "same.pdf", {"name": "同名"})
assert a["document_id"] != b["document_id"]
assert len(vault.list_rows()) == 2
assert vault.chunks.count() >= 2
def test_duplicate_dedup_persists_across_reload(vault, tmp_path, monkeypatch):
"""#52b:去重依据(content_sha)落盘持久——重启后(新实例 load)仍不判重。"""
content = "持久化内容。".encode("utf-8")
vault.add(content, "a.txt", {"name": "甲"})
vault2 = Vault(tmp_path / "vault", tmp_path / "index")
vault2.index.load()
vault2.chunks.load()
item = vault2.add(content, "a.txt", {"name": "甲"})
assert _doc_ids_for_content(vault2, content) == [item["document_id"]]
assert vault2.chunks.count() >= 1
+89 -11
View File
@@ -130,22 +130,22 @@ class Vault:
# ---- 增删改查 ----
def add(
def _add_file(
self,
doc_id: str,
file_bytes: bytes,
filename: str,
meta: dict[str, Any],
as_template: bool = False,
name: str,
category: str,
tags: str,
description: str,
as_template: bool,
) -> dict[str, Any]:
"""入库一条。原文只在函数内流转,末尾丢弃引用。"""
doc_id = str(uuid.uuid4())
name = str(meta.get("name") or filename or "未命名")
category = str(meta.get("category") or "")
tags = str(meta.get("tags") or "")
description = str(meta.get("description") or "")
"""入库一条(覆盖语义已由调用方保证:同内容 doc 已被移除)。
if not file_bytes:
raise VaultError("上传文件为空")
与旧 add() 唯一差异是 doc_id 由调用方传入;函数内失败照常抛 VaultError,
由调用方决定失败回滚语义。
"""
try:
extracted = extract(file_bytes, filename)
except ParsingError as exc:
@@ -162,6 +162,7 @@ class Vault:
"description": description,
"as_template": bool(as_template),
"size": len(file_bytes),
"content_sha": hashlib.sha256(bytes(file_bytes)).hexdigest(),
"created_at": _now_iso(),
"embed_chunks": 0,
}
@@ -252,6 +253,7 @@ class Vault:
"description": description,
"as_template": bool(as_template),
"enc_path": str(enc_path),
"content_sha": doc_meta["content_sha"],
"embed_sha": embed_sha,
"embed_chunks": embed_chunks,
# FR-PRIV-002:chunk 级索引结果(失败不阻断 doc 级)
@@ -260,6 +262,75 @@ class Vault:
"chunk_error": chunk_error,
}
def _find_content_duplicate(self, content_sha: str) -> dict[str, Any] | None:
"""库中同内容哈希的 doc 行(无则 None)。
内容哈希存于 index.json 每行的 content_sha 字段(#52b 新增);历史行
无该字段(字段缺失/不一致时绝不判重)。直接读 index.json 而非内存
self.index._rows——覆盖语义要求与磁盘一致(同内容密文先删后建)。
"""
try:
data = json.loads(self.index.json_path.read_text(encoding="utf-8"))
items = data.get("items", []) if isinstance(data, dict) else []
except (ValueError, OSError):
return None
for item in items:
if isinstance(item, dict) and str(item.get("content_sha") or "") == content_sha:
return item
return None
def add(
self,
file_bytes: bytes,
filename: str,
meta: dict[str, Any],
as_template: bool = False,
) -> dict[str, Any]:
"""入库一条(内容 SHA256 去重,缺陷 #52b)。原文只在函数内流转,末尾丢弃引用。
去重语义(验收 §6 第 2 条 + 回归 G3):
- 内容哈希 = SHA256(原文件字节)。同内容重复上传 → 移除旧 doc 的密文/
检索行/chunk 行 → 落一条新 doc;该内容在库中始终仅 1 个 doc_id,
chunk 总数不翻倍。
- 旧行清理先于新 doc 落盘;新 doc 建库失败时旧 doc 已删(覆盖语义,
失败由调用方重传即可,绝无同内容双份)。
- 新 doc 的 meta 以本次上传为准(name/category/tags/description 覆盖,
created_at 刷新);as_template 以本次为准。
"""
doc_id = str(uuid.uuid4())
name = str(meta.get("name") or filename or "未命名")
category = str(meta.get("category") or "")
tags = str(meta.get("tags") or "")
description = str(meta.get("description") or "")
if not file_bytes:
raise VaultError("上传文件为空")
# ---- 内容去重(#52b):同内容哈希 → 先清旧 doc 再落新 doc ----
content_sha = hashlib.sha256(bytes(file_bytes)).hexdigest()
dup = self._find_content_duplicate(content_sha)
if dup:
old_doc_id = str(dup.get("id") or "")
if old_doc_id and old_doc_id != doc_id:
try:
self.index.remove_item(old_doc_id)
except Exception as exc:
logger.warning("vault: 去重移除旧检索行失败(%s):%s", old_doc_id, exc)
old_enc = self.vault_dir / f"{old_doc_id}.enc"
if old_enc.exists():
try:
secure_delete(old_enc)
except OSError as exc:
logger.warning("vault: 去重移除旧密文失败(%s):%s", old_enc, exc)
try:
self.chunks.remove_document(old_doc_id)
except Exception as exc:
logger.warning("vault: 去重移除旧 chunk 行失败(%s):%s", old_doc_id, exc)
return self._add_file(
doc_id, file_bytes, filename, name, category, tags, description, as_template
)
def get_decrypted(self, doc_id: str) -> dict[str, Any] | None:
"""取解密后的文档结构(meta + extracted)。不存在返回 None。"""
enc_path = self.vault_dir / f"{doc_id}.enc"
@@ -362,6 +433,13 @@ class Vault:
row = dict(meta)
# keywords 只用 meta,不含正文派生的 title(明文纪律)
row["keywords"] = _meta_to_keywords(meta)
# content_sha(#52b):历史密文 meta 若缺失则以密文内容补算(与入库时一致),
# 保证重建后去重依据不丢
if not row.get("content_sha"):
try:
row["content_sha"] = hashlib.sha256(load_encrypted(enc)).hexdigest()
except (CryptoError, VaultError, OSError):
row["content_sha"] = ""
items.append((row, vectors[0]))
try:
count = self.index.rebuild(items)