Files
rag/docs/向量库边界风险分析.md
lacerate551 cb75b9b274 fix(boundary): 修复多库边界问题、版本管理及删除清理
多库检索与存储修复:
- RRF 融合去重改用 (collection, chunk_id) 复合键,修复同名文件结果被吞
- DocStore 存储路径加 collection 前缀,修复跨库同名切片数据覆盖
- search_multiple 去重改用复合键
- chunk_id 解析改用 rsplit 兼容下划线文件名

上传与版本管理修复:
- 同名文件上传改为覆盖模式,自动清理旧切片
- 修复首次上传不创建版本记录
- 修复覆盖上传版本号回退到 v1
- sync ADDED 分支改用动态版本号生成
- _generate_version_id 改为基于全部版本递增
- 废止/恢复操作同步 SQLite 版本记录
- mark_document_as_superseded 改为仅更新 SQLite

删除清理修复:
- 删除文档时同步清理 SQLite 版本记录和变更日志
- 删除向量库时同步清理该库所有版本记录
- cleanup 改为清理 SQLite 记录而非 ChromaDB

测试:
- test_version_management.py: 27 条版本管理单元测试
- test_edge_cases.py: 28 条边界用例测试
- test_upload_dedup.py: 5 条上传去重测试
- e2e_risk_test.py: 27 条端到端风险测试

文档:
- 新增风险边界问题修复注意事项.md(面向后端的对接文档)
- 新增向量库边界风险分析.md
- 更新多篇现有文档
2026-06-04 23:58:44 +08:00

