多库检索与存储修复: - 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 - 更新多篇现有文档
165 lines
8.4 KiB
Markdown
165 lines
8.4 KiB
Markdown
## 向量库边界风险分析
|
||
|
||
基于对 `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 处理。
|
||
|
||
---
|
||
|
||
### P0:DocStore 文件碰撞 — 跨库同名切片的表格/图片数据互相覆盖
|
||
|
||
**位置**:`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 兼容) | — |
|