165 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 向量库边界风险分析
基于对 `knowledge/``core/engine.py``api/chat_routes.py``api/document_routes.py` 全链路代码的审查,覆盖 chunk_id 生成、多库检索合并、RRF 融合、引用构建、文档预览、DocStore 存储、上传/删除等环节。
---
### P0多库 RRF 融合按 chunk_id 去重 — 同名文件结果被吞
**位置**`core/engine.py` 第 1841 行 `reciprocal_rank_fusion()`
```python
if doc_id not in doc_scores:
doc_scores[doc_id] = {'score': 0.0, 'doc': doc, 'meta': meta}
doc_scores[doc_id]['score'] += rrf_score
```
**场景**`public_kb``dept_1_kb` 都上传了 `规章制度.pdf`,两个库中的 chunk_id 完全相同(`规章制度.pdf_0``规章制度.pdf_1` ...)。多库并行检索后进入 RRF 融合,`doc_scores` 以 chunk_id 为 key来自不同 collection 的同名 chunk 被视为同一文档——分数累加,但只保留先入的那条 `doc``meta`,另一个 collection 的切片被静默丢弃。
**影响**
- 用户查询可能只看到 `public_kb` 的版本,`dept_1_kb` 的同名文件完全不出现在结果中
- 引用溯源的 `_collection` 字段指向错误的库
**修复方向**:将 RRF 的 key 从 `doc_id` 改为 `f"{coll_name}/{doc_id}"`(复合键),或者在 chunk_id 前拼接 collection 名称。需要同步修改下游 MMR、Rerank、上下文扩展等环节的 ID 处理。
---
### P0DocStore 文件碰撞 — 跨库同名切片的表格/图片数据互相覆盖
**位置**`knowledge/processing.py` 第 345、374 行
```python
doc_path = docstore_dir / f"{doc_id}.json" # doc_id = chunk_id = "filename_N"
```
**场景**`public_kb``规章制度.pdf` 第 3 个切片chunk_id = `规章制度.pdf_2`)包含一个表格,写入 `.data/docstore/规章制度.pdf_2.json`。之后 `dept_1_kb` 也上传了同名文件,其第 3 个切片也写入 `.data/docstore/规章制度.pdf_2.json`,直接覆盖了前者。
**影响**:表格原始 Markdown 或图片引用路径被覆盖,引用跳转时展示的是错误 collection 的表格内容。
**修复方向**DocStore 路径改为 `{collection}_{doc_id}.json`,或在 JSON 内增加 collection 字段并检测冲突。
---
### P1同名文件重复上传 — 旧切片残留 + 搜索结果重复
**位置**`api/document_routes.py` 第 246-250 行
```python
if os.path.exists(filepath):
timestamp = datetime.now().strftime('_%Y%m%d_%H%M%S')
filename = f"{name}{timestamp}{ext_part}"
```
**场景**:用户向 `public_kb` 上传 `制度.pdf`v1后来更新为 v2 再次上传同名文件。系统自动重命名为 `制度_20260604_120000.pdf`v1 的旧切片(`制度.pdf_0` ... `制度.pdf_N`)仍然存在于向量库中。搜索结果同时返回 v1 和 v2 的内容,用户无法区分。
**影响**
- 搜索结果中出现重复内容(不同文件名但内容相近)
- MMR 去重可能保留 v1 的切片而排除 v2 的
- 文档列表 `list_documents()` 显示两个文件,用户难以判断哪个是最新版
**修复方向**:上传时检测是否存在同名文件,若有则先调用 `delete_document()` 清除旧切片,再写入新版本(或增加版本管理字段自动关联)。
---
### P1`_collection` 字段回退逻辑 — 多库时始终指向第一个 collection
**位置**`api/chat_routes.py` 第 1529-1536 行
```python
if not meta.get('_collection'):
if collections and len(collections) == 1:
meta['_collection'] = collections[0]
elif collections:
meta['_collection'] = collections[0] # 多知识库时回退到第一个
else:
meta['_collection'] = 'public_kb'
```
**场景**:在单库检索路径下(非 `_search_multi_kb`ChromaDB 原生返回的 metadata 不含 `_collection` 字段。当 `collections` 列表包含多个库名时,所有结果的 `_collection` 都被设为 `collections[0]`——即使某些结果实际来自其他库。
**影响**:引用跳转时 `preview API` 用错误的 collection 查询,返回 404 或其他文档的切片。
**现状缓解**:生产环境走 `_search_multi_kb` 路径时,每个 collection 的 metadata 都在查询时被正确注入了 `_collection`engine.py 第 1406 行),所以这个回退逻辑实际上很少被触发。但作为防御性代码,它的行为是错误的。
**修复方向**:回退逻辑应使用 `meta.get('collection')`(入库时写入的字段),而不是硬编码 `collections[0]`
---
### P1`search_multiple()` 去重仅按 ID — 低层 API 的同名文件问题
**位置**`knowledge/search.py` 第 289-292 行 `_merge_multiple_results()`
```python
seen = set()
for item in all_items:
if item['id'] not in seen:
seen.add(item['id'])
unique_items.append(item)
```
**场景**:与 P0 RRF 问题类似,但在 `SearchMixin.search_multiple()` 这个独立的检索 API 中。虽然生产 RAG 路径不走这个方法(走 engine 的 `_search_multi_kb`),但任何直接调用 `kb_manager.search_multiple()` 的代码都会遇到同样的去重问题。
**修复方向**:去重键改为 `(collection, id)` 复合键。
---
### P2引用构建的字段名不一致 — `collection` vs `_collection`
**位置**`knowledge/manager.py` 第 281 行写入 `"collection": kb_name``api/chat_routes.py` 第 590 行读取 `meta.get('_collection', '')`
**场景**:入库时 metadata 存的是 `collection`(无下划线),但 citation 构建时读的是 `_collection`(有下划线)。两个字段名不一致,如果 metadata 中没有被后续流程注入 `_collection`citation 的 `collection` 字段会返回空字符串。
**影响**:前端引用跳转 URL 缺少 collection 部分preview API 路径不完整。
**现状缓解**:生产 RAG 路径在检索阶段会注入 `_collection`engine.py 第 1406 行),所以正常流程下不会触发。但其他非标准路径(如直接查询 ChromaDB会暴露此问题。
**修复方向**`_build_citation` 同时读取两个字段名:`meta.get('_collection') or meta.get('collection', '')`
---
### P2文件无原地更新机制
**场景**:用户上传 `制度.pdf` v1 后发现内容有误,修改后想替换。当前系统没有 "更新文件" 接口,只能删除后重新上传。如果用户不知道要先删除,就会触发 P1 的重复问题。
**修复方向**upload 接口增加 "如果同名文件已存在则替换" 选项(先 delete_document 再 add_file_to_kb或提供独立的 "更新文档" API。
---
### P2元数据文件与磁盘状态不同步
**位置**`knowledge/collection.py` 第 362 行 `list_collections()` 依赖 `self._metadata` JSON 文件
**场景**metadata JSON 文件存储在 `knowledge/vector_store/kb_metadata.json`。如果该文件损坏或被手动修改,`list_collections()` 返回的列表与磁盘上实际的 ChromaDB 目录不一致。虽然有自动补充逻辑(扫描磁盘目录自动注册),但仅限于回退场景。
**影响**:向量库可能在列表中不可见,或列表中出现已不存在的向量库。
---
### P3文件名含下划线
**位置**`api/chat_routes.py` 第 578 行
```python
chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
```
**场景**:文件名为 `2026_年度_制度.pdf`chunk_id 为 `2026_年度_制度.pdf_0`
**现状**:使用 `rsplit('_', 1)` 从右边分割,最后一个 `_` 后的数字能正确解析。此场景当前不会出错。但如果文件名为 `test_.pdf`尾部下划线chunk_id 为 `test_.pdf_0``rsplit` 仍正常工作。**无需修复**,记录备查。
---
### 风险总结
| 等级 | 风险 | 核心原因 | 触发条件 |
|------|------|----------|----------|
| **P0** | RRF 融合吞结果 | 去重 key 缺少 collection | 多库有同名文件 |
| **P0** | DocStore 覆盖 | 存储路径缺少 collection | 多库有同名含表格/图片文件 |
| **P1** | 旧切片残留 | 重复上传只改名不替换 | 同名文件二次上传 |
| **P1** | _collection 回退错误 | 硬编码 collections[0] | 单库路径 + 多 collection |
| **P1** | search_multiple 去重 | 去重 key 缺少 collection | 直接调用低层 API |
| **P2** | citation 字段名不一致 | `collection` vs `_collection` | 非标准查询路径 |
| **P2** | 无文件更新机制 | 设计缺失 | 用户需要替换文档 |
| **P2** | 元数据不同步 | JSON 文件可能损坏 | 手动操作或异常退出 |
| **P3** | 文件名含下划线 | 无问题rsplit 兼容) | — |