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 - 更新多篇现有文档
This commit is contained in:
@@ -415,12 +415,16 @@ def _attach_citations(answer: str, contexts: List[Dict]) -> Dict[str, Any]:
|
||||
if not contexts:
|
||||
return {"answer_with_refs": answer, "citations": []}
|
||||
|
||||
# 按 chunk_id 组织 contexts
|
||||
# 按 (collection, chunk_id) 复合键组织 contexts,防止跨库同名文件覆盖
|
||||
ctx_by_chunk = {}
|
||||
for ctx in contexts:
|
||||
meta = ctx.get('meta', {})
|
||||
chunk_id = meta.get('chunk_id') or f"{meta.get('source')}_{meta.get('chunk_index', 0)}"
|
||||
ctx_by_chunk[chunk_id] = ctx
|
||||
coll = meta.get('_collection') or meta.get('collection') or ''
|
||||
composite_key = f"{coll}/{chunk_id}" if coll else chunk_id
|
||||
# 保存原始 chunk_id,用于对外输出(ref tag / citation)
|
||||
ctx['_raw_chunk_id'] = chunk_id
|
||||
ctx_by_chunk[composite_key] = ctx
|
||||
|
||||
# jieba 分词函数(fallback 到字符级)
|
||||
try:
|
||||
@@ -485,20 +489,27 @@ def _attach_citations(answer: str, contexts: List[Dict]) -> Dict[str, Any]:
|
||||
if cid not in cited_set:
|
||||
cited_set.add(cid)
|
||||
cited_chunks_ordered.append(cid)
|
||||
# 在段落末尾插入引用标记(多个引用连续排列)
|
||||
ref_tags = "".join(f"[ref:{cid}]" for cid in selected_ids)
|
||||
# 在段落末尾插入引用标记(使用原始 chunk_id,不暴露复合键)
|
||||
ref_tags = "".join(
|
||||
f"[ref:{ctx_by_chunk[cid].get('_raw_chunk_id', cid)}]"
|
||||
for cid in selected_ids
|
||||
)
|
||||
result_parts.append(f"{para}{ref_tags}{sep}")
|
||||
else:
|
||||
result_parts.append(para + sep)
|
||||
|
||||
# 构建引用列表(按出现顺序)
|
||||
# 构建引用列表(按出现顺序),使用原始 chunk_id 构建 citation
|
||||
citations = []
|
||||
for chunk_id in cited_chunks_ordered:
|
||||
ctx = ctx_by_chunk.get(chunk_id)
|
||||
for composite_key in cited_chunks_ordered:
|
||||
ctx = ctx_by_chunk.get(composite_key)
|
||||
if ctx:
|
||||
meta = ctx.get('meta', {})
|
||||
full_content = ctx.get('doc', '')
|
||||
citation = _build_citation(meta, full_content)
|
||||
# 确保 citation 中的 chunk_id 使用原始值(不含 collection 前缀)
|
||||
raw_id = ctx.get('_raw_chunk_id')
|
||||
if raw_id:
|
||||
citation['chunk_id'] = raw_id
|
||||
citations.append(citation)
|
||||
|
||||
return {
|
||||
@@ -587,7 +598,7 @@ def _build_citation(meta: Dict, full_content: str = '') -> Dict[str, Any]:
|
||||
"chunk_id": chunk_id_raw,
|
||||
"chunk_index": chunk_index, # 全局切片序号,用于精准定位文档位置
|
||||
"source": meta.get('source', ''),
|
||||
"collection": meta.get('_collection', ''), # 所属向量库,用于前端文档预览跳转
|
||||
"collection": meta.get('_collection') or meta.get('collection', ''), # 所属向量库,用于前端文档预览跳转
|
||||
"doc_type": meta.get('doc_type', 'other'),
|
||||
"section": _clean_section(meta.get('section', '')),
|
||||
"preview": meta.get('preview', ''),
|
||||
@@ -1528,12 +1539,8 @@ def rag():
|
||||
meta['_retrieval_rank'] = rank
|
||||
# 确保 _collection 字段存在(单知识库路径下 ChromaDB 原生不返回此字段)
|
||||
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'
|
||||
# 优先使用入库时写入的 collection 字段
|
||||
meta['_collection'] = meta.get('collection') or (collections[0] if collections else 'public_kb')
|
||||
source_name = meta.get('source', '未知')
|
||||
if source_name not in seen_sources or score > seen_sources[source_name]['score']:
|
||||
doc_type = meta.get('doc_type', 'other')
|
||||
|
||||
@@ -243,14 +243,75 @@ def upload_document() -> Tuple[Any, int]:
|
||||
filename = f"upload_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}"
|
||||
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
timestamp = datetime.now().strftime('_%Y%m%d_%H%M%S')
|
||||
name, ext_part = os.path.splitext(filename)
|
||||
filename = f"{name}{timestamp}{ext_part}"
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
|
||||
# 同名文件处理:清理旧切片 + 覆盖旧文件(替代原来的时间戳重命名策略)
|
||||
replaced = False
|
||||
if os.path.exists(filepath):
|
||||
replaced = True
|
||||
# 1. 清理旧切片(ChromaDB + BM25 + DocStore)
|
||||
kb_manager = _get_kb_manager()
|
||||
if kb_manager:
|
||||
old_chunks = kb_manager.get_document_chunks(collection, filename)
|
||||
if old_chunks:
|
||||
old_ids = [c['id'] for c in old_chunks]
|
||||
coll_obj = kb_manager.get_collection(collection)
|
||||
if coll_obj:
|
||||
coll_obj.delete(ids=old_ids)
|
||||
logger.info(f"替换上传: 清理旧切片 {filename} -> {collection}, 共 {len(old_ids)} 个")
|
||||
|
||||
# 清理关联的 DocStore 文件
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
docstore_dir = _Path('.data/docstore')
|
||||
if docstore_dir.exists():
|
||||
for ds_file in docstore_dir.glob(f'{collection}_{filename}_*.json'):
|
||||
ds_file.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"清理 DocStore 失败: {e}")
|
||||
|
||||
# 重建 BM25 索引
|
||||
kb_manager.rebuild_bm25_index(collection)
|
||||
|
||||
# 2. 清理同步哈希记录(以便 sync 能正确检测为新文件)
|
||||
try:
|
||||
from knowledge.sync import SyncDatabase
|
||||
sync_db = SyncDatabase()
|
||||
sync_db.delete_document_hash(f"{collection}/{filename}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
# 保存文件(覆盖同名旧文件)
|
||||
file.save(filepath)
|
||||
|
||||
# 版本管理:覆盖上传时标记旧版本为 superseded(新版本记录由 sync 服务统一创建)
|
||||
if replaced:
|
||||
try:
|
||||
from knowledge.document_versions import get_version_query
|
||||
from data.db import get_connection
|
||||
vq = get_version_query()
|
||||
active = vq.get_active_version(collection, filename)
|
||||
|
||||
if active:
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute("""
|
||||
UPDATE document_versions
|
||||
SET status='superseded', deprecated_date=?,
|
||||
deprecated_reason='重新上传覆盖'
|
||||
WHERE collection=? AND document_id=? AND version=?
|
||||
""", (datetime.now().isoformat(), collection,
|
||||
filename, active.version))
|
||||
conn.commit()
|
||||
vq.log_version_change(
|
||||
collection, filename,
|
||||
change_type="reupload",
|
||||
old_version=active.version,
|
||||
old_status="active", new_status="superseded",
|
||||
reason="重新上传覆盖",
|
||||
changed_by=request.current_user.get('user_id', '')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"标记旧版本失败: {e}")
|
||||
|
||||
# 6. 触发向量化
|
||||
sync_status = "已保存,等待手动同步"
|
||||
sync_service = _get_sync_service()
|
||||
@@ -276,7 +337,8 @@ def upload_document() -> Tuple[Any, int]:
|
||||
"filename": filename,
|
||||
"collection": collection,
|
||||
"path": f"{target_subdir}/{filename}",
|
||||
"size": file_size
|
||||
"size": file_size,
|
||||
"replaced": replaced
|
||||
},
|
||||
"sync_status": sync_status
|
||||
},
|
||||
@@ -350,19 +412,56 @@ def batch_upload_documents() -> Tuple[Any, int]:
|
||||
filename = f"upload_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}"
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
|
||||
# 处理重名
|
||||
# 同名文件处理:清理旧切片 + 覆盖旧文件
|
||||
replaced = False
|
||||
if os.path.exists(filepath):
|
||||
timestamp = datetime.now().strftime('_%Y%m%d_%H%M%S')
|
||||
name, ext_part = os.path.splitext(filename)
|
||||
filename = f"{name}{timestamp}{ext_part}"
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
replaced = True
|
||||
kb_mgr = _get_kb_manager()
|
||||
if kb_mgr:
|
||||
old_chunks = kb_mgr.get_document_chunks(collection, filename)
|
||||
if old_chunks:
|
||||
old_ids = [c['id'] for c in old_chunks]
|
||||
coll_obj = kb_mgr.get_collection(collection)
|
||||
if coll_obj:
|
||||
coll_obj.delete(ids=old_ids)
|
||||
logger.info(f"批量替换: 清理旧切片 {filename} -> {collection}, 共 {len(old_ids)} 个")
|
||||
kb_mgr.rebuild_bm25_index(collection)
|
||||
|
||||
try:
|
||||
from knowledge.sync import SyncDatabase
|
||||
sync_db = SyncDatabase()
|
||||
sync_db.delete_document_hash(f"{collection}/{filename}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
file.save(filepath)
|
||||
|
||||
# 版本管理:覆盖上传时标记旧版本为 superseded(新版本记录由 sync 服务统一创建)
|
||||
if replaced:
|
||||
try:
|
||||
from knowledge.document_versions import get_version_query
|
||||
from data.db import get_connection
|
||||
vq = get_version_query()
|
||||
active = vq.get_active_version(collection, filename)
|
||||
|
||||
if active:
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute("""
|
||||
UPDATE document_versions
|
||||
SET status='superseded', deprecated_date=?,
|
||||
deprecated_reason='批量上传覆盖'
|
||||
WHERE collection=? AND document_id=? AND version=?
|
||||
""", (datetime.now().isoformat(), collection,
|
||||
filename, active.version))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"批量上传标记旧版本失败: {e}")
|
||||
|
||||
results.append({
|
||||
"filename": filename,
|
||||
"status": "success",
|
||||
"path": f"{target_subdir}/{filename}"
|
||||
"path": f"{target_subdir}/{filename}",
|
||||
"replaced": replaced
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
|
||||
@@ -1112,6 +1112,13 @@ class RAGEngine:
|
||||
):
|
||||
items.append((doc_id, doc, meta, dist))
|
||||
existing_ids = {item[0] for item in items}
|
||||
# 兼容 ID 前缀:同时存储原始 ID 和去前缀版本,确保邻居匹配不遗漏
|
||||
_raw_id_set = set()
|
||||
for _eid in existing_ids:
|
||||
if '/' in _eid:
|
||||
_raw_id_set.add(_eid.split('/', 1)[1])
|
||||
else:
|
||||
_raw_id_set.add(_eid)
|
||||
|
||||
seeds = [
|
||||
(doc_id, doc, meta, dist)
|
||||
@@ -1178,7 +1185,8 @@ class RAGEngine:
|
||||
break
|
||||
if seed_neighbors_added >= MAX_EXPANDED_NEIGHBORS:
|
||||
break
|
||||
if n_id in existing_ids:
|
||||
# 兼容前缀 ID:用原始 ID 和去前缀版本双重匹配
|
||||
if n_id in existing_ids or n_id in _raw_id_set:
|
||||
continue
|
||||
n_meta = dict(n_meta or {})
|
||||
if seed_meta.get('_collection') and not n_meta.get('_collection'):
|
||||
@@ -1188,6 +1196,7 @@ class RAGEngine:
|
||||
distance = seed_dist + 0.0001 * abs(n_index - seed_index)
|
||||
items.append((n_id, n_doc, n_meta, distance))
|
||||
existing_ids.add(n_id)
|
||||
_raw_id_set.add(n_id.split('/', 1)[1] if '/' in n_id else n_id)
|
||||
added += 1
|
||||
seed_neighbors_added += 1
|
||||
|
||||
@@ -1830,6 +1839,7 @@ class RAGEngine:
|
||||
if weights is None:
|
||||
weights = [1.0] * len(results_list)
|
||||
|
||||
# 使用 (collection, doc_id) 复合键去重,防止跨库同名文件的结果被吞
|
||||
doc_scores = {}
|
||||
for results, weight in zip(results_list, weights):
|
||||
if not results['documents'] or not results['documents'][0]:
|
||||
@@ -1838,13 +1848,27 @@ class RAGEngine:
|
||||
results['ids'][0], results['documents'][0], results['metadatas'][0]
|
||||
)):
|
||||
rrf_score = weight / (k + rank + 1)
|
||||
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
|
||||
coll = meta.get('_collection') or meta.get('collection') or ''
|
||||
# 复合键:同 collection 内同名 doc_id 合并分数(向量+BM25),跨 collection 不合并
|
||||
composite_key = f"{coll}\x00{doc_id}" if coll else doc_id
|
||||
if composite_key not in doc_scores:
|
||||
doc_scores[composite_key] = {'score': 0.0, 'doc': doc, 'meta': meta, 'coll': coll, 'raw_id': doc_id}
|
||||
doc_scores[composite_key]['score'] += rrf_score
|
||||
|
||||
sorted_items = sorted(doc_scores.items(), key=lambda x: x[1]['score'], reverse=True)
|
||||
|
||||
# 输出 ID 加 collection 前缀,确保跨库同名切片在全链路中可区分
|
||||
out_ids = []
|
||||
for item in sorted_items:
|
||||
coll = item[1]['coll']
|
||||
raw_id = item[1]['raw_id']
|
||||
if coll and not raw_id.startswith(f"{coll}/"):
|
||||
out_ids.append(f"{coll}/{raw_id}")
|
||||
else:
|
||||
out_ids.append(raw_id)
|
||||
|
||||
return {
|
||||
'ids': [[item[0] for item in sorted_items]],
|
||||
'ids': [out_ids],
|
||||
'documents': [[item[1]['doc'] for item in sorted_items]],
|
||||
'metadatas': [[item[1]['meta'] for item in sorted_items]],
|
||||
'distances': [[item[1]['score'] for item in sorted_items]],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Agentic RAG 完整指南
|
||||
|
||||
> **版本**: v3.1 (文档勘误修正)
|
||||
> **版本**: v3.2(模型/Reranker/管线更新)
|
||||
> **生产入口**: `api/chat_routes.py::rag()` → `core/engine.py`(轻量编排,当前启用)
|
||||
> **备用编排**: `core/agentic.py::AgenticRAG.process()` + 8 个 Mixin(完整决策循环,未接线)
|
||||
> **最后更新**: 2026-06-03
|
||||
> **最后更新**: 2026-06-04
|
||||
>
|
||||
> ⚠️ 项目存在两套编排,生产 `/rag` 走的不是 `AgenticRAG`——详见下方「一·五、两套编排路径」。
|
||||
|
||||
@@ -16,7 +16,7 @@ Agentic RAG 是一个智能问答系统,基于 Mixin 模式组合 8 个功能
|
||||
| **意图分析** | LLM 驱动的查询改写 + 双层判断(是否需要检索) | `IntentAnalyzer`(独立模块) |
|
||||
| **查询重写** | 口语化→专业术语、实体补全、指代消解 | `QueryRewriteMixin` |
|
||||
| **混合检索** | 向量检索 + BM25 + RRF 融合 + Rerank 重排 | `SearchMixin` → `RAGEngine` |
|
||||
| **多源融合** | 知识库 + 网络搜索 + 知识图谱,智能处理冲突 | `AnswerMixin` |
|
||||
| **多源融合** | 知识库 + 网络搜索,智能处理冲突 | `AnswerMixin` |
|
||||
| **幻觉验证** | 基于参考信息验证答案,防止 LLM 编造 | `AnswerMixin` |
|
||||
| **引用标注** | 自动标注信息来源和引用编号 | `CitationMixin` |
|
||||
| **富媒体提取** | 图片/表格的智能提取与展示 | `RichMediaMixin` |
|
||||
@@ -132,7 +132,7 @@ grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .proce
|
||||
│ 3. 知识库检索 (RAGEngine.search_knowledge)│
|
||||
│ 4. 上下文压缩 (ContextMixin) │
|
||||
│ 5. 网络搜索 (SearchMixin, 可选) │
|
||||
│ 6. 图谱检索 (SearchMixin, 可选) │
|
||||
│ 6. (图谱检索已废弃,graph/ 目录已清空) │
|
||||
│ 7. 融合答案生成 (AnswerMixin) │
|
||||
│ 8. 幻觉验证 (AnswerMixin) │
|
||||
│ 9. 富媒体提取 (RichMediaMixin) │
|
||||
@@ -152,9 +152,9 @@ grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .proce
|
||||
│ └──────┬───────┘ │
|
||||
│ ↓ │
|
||||
│ ┌───────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 废止过滤 │→ │ MMR 去重 │→ │ Rerank 重排 │ │
|
||||
│ └───────────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ ↓ ↓ │
|
||||
│ │ 废止过滤 │→ │ Rerank 重排 │→ │ MMR 去重 │ │
|
||||
│ └───────────┘ │ (云端API) │ └──────┬───────┘ │
|
||||
│ └──────┬───────┘ ↓ │
|
||||
│ ┌───────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ FAQ 加权 │→ │ 黑名单过滤 │→ │ 时间衰减 │ │
|
||||
│ └───────────┘ └──────────────┘ └──────┬───────┘ │
|
||||
@@ -177,7 +177,7 @@ grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .proce
|
||||
```python
|
||||
class AgenticRAG(
|
||||
QueryRewriteMixin, # 查询重写:口语化→专业术语、实体补全
|
||||
SearchMixin, # 检索功能:网络搜索、图谱检索
|
||||
SearchMixin, # 检索功能:网络搜索
|
||||
AnswerMixin, # 答案生成:融合回答、幻觉验证
|
||||
CitationMixin, # 引用处理:来源标注、引用编号
|
||||
RichMediaMixin, # 富媒体:图片/表格提取
|
||||
@@ -208,8 +208,9 @@ POST /rag (SSE 流式)
|
||||
│ └─[DEV] 发 SSE: intent_result
|
||||
│
|
||||
├─ 2. 混合检索 search_hybrid() → engine.search_knowledge() # chat_routes:1300
|
||||
│ (内部:向量+BM25+RRF+废止过滤+章节过滤+MMR+Rerank
|
||||
│ +FAQ加权+黑名单+时间衰减+上下文扩展+自适应TopK)
|
||||
│ (内部:向量+BM25+RRF+废止过滤+章节过滤
|
||||
│ +云端Rerank+MMR去重+FAQ加权+黑名单+时间衰减
|
||||
│ +上下文扩展+自适应TopK)
|
||||
│ └─[DEV] 发 SSE: retrieval_debug
|
||||
│
|
||||
├─ 3. 提取上下文/来源(按 source 去重,doc_type 驱动溯源展示) # chat_routes:1362
|
||||
@@ -313,26 +314,25 @@ search_knowledge(query, top_k=30)
|
||||
│
|
||||
├─ 7. 章节过滤(查询提到章节时优先匹配)
|
||||
│
|
||||
├─ 8. 上下文扩展(MMR 前,防止邻居被去重)
|
||||
│
|
||||
├─ 9. MMR 去重(前置到 Rerank 前)
|
||||
│ ├─ 语义向量版(MMR_USE_EMBEDDING=True)
|
||||
│ └─ 文本 Jaccard 版(MMR_USE_EMBEDDING=False)
|
||||
│
|
||||
├─ 10. ★ Rerank 重排 ★
|
||||
├─ 8. ★ Rerank 重排 ★(云端 DashScope qwen3-rerank 或本地 BGE)
|
||||
│ └─ rerank_results(query, results, top_k)
|
||||
│ └─ 由 RERANK_BACKEND 控制(local/cloud/fallback)
|
||||
│
|
||||
├─ 11. FAQ 分数加权(Score Boosting)
|
||||
├─ 9. MMR 去重
|
||||
│ ├─ 语义向量版(MMR_USE_EMBEDDING=True)
|
||||
│ └─ 文本 Jaccard 版(MMR_USE_EMBEDDING=False,生产推荐)
|
||||
│
|
||||
├─ 12. 黑名单过滤(负反馈降权)
|
||||
├─ 10. FAQ 分数加权(Score Boosting)
|
||||
│
|
||||
├─ 13. 时间衰减(Time Decay)
|
||||
├─ 11. 黑名单过滤(负反馈降权)
|
||||
│
|
||||
├─ 14. 上下文扩展(Rerank 后,补充相邻切片)
|
||||
├─ 12. 时间衰减(Time Decay)
|
||||
│
|
||||
├─ 15. 自适应 TopK(根据置信度调整返回数量)
|
||||
├─ 13. 上下文扩展(Rerank 后,补充相邻切片)
|
||||
│
|
||||
└─ 16. 缓存写入 → 返回结果
|
||||
├─ 14. 自适应 TopK(根据置信度调整返回数量)
|
||||
│
|
||||
└─ 15. 缓存写入 → 返回结果
|
||||
```
|
||||
|
||||
### 4.2 混合检索代码示例
|
||||
@@ -353,11 +353,11 @@ image_results = collection.query(query_embeddings=[query_vector], n_results=5, w
|
||||
# RRF 融合(动态权重)
|
||||
fused = reciprocal_rank_fusion([vector_results, bm25_results], weights=[vector_w, bm25_w])
|
||||
|
||||
# MMR 去重
|
||||
mmr_results = mmr_rerank(query_emb, candidates, top_k=30, lambda_param=0.5)
|
||||
# Rerank 重排(云端 DashScope qwen3-rerank 或本地 BGE)
|
||||
reranked = rerank_results(query, fused, top_k=15)
|
||||
|
||||
# Rerank 重排
|
||||
final = rerank_results(query, mmr_results, top_k=15)
|
||||
# MMR 去重
|
||||
mmr_results = mmr_rerank(query_emb, reranked, top_k=30, lambda_param=0.5)
|
||||
```
|
||||
|
||||
### 4.3 RRF 融合算法
|
||||
@@ -378,19 +378,42 @@ RRF分数 = Σ (权重 / (k + 排名位置))
|
||||
|
||||
### 4.4 Rerank 重排
|
||||
|
||||
**模型**: `BAAI/bge-reranker-base`(CrossEncoder 交叉编码器,~278MB)
|
||||
**后端**: 支持三种模式,由 `RERANK_BACKEND` 环境变量控制
|
||||
|
||||
| RERANK_BACKEND | 说明 |
|
||||
|----------------|------|
|
||||
| `"cloud"` | 仅使用云端 DashScope `qwen3-rerank` API |
|
||||
| `"local"` | 仅使用本地 `BAAI/bge-reranker-base`(CrossEncoder / ONNX) |
|
||||
| `"fallback"` | 优先云端,失败时自动回退本地(推荐生产环境) |
|
||||
|
||||
**云端 Reranker(推荐)**:
|
||||
|
||||
```python
|
||||
# config.py
|
||||
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local") # local / cloud / fallback
|
||||
RERANK_CLOUD_MODEL = "qwen3-rerank" # DashScope 云端 Rerank 模型
|
||||
RERANK_CLOUD_API_KEY = os.getenv("RERANK_CLOUD_API_KEY", DASHSCOPE_API_KEY)
|
||||
RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
|
||||
RERANK_CLOUD_TIMEOUT = 15 # 云端请求超时(秒)
|
||||
```
|
||||
|
||||
`CloudReranker` 类(`core/engine.py`)封装 DashScope 的 `/compatible-api/v1/reranks` 接口,提供与本地 `CrossEncoder.predict()` / `ONNXReranker.predict()` 一致的调用接口。
|
||||
|
||||
**本地 Reranker(备选)**:
|
||||
|
||||
```python
|
||||
def rerank_results(self, query, results, top_k=5):
|
||||
pairs = [(query, doc) for doc in results['documents'][0]]
|
||||
scores = self.reranker.predict(pairs) # CrossEncoder 或 ONNXReranker
|
||||
scores = self.reranker.predict(pairs) # CrossEncoder / ONNXReranker / CloudReranker
|
||||
sorted_indices = np.argsort(scores)[::-1]
|
||||
# 返回 top_k 个最高分结果
|
||||
```
|
||||
|
||||
**调用位置**: `core/engine.py` 的 `search_knowledge()` 和 `_search_multi_kb()` 中,MMR 去重之后执行。
|
||||
**调用位置**: `core/engine.py` 的 `search_knowledge()` 和 `_search_multi_kb()` 中,RRF 融合 + 废止/章节过滤之后、MMR 去重之前执行。
|
||||
|
||||
**ONNX 加速**: `ONNXReranker` 使用 `optimum.onnxruntime` 进行推理,CPU 上比原生 PyTorch 快 2-3 倍。首次使用时自动将 PyTorch 模型导出为 ONNX 格式。
|
||||
**引擎初始化顺序**: `RAGEngine.__init__()` 中按 `RERANK_BACKEND` 决定加载策略:
|
||||
- `cloud` / `fallback`:先尝试创建 `CloudReranker`,需要 `RERANK_CLOUD_API_KEY`
|
||||
- `local` / `fallback`(云端失败时):加载本地 `BAAI/bge-reranker-base`,支持 ONNX 加速
|
||||
|
||||
---
|
||||
|
||||
@@ -444,7 +467,7 @@ def process(self, query, verbose=True, history=None,
|
||||
| 3 | `engine.search_knowledge()` / `engine.search_multiple()` | 知识库检索(含向量+BM25+RRF+MMR+Rerank) |
|
||||
| 4 | `_compress_contexts()` | 上下文压缩(Rerank 阈值过滤) |
|
||||
| 5 | `_web_search_flow()` | 网络搜索(可选,需 SERPER_API_KEY) |
|
||||
| 6 | `_graph_search()` | 图谱检索(可选,需 Neo4j) |
|
||||
| 6 | ~~`_graph_search()`~~ | ~~图谱检索(已废弃,graph/ 目录已清空)~~ |
|
||||
| 7 | `_generate_fused_answer()` | 融合答案生成(多源信息+冲突处理) |
|
||||
| 8 | `_verify_and_refine_answer()` | 幻觉验证(防止 LLM 编造) |
|
||||
| 9 | `_extract_rich_media()` | 富媒体提取(图片/表格) |
|
||||
@@ -502,7 +525,7 @@ for token in engine.generate_answer_stream(query, context, history=history):
|
||||
```python
|
||||
from core.agentic import AgenticRAG
|
||||
|
||||
rag = AgenticRAG(max_iterations=3, enable_web_search=True, enable_graph=True)
|
||||
rag = AgenticRAG(max_iterations=3, enable_web_search=True)
|
||||
result = rag.process("出差补助标准是什么?")
|
||||
|
||||
print(f"答案: {result['answer']}")
|
||||
@@ -521,9 +544,10 @@ print(f"引用: {result['citations']}")
|
||||
# config.py
|
||||
DASHSCOPE_API_KEY = "your-api-key" # 通义千问 API
|
||||
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
DASHSCOPE_MODEL = "qwen-max" # 文本生成模型
|
||||
DASHSCOPE_VL_MODEL = "qwen-vl-plus" # 视觉模型(图片描述)
|
||||
RAG_CHAT_MODEL = "qwen-max" # RAG 对话模型
|
||||
DASHSCOPE_MODEL = "qwen3.6-flash" # 文本生成模型(主力 LLM)
|
||||
INTENT_MODEL = "qwen-turbo" # 意图分析模型(轻量快速)
|
||||
VLM_MODEL = "qwen-vl-plus" # 视觉语言模型(图片描述)
|
||||
RAG_CHAT_MODEL = "qwen3.6-flash" # RAG 对话模型
|
||||
```
|
||||
|
||||
### 8.2 检索参数
|
||||
@@ -540,9 +564,11 @@ RECALL_MULTIPLIER = 3 # 候选池最小倍数
|
||||
|
||||
# 重排序
|
||||
USE_RERANK = True # 启用重排序
|
||||
RERANK_BACKEND = "local" # "local"=本地模型, "cloud"=云端API, "fallback"=优先云端失败回退本地
|
||||
RERANK_CLOUD_MODEL = "qwen3-rerank" # 云端 Rerank 模型(DashScope API)
|
||||
RERANK_CANDIDATES = 20 # 送入重排序的候选数
|
||||
RERANK_TOP_K = 15 # 重排序后保留数
|
||||
RERANK_USE_ONNX = True # ONNX 加速(环境变量控制,默认开启)
|
||||
RERANK_USE_ONNX = True # ONNX 加速(仅本地模式,环境变量控制,默认开启)
|
||||
|
||||
# RRF 融合
|
||||
RRF_K = 60 # RRF 常数
|
||||
@@ -550,7 +576,7 @@ DYNAMIC_RRF_ENABLED = True # 动态权重
|
||||
|
||||
# MMR 去重
|
||||
MMR_ENABLED = True
|
||||
MMR_USE_EMBEDDING = True # True=语义向量(慢),False=文本相似度(快)
|
||||
MMR_USE_EMBEDDING = False # True=语义向量(慢),False=文本Jaccard相似度(快,生产推荐)
|
||||
MMR_TOP_K = 30 # MMR 保留数
|
||||
MMR_LAMBDA = 0.5 # 相关性 vs 多样性权衡
|
||||
```
|
||||
@@ -596,7 +622,7 @@ core/ # RAG 核心引擎
|
||||
├── agentic.py # AgenticRAG 主类(Mixin 组合)
|
||||
├── agentic_base.py # 基础常量与条件导入
|
||||
├── agentic_query.py # QueryRewriteMixin(查询重写)
|
||||
├── agentic_search.py # SearchMixin(网络/图谱检索)
|
||||
├── agentic_search.py # SearchMixin(网络搜索)
|
||||
├── agentic_answer.py # AnswerMixin(答案生成、幻觉验证)
|
||||
├── agentic_citation.py # CitationMixin(引用标注)
|
||||
├── agentic_media.py # RichMediaMixin(富媒体提取)
|
||||
@@ -656,11 +682,7 @@ services/ # 业务服务层
|
||||
├── feedback.py # 反馈处理服务
|
||||
└── outline.py # 大纲生成服务
|
||||
|
||||
graph/ # 知识图谱
|
||||
├── graph_manager.py # 图谱管理器(Neo4j 连接/操作)
|
||||
├── entity_extractor.py # 实体提取器
|
||||
├── graph_build.py # 图谱构建(从文档→实体→关系)
|
||||
└── graph_rag.py # 图谱 RAG 检索
|
||||
graph/ # (已清空,Graph RAG 不再使用)
|
||||
|
||||
auth/ # 认证与安全
|
||||
├── gateway.py # API 网关认证
|
||||
@@ -719,11 +741,11 @@ config/ # 运行时配置
|
||||
| 检索方式 | 单一向量检索 | 向量 + BM25 + FAQ + 图片独立召回 |
|
||||
| 融合算法 | 无 | RRF 动态权重融合 |
|
||||
| 去重 | 无 | MMR 语义去重 |
|
||||
| 重排序 | 无 | CrossEncoder Rerank(支持 ONNX 加速) |
|
||||
| 重排序 | 无 | 云端 qwen3-rerank API(支持本地 BGE 回退) |
|
||||
| 问题分解 | 无 | 自动拆分对比/推理类查询 |
|
||||
| 闲聊处理 | 无 | 意图分析自动判断 |
|
||||
| 网络搜索 | 无 | 可选支持(Serper API) |
|
||||
| 知识图谱 | 无 | 可选支持(Neo4j) |
|
||||
| 知识图谱 | 无 | ~~可选支持(Neo4j)~~(已废弃,graph/ 目录已清空) |
|
||||
| 幻觉验证 | 无 | 基于参考信息的答案验证 |
|
||||
| 置信度门控 | 无 | Reranker 分数驱动,低分触发补救 |
|
||||
| 缓存 | 无 | 三层缓存 + 语义缓存 |
|
||||
@@ -741,16 +763,16 @@ Rerank 在系统中有 **两个独立调用路径**:
|
||||
|
||||
| 路径 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 主检索管线 | `engine.rerank_results()` | MMR 去重后执行,对 30 个候选重排取 top_k |
|
||||
| 主检索管线 | `engine.rerank_results()` | RRF 融合后、MMR 去重前执行,对候选重排取 top_k |
|
||||
| 置信度门控 | `confidence_gate._compute_scores()` | 直接调用 `reranker.predict()`,可能重复推理 |
|
||||
|
||||
### 11.2 性能瓶颈
|
||||
|
||||
| 瓶颈 | 严重程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| Rerank 缓存命中率偏低 | 🟡 中 | `rerank_results()` 已正确调用缓存读写,但缓存 key 基于 `query + sorted(doc_ids)` 精确匹配,MMR 去重产出稍有不同就无法命中 |
|
||||
| Rerank 缓存命中率偏低 | 🟡 中 | `rerank_results()` 已正确调用缓存读写,但缓存 key 基于 `query + sorted(doc_ids)` 精确匹配,RRF 融合产出稍有不同就无法命中 |
|
||||
| 置信度门控重复推理 | 🟡 中 | 同一 query+documents 可能被 Rerank 两次(当前仅备用路径使用,暂未影响生产) |
|
||||
| 无性能计时 | 🟡 中 | `rerank_results()` 内无计时代码,无法量化耗时占比 |
|
||||
| ~~无性能计时~~ | ~~🟡 中~~ | 已修复:`rerank_results()` 现返回 `_rerank_time_ms` 计时字段 |
|
||||
| 查询分类器策略未生效 | 🟢 低 | `QueryClassifier` 定义的差异化 rerank 参数未传递到引擎 |
|
||||
|
||||
### 11.3 Rerank 配置参数
|
||||
@@ -758,11 +780,16 @@ Rerank 在系统中有 **两个独立调用路径**:
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `USE_RERANK` | `True` | 总开关 |
|
||||
| `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 模型路径 |
|
||||
| `RERANK_BACKEND` | `"local"` | 后端选择:`local`=本地模型, `cloud`=云端API, `fallback`=优先云端失败回退本地 |
|
||||
| `RERANK_CLOUD_MODEL` | `"qwen3-rerank"` | 云端 Rerank 模型名称(DashScope API) |
|
||||
| `RERANK_CLOUD_API_KEY` | 同 `DASHSCOPE_API_KEY` | 云端 API 密钥 |
|
||||
| `RERANK_CLOUD_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-api/v1/reranks` | 云端 API 地址 |
|
||||
| `RERANK_CLOUD_TIMEOUT` | `15` | 云端请求超时(秒) |
|
||||
| `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 本地模型路径(仅 local/fallback 模式) |
|
||||
| `RERANK_CANDIDATES` | `20` | 送入 Rerank 的候选数 |
|
||||
| `RERANK_TOP_K` | `15` | Rerank 后保留数 |
|
||||
| `RERANK_USE_ONNX` | `True`(环境变量默认) | ONNX 加速开关 |
|
||||
| `RERANK_DEVICE` | 跟随 `DEVICE` | 设备选择 |
|
||||
| `RERANK_USE_ONNX` | `True`(环境变量默认) | ONNX 加速开关(仅本地模式) |
|
||||
| `RERANK_DEVICE` | 跟随 `DEVICE` | 设备选择(仅本地模式) |
|
||||
| `RERANK_THRESHOLD` | `0.3` | 上下文过滤阈值 |
|
||||
| `RERANK_CACHE_ENABLED` | `True` | 缓存开关(已在 `rerank_results()` 中使用) |
|
||||
|
||||
@@ -820,8 +847,8 @@ Agentic RAG 构建了动态的决策闭环,核心组件包括:
|
||||
- **意图分析器**:LLM 驱动的双层判断,替代硬编码规则
|
||||
- **查询重写器**:口语化→专业术语、实体补全、指代消解
|
||||
- **混合检索引擎**:向量 + BM25 + FAQ + 图片独立召回 + RRF 融合
|
||||
- **MMR 去重**:平衡相关性与多样性,前置到 Rerank 前减少输入量
|
||||
- **Rerank 重排**:CrossEncoder 精确排序,支持 ONNX 加速
|
||||
- **MMR 去重**:平衡相关性与多样性,Rerank 后进一步精炼结果
|
||||
- **Rerank 重排**:云端 qwen3-rerank API 精确排序,支持本地 BGE 回退
|
||||
- **置信度门控**:Reranker 分数驱动,低分触发补救流程
|
||||
- **幻觉验证**:基于参考信息验证答案,防止 LLM 编造
|
||||
|
||||
@@ -837,8 +864,8 @@ Agentic RAG 构建了动态的决策闭环,核心组件包括:
|
||||
|
||||
- **多路召回与融合**:向量 + BM25 + FAQ + 图片独立召回
|
||||
- **动态 RRF 权重**:查询类型/长度驱动的权重调整
|
||||
- **MMR 去重**:前置到 Rerank 前,减少 Rerank 输入量(召回100 → MMR取30 → Rerank取15)
|
||||
- **Rerank 重排**:CrossEncoder 精排,置信度门控过滤低质量结果
|
||||
- **MMR 去重**:Rerank 后进一步精炼,平衡相关性与多样性(召回100 → Rerank取15 → MMR精炼)
|
||||
- **Rerank 重排**:云端 qwen3-rerank 精排,置信度门控过滤低质量结果
|
||||
|
||||
#### 3. 检索后:质量评估与自我迭代
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
# Agent 工具调用与 MCP 协议详解
|
||||
|
||||
## 研究目标
|
||||
|
||||
理解智能体(Agent)中:
|
||||
1. 大模型如何准确调用工具(Function Calling 机制)
|
||||
2. MCP 协议是什么,与传统工具调用的区别
|
||||
|
||||
---
|
||||
|
||||
## 一、大模型工具调用的核心原理
|
||||
|
||||
### 1.1 本质:结构化输出生成
|
||||
|
||||
工具调用本质是**让模型生成结构化的 JSON 输出**,而非自然语言:
|
||||
|
||||
```
|
||||
用户输入 + 工具定义 → 模型推理 → 选择工具 + 生成参数(JSON)
|
||||
```
|
||||
|
||||
模型经过特殊训练,能够:
|
||||
1. **理解工具定义**:解析工具名称、描述、参数 schema
|
||||
2. **判断调用时机**:根据用户输入决定是否需要调用工具
|
||||
3. **生成结构化参数**:按 JSON Schema 格式输出参数
|
||||
|
||||
### 1.2 Claude 工具调用示例
|
||||
|
||||
```python
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic()
|
||||
|
||||
# 1. 定义工具
|
||||
tools = [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "获取指定城市的天气信息",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "城市名称,如:北京、上海"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# 2. 发送请求
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
tools=tools,
|
||||
messages=[{"role": "user", "content": "北京今天天气怎么样?"}]
|
||||
)
|
||||
|
||||
# 3. 检查模型是否要调用工具
|
||||
if message.stop_reason == "tool_use":
|
||||
for block in message.content:
|
||||
if block.type == "tool_use":
|
||||
tool_name = block.name # "get_weather"
|
||||
tool_input = block.input # {"city": "北京"}
|
||||
tool_id = block.id # 用于返回结果
|
||||
|
||||
# 4. 执行工具后,返回结果给模型继续对话
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
tools=tools,
|
||||
messages=[
|
||||
{"role": "user", "content": "北京今天天气怎么样?"},
|
||||
{"role": "assistant", "content": [tool_use_block]},
|
||||
{"role": "user", "content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_id,
|
||||
"content": "北京今天晴天,气温 18°C"
|
||||
}
|
||||
]}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### 1.3 OpenAI Function Calling 示例
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "北京天气怎么样?"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "获取城市天气",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string"}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
tool_choice="auto" # auto | required | none
|
||||
)
|
||||
|
||||
# 检查是否需要调用工具
|
||||
if response.choices[0].message.tool_calls:
|
||||
tool_call = response.choices[0].message.tool_calls[0]
|
||||
function_name = tool_call.function.name
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
```
|
||||
|
||||
### 1.4 模型如何"准确"选择工具
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 工具选择流程 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. 解析用户意图 - 理解用户想要做什么 │
|
||||
│ 2. 匹配工具描述 - 根据工具名和描述进行语义匹配 │
|
||||
│ 3. 验证参数可行性 - 检查是否有足够信息构造参数 │
|
||||
│ 4. 生成结构化输出 - 按 input_schema 生成 JSON │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键因素:**
|
||||
| 因素 | 影响 |
|
||||
|------|------|
|
||||
| 工具描述质量 | 清晰的 description 能大幅提高匹配准确率 |
|
||||
| 参数描述精确性 | 每个参数需要详细说明用途和约束 |
|
||||
| 工具数量 | 工具越多,选择难度越大(建议不超过 10-20 个)|
|
||||
| 用户意图清晰度 | 模糊请求可能导致错误选择 |
|
||||
|
||||
---
|
||||
|
||||
## 二、JSON Schema 工具定义详解
|
||||
|
||||
JSON Schema 是定义工具参数的标准格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "搜索关键词"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 10
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
```
|
||||
|
||||
**常用类型:**
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `string` | 字符串 | `{"type": "string"}` |
|
||||
| `integer` | 整数 | `{"type": "integer", "minimum": 0}` |
|
||||
| `number` | 浮点数 | `{"type": "number"}` |
|
||||
| `boolean` | 布尔值 | `{"type": "boolean"}` |
|
||||
| `array` | 数组 | `{"type": "array", "items": {...}}` |
|
||||
| `object` | 对象 | `{"type": "object", "properties": {...}}` |
|
||||
| `enum` | 枚举 | `{"type": "string", "enum": ["a", "b", "c"]}` |
|
||||
|
||||
---
|
||||
|
||||
## 三、MCP (Model Context Protocol) 协议
|
||||
|
||||
### 3.1 什么是 MCP
|
||||
|
||||
**MCP 是 Anthropic 2024 年推出的开放协议**,旨在标准化 AI 应用与外部资源的连接。
|
||||
|
||||
```
|
||||
类比:USB-C 接口统一了外设连接
|
||||
MCP 统一了 AI 与外部资源的连接
|
||||
```
|
||||
|
||||
**核心能力:**
|
||||
- 连接各种数据源(数据库、文件系统、API)
|
||||
- 使用外部工具
|
||||
- 访问预定义的提示词模板(Prompts)
|
||||
- 保持与上下文的持久连接
|
||||
|
||||
### 3.2 MCP 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ MCP 架构 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ MCP Host │ ◄─────► │ MCP Client │ ◄─────► MCP │
|
||||
│ │ (Claude App)│ │ (连接器) │ Server│
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ ┌──────┐│
|
||||
│ │ AI Model │ │数据源/││
|
||||
│ │ (Claude) │ │工具 ││
|
||||
│ └─────────────┘ └──────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 组件 | 角色 | 示例 |
|
||||
|------|------|------|
|
||||
| **MCP Host** | 运行 AI 应用的宿主 | Claude Desktop, IDE 插件 |
|
||||
| **MCP Client** | 管理与服务器的连接 | 内置在 Host 中 |
|
||||
| **MCP Server** | 提供工具、资源、提示词 | PostgreSQL Server, GitHub Server |
|
||||
|
||||
### 3.3 MCP 核心功能
|
||||
|
||||
#### Tools(工具)
|
||||
```python
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="query_database",
|
||||
description="执行 SQL 查询",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sql": {"type": "string", "description": "SQL 语句"}
|
||||
},
|
||||
"required": ["sql"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
if name == "query_database":
|
||||
result = await db.query(arguments["sql"])
|
||||
return [{"type": "text", "text": str(result)}]
|
||||
```
|
||||
|
||||
#### Resources(资源)- 只读数据源
|
||||
```python
|
||||
@server.list_resources()
|
||||
async def list_resources():
|
||||
return [
|
||||
Resource(uri="file:///project/README.md", name="README")
|
||||
]
|
||||
|
||||
@server.read_resource()
|
||||
async def read_resource(uri: str):
|
||||
# 返回资源内容
|
||||
return open(uri[7:]).read()
|
||||
```
|
||||
|
||||
#### Prompts(提示词模板)
|
||||
```python
|
||||
@server.list_prompts()
|
||||
async def list_prompts():
|
||||
return [
|
||||
Prompt(name="code_review", description="代码审查提示词")
|
||||
]
|
||||
```
|
||||
|
||||
### 3.4 MCP 传输方式
|
||||
|
||||
| 方式 | 适用场景 | 说明 |
|
||||
|------|---------|------|
|
||||
| **Stdio** | 本地工具 | 服务器作为子进程,通过 stdin/stdout 通信 |
|
||||
| **HTTP + SSE** | 远程服务 | 通过 HTTP 和 Server-Sent Events 通信 |
|
||||
|
||||
### 3.5 MCP 配置示例
|
||||
|
||||
```json
|
||||
// Claude Desktop 配置 (claude_desktop_config.json)
|
||||
{
|
||||
"mcpServers": {
|
||||
"postgres": {
|
||||
"command": "mcp-server-postgres",
|
||||
"args": ["postgresql://localhost/mydb"]
|
||||
},
|
||||
"github": {
|
||||
"command": "mcp-server-github",
|
||||
"args": ["--token", "${GITHUB_TOKEN}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置后,MCP 服务器**自动**:
|
||||
- 向 AI 暴露可用工具
|
||||
- 提供资源访问
|
||||
- 无需修改应用代码
|
||||
|
||||
---
|
||||
|
||||
## 四、Function Calling vs MCP 对比
|
||||
|
||||
| 特性 | Function Calling | MCP 协议 |
|
||||
|------|------------------|----------|
|
||||
| **标准化** | 各厂商格式不同 | 统一开放标准 |
|
||||
| **工具发现** | 需硬编码工具定义 | 动态发现 |
|
||||
| **上下文管理** | 需手动管理 | 内置资源管理 |
|
||||
| **跨平台** | 限于特定 API | 任何 MCP 兼容应用 |
|
||||
| **扩展性** | 添加工具需改代码 | 插件式架构 |
|
||||
| **提示词模板** | 不支持 | 内置支持 |
|
||||
| **资源访问** | 需自行实现 | 标准化接口 |
|
||||
| **实现复杂度** | 简单直接 | 需要服务器实现 |
|
||||
|
||||
### 选择建议
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|------|---------|
|
||||
| 简单场景、单次 API 调用 | Function Calling |
|
||||
| 需要资源访问、多应用复用 | MCP |
|
||||
| 快速原型开发 | Function Calling |
|
||||
| 生产环境、需要标准化 | MCP |
|
||||
|
||||
---
|
||||
|
||||
## 五、关键要点总结
|
||||
|
||||
1. **工具调用本质**:大模型生成结构化 JSON,而非直接执行代码
|
||||
2. **准确性的关键**:清晰的工具描述 + 精确的参数 Schema
|
||||
3. **MCP 的价值**:统一标准,实现"一次开发,处处可用"
|
||||
4. **MCP 三大能力**:Tools(工具)、Resources(资源)、Prompts(提示词)
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- [Anthropic Tool Use 文档](https://docs.anthropic.com/en/docs/build-with-claude/tool-use)
|
||||
- [OpenAI Function Calling 指南](https://platform.openai.com/docs/guides/function-calling)
|
||||
- [MCP 官方规范](https://modelcontextprotocol.io/)
|
||||
- [MCP GitHub 仓库](https://github.com/modelcontextprotocol)
|
||||
@@ -35,7 +35,11 @@ MinerU 配置文件查找顺序:
|
||||
**Windows**:`C:\Users\<username>\mineru.json`
|
||||
**Linux**:`/root/mineru.json` 或 `/home/<user>/mineru.json`
|
||||
|
||||
### 1.3 当前项目使用方式
|
||||
### 1.3 MinerU 在线 API 模式
|
||||
|
||||
项目同时支持 MinerU 在线 API 解析,通过 `.env.production` 中的 `MINERU_API_TOKEN` 环境变量配置。当设置了该 Token 时,可直接调用 OpenDataLab 云端 API 进行文档解析,无需在本地部署模型。
|
||||
|
||||
### 1.4 当前项目使用方式
|
||||
|
||||
查看 `parsers/mineru_parser.py` 第 186-197 行:
|
||||
|
||||
@@ -52,9 +56,10 @@ cmd = [
|
||||
```
|
||||
|
||||
**关键发现**:
|
||||
- ✅ 代码中**没有硬编码路径**
|
||||
- ✅ 使用命令行调用 `mineru` 可执行文件
|
||||
- ✅ MinerU 自动读取配置文件或环境变量
|
||||
- 代码中**没有硬编码路径**
|
||||
- 使用命令行调用 `mineru` 可执行文件
|
||||
- MinerU 自动读取配置文件或环境变量
|
||||
- 所有配置均通过 `.env.production` 环境变量注入,不依赖 `config.py` 硬编码
|
||||
|
||||
---
|
||||
|
||||
@@ -81,7 +86,7 @@ cmd = [
|
||||
|
||||
## 三、解决方案
|
||||
|
||||
### 方案 A:本地模型模式(推荐)✅
|
||||
### 方案 A:本地模型模式(推荐)
|
||||
|
||||
**适用场景**:
|
||||
- 服务器无法访问 HuggingFace
|
||||
@@ -118,60 +123,90 @@ mineru-models-download -s huggingface -m all -d models\mineru
|
||||
"models-dir": {
|
||||
"pipeline": "/app/models/mineru/pipeline",
|
||||
"vlm": "/app/models/mineru/vlm"
|
||||
}
|
||||
},
|
||||
"config_version": "1.3.1"
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:路径使用 Docker 容器内的路径 `/app/`。
|
||||
|
||||
#### Step 3:修改 Dockerfile
|
||||
#### Step 3:生产环境 Dockerfile
|
||||
|
||||
当前项目使用 `deploy/Dockerfile.prod`(基于 Python 3.10-slim,CPU-only PyTorch):
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
# Dockerfile.prod - 生产环境优化版
|
||||
# ================================
|
||||
# 特点:CPU-only、精简依赖、最小化镜像
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# 使用阿里云镜像源
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# 系统依赖(精简版)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
poppler-utils \
|
||||
libmagic1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip install gunicorn>=21.0.0
|
||||
# ==================== PyTorch(CPU 模式) ====================
|
||||
# 先从阿里云安装 PyTorch 依赖(避免从 PyPI 下载超时)
|
||||
RUN pip install --no-cache-dir networkx sympy mpmath typing-extensions \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/
|
||||
# 再从 PyTorch 官方源下载 CPU-only 版本(约 200MB),跳过依赖解析
|
||||
RUN pip install --no-cache-dir --no-deps torch --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# 应用代码
|
||||
# 设置 PyTorch 使用 CPU 模式
|
||||
ENV CUDA_VISIBLE_DEVICES=""
|
||||
|
||||
# ==================== Python 依赖 ====================
|
||||
COPY requirements-prod.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-prod.txt \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/
|
||||
|
||||
# ==================== 应用代码 ====================
|
||||
COPY . .
|
||||
|
||||
# 复制模型文件(重要!)
|
||||
COPY models /app/models
|
||||
# ==================== MinerU 配置 ====================
|
||||
# 生产环境使用本地模型
|
||||
RUN mkdir -p /root && echo '{\n\
|
||||
"models-dir": {\n\
|
||||
"pipeline": "/app/models/mineru/pipeline",\n\
|
||||
"vlm": "/app/models/mineru/vlm"\n\
|
||||
},\n\
|
||||
"config_version": "1.3.1"\n\
|
||||
}' > /root/mineru.json
|
||||
|
||||
# 复制配置文件到容器内用户目录
|
||||
COPY mineru.json /root/mineru.json
|
||||
|
||||
# 设置环境变量
|
||||
ENV MINERU_MODEL_SOURCE=local
|
||||
ENV MINERU_TOOLS_CONFIG_JSON=/root/mineru.json
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p data knowledge/vector_store .data documents
|
||||
# ==================== 数据目录 ====================
|
||||
RUN mkdir -p knowledge/vector_store documents models .data
|
||||
|
||||
# ==================== 环境变量 ====================
|
||||
ENV APP_ENV=prod
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
# 生产模式启动
|
||||
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
|
||||
CMD ["gunicorn", "-c", "deploy/gunicorn.conf.py", "deploy.wsgi:app"]
|
||||
```
|
||||
|
||||
#### Step 4:构建镜像
|
||||
|
||||
```bash
|
||||
# 构建镜像(会包含模型文件)
|
||||
docker build -t rag-service:latest .
|
||||
# 从项目根目录执行构建
|
||||
docker-compose -f deploy/docker-compose.prod.yml up -d --build
|
||||
|
||||
# 或单独构建镜像
|
||||
docker build -f deploy/Dockerfile.prod -t rag-service:latest .
|
||||
|
||||
# 查看镜像大小
|
||||
docker images rag-service
|
||||
@@ -213,42 +248,67 @@ mineru-models-download -s huggingface -m all -d /data/mineru-models
|
||||
"models-dir": {
|
||||
"pipeline": "/models/pipeline",
|
||||
"vlm": "/models/vlm"
|
||||
}
|
||||
},
|
||||
"config_version": "1.3.1"
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3:Docker Compose 配置
|
||||
|
||||
当前项目使用 `deploy/docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
# docker-compose.prod.yml - 生产环境部署配置
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
rag-service:
|
||||
image: rag-service:latest
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.prod
|
||||
container_name: rag-service
|
||||
env_file:
|
||||
- .env.production
|
||||
ports:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
# 挂载模型目录
|
||||
- /data/mineru-models:/models:ro
|
||||
# 挂载配置文件
|
||||
- /data/mineru.json:/root/mineru.json:ro
|
||||
# 挂载数据目录
|
||||
- ./data:/app/data
|
||||
- ./documents:/app/documents
|
||||
- ./knowledge:/app/knowledge
|
||||
environment:
|
||||
- MINERU_MODEL_SOURCE=local
|
||||
- MINERU_TOOLS_CONFIG_JSON=/root/mineru.json
|
||||
- APP_ENV=prod
|
||||
- ENABLE_SESSION=false
|
||||
# 数据目录挂载(代码在镜像内,不挂载)
|
||||
- ../knowledge/vector_store:/app/knowledge/vector_store
|
||||
- ../documents:/app/documents
|
||||
- ../models:/app/models
|
||||
- ../.data:/app/.data
|
||||
- ../data:/app/data
|
||||
restart: unless-stopped
|
||||
shm_size: '256m'
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 2G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:5001/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
**关键配置说明**:
|
||||
- `env_file: .env.production`:通过 `.env.production` 文件注入所有环境变量(包括 `MINERU_API_TOKEN`、`DASHSCOPE_API_KEY` 等),不使用 `config.py` 硬编码
|
||||
- `../models:/app/models`:将宿主机模型目录挂载到容器内,Dockerfile.prod 中已配置 `MINERU_MODEL_SOURCE=local` 和 `MINERU_TOOLS_CONFIG_JSON=/root/mineru.json`
|
||||
- 端口映射 `5001:5001`,容器名为 `rag-service`
|
||||
|
||||
#### Step 4:启动服务
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
# 从项目根目录执行
|
||||
docker-compose -f deploy/docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
@@ -285,9 +345,51 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## 四、验证部署
|
||||
## 四、环境变量配置(.env.production)
|
||||
|
||||
### 4.1 检查模型路径
|
||||
生产环境所有配置通过 `deploy/.env.production` 文件注入,不依赖 `config.py` 硬编码。
|
||||
|
||||
### 4.1 .env.production 示例
|
||||
|
||||
```bash
|
||||
# .env.production - 生产环境配置
|
||||
# 部署到服务器时复制到 deploy/.env.production
|
||||
|
||||
# 环境标识
|
||||
APP_ENV=prod
|
||||
|
||||
# LLM API
|
||||
DASHSCOPE_API_KEY=<your-api-key>
|
||||
DASHSCOPE_BASE_URL=<your-base-url>
|
||||
DASHSCOPE_MODEL=mimo-v2.5
|
||||
RAG_CHAT_MODEL=mimo-v2.5
|
||||
INTENT_MODEL=mimo-v2.5
|
||||
VLM_MODEL=qwen-vl-plus
|
||||
|
||||
# MinerU 在线 API(可选,设置后无需本地模型)
|
||||
MINERU_API_TOKEN=<your-mineru-api-token>
|
||||
|
||||
# 网络搜索(按需开启)
|
||||
ENABLE_WEB_SEARCH=false
|
||||
SERPER_API_KEY=<your-serper-key>
|
||||
|
||||
# Rerank ONNX 加速(CPU 服务器建议关闭)
|
||||
RERANK_USE_ONNX=false
|
||||
```
|
||||
|
||||
### 4.2 MinerU 模型路径配置方式
|
||||
|
||||
| 方式 | 配置位置 | 说明 |
|
||||
|------|----------|------|
|
||||
| `MINERU_API_TOKEN` 环境变量 | `.env.production` | 使用 MinerU 云端 API,无需本地模型 |
|
||||
| `mineru.json` 配置文件 | `/root/mineru.json`(容器内) | 指定本地模型路径,Dockerfile.prod 已自动生成 |
|
||||
| `MINERU_MODEL_SOURCE` 环境变量 | Dockerfile.prod 中设置 | `local` 使用本地模型,`huggingface` 自动下载 |
|
||||
|
||||
---
|
||||
|
||||
## 五、验证部署
|
||||
|
||||
### 5.1 检查模型路径
|
||||
|
||||
进入容器检查:
|
||||
|
||||
@@ -306,7 +408,7 @@ ls -lh /app/models/mineru/vlm/
|
||||
python -c "from mineru.utils.config_reader import read_config; print(read_config())"
|
||||
```
|
||||
|
||||
### 4.2 测试解析
|
||||
### 5.2 测试解析
|
||||
|
||||
```bash
|
||||
# 在容器内测试
|
||||
@@ -314,7 +416,7 @@ cd /app
|
||||
python parsers/mineru_parser.py documents/test.pdf
|
||||
```
|
||||
|
||||
### 4.3 查看日志
|
||||
### 5.3 查看日志
|
||||
|
||||
```bash
|
||||
# 查看容器日志
|
||||
@@ -327,7 +429,7 @@ docker logs -f rag-service
|
||||
|
||||
---
|
||||
|
||||
## 五、模型文件清单
|
||||
## 六、模型文件清单
|
||||
|
||||
### Pipeline 模型(必需)
|
||||
|
||||
@@ -368,7 +470,7 @@ models/mineru/vlm/
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
## 七、常见问题
|
||||
|
||||
### Q1: 镜像太大怎么办?
|
||||
|
||||
@@ -401,282 +503,47 @@ models/mineru/vlm/
|
||||
3. 配置文件格式是否正确(JSON 语法)
|
||||
4. 模型目录路径是否存在
|
||||
|
||||
### Q6: 如何使用 MinerU 在线 API 代替本地模型?
|
||||
|
||||
**A**: 在 `.env.production` 中设置 `MINERU_API_TOKEN=<your-token>`,无需在本地部署模型。Token 可从 OpenDataLab 平台获取。
|
||||
|
||||
---
|
||||
|
||||
## 七、推荐方案总结
|
||||
## 八、推荐方案总结
|
||||
|
||||
| 方案 | 优点 | 缺点 | 适用场景 |
|
||||
|------|------|------|----------|
|
||||
| **方案 A:打包到镜像** | 部署简单、启动快 | 镜像大、更新麻烦 | 单机部署、离线环境 |
|
||||
| **方案 B:挂载目录** | 灵活、易更新、多容器共享 | 需要管理宿主机文件 | 多节点、生产环境 |
|
||||
| **方案 C:自动下载** | 镜像小 | 首次启动慢、依赖网络 | 测试环境 |
|
||||
| **在线 API** | 无需本地模型、镜像最小 | 依赖网络、有调用限制 | 轻量部署、测试环境 |
|
||||
|
||||
**生产环境推荐**:方案 B(挂载模型目录)
|
||||
**生产环境推荐**:方案 B(挂载模型目录)+ `.env.production` 环境变量注入
|
||||
|
||||
---
|
||||
|
||||
## 八、部署检查清单
|
||||
## 九、部署检查清单
|
||||
|
||||
部署前检查:
|
||||
|
||||
- [ ] 模型文件已下载到 `models/mineru/` 目录
|
||||
- [ ] 创建了 `mineru.json` 配置文件
|
||||
- [ ] Dockerfile 中添加了 `COPY models` 和环境变量
|
||||
- [ ] 创建了 `deploy/.env.production` 配置文件(含 `MINERU_API_TOKEN` 等环境变量)
|
||||
- [ ] 确认使用 `deploy/Dockerfile.prod` 和 `deploy/docker-compose.prod.yml`
|
||||
- [ ] 测试了本地解析功能
|
||||
- [ ] 确认模型文件大小(3-8GB)
|
||||
|
||||
部署后检查:
|
||||
|
||||
- [ ] 容器启动成功
|
||||
- [ ] 容器启动成功:`docker ps | grep rag-service`
|
||||
- [ ] 配置文件存在:`docker exec rag-service cat /root/mineru.json`
|
||||
- [ ] 模型目录存在:`docker exec rag-service ls /app/models/mineru`
|
||||
- [ ] 环境变量正确:`docker exec rag-service env | grep MINERU`
|
||||
- [ ] 健康检查通过:`curl http://localhost:5001/health`
|
||||
- [ ] 测试解析功能:上传一个 PDF 测试
|
||||
- [ ] 查看日志无错误
|
||||
- [ ] 查看日志无错误:`docker logs -f rag-service`
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-04-20
|
||||
**文档版本**: v1.1
|
||||
**最后更新**: 2026-06-04
|
||||
**维护者**: RAG 服务开发组
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 附加篇:MinerU安装深度解析与Windows框架全景 (Technical Analysis and Deployment Framework for MinerU)
|
||||
|
||||
Comprehensive Technical Analysis and Deployment Framework for MinerU Document Parsing Systems on Windows Platforms
|
||||
The rapid proliferation of large language models (LLMs) has fundamentally transformed the landscape of data engineering, necessitating high-fidelity document parsing tools that can bridge the gap between unstructured PDF, image, and office formats and machine-readable structured data. MinerU, an open-source document parsing toolkit born from the pre-training requirements of the InternLM series, has emerged as a preeminent solution for converting complex documents into Markdown and JSON formats while preserving structural integrity, mathematical formulas, and tabular data.[1, 2] As an orchestrator of multiple deep learning components—including layout detection, optical character recognition (OCR), and vision-language model (VLM) reasoning—MinerU provides a robust pipeline for downstream retrieval-augmented generation (RAG) and agentic workflows.[3, 4] However, the deployment of such a sophisticated system on Windows operating systems presents a unique set of architectural and dependency-related challenges, primarily due to its heavy reliance on libraries traditionally optimized for Linux environments. This report provides an exhaustive investigation into the installation methodologies, operational configurations, and troubleshooting frameworks required to successfully implement MinerU on Windows platforms.
|
||||
Architectural Evolution and Component Logic
|
||||
Understanding the implementation of MinerU requires a nuanced appreciation of its underlying architecture and how its components interact within a Windows-based environment. Originally conceived as the Magic-PDF project, the tool has evolved into a comprehensive suite capable of handling not only PDFs but also DOCX, PPTX, and XLSX inputs.[5, 6] The system is designed to remove semantic noise—such as headers, footers, and page numbers—while accurately reconstructing the human reading order in multi-column or complex layouts.[3, 4]
|
||||
Inference Backends and Hardware Specificity
|
||||
The versatility of MinerU is largely attributed to its support for diverse inference backends, which allow users to balance accuracy against available hardware resources. On Windows, the choice of backend is the most significant predictor of installation complexity and operational stability.
|
||||
Backend Type
|
||||
Core Technologies
|
||||
Hardware Optimization
|
||||
Minimum VRAM
|
||||
Accuracy (OmniDocBench)
|
||||
Pipeline
|
||||
Layout detection + OCR
|
||||
CPU (MKL/AVX) or GPU (Volta+)
|
||||
4 GB
|
||||
86.2 [5, 7]
|
||||
Hybrid
|
||||
Layout + OCR + VLM Reasoning
|
||||
NVIDIA GPU (Volta+)
|
||||
8 GB
|
||||
90+ [5]
|
||||
VLM-Transformers
|
||||
Transformers-based Vision models
|
||||
NVIDIA GPU (Ampere+)
|
||||
8 GB
|
||||
90+ [8]
|
||||
VLM-SGLang
|
||||
SGLang Optimized Inference
|
||||
NVIDIA GPU (Ampere+)
|
||||
24 GB
|
||||
90+ [8]
|
||||
The "Pipeline" backend is the most compatible with Windows systems, especially those lacking high-end GPUs, as it supports pure CPU inference through Intel MKL and AVX instruction sets.[5, 9] The recent release of version 3.0.0 introduced a systematic upgrade to this architecture, replacing several AGPLv3-licensed models with high-precision alternatives that achieve higher accuracy on the OmniDocBench benchmark.[5] This version also pioneered native DOCX parsing, which bypasses the traditional PDF-to-Markdown conversion, improving end-to-end speed by tens of times.[5]
|
||||
The Role of OCR and Vision Models
|
||||
A fundamental feature of the MinerU ecosystem is its dual-engine approach to content extraction. For text-based PDFs, it utilizes sophisticated reading order analysis; for scanned or "garbled" documents, it automatically triggers an OCR pipeline supporting 109 languages.[1, 5] In Windows environments, the OCR functionality is primarily powered by PaddleOCR, which necessitates a specific version of the PaddlePaddle framework that matches the system's CUDA environment.[9, 10] This requirement is often a source of friction during installation, as version mismatches between CUDA, cuDNN, and PaddlePaddle can lead to silent failures or runtime errors.[11, 12]
|
||||
Foundational Requirements and Environment Preparation
|
||||
A successful Windows installation is predicated on the rigorous preparation of the software toolchain and hardware drivers. Windows lacks the integrated development headers found in most Linux distributions, making the manual configuration of the C++ build environment mandatory.
|
||||
Hardware Prerequisites and Resource Allocation
|
||||
MinerU is a resource-intensive application, particularly during the model initialization and inference stages. Windows users must ensure their systems adhere to the following hardware guidelines to avoid performance bottlenecks or out-of-memory (OOM) conditions.
|
||||
Component
|
||||
Minimum Specification
|
||||
Recommended Specification
|
||||
Rationale
|
||||
RAM
|
||||
16 GB
|
||||
32 GB or more
|
||||
Required for loading multiple model weights simultaneously.[5, 7]
|
||||
GPU
|
||||
NVIDIA Volta (e.g., V100)
|
||||
Ampere (30-series) or later
|
||||
Newer architectures support advanced quantization and faster inference.[7, 8]
|
||||
VRAM
|
||||
4 GB (Pipeline mode)
|
||||
24 GB (VLM mode)
|
||||
VLM backends require substantial memory for high-resolution document imagery.[8]
|
||||
Storage
|
||||
20 GB (SSD)
|
||||
50 GB+ (SSD)
|
||||
Models and intermediate rendering files require fast I/O and significant space.[7, 13]
|
||||
Processor
|
||||
64-bit x86 with AVX
|
||||
Multi-core Intel/AMD
|
||||
AVX support is critical for the CPU version of PaddlePaddle.[9, 14]
|
||||
The Windows C++ Build Toolchain
|
||||
One of the most common points of failure in the installation process is the absence of Microsoft Visual C++ Build Tools. Many of the underlying Python libraries, most notably detectron2 and pycocotools, involve C++ and CUDA extensions that must be compiled during installation if pre-built wheels are unavailable.[15, 16]
|
||||
To resolve this, the user must install the Visual Studio Community edition and select the "Desktop development with C++" workload.[15, 17] Within this workload, it is essential to verify the presence of:
|
||||
MSVC v142 or v143 C++ x64/x86 build tools.
|
||||
Windows 10 or 11 SDK (matching the host OS version).[15, 16]
|
||||
C++/CLI support for the respective build tools.[15]
|
||||
Following the installation of these tools, a system restart is strongly recommended to ensure that the compiler paths are correctly registered in the Windows environment.[16]
|
||||
Python Version Strategy
|
||||
MinerU currently supports Python versions 3.10 through 3.12, with some documentation suggesting the beginning of support for 3.13.[8, 18] However, for Windows users, Python 3.10 remains the recommended version. This is because many critical dependencies, such as the precompiled wheels for detectron2 provided by the community, are specifically built for Python 3.10.[13, 19, 20] Using newer versions of Python often forces the system to attempt a source compilation of detectron2, which is notoriously difficult to complete successfully on Windows without extensive manual patching.[21]
|
||||
Detailed Installation Procedures
|
||||
There are three primary avenues for installing MinerU on Windows: the pip/uv method, installation from source, and Docker-based deployment.
|
||||
Path A: Streamlined Installation via uv
|
||||
The uv package manager has become the preferred tool for MinerU installation due to its superior dependency resolution and execution speed compared to standard pip.[6, 8]
|
||||
Environment Setup: It is a best practice to use a clean Conda environment to avoid library version conflicts.
|
||||
Manager Installation: Ensure pip and uv are up to date.
|
||||
Core Package Installation: The mineru[all] extra ensures that all components, including those for VLM and pipeline acceleration, are fetched.
|
||||
Path B: Addressing the Detectron2 Hurdle
|
||||
The detectron2 library is a frequent obstacle as it lacks official Windows support from Meta.[21, 22] If the standard installation fails, users should manually install a precompiled wheel tailored for Windows and Python 3.10.
|
||||
pip install detectron2 --extra-index-url https://myhloli.github.io/wheels/
|
||||
``` [13, 23]
|
||||
|
||||
This wheel bypasses the need for local compilation. If this index is inaccessible or the user is on a different Python version, they must clone the repository and build it manually, which requires the `CUDA_HOME` environment variable to be set correctly to the location of the installed CUDA Toolkit.[21] Failure to match the CUDA version used to compile `detectron2` with the CUDA version used for PyTorch will result in a runtime crash.[21]
|
||||
|
||||
### Path C: GPU and OCR Acceleration Setup
|
||||
|
||||
To enable GPU acceleration on Windows, the default PyTorch installation must often be overwritten with a version specifically linked to the system's CUDA version.[10, 13]
|
||||
|
||||
| CUDA Version | PyTorch Installation Command |
|
||||
| :--- | :--- |
|
||||
| **11.8** | `pip install --force-reinstall torch==2.3.1 torchvision==0.18.1 --index-url https://download.pytorch.org/whl/cu118` [10, 13] |
|
||||
| **12.1+** | Refer to the PyTorch official website for the matching `torch` and `torchvision` versions.[24] |
|
||||
|
||||
Furthermore, to activate OCR acceleration, the `paddlepaddle-gpu` library must be installed. For users with CUDA 11.8, version 2.6.1 or 3.0.0b1 is recommended.[10] For users on newer CUDA versions (e.g., 12.6 or 12.9), specific GPU-enabled wheels from the PaddlePaddle repository must be used to ensure compatibility with the GPU driver.[9, 25]
|
||||
|
||||
```bash
|
||||
# Example for CUDA 12.6
|
||||
python -m pip install paddlepaddle-gpu==3.2.2 -i https://www.paddlepaddle.org.cn/packages/stable/cu126/
|
||||
``` [25]
|
||||
|
||||
## Configuration and Model Management
|
||||
|
||||
Post-installation, MinerU requires the presence of several model weights to function. The management of these weights and the system's global configuration is handled through a JSON-based framework.
|
||||
|
||||
### The Configuration Shift: magic-pdf.json to mineru.json
|
||||
|
||||
Earlier versions of the project (v1.x) utilized a configuration file named `magic-pdf.json`. With the transition to version 2.0 and above, the system now prioritizes `mineru.json`.[24] This file is automatically generated in the user's home directory (e.g., `C:\Users\YourUsername`) the first time the model download utility is run.[8, 10, 18]
|
||||
|
||||
Key configuration parameters within `mineru.json` include:
|
||||
* **models-dir**: This specifies the absolute path to the directory where model weights are stored. On Windows, it is critical to use forward slashes (e.g., `D:/models`) or escaped backslashes (`D:\\models`) to prevent JSON parsing errors.[26, 27]
|
||||
* **device-mode**: Set to `cuda` for GPU acceleration or `cpu` for standard inference.[10, 13]
|
||||
* **latex-delimiter-config**: Allows customization of the symbols used to mark LaTeX formulas in the output Markdown.[8, 24]
|
||||
* **llm-aided-config**: Configures an external LLM (via OpenAI-compatible API) to assist in determining the title hierarchy and logical structure of the document.[24]
|
||||
|
||||
### Automated and Manual Model Acquisition
|
||||
|
||||
The system provides a built-in utility, `mineru-models-download`, to facilitate model acquisition. By default, this tool attempts to fetch models from HuggingFace. For users in mainland China or other regions with network restrictions, the model source can be switched to ModelScope.[24, 28]
|
||||
|
||||
This change is implemented via an environment variable:
|
||||
```cmd
|
||||
set MINERU_MODEL_SOURCE=modelscope
|
||||
mineru-models-download
|
||||
``` [24, 28]
|
||||
|
||||
If models are moved to a different disk after download, the `models-dir` path in `mineru.json` must be updated accordingly. It is important to note that the `mineru-models-download` tool does not currently support custom download paths; it will always download to a default location and then update the JSON configuration with that path.[28]
|
||||
|
||||
## Operational Framework and Usage Scenarios
|
||||
|
||||
MinerU provides three primary modes of operation: the Command Line Interface (CLI), the Python API (SDK), and the Web-based User Interface (WebUI).
|
||||
|
||||
### Command Line Excellence
|
||||
|
||||
The CLI is the primary tool for batch processing and integration into automated shell scripts. The command structure has transitioned from `magic-pdf` in older versions to the unified `mineru` command in version 2.x and later.[5, 8]
|
||||
|
||||
**Basic Syntax**:
|
||||
`mineru -p <input_path> -o <output_path> -m auto` [6, 8]
|
||||
|
||||
**Advanced Parameters**:
|
||||
* `-m` / `--method`: Determines the parsing logic. `txt` is optimized for text-based PDFs, `ocr` for images and scans, and `auto` allows the system to intelligently switch based on document characteristics.[8, 26]
|
||||
* `-b` / `--backend`: Specifies the inference engine, such as `pipeline` or `vlm-transformers`.[8]
|
||||
* `-s` / `--start` and `-e` / `--end`: Enables partial parsing of long documents by specifying a range of page numbers (0-indexed).[8]
|
||||
* `-f` / `--formula` and `-t` / `--table`: Boolean flags to enable or disable the recognition of mathematical formulas and tables.[8]
|
||||
|
||||
### Integration via Python SDK
|
||||
|
||||
For sophisticated developers, the Python SDK offers the `Dataset` class, allowing for programmatic control over the parsing stages.[8] This is particularly useful for building custom RAG pipelines where documents need to be processed and then immediately ingested into a vector database. The new `Stage` architecture allows users to define custom processing steps and combine them creatively.[8]
|
||||
|
||||
### WebUI and API Services
|
||||
|
||||
The project includes a Gradio-based demo for interactive testing. This can be launched by running `python demo.py` from the demo directory or using the `mineru-gradio` command.[3, 24] For production deployments, `mineru-api` provides a FastAPI service that supports both synchronous (`/file_parse`) and asynchronous (`/tasks`) endpoints.[5] Windows users can also integrate MinerU into tools like Dify via its official plugin, which supports both the cloud-hosted API and local deployments.[4]
|
||||
|
||||
## Troubleshooting and Issue Mitigation on Windows
|
||||
|
||||
The Windows environment introduces specific failure modes that are often absent on Linux. These typically involve DLL dependencies, path handling, and memory management.
|
||||
|
||||
### Dependency and DLL Failures
|
||||
|
||||
* **libiomp5md.dll Missing**: This is the Intel OpenMP Runtime library, essential for parallelizing CPU-based computations. This error occurs if the Intel CPU library dependencies are out of sync. Solutions include installing the Intel Fortran runtime or ensuring that the DLL is present in the `System32` directory or the Python environment's library path.[29, 30]
|
||||
* **zlib.dll Errors**: A "missing" `zlib.dll` can prevent the application from starting. This is often caused by accidental deletion or incomplete package installation. Reinstalling the program that uses the library or running `sfc /scannow` to repair the Windows system files are common remedies.[31]
|
||||
* **ModuleNotFoundError: No module named 'package'**: This deceptive error is often triggered by an incompatible version of the `ultralytics` package. The verified fix is to downgrade `ultralytics` to version 8.3.43 or 8.3.40.[32]
|
||||
|
||||
### Path and JSON Parsing Issues
|
||||
|
||||
The Windows backslash (`\`) is a frequent source of "Unexpected Token" errors in the JSON configuration files. Because the backslash is an escape character in JSON, it must be escaped with another backslash (e.g., `D:\\MinerU\\models`) or replaced with a forward slash (`D:/MinerU/models`).[26, 27] Furthermore, Windows users with non-ASCII characters or spaces in their usernames (e.g., `C:\Users\User Name`) may encounter issues with the default configuration path. Setting the `MINERU_TOOLS_CONFIG_JSON` environment variable to a custom path on a different drive can bypass these restrictions.[33]
|
||||
|
||||
### Optical Character Recognition (OCR) Degradation
|
||||
|
||||
In documents with a high density of inline formulas, users may observe that some text lines or symbols are missing from the output. This is often caused by overly aggressive binarization thresholds in the layout detector or OCR engine.[34] Adjusting the `det_db_thresh` and `det_db_box_thresh` parameters in the OCR initialization configuration can mitigate this. Lowering `det_db_box_thresh` from the default 0.6 to 0.3 allows the engine to retain more bounding boxes that might otherwise be filtered out as noise.[34]
|
||||
|
||||
## Performance Optimization and Stability
|
||||
|
||||
Moving from experimental parsing to production-scale document processing on Windows requires the application of several optimization strategies.
|
||||
|
||||
### Memory Optimization for Long Documents
|
||||
|
||||
Historically, parsing ultra-long documents (thousands of pages) led to massive memory spikes and eventual crashes. MinerU has addressed this by introducing a "sliding-window" mechanism that processes the document in smaller, manageable chunks.[5]
|
||||
|
||||
The parameter `MINERU_PROCESSING_WINDOW_SIZE` (defaulting to 64) can be adjusted via environment variables to fine-tune the memory usage.[35] On systems with limited RAM, reducing this window size ensures stability, while on high-end workstations, increasing it can improve throughput. Additionally, the system now supports streaming writes to disk, allowing results to be persisted as they are completed rather than waiting for the entire document to finish.[5]
|
||||
|
||||
### VRAM and GPU Tuning
|
||||
|
||||
For users leveraging GPU acceleration, the `MINERU_HYBRID_BATCH_RATIO` environment variable allows for the dynamic adjustment of VRAM usage. This is critical for fitting the VLM and OCR models into consumer-grade GPUs with limited memory.[35]
|
||||
|
||||
| Available VRAM | Recommended Batch Ratio |
|
||||
| :--- | :--- |
|
||||
| **<= 2 GB** | 1 |
|
||||
| **<= 3 GB** | 2 |
|
||||
| **<= 4 GB** | 4 |
|
||||
| **<= 6 GB** | 8 [35] |
|
||||
|
||||
For large-scale deployments, the introduction of `mineru-router` in version 3.0.0 allows for unified entry deployment across multiple services and GPUs. It provides automatic task load balancing and is fully compatible with the standard `mineru-api` interfaces.[5]
|
||||
|
||||
## Conclusion and Strategic Outlook
|
||||
|
||||
The implementation of MinerU on Windows platforms provides a powerful mechanism for high-precision document extraction, despite the inherent complexity of the installation process. By navigating the nuances of C++ build toolchains, specialized `detectron2` wheels, and CUDA-aligned PaddlePaddle versions, professional users can establish a stable and efficient parsing environment.
|
||||
|
||||
The transition toward version 3.0.0 represents a significant milestone in the project's maturity, emphasizing not just raw accuracy but also engineering stability and architectural flexibility. Features like native DOCX parsing and the sliding-window mechanism for long documents demonstrate a commitment to production-readiness. As MinerU continues to integrate with the broader AI ecosystem—serving as a foundational layer for RAG frameworks and agentic platforms—its role in the document intelligence domain is poised to expand significantly. For Windows-based enterprises and researchers, following the structured deployment framework detailed in this report ensures that they can capitalize on these advancements while maintaining the data sovereignty and performance benefits of a local installation.
|
||||
|
||||
---
|
||||
|
||||
1. MinerU, [https://opendatalab.github.io/MinerU/](https://opendatalab.github.io/MinerU/)
|
||||
2. papayalove/Magic-PDF - GitHub, [https://github.com/papayalove/Magic-PDF](https://github.com/papayalove/Magic-PDF)
|
||||
3. Extract Any PDF with MinerU 2.5 (Easy Tutorial) - Sonusahani.com, [https://sonusahani.com/blogs/mineru](https://sonusahani.com/blogs/mineru)
|
||||
4. MinerU - Dify Marketplace, [https://marketplace.dify.ai/plugin/langgenius/mineru](https://marketplace.dify.ai/plugin/langgenius/mineru)
|
||||
5. GitHub - opendatalab/MinerU: Transforms complex documents like PDFs into LLM-ready markdown/JSON for your Agentic workflows., [https://github.com/opendatalab/mineru](https://github.com/opendatalab/mineru)
|
||||
6. Quick Start - MinerU, [https://opendatalab.github.io/MinerU/quick_start/](https://opendatalab.github.io/MinerU/quick_start/)
|
||||
7. FAQ - MinerU - OpenDataLab | Data-Centric AI Research, [https://opendatalab.github.io/MinerU/faq/](https://opendatalab.github.io/MinerU/faq/)
|
||||
8. mineru - PyPI, [https://pypi.org/project/mineru/2.0.0/](https://pypi.org/project/mineru/2.0.0/)
|
||||
9. Install on Windows via PIP-Document-PaddlePaddle Deep Learning Platform, [https://www.paddlepaddle.org.cn/documentation/docs/en/install/pip/windows-pip_en.html](https://www.paddlepaddle.org.cn/documentation/docs/en/install/pip/windows-pip_en.html)
|
||||
10. Installation - Boost With Cuda - 《MinerU v1.1 Documentation》 - 书栈网 · BookStack, [https://www.bookstack.cn/read/mineru-1.1-en/e60b2c33b3945c15.md](https://www.bookstack.cn/read/mineru-1.1-en/e60b2c33b3945c15.md)
|
||||
11. [R] Struggle with PaddlePaddle OCR Vision Language installation - Reddit, [https://www.reddit.com/r/MachineLearning/comments/1p5d1gn/r_struggle_with_paddlepaddle_ocr_vision_language/](https://www.reddit.com/r/MachineLearning/comments/1p5d1gn/r_struggle_with_paddlepaddle_ocr_vision_language/)
|
||||
12. Can't install paddle · PaddlePaddle PaddleOCR · Discussion #14556 - GitHub, [https://github.com/PaddlePaddle/PaddleOCR/discussions/14556](https://github.com/PaddlePaddle/PaddleOCR/discussions/14556)
|
||||
13. AiBotLab/MinerU - Gitee, [https://gitee.com/yuzhu_yang/MinerU/blob/master/README.md](https://gitee.com/yuzhu_yang/MinerU/blob/master/README.md)
|
||||
14. Installation Guide-Document-PaddlePaddle Deep Learning Platform, [https://www.paddlepaddle.org.cn/documentation/docs/en/2.2/install/index_en.html](https://www.paddlepaddle.org.cn/documentation/docs/en/2.2/install/index_en.html)
|
||||
15. Step-by-Step Guide: How to Install Detectron2 from Scratch on Windows - Medium, [https://medium.com/@lukmanshaikh/step-by-step-guide-how-to-install-detectron2-from-scratch-on-windows-748b69cf18f1](https://medium.com/@lukmanshaikh/step-by-step-guide-how-to-install-detectron2-from-scratch-on-windows-748b69cf18f1)
|
||||
16. How to install Detectron2 on Your Windows local system? - Shreyas Kulkarni, [https://helloshreyas.com/how-to-install-detectron2-on-windows-machine](https://helloshreyas.com/how-to-install-detectron2-on-windows-machine)
|
||||
17. Detectron 2 Installation on Windows | by Vigneshwara - Medium, [https://medium.com/@rockingstarvic/detectron-2-installation-on-windows-3c8e717b44fd](https://medium.com/@rockingstarvic/detectron-2-installation-on-windows-3c8e717b44fd)
|
||||
18. magic-pdf - PyPI, [https://pypi.org/project/magic-pdf/](https://pypi.org/project/magic-pdf/)
|
||||
19. How to install Detectron2 - Stack Overflow, [https://stackoverflow.com/questions/75357936/how-to-install-detectron2](https://stackoverflow.com/questions/75357936/how-to-install-detectron2)
|
||||
20. fail to install detectron2 · Issue #199 · opendatalab/MinerU - GitHub, [https://github.com/opendatalab/MinerU/issues/199](https://github.com/opendatalab/MinerU/issues/199)
|
||||
21. Installation — detectron2 0.6 documentation, [https://detectron2.readthedocs.io/tutorials/install.html](https://detectron2.readthedocs.io/tutorials/install.html)
|
||||
22. Detectron2 & Python Wheels Cache - Medium, [https://medium.com/data-science/detectron2-python-wheels-cache-bfb94a0267ef](https://medium.com/data-science/detectron2-python-wheels-cache-bfb94a0267ef)
|
||||
23. It is impossible to start magic-pdf --version using the command line, and a TypeError is reported. · Issue #232 · opendatalab/MinerU - GitHub, [https://github.com/opendatalab/MinerU/issues/232](https://github.com/opendatalab/MinerU/issues/232)
|
||||
24. Quick Usage - MinerU, [https://opendatalab.github.io/MinerU/usage/quick_usage/](https://opendatalab.github.io/MinerU/usage/quick_usage/)
|
||||
25. Install on Windows via PIP, [https://www.paddlepaddle.org.cn/en/install/quick?docurl=/documentation/docs/en/install/pip/windows-pip_en.html](https://www.paddlepaddle.org.cn/en/install/quick?docurl=/documentation/docs/en/install/pip/windows-pip_en.html)
|
||||
26. lm_tool/MinerU - Gitee, [https://gitee.com/lm_tool/MinerU/blob/master/README.md](https://gitee.com/lm_tool/MinerU/blob/master/README.md)
|
||||
27. Error in parsing backslash escape sequence in JSON - Stack Overflow, [https://stackoverflow.com/questions/52343137/error-in-parsing-backslash-escape-sequence-in-json](https://stackoverflow.com/questions/52343137/error-in-parsing-backslash-escape-sequence-in-json)
|
||||
28. Model Source - MinerU, [https://opendatalab.github.io/MinerU/usage/model_source/](https://opendatalab.github.io/MinerU/usage/model_source/)
|
||||
29. Libiomp5md.dll Not Found? Here's the Fast Fix! - YouTube, [https://www.youtube.com/watch?v=IltGCJLbW5I](https://www.youtube.com/watch?v=IltGCJLbW5I)
|
||||
30. Missing DLL problem - CoPS, [https://www.copsmodels.com/gpmissingdll.htm](https://www.copsmodels.com/gpmissingdll.htm)
|
||||
31. Download.... Missing Zlib.dll from ur computer ?? - Microsoft Q&A, [https://learn.microsoft.com/en-us/answers/questions/2456917/download-missing-zlib-dll-from-ur-computer](https://learn.microsoft.com/en-us/answers/questions/2456917/download-missing-zlib-dll-from-ur-computer)
|
||||
32. pip installation successful, but 'magic-pdf --version' cannot display the version · Issue #1219 · opendatalab/MinerU - GitHub, [https://github.com/opendatalab/MinerU/issues/1219](https://github.com/opendatalab/MinerU/issues/1219)
|
||||
33. MinerU customized weight folder · Issue #2955 - GitHub, [https://github.com/opendatalab/MinerU/issues/2955](https://github.com/opendatalab/MinerU/issues/2955)
|
||||
34. Layout Bug · Issue #450 · opendatalab/MinerU - GitHub, [https://github.com/opendatalab/MinerU/issues/450](https://github.com/opendatalab/MinerU/issues/450)
|
||||
35. CLI Tools - MinerU, [https://opendatalab.github.io/MinerU/usage/cli_tools/](https://opendatalab.github.io/MinerU/usage/cli_tools/)
|
||||
@@ -1,50 +0,0 @@
|
||||
# 代码优化进度记录
|
||||
|
||||
**日期**: 2026-05-17
|
||||
**最后更新**: 12:45
|
||||
|
||||
---
|
||||
|
||||
## 已完成工作
|
||||
|
||||
### 阶段1-3: 异常处理规范化 ✅
|
||||
- 全项目裸异常已清零(19处修复)
|
||||
- 提交: `0d86bdd`
|
||||
|
||||
### P0: print→logging迁移 ✅ (90%完成)
|
||||
- 提交: `d198200`, `6a9bc9b`
|
||||
- 业务代码全部迁移,残留print均在测试块/CLI交互中
|
||||
|
||||
### P1: LLM调用统一 ✅
|
||||
- 提交: `5645c79`
|
||||
- 31处直接LLM调用统一到 `core/llm_utils.py`
|
||||
|
||||
**迁移模块**:
|
||||
| 模块 | 文件 | 调用数 |
|
||||
|------|------|--------|
|
||||
| core/ | agentic.py | 9 |
|
||||
| core/ | engine.py | 2 |
|
||||
| core/ | intent_analyzer.py | 1 |
|
||||
| core/ | quality_assessor.py | 1 |
|
||||
| core/ | query_decomposer.py | 1 |
|
||||
| core/ | reasoning_reflector.py | 1 |
|
||||
| exam_pkg/ | generator.py | 2 |
|
||||
| exam_pkg/ | grader.py | 1 |
|
||||
| api/ | chat_routes.py | 2 |
|
||||
| services/ | feedback.py | 2 |
|
||||
| services/ | outline.py | 1 |
|
||||
| graph/ | graph_build.py | 1 |
|
||||
| graph/ | graph_rag.py | 2 |
|
||||
| graph/ | entity_extractor.py | 1 |
|
||||
| knowledge/ | manager.py | 2 |
|
||||
| knowledge/ | router.py | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
| 优先级 | 角度 | 说明 |
|
||||
|--------|------|------|
|
||||
| P2 | 大文件拆分 | agentic.py(3526行), manager.py(3106行) |
|
||||
| P3 | 类型注解补全 | 提升IDE支持和静态检查 |
|
||||
| P4 | 性能热点优化 | BM25持久化、reranker批处理 |
|
||||
@@ -1,649 +0,0 @@
|
||||
# RAG幻觉问题优化方案
|
||||
|
||||
本文档分析RAG(检索增强生成)系统中幻觉问题产生的原因,并提供对应的解决方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、什么是RAG幻觉?
|
||||
|
||||
RAG幻觉是指大模型在基于检索到的知识库内容回答问题时,产生了以下问题:
|
||||
- 编造了知识库中不存在的信息
|
||||
- 给出了错误的答案
|
||||
- 混淆了不同来源的信息
|
||||
- 无法正确引用来源
|
||||
|
||||
---
|
||||
|
||||
## 二、幻觉产生的七大原因
|
||||
|
||||
### 1. 检索失效
|
||||
|
||||
**问题描述:**
|
||||
|
||||
用户的提问往往是口语化、模糊的,直接检索可能找不到相关内容,大模型就会"自己编"。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
用户问题: "那东西怎么用?"
|
||||
向量检索: 找不到匹配内容(问题太模糊)
|
||||
大模型行为: 开始编造答案
|
||||
```
|
||||
|
||||
**产生原因:**
|
||||
| 原因 | 说明 |
|
||||
|------|------|
|
||||
| 语义鸿沟 | 用户表达与文档表述差异大 |
|
||||
| 词汇不匹配 | 同义词、近义词未被识别 |
|
||||
| 问题不完整 | 缺少上下文信息 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 多跳推理难题
|
||||
|
||||
**问题描述:**
|
||||
|
||||
复杂问题需要多次检索、逐步推理,传统RAG难以处理。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
用户问题: "A公司CEO的妻子的母校在哪里?"
|
||||
|
||||
传统检索会分别检索:
|
||||
- "A公司" → 找到公司信息
|
||||
- "CEO" → 找到CEO相关信息
|
||||
- "妻子" → 找不到相关信息
|
||||
- "母校" → 找不到相关信息
|
||||
|
||||
最终答案缺失关键环节,大模型开始猜测。
|
||||
```
|
||||
|
||||
**产生原因:**
|
||||
- 单次检索无法获取完整信息链
|
||||
- 实体关系未被建模
|
||||
- 推理路径不明确
|
||||
|
||||
---
|
||||
|
||||
### 3. 上下文迷失
|
||||
|
||||
**问题描述:**
|
||||
|
||||
检索返回大量片段,但相关内容被噪声淹没,大模型"看漏了"关键信息。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
检索返回10个片段:
|
||||
- 片段1-3: 相关度高
|
||||
- 片段4-7: 相关度低(噪声)
|
||||
- 片段8-10: 相关度中
|
||||
|
||||
问题: 关键信息在片段2,但被噪声干扰,大模型关注了错误内容。
|
||||
```
|
||||
|
||||
**产生原因:**
|
||||
| 原因 | 说明 |
|
||||
|------|------|
|
||||
| 召回数量不当 | top_k过大或过小 |
|
||||
| 排序不准确 | 向量相似度≠语义相关度 |
|
||||
| 上下文过长 | 超出模型有效注意力范围 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 大模型"固执"问题
|
||||
|
||||
**问题描述:**
|
||||
|
||||
大模型的参数化记忆(训练数据)覆盖了检索到的事实内容。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
知识库内容: "公司成立于2018年"
|
||||
大模型训练数据: "该公司成立于2015年"
|
||||
|
||||
结果: 大模型可能回答"2015年",因为它"记得"这个信息。
|
||||
```
|
||||
|
||||
**产生原因:**
|
||||
- 大模型对训练数据有"偏好"
|
||||
- Prompt约束不够强
|
||||
- 模型倾向于"自信"地回答
|
||||
|
||||
---
|
||||
|
||||
### 5. 知识库质量问题
|
||||
|
||||
**问题描述:**
|
||||
|
||||
知识库本身存在问题,导致大模型基于错误信息回答。
|
||||
|
||||
**产生原因:**
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 文档解析错误 | PDF解析丢失信息、表格格式错乱 |
|
||||
| 切片不当 | 切断语义完整性,丢失上下文 |
|
||||
| 向量化信息丢失 | 重要信息在向量化过程中损失 |
|
||||
| 数据过时 | 知识库内容未更新 |
|
||||
|
||||
---
|
||||
|
||||
### 6. 检索结果冲突
|
||||
|
||||
**问题描述:**
|
||||
|
||||
知识库中存在矛盾信息,大模型无法判断哪个正确。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
文档A (2020年): 公司有200名员工
|
||||
文档B (2023年): 公司有300名员工
|
||||
文档C (无日期): 公司有256名员工
|
||||
|
||||
用户问: 公司有多少员工?
|
||||
大模型: 随机选择一个回答,或编造新数字。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 时效性问题
|
||||
|
||||
**问题描述:**
|
||||
|
||||
知识库内容过时,但用户询问最新信息。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
知识库: 2022年的政策文档
|
||||
用户问题: "现在的政策是什么?"
|
||||
|
||||
问题: 大模型可能基于过时信息回答,或编造"最新"内容。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、解决方案
|
||||
|
||||
### 方案1: Query预处理(解决检索失效)
|
||||
|
||||
**核心思路:** 在检索前,先用小模型优化用户问题。
|
||||
|
||||
#### 1.1 问题改写
|
||||
|
||||
```python
|
||||
def rewr ite_query(original_query):
|
||||
"""将口语化问题改写为标准检索语句"""
|
||||
prompt = f"""请将以下口语化问题改写为更清晰的检索语句。
|
||||
|
||||
原问题: {original_query}
|
||||
改写后: """
|
||||
|
||||
# 调用小模型改写
|
||||
rewritten = call_llm(prompt)
|
||||
return rewritten
|
||||
|
||||
# 示例
|
||||
原始问题: "那东西怎么用?"
|
||||
改写后: "智能手表X1的使用方法是什么?"
|
||||
```
|
||||
|
||||
#### 1.2 同义词扩展(Query Expansion)
|
||||
|
||||
```python
|
||||
def expand_query(query):
|
||||
"""扩展同义词,生成多个检索词"""
|
||||
prompt = f"""请为以下问题生成3-5个语义相似的检索词/短语。
|
||||
|
||||
问题: {query}
|
||||
同义词/相关词: """
|
||||
|
||||
expansions = call_llm(prompt).split('\n')
|
||||
return expansions
|
||||
|
||||
# 示例
|
||||
原始问题: "怎么操作?"
|
||||
扩展词: ["操作方法", "使用教程", "操作指南", "说明书", "使用方法"]
|
||||
```
|
||||
|
||||
#### 1.3 HyDE(假设文档生成)
|
||||
|
||||
```python
|
||||
def hyde_retrieval(query):
|
||||
"""先让模型猜测答案,再用猜测内容检索"""
|
||||
|
||||
# 步骤1: 生成假设性答案
|
||||
prompt = f"""请猜测以下问题的答案(即使不确定也可以编造)。
|
||||
|
||||
问题: {query}
|
||||
假设答案: """
|
||||
|
||||
hypothetical_answer = call_llm(prompt)
|
||||
|
||||
# 步骤2: 用假设答案检索(而非原问题)
|
||||
results = vector_search(hypothetical_answer)
|
||||
|
||||
return results
|
||||
|
||||
# 原理: 假设答案与真实答案在向量空间中更接近
|
||||
```
|
||||
|
||||
#### 1.4 多路检索融合
|
||||
|
||||
```python
|
||||
def multi_path_retrieval(query):
|
||||
"""多路检索,结果融合"""
|
||||
|
||||
results = []
|
||||
|
||||
# 路径1: 原问题检索
|
||||
results.append(vector_search(query))
|
||||
|
||||
# 路径2: 改写问题检索
|
||||
rewritten = rewrite_query(query)
|
||||
results.append(vector_search(rewritten))
|
||||
|
||||
# 路径3: 扩展词检索
|
||||
expansions = expand_query(query)
|
||||
for exp in expansions[:3]:
|
||||
results.append(vector_search(exp))
|
||||
|
||||
# 融合结果(RRF算法)
|
||||
final_results = reciprocal_rank_fusion(results)
|
||||
|
||||
return final_results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案2: 迭代检索(解决多跳推理)
|
||||
|
||||
**核心思路:** 将复杂问题分解,多次检索,逐步推理。
|
||||
|
||||
```python
|
||||
def iterative_retrieval(query):
|
||||
"""迭代检索,处理多跳问题"""
|
||||
|
||||
# 步骤1: 识别需要检索的实体
|
||||
entities = extract_entities(query)
|
||||
# 示例: ["A公司", "CEO", "妻子", "母校"]
|
||||
|
||||
# 步骤2: 逐步检索
|
||||
context = {}
|
||||
|
||||
# 第1跳: 检索A公司的CEO
|
||||
result1 = vector_search(f"A公司的CEO是谁")
|
||||
context["CEO"] = extract_answer(result1) # → "张三"
|
||||
|
||||
# 第2跳: 检索张三的妻子
|
||||
result2 = vector_search(f"{context['CEO']}的妻子")
|
||||
context["妻子"] = extract_answer(result2) # → "李四"
|
||||
|
||||
# 第3跳: 检索李四的母校
|
||||
result3 = vector_search(f"{context['妻子']}的母校")
|
||||
context["母校"] = extract_answer(result3) # → "北京大学"
|
||||
|
||||
return context
|
||||
|
||||
# 更智能的方式: 让大模型规划检索步骤
|
||||
def agent_retrieval(query):
|
||||
"""基于Agent的迭代检索"""
|
||||
|
||||
prompt = f"""为了回答问题"{query}",我需要检索哪些信息?请按顺序列出检索步骤。
|
||||
|
||||
步骤1: 检索什么?
|
||||
步骤2: 基于步骤1的结果,检索什么?
|
||||
..."""
|
||||
|
||||
steps = call_llm(prompt)
|
||||
|
||||
# 执行每一步
|
||||
for step in steps:
|
||||
result = vector_search(step)
|
||||
# 让模型决定下一步...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案3: 重排序与压缩(解决上下文迷失)
|
||||
|
||||
#### 3.1 Rerank重排序
|
||||
|
||||
```python
|
||||
from sentence_transformers import CrossEncoder
|
||||
|
||||
def rerank_results(query, initial_results, top_k=5):
|
||||
"""对初步召回结果进行精排"""
|
||||
|
||||
# 加载重排序模型
|
||||
reranker = CrossEncoder('BAAI/bge-reranker-base')
|
||||
|
||||
# 构建查询-文档对
|
||||
pairs = [(query, doc) for doc in initial_results]
|
||||
|
||||
# 计算精排分数
|
||||
scores = reranker.predict(pairs)
|
||||
|
||||
# 按分数排序,取top_k
|
||||
ranked_results = sorted(
|
||||
zip(initial_results, scores),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:top_k]
|
||||
|
||||
return [r[0] for r in ranked_results]
|
||||
```
|
||||
|
||||
**重排序模型推荐:**
|
||||
|
||||
| 模型 | 大小 | 效果 |
|
||||
|------|------|------|
|
||||
| `BAAI/bge-reranker-base` | 278M | 推荐,平衡 |
|
||||
| `BAAI/bge-reranker-large` | 560M | 更准 |
|
||||
| `BAAI/bge-reranker-v2-m3` | 560M | 多语言支持 |
|
||||
|
||||
#### 3.2 提示词压缩
|
||||
|
||||
```python
|
||||
def compress_context(context, query):
|
||||
"""压缩上下文,保留核心信息"""
|
||||
|
||||
prompt = f"""请提取以下内容中与问题"{query}"最相关的核心信息,去除无关内容。
|
||||
|
||||
原始内容:
|
||||
{context}
|
||||
|
||||
压缩后(保留关键信息,去除冗余): """
|
||||
|
||||
compressed = call_llm(prompt)
|
||||
return compressed
|
||||
|
||||
# 或使用专门的压缩工具如 LLMLingua
|
||||
from llmlingua import PromptCompressor
|
||||
|
||||
def llmlingua_compress(context, query):
|
||||
compressor = PromptCompressor()
|
||||
compressed = compressor.compress_prompt(
|
||||
context,
|
||||
instruction=query,
|
||||
rate=0.5 # 压缩到50%
|
||||
)
|
||||
return compressed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案4: 严格Prompt约束(解决大模型"固执")
|
||||
|
||||
**优化前:**
|
||||
```python
|
||||
prompt = f"""你是一个智能助手,请根据参考资料回答问题。
|
||||
参考资料: {context}
|
||||
问题: {query}
|
||||
回答: """
|
||||
```
|
||||
|
||||
**优化后:**
|
||||
```python
|
||||
STRICT_PROMPT = """你是一个严谨的信息提取助手,请严格遵循以下规则:
|
||||
|
||||
【严格约束】
|
||||
1. 只能基于【参考资料】中的信息回答,禁止使用你的先验知识
|
||||
2. 若参考资料中没有答案,直接回复"参考资料中未找到相关信息"
|
||||
3. 不要推测、不要补充、不要编造
|
||||
4. 必须标注信息来源(文件名、页码等)
|
||||
|
||||
【回答格式】
|
||||
- 答案: [基于资料的具体回答,引用原文]
|
||||
- 来源: [文件名 第X页]
|
||||
- 置信度: 高/中/低
|
||||
|
||||
【特殊情况处理】
|
||||
- 如果参考资料中有矛盾信息,列出所有版本并标注来源
|
||||
- 如果问题涉及时效性,提示用户"知识库日期为XXX,请核实最新信息"
|
||||
|
||||
【参考资料】
|
||||
{context}
|
||||
|
||||
【用户问题】
|
||||
{query}
|
||||
|
||||
请回答:"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案5: 知识库质量优化
|
||||
|
||||
#### 5.1 语义切片
|
||||
|
||||
```python
|
||||
from langchain.text_splitter import SemanticChunker
|
||||
|
||||
def semantic_chunking(text):
|
||||
"""按语义边界切分,而非固定长度"""
|
||||
|
||||
splitter = SemanticChunker(
|
||||
embedding_model,
|
||||
breakpoint_threshold_type="percentile"
|
||||
)
|
||||
|
||||
chunks = splitter.split_text(text)
|
||||
return chunks
|
||||
|
||||
# 对比:
|
||||
# 固定切分: ["表格如下:产品A销量100,产品B"] ["销量200,产品C..."] ← 表格被切断
|
||||
# 语义切分: ["表格: 产品A销量100,产品B销量200,产品C销量150"] ← 完整保留
|
||||
```
|
||||
|
||||
#### 5.2 多解析器对比
|
||||
|
||||
```python
|
||||
def robust_pdf_parse(filepath):
|
||||
"""多解析器对比,提高解析质量"""
|
||||
|
||||
results = {}
|
||||
|
||||
# 解析器1: pdfplumber
|
||||
try:
|
||||
text1 = parse_with_pdfplumber(filepath)
|
||||
results['pdfplumber'] = text1
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析器2: PyMuPDF
|
||||
try:
|
||||
text2 = parse_with_pymupdf(filepath)
|
||||
results['pymupdf'] = text2
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析器3: pypdf
|
||||
try:
|
||||
text3 = parse_with_pypdf(filepath)
|
||||
results['pypdf'] = text3
|
||||
except:
|
||||
pass
|
||||
|
||||
# 选择最佳结果(如最长、最完整)
|
||||
best = max(results.values(), key=len)
|
||||
return best
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案6: 冲突检测与告知
|
||||
|
||||
```python
|
||||
def detect_conflicts(results):
|
||||
"""检测检索结果中的冲突信息"""
|
||||
|
||||
# 提取数值型信息
|
||||
numbers = extract_numbers(results)
|
||||
|
||||
# 检测矛盾
|
||||
conflicts = []
|
||||
for key, values in numbers.items():
|
||||
if len(set(values)) > 1:
|
||||
conflicts.append({
|
||||
'field': key,
|
||||
'values': values,
|
||||
'sources': [r['source'] for r in results if key in r]
|
||||
})
|
||||
|
||||
return conflicts
|
||||
|
||||
def answer_with_conflict_detection(query, results):
|
||||
"""回答时检测并告知冲突"""
|
||||
|
||||
conflicts = detect_conflicts(results)
|
||||
|
||||
if conflicts:
|
||||
conflict_info = "\n".join([
|
||||
f"- {c['field']}: 存在{len(c['values'])}个不同说法"
|
||||
for c in conflicts
|
||||
])
|
||||
|
||||
return f"""检测到知识库中存在矛盾信息:
|
||||
|
||||
{conflict_info}
|
||||
|
||||
以下是基于最早/最新文档的回答:
|
||||
..."""
|
||||
|
||||
return normal_answer(query, results)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案7: 时效性管理
|
||||
|
||||
```python
|
||||
def add_timestamp_metadata(documents):
|
||||
"""为文档添加时间戳元数据"""
|
||||
|
||||
for doc in documents:
|
||||
# 尝试从文件名提取日期
|
||||
date = extract_date_from_filename(doc['filename'])
|
||||
|
||||
# 或从文件修改时间获取
|
||||
if not date:
|
||||
date = get_file_modified_time(doc['filepath'])
|
||||
|
||||
doc['metadata']['doc_date'] = date
|
||||
doc['metadata']['indexed_date'] = datetime.now()
|
||||
|
||||
def search_with_freshness(query, prefer_recent=True):
|
||||
"""检索时考虑文档时效性"""
|
||||
|
||||
results = vector_search(query)
|
||||
|
||||
# 按时效性排序
|
||||
if prefer_recent:
|
||||
results = sorted(
|
||||
results,
|
||||
key=lambda x: x['metadata'].get('doc_date', ''),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、综合架构优化
|
||||
|
||||
### 优化后的完整流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 用户问题 │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Query预处理层 │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ 问题改写 │ │ 同义词扩展 │ │ HyDE假设文档生成 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 混合检索层 │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ 向量检索 │ │ 关键词检索(BM25) │ │
|
||||
│ └──────────┬──────────┘ └──────────────┬──────────────┘ │
|
||||
│ └──────────────┬───────────────┘ │
|
||||
│ ↓ │
|
||||
│ 结果融合(RRF) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 重排序层 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Rerank模型精排 → 取TOP-K → 过滤低分结果 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 上下文优化层 │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ 提示词压缩 │ │ 冲突检测 │ │ 时效性标注 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 大模型生成层 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 严格Prompt约束 + 来源引用 + 置信度标注 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后处理验证层 │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ 事实核查 │ │ 来源验证 │ │ 幻觉检测 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 最终回答 │
|
||||
│ 答案 + 来源 + 置信度 + (冲突/时效提示) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、实施优先级
|
||||
|
||||
| 优先级 | 优化项 | 实现难度 | 效果提升 | 说明 |
|
||||
|--------|--------|----------|----------|------|
|
||||
| 🔴 P0 | Prompt约束 | 低 | 显著 | 改动最小,效果明显 |
|
||||
| 🔴 P0 | Rerank重排序 | 中 | 显著 | 提升检索精度 |
|
||||
| 🟡 P1 | Query改写 | 低 | 中等 | 解决口语化问题 |
|
||||
| 🟡 P1 | 混合检索 | 中 | 中等 | 向量+关键词互补 |
|
||||
| 🟡 P1 | 置信度标注 | 低 | 中等 | 让用户知道答案可靠程度 |
|
||||
| 🟢 P2 | HyDE | 中 | 中等 | 特定场景效果好 |
|
||||
| 🟢 P2 | 冲突检测 | 中 | 中等 | 提升可信度 |
|
||||
| 🟢 P2 | 语义切片 | 中 | 中等 | 优化知识库质量 |
|
||||
| 🔵 P3 | 迭代检索 | 高 | 针对性强 | 解决多跳问题 |
|
||||
| 🔵 P3 | GraphRAG | 高 | 针对性强 | 复杂关联查询 |
|
||||
|
||||
---
|
||||
|
||||
## 六、评估指标
|
||||
|
||||
优化后可通过以下指标评估效果:
|
||||
|
||||
| 指标 | 说明 | 计算方式 |
|
||||
|------|------|----------|
|
||||
| **准确率** | 回答正确的比例 | 正确回答数 / 总问题数 |
|
||||
| **召回率** | 相关内容被检索到的比例 | 召回相关片段数 / 总相关片段数 |
|
||||
| **幻觉率** | 编造信息的比例 | 包含幻觉的回答数 / 总回答数 |
|
||||
| **来源引用率** | 正确引用来源的比例 | 正确引用数 / 应引用数 |
|
||||
| **用户满意度** | 主观评价 | 问卷/反馈 |
|
||||
|
||||
---
|
||||
|
||||
## 七、参考资料
|
||||
|
||||
- [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401)
|
||||
- [HyDE: Precise Zero-Shot Dense Retrieval](https://arxiv.org/abs/2212.10496)
|
||||
- [BGE Reranker Models](https://huggingface.co/BAAI/bge-reranker-base)
|
||||
- [LLMLingua: Prompt Compression](https://github.com/microsoft/LLMLingua)
|
||||
609
docs/RAG数据流程.md
Normal file
609
docs/RAG数据流程.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# RAG 数据流程
|
||||
|
||||
> 本文档基于 v7.0.0 架构,详细梳理 RAG 系统从用户查询到回答生成的完整数据流。
|
||||
> 涵盖意图分析、混合检索、云端重排序、MMR 去重、上下文构建、LLM 生成和引用溯源等核心环节。
|
||||
|
||||
---
|
||||
|
||||
## 一、概述
|
||||
|
||||
### 1.1 检索管线总览
|
||||
|
||||
```
|
||||
用户查询
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 意图分析 (intent_analyzer) │
|
||||
│ 问题改写 · 指代消解 · 是否需要检索 · 重复提问检测 │
|
||||
│ 模型:qwen-turbo │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. 混合检索 (engine.search_knowledge) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 向量检索 │ │ BM25 检索 │ │
|
||||
│ │ (ChromaDB) │ │ (关键词匹配) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ └────────┬──────────┘ │
|
||||
│ ▼ │
|
||||
│ RRF 融合 (Reciprocal Rank Fusion) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. 云端 Rerank (DashScope API) │
|
||||
│ 模型:qwen3-rerank │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 4. MMR 去重 (文本 Jaccard 模式) │
|
||||
│ MMR_USE_EMBEDDING=false │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 5. 上下文扩展 + 图片选择 │
|
||||
│ 上下文增强 · 图文关联补充 · 懒加载 VLM 描述 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 6. LLM 生成 (AgenticRAG 引擎) │
|
||||
│ 模型:qwen3.6-flash(主 LLM)/ qwen-vl-plus(VLM) │
|
||||
│ 流式输出 · 引用标注 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 7. 返回结果 │
|
||||
│ { answer, sources, images } │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 模型配置一览
|
||||
|
||||
| 用途 | 模型 | 说明 |
|
||||
|------|------|------|
|
||||
| 主 LLM | qwen3.6-flash | 回答生成、上下文理解 |
|
||||
| 意图分析 | qwen-turbo | 轻量快速,用于问题改写与意图判断 |
|
||||
| VLM | qwen-vl-plus | 图片理解与描述生成 |
|
||||
| 云端重排序 | qwen3-rerank | DashScope API 调用,替代本地 BGE-reranker |
|
||||
|
||||
### 1.3 v7.0.0 架构变更要点
|
||||
|
||||
- **Reranker**:从本地 BGE-reranker 切换为云端 DashScope API(qwen3-rerank)
|
||||
- **MMR 去重**:使用文本相似度模式(`MMR_USE_EMBEDDING=false`),基于 Jaccard 系数
|
||||
- **Agentic 引擎**:拆分为多个子模块(agentic_search / agentic_answer / agentic_citation 等)
|
||||
- **Graph RAG**:模块已清空,不再使用
|
||||
|
||||
---
|
||||
|
||||
## 二、请求入口
|
||||
|
||||
### 2.1 API 路由
|
||||
|
||||
**入口文件**:`api/chat_routes.py`
|
||||
|
||||
用户通过 API 发送查询请求,由 `generate()` 函数统一调度:
|
||||
|
||||
```
|
||||
POST /rag 或 POST /chat
|
||||
Body: { "query": "用户问题", "kb_name": "知识库名称" }
|
||||
```
|
||||
|
||||
`generate()` 的职责:
|
||||
|
||||
1. 调用意图分析模块,获取改写后的查询与检索决策
|
||||
2. 若需要检索,调用混合检索管线
|
||||
3. 执行图片选择与上下文构建
|
||||
4. 调用 LLM 生成回答(流式输出)
|
||||
5. 组装最终响应(回答 + 引用来源 + 图片)
|
||||
|
||||
### 2.2 请求数据结构
|
||||
|
||||
```python
|
||||
# 请求
|
||||
{
|
||||
"query": "蓄水以来逐年发电量",
|
||||
"kb_name": "public_kb",
|
||||
"history": [...] # 可选:对话历史
|
||||
}
|
||||
|
||||
# 响应
|
||||
{
|
||||
"type": "finish",
|
||||
"answer": "完整回答文本",
|
||||
"sources": [...], # 引用来源列表
|
||||
"images": [...] # 精选图片列表
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、意图分析
|
||||
|
||||
**入口文件**:`core/intent_analyzer.py`
|
||||
**使用模型**:qwen-turbo(轻量快速)
|
||||
|
||||
### 3.1 核心功能
|
||||
|
||||
意图分析器在检索前对用户查询进行预处理,输出结构化决策:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class IntentAnalysis:
|
||||
rewritten_query: str # 改写后的查询(指代消解、省略补全)
|
||||
use_context: bool # 是否使用历史上下文
|
||||
need_retrieval: bool # 是否需要检索知识库
|
||||
```
|
||||
|
||||
### 3.2 处理逻辑
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 问题改写 | 指代消解("它" → 具体实体)、省略补全(补全缺失主语) |
|
||||
| 上下文判断 | 根据对话历史决定是否需要前文信息 |
|
||||
| 检索决策 | 判断是否需要查询知识库(闲聊类问题可跳过) |
|
||||
| 重复提问检测 | 用户重复提问相同问题时,强制设置 `need_retrieval=true` |
|
||||
|
||||
### 3.3 重复提问规则
|
||||
|
||||
当用户重复提问相同或相似问题时,意图分析器必须设置 `need_retrieval = true`。原因:用户可能对之前的回答不满意,或之前的回答包含错误信息,需要重新检索以获取更准确的结果。
|
||||
|
||||
---
|
||||
|
||||
## 四、检索阶段
|
||||
|
||||
**入口文件**:`core/engine.py`
|
||||
**核心函数**:`search_knowledge()`
|
||||
|
||||
### 4.1 查询缓存检查
|
||||
|
||||
检索前先检查缓存,命中则直接返回历史结果,避免重复计算:
|
||||
|
||||
```python
|
||||
cached = cache.get_query_result(query, kb_name)
|
||||
if cached:
|
||||
return cached
|
||||
```
|
||||
|
||||
### 4.2 向量检索
|
||||
|
||||
使用 embedding 模型将查询编码为向量,在 ChromaDB 中执行 ANN 检索:
|
||||
|
||||
```python
|
||||
query_vector = embedding_model.encode(query)
|
||||
text_results = collection.query(
|
||||
query_embeddings=[query_vector],
|
||||
n_results=100 # recall_k,召回候选数量
|
||||
)
|
||||
```
|
||||
|
||||
**图片独立召回**:同时对图片切片进行独立检索,保证图片有足够的召回机会:
|
||||
|
||||
```python
|
||||
image_results = collection.query(
|
||||
query_embeddings=[query_vector],
|
||||
where={'chunk_type': {'$in': ['image', 'chart', 'table']}},
|
||||
n_results=5
|
||||
)
|
||||
```
|
||||
|
||||
### 4.3 BM25 关键词检索
|
||||
|
||||
基于 BM25 算法执行关键词匹配检索,弥补向量检索在精确匹配上的不足:
|
||||
|
||||
```python
|
||||
bm25_results = bm25_index.search(query, top_k=100)
|
||||
```
|
||||
|
||||
### 4.4 RRF 融合
|
||||
|
||||
使用 Reciprocal Rank Fusion 算法将向量检索和 BM25 检索的结果进行融合排序:
|
||||
|
||||
```python
|
||||
fused_results = reciprocal_rank_fusion(
|
||||
[text_results, image_results, bm25_results],
|
||||
weights=[VECTOR_WEIGHT, IMAGE_WEIGHT, BM25_WEIGHT]
|
||||
)
|
||||
```
|
||||
|
||||
**融合参数**:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `recall_k` | 100 | 各通道召回候选数量 |
|
||||
| `VECTOR_WEIGHT` | 0.6 | 向量检索权重 |
|
||||
| `BM25_WEIGHT` | 0.4 | BM25 检索权重 |
|
||||
|
||||
---
|
||||
|
||||
## 五、重排序
|
||||
|
||||
### 5.1 云端 Rerank
|
||||
|
||||
**v7.0.0 变更**:从本地 BGE-reranker 切换为云端 DashScope API。
|
||||
|
||||
**调用模型**:qwen3-rerank
|
||||
|
||||
RRF 融合后的候选结果通过云端 Reranker 进行精排,基于查询与文档的语义相关性重新打分排序:
|
||||
|
||||
```python
|
||||
reranked = rerank_results(
|
||||
query,
|
||||
fused_results,
|
||||
model="qwen3-rerank", # DashScope API
|
||||
top_k=15
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 与旧版的区别
|
||||
|
||||
| 对比项 | 旧版(本地 BGE-reranker) | v7.0.0(云端 qwen3-rerank) |
|
||||
|--------|--------------------------|---------------------------|
|
||||
| 部署方式 | 本地模型加载 | DashScope API 远程调用 |
|
||||
| 资源占用 | 需要 GPU 显存 | 无本地资源消耗 |
|
||||
| 模型能力 | BGE-reranker(较小) | qwen3-rerank(更强) |
|
||||
| 延迟 | 低(本地推理) | 中等(网络往返) |
|
||||
|
||||
---
|
||||
|
||||
## 六、MMR 去重
|
||||
|
||||
### 6.1 配置
|
||||
|
||||
v7.0.0 采用文本相似度模式进行 MMR(Maximal Marginal Relevance)去重:
|
||||
|
||||
```
|
||||
MMR_USE_EMBEDDING = false
|
||||
```
|
||||
|
||||
### 6.2 工作原理
|
||||
|
||||
使用文本 Jaccard 相似度(而非 embedding 向量余弦相似度)来衡量候选文档之间的重复程度:
|
||||
|
||||
```python
|
||||
def _apply_mmr(query, candidates, top_k=30, lambda_param=0.7):
|
||||
"""
|
||||
MMR 去重:在相关性和多样性之间取得平衡
|
||||
|
||||
lambda_param=0.7: 70% 权重给相关性,30% 权重给多样性
|
||||
相似度度量:文本 Jaccard 系数(基于词集合交集/并集)
|
||||
"""
|
||||
selected = []
|
||||
for candidate in candidates:
|
||||
if not selected:
|
||||
selected.append(candidate)
|
||||
continue
|
||||
|
||||
# Jaccard 相似度(文本模式)
|
||||
max_sim = max(
|
||||
jaccard_similarity(candidate.tokens, s.tokens)
|
||||
for s in selected
|
||||
)
|
||||
|
||||
mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim
|
||||
if mmr_score > threshold:
|
||||
selected.append(candidate)
|
||||
|
||||
return selected[:top_k]
|
||||
```
|
||||
|
||||
### 6.3 选择文本模式的原因
|
||||
|
||||
- **速度更快**:无需计算 embedding 向量之间的余弦相似度
|
||||
- **效果直观**:Jaccard 系数直接反映文本内容的重叠程度
|
||||
- **避免向量偏差**:embedding 模型可能对格式化内容(如图片描述)产生不准确的相似度
|
||||
|
||||
---
|
||||
|
||||
## 七、上下文构建
|
||||
|
||||
### 7.1 检索结果处理
|
||||
|
||||
**入口文件**:`api/chat_routes.py`
|
||||
|
||||
将检索结果转换为 LLM 可用的上下文列表:
|
||||
|
||||
```python
|
||||
contexts = []
|
||||
for result in search_results:
|
||||
meta = result['metadata']
|
||||
if meta['chunk_type'] in ('image', 'chart'):
|
||||
# 图片切片:使用完整描述(而非轻量描述)
|
||||
doc = meta.get('full_description', result['document'])
|
||||
else:
|
||||
doc = result['document']
|
||||
contexts.append({'doc': doc, 'meta': meta})
|
||||
```
|
||||
|
||||
### 7.2 懒加载增强
|
||||
|
||||
对没有 VLM 描述的图片切片,按需调用 VLM 生成更精准的语义描述:
|
||||
|
||||
```python
|
||||
enhance_retrieved_chunks(contexts, query, kb_name)
|
||||
# 对缺少 VLM 描述的图片 → 调用 qwen-vl-plus 生成描述
|
||||
```
|
||||
|
||||
### 7.3 图片选择
|
||||
|
||||
**核心函数**:`select_images()`
|
||||
|
||||
从检索结果中筛选与查询最相关的图片:
|
||||
|
||||
**步骤一:意图检测**
|
||||
|
||||
| 查询类型 | 参数调整 |
|
||||
|----------|----------|
|
||||
| 精确图号查询("图2.3") | `MAX_IMAGES=2, MIN_SCORE=5.0` |
|
||||
| 弱图片意图("发电量图") | `MAX_IMAGES=1` |
|
||||
| 普通查询 | `MAX_IMAGES=2` |
|
||||
|
||||
**步骤二:提取图表引用**
|
||||
|
||||
从 top 5 文本块中提取 "见图2.3"、"如表2.2" 等引用,建立图号与来源文件的映射:
|
||||
|
||||
```python
|
||||
referenced_figures = {'2.3': {'source_file': 'xxx.pdf'}, ...}
|
||||
```
|
||||
|
||||
**步骤三:图片相关性打分**
|
||||
|
||||
`score_image_relevance()` 打分规则:
|
||||
|
||||
| 匹配项 | 加分 |
|
||||
|--------|------|
|
||||
| 图号精确匹配(查询中有"图2.3") | +10 分 |
|
||||
| 表号精确匹配 | +10 分 |
|
||||
| 关键词匹配("发电量"等) | +2 分/个 |
|
||||
| 字符重叠 | +0.2 分/字符 |
|
||||
| 章节匹配 | +1.5 分 |
|
||||
| 图片类型(chart > image) | +2 / +1 分 |
|
||||
| 向量相似度 | +2 分(最高) |
|
||||
| 引用匹配(需章节相关) | +8 分 |
|
||||
|
||||
**步骤四:图文关联补充**
|
||||
|
||||
遍历 top 5 文本块中引用的图表编号,查找对应的图片切片并补充到结果中。
|
||||
|
||||
**步骤五:返回 top N 图片**
|
||||
|
||||
```python
|
||||
scored_images.sort(key=lambda x: x['score'], reverse=True)
|
||||
return scored_images[:MAX_IMAGES]
|
||||
```
|
||||
|
||||
### 7.4 构建 LLM Prompt
|
||||
|
||||
```python
|
||||
# 文本上下文
|
||||
context_text = "\n\n".join([ctx['doc'] for ctx in contexts[:5]])
|
||||
|
||||
# 图片信息
|
||||
if selected_images:
|
||||
image_info = "【可用图片】\n" + 图片描述列表
|
||||
|
||||
# 最终上下文
|
||||
enhanced_context = context_text + image_info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、LLM 生成
|
||||
|
||||
### 8.1 AgenticRAG 引擎
|
||||
|
||||
v7.0.0 将 Agentic 引擎拆分为独立的子模块,各司其职:
|
||||
|
||||
| 子模块 | 职责 |
|
||||
|--------|------|
|
||||
| `agentic_search.py` | 检索调度:管理多轮检索、查询分解 |
|
||||
| `agentic_answer.py` | 回答生成:基于上下文生成最终回答 |
|
||||
| `agentic_citation.py` | 引用标注:在回答中插入来源引用标记 |
|
||||
| `agentic_context.py` | 上下文管理:上下文窗口控制、截断策略 |
|
||||
| `agentic_query.py` | 查询处理:查询改写、多查询生成 |
|
||||
| `agentic_media.py` | 多媒体处理:图片理解、表格解析 |
|
||||
| `agentic_quality.py` | 质量控制:回答质量评估、幻觉检测 |
|
||||
| `agentic_meta.py` | 元数据管理:知识库信息、检索统计 |
|
||||
|
||||
### 8.2 流式生成
|
||||
|
||||
使用 SSE(Server-Sent Events)实现流式输出:
|
||||
|
||||
```python
|
||||
for token in engine.generate_answer_stream(query, enhanced_context):
|
||||
yield token # 逐 token 推送给前端
|
||||
```
|
||||
|
||||
**主 LLM 模型**:qwen3.6-flash
|
||||
**VLM 模型**:qwen-vl-plus(处理图片理解任务)
|
||||
|
||||
### 8.3 回答结构
|
||||
|
||||
```python
|
||||
{
|
||||
"type": "finish",
|
||||
"answer": "根据蓄水以来的统计数据,三峡电站逐年发电量呈现波动上升趋势...",
|
||||
"sources": [
|
||||
{
|
||||
"source": "三峡公报_2022.pdf",
|
||||
"page": 12,
|
||||
"section": "综述 > 2.3 发电",
|
||||
"chunk_id": "三峡公报_text_24"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"id": "ab77281e7913.jpg",
|
||||
"url": "/images/ab77281e7913.jpg",
|
||||
"type": "chart",
|
||||
"source": "三峡公报_2022.pdf",
|
||||
"page": 12,
|
||||
"description": "图2.3 柱状图:2003-2022年逐年发电量"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、引用溯源
|
||||
|
||||
### 9.1 引用标注机制
|
||||
|
||||
`agentic_citation.py` 负责在回答中插入引用标记,将回答内容与知识库来源关联:
|
||||
|
||||
```
|
||||
根据统计数据[1],2022年三峡电站年度发电量为787.90亿千瓦时[2]。
|
||||
|
||||
[1] 来源:三峡公报_2022.pdf,第12页,综述 > 2.3 发电
|
||||
[2] 来源:三峡公报_2022.pdf,第15页,表2.1
|
||||
```
|
||||
|
||||
### 9.2 引用数据来源
|
||||
|
||||
每个引用标记对应检索结果中的一个切片,包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `source` | 源文件名 |
|
||||
| `page` | 页码 |
|
||||
| `section` | 章节路径 |
|
||||
| `chunk_id` | 切片唯一标识 |
|
||||
| `chunk_type` | 类型(text / table / image / chart) |
|
||||
|
||||
### 9.3 前端引用跳转
|
||||
|
||||
前端解析回答中的引用标记(如 `[1]`),渲染为可点击的链接,点击后跳转到对应的来源文件或页面。
|
||||
|
||||
---
|
||||
|
||||
## 十、文档入库流程(补充参考)
|
||||
|
||||
> 入库流程为检索提供数据基础,以下简要说明关键环节。
|
||||
|
||||
### 10.1 文档解析
|
||||
|
||||
**入口文件**:`parsers/mineru_parser.py`
|
||||
**核心函数**:`parse_with_mineru()`
|
||||
|
||||
MinerU 解析 PDF/Word/Excel 文件,输出结构化内容:
|
||||
|
||||
```
|
||||
.data/mineru_temp/{file_hash}/
|
||||
├── auto/
|
||||
│ ├── {doc_name}.md # Markdown 内容
|
||||
│ ├── {doc_name}_content_list.json # 结构化内容列表(核心)
|
||||
│ └── images/ # 提取的图片文件
|
||||
```
|
||||
|
||||
**content_list.json 中的条目类型**:
|
||||
|
||||
| item_type | 处理方式 | 关键字段 |
|
||||
|-----------|----------|----------|
|
||||
| `text` | 文本块 | content, section_path, text_level |
|
||||
| `table` | 表格 | content, table_html, image_path |
|
||||
| `image` | 图片 | content(=caption), image_path, context_before/after |
|
||||
| `chart` | 图表 | content(=caption), image_path, context_before/after |
|
||||
|
||||
### 10.2 MinerUChunk 数据结构
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MinerUChunk:
|
||||
content: str # 文本内容
|
||||
chunk_type: str # 类型: text, table, image, chart
|
||||
page_start: int = 1 # 起始页码
|
||||
page_end: int = 1 # 结束页码
|
||||
text_level: int = 0 # 标题级别 (0=正文, 1=h1, 2=h2...)
|
||||
title: str = "" # 标题文本
|
||||
section_path: str = "" # 章节路径
|
||||
bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
|
||||
source_file: str = "" # 源文件名
|
||||
table_html: Optional[str] = None # 表格 HTML
|
||||
image_path: Optional[str] = None # 图片路径
|
||||
images: Optional[List[Dict]] = None # 关联图片列表
|
||||
context_before: str = "" # 图片前的文本上下文
|
||||
context_after: str = "" # 图片后的文本上下文
|
||||
```
|
||||
|
||||
### 10.3 切片入库
|
||||
|
||||
**入口文件**:`knowledge/manager.py`
|
||||
**核心函数**:`add_file_to_kb()`
|
||||
|
||||
```
|
||||
MinerUChunk 列表
|
||||
│
|
||||
├── 文本块 → 计算 embedding → 存入 ChromaDB
|
||||
│
|
||||
├── 表格块 → 生成语义增强摘要 → 存入 ChromaDB
|
||||
│
|
||||
└── 图片块 → VLM 缓存检查 → 生成描述 → 存入 ChromaDB
|
||||
```
|
||||
|
||||
**图片描述策略**:
|
||||
1. 优先使用 VLM 缓存描述(语义更丰富,由 qwen-vl-plus 生成)
|
||||
2. 若无缓存,生成轻量描述(基于文件名 + 章节路径 + 上下文)
|
||||
|
||||
轻量描述示例:
|
||||
```
|
||||
图表:图2.3,位于「综述 > 2.3发电」,第12页
|
||||
前文:受长江流域性严重枯水影响,2022年三峡电站年度发电量为787.90亿千瓦时...
|
||||
后文:2.4航运 三峡船闸和葛洲坝船闸实行统一调度...
|
||||
```
|
||||
|
||||
VLM 描述示例(更精准):
|
||||
```
|
||||
图2.3 柱状图 主要内容描述:该柱状图展示了2003年至2022年每年的发电量(单位:亿千瓦时)。
|
||||
发电量在2003年为86.07亿千瓦时,随后逐年波动上升,至2020年达到峰值1118.02亿千瓦时...
|
||||
```
|
||||
|
||||
### 10.4 ChromaDB 存储结构
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ids` | str | 切片唯一标识,如 `doc.pdf_text_24` |
|
||||
| `embeddings` | List[float] | 向量表示 |
|
||||
| `documents` | str | 切片内容(文本/描述/摘要) |
|
||||
| `metadatas` | dict | 元数据(见下表) |
|
||||
|
||||
**图片切片 metadata 示例**:
|
||||
|
||||
```python
|
||||
{
|
||||
'source': '三峡公报_2022.pdf',
|
||||
'page': 12,
|
||||
'chunk_type': 'chart',
|
||||
'section': '综述 > 2.3发电',
|
||||
'figure_number': '2.3',
|
||||
'image_path': 'ab77281e7913.jpg',
|
||||
'has_vlm_desc': True,
|
||||
'preview': '图2.3 柱状图 主要内容...'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、关键文件索引
|
||||
|
||||
| 文件 | 职责 | 关键函数/类 |
|
||||
|------|------|-------------|
|
||||
| `api/chat_routes.py` | API 路由与请求调度 | `generate()`, `select_images()`, `score_image_relevance()` |
|
||||
| `core/intent_analyzer.py` | 意图分析 | `analyze()`, `IntentAnalysis` |
|
||||
| `core/engine.py` | 检索引擎核心 | `search_knowledge()`, `reciprocal_rank_fusion()`, `rerank_results()` |
|
||||
| `core/agentic_search.py` | 检索调度 | 多轮检索、查询分解 |
|
||||
| `core/agentic_answer.py` | 回答生成 | 基于上下文生成最终回答 |
|
||||
| `core/agentic_citation.py` | 引用标注 | 来源引用标记插入 |
|
||||
| `core/agentic_context.py` | 上下文管理 | 上下文窗口控制、截断 |
|
||||
| `core/agentic_query.py` | 查询处理 | 查询改写、多查询生成 |
|
||||
| `core/agentic_media.py` | 多媒体处理 | 图片理解、表格解析 |
|
||||
| `core/agentic_quality.py` | 质量控制 | 回答质量评估、幻觉检测 |
|
||||
| `core/agentic_meta.py` | 元数据管理 | 知识库信息、检索统计 |
|
||||
| `parsers/mineru_parser.py` | 文档解析 | `parse_with_mineru()`, `MinerUChunk` |
|
||||
| `knowledge/manager.py` | 知识库管理 | `add_file_to_kb()`, `generate_lightweight_image_description()` |
|
||||
| `knowledge/lazy_enhance.py` | 懒加载增强 | `lazy_vlm_description()`, `enhance_retrieved_chunks()` |
|
||||
@@ -1,448 +0,0 @@
|
||||
# RAG 数据流程完整分析
|
||||
|
||||
> 本文档详细分析 RAG 系统从文档上传到回答生成的完整数据流程,
|
||||
> 帮助理解各模块职责和定位问题。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 用户查询 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ api/chat_routes.py - generate() │
|
||||
│ ├── 意图分析 (intent_analyzer.py) - 问题改写 + 是否需要检索 │
|
||||
│ ├── 混合检索 (search_hybrid → engine.search_knowledge) │
|
||||
│ ├── 图片选择 (select_images) - 打分排序 │
|
||||
│ └── LLM 生成回答 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、文档入库流程
|
||||
|
||||
### 2.1 解析层 (parsers/mineru_parser.py)
|
||||
|
||||
**输入**:PDF/Word/Excel 文件
|
||||
|
||||
**核心函数**:`parse_with_mineru()`
|
||||
|
||||
**处理流程**:
|
||||
1. MinerU 解析文档 → 输出 `content_list.json` + 图片文件
|
||||
2. 遍历 content_list,按类型处理:
|
||||
|
||||
| item_type | 处理方式 | MinerUChunk 字段 |
|
||||
|-----------|----------|------------------|
|
||||
| `text` | 文本块 | content, section_path, text_level |
|
||||
| `table` | 表格 | content, table_html, image_path(表格图片) |
|
||||
| `image` | 图片 | content(=caption), image_path, context_before/after |
|
||||
| `chart` | 图表 | content(=caption), image_path, context_before/after |
|
||||
|
||||
**图片上下文提取** (第 366-384 行):
|
||||
```python
|
||||
def get_context_for_image(image_idx: int, page_idx: int, window: int = 3) -> tuple:
|
||||
"""获取图片前后的文本上下文"""
|
||||
context_before = []
|
||||
context_after = []
|
||||
|
||||
# 查找图片前后的文本项
|
||||
for item_idx, text, item_page in text_items:
|
||||
if item_idx < image_idx and item_page >= page_idx - 1:
|
||||
context_before.append(text) # 图片之前的文本
|
||||
elif item_idx > image_idx and item_page <= page_idx + 1:
|
||||
context_after.append(text) # 图片之后的文本
|
||||
|
||||
# 只保留最近的 window 条
|
||||
return " ".join(context_before[-window:]), " ".join(context_after[:window])
|
||||
```
|
||||
|
||||
**输出**:`MinerUChunk` 列表
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MinerUChunk:
|
||||
content: str # 文本内容(图片类型通常是 caption 或默认值)
|
||||
chunk_type: str # 类型: text, table, image, chart
|
||||
page_start: int = 1 # 起始页码
|
||||
page_end: int = 1 # 结束页码
|
||||
text_level: int = 0 # 标题级别 (0=body, 1=h1, 2=h2...)
|
||||
title: str = "" # 标题文本
|
||||
section_path: str = "" # 章节路径
|
||||
bbox: Optional[List[float]] = None # 边界框
|
||||
source_file: str = "" # 源文件名
|
||||
table_html: Optional[str] = None # 表格 HTML
|
||||
image_path: Optional[str] = None # 图片路径
|
||||
images: Optional[List[Dict]] = None # 关联图片列表
|
||||
context_before: str = "" # 图片前的文本上下文
|
||||
context_after: str = "" # 图片后的文本上下文
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 入库层 (knowledge/manager.py)
|
||||
|
||||
**核心函数**:`add_file_to_kb()`
|
||||
|
||||
**处理流程**:
|
||||
|
||||
```
|
||||
MinerUChunk 列表
|
||||
│
|
||||
├── 文本块 ──────────────────────► 文本切片入库
|
||||
│ ├── 计算 embedding
|
||||
│ └── collection.add(id, embedding, document, metadata)
|
||||
│
|
||||
├── 表格块 ──────────────────────► 表格切片入库
|
||||
│ ├── 生成语义增强内容
|
||||
│ └── collection.add(...)
|
||||
│
|
||||
└── 图片块 ──────────────────────► 图片切片入库
|
||||
├── 检查 VLM 缓存(新增)
|
||||
├── 生成描述(VLM 或轻量描述)
|
||||
└── collection.add(...)
|
||||
```
|
||||
|
||||
**图片切片入库详细流程** (第 1296-1378 行):
|
||||
|
||||
```python
|
||||
# 1. 检查 VLM 缓存(优先使用)
|
||||
vlm_desc = self._get_vlm_cache(full_image_path)
|
||||
|
||||
if vlm_desc:
|
||||
# 使用 VLM 描述(语义更丰富)
|
||||
description = vlm_desc
|
||||
image_meta['has_vlm_desc'] = True
|
||||
else:
|
||||
# 生成轻量描述(包含上下文)
|
||||
description = self.generate_lightweight_image_description(...)
|
||||
|
||||
# 2. 计算 embedding
|
||||
vector = embedding_model.encode(description).tolist()
|
||||
|
||||
# 3. 存入向量库
|
||||
collection.add(
|
||||
ids=[chunk_id],
|
||||
embeddings=[vector],
|
||||
documents=[description],
|
||||
metadatas=[image_meta]
|
||||
)
|
||||
```
|
||||
|
||||
**generate_lightweight_image_description() 输出格式**:
|
||||
|
||||
```
|
||||
图表:图2.3,位于「... > 2.3发电」,第12页
|
||||
前文:受长江流域性严重枯水影响,2022 年三峡电站年度发电量为 787.90 亿千瓦时...
|
||||
后文:2.4航运 三峡船闸和葛洲坝船闸实行统一调度...
|
||||
```
|
||||
|
||||
**VLM 缓存描述格式**(更精准):
|
||||
|
||||
```
|
||||
图2.3 柱状图 主要内容描述:该柱状图展示了2003年至2022年每年的发电量(单位:亿千瓦时)。
|
||||
发电量在2003年为86.07亿千瓦时,随后逐年波动上升,至2020年达到峰值1118.02亿千瓦时...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 向量库存储结构
|
||||
|
||||
**ChromaDB 存储字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ids` | str | 切片唯一标识,如 `三峡公报_1-15页.pdf_text_24` |
|
||||
| `embeddings` | List[float] | 768 维向量(bge-base-zh-v1.5) |
|
||||
| `documents` | str | 切片内容(用于 LLM 上下文和相似度计算) |
|
||||
| `metadatas` | dict | 元数据 |
|
||||
|
||||
**图片切片 metadata 字段**:
|
||||
|
||||
```python
|
||||
{
|
||||
'source': '三峡公报_1-15页.pdf',
|
||||
'page': 12,
|
||||
'chunk_type': 'chart', # image 或 chart
|
||||
'section': '综述 > 2.3发电',
|
||||
'caption': '图表', # 通常为默认值
|
||||
'figure_number': '2.3', # 从上下文提取的图号
|
||||
'image_path': 'ab77281e7913.jpg',
|
||||
'has_vlm_desc': True, # 是否有 VLM 描述
|
||||
'preview': '图2.3 柱状图 主要内容...' # 描述预览
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、检索流程
|
||||
|
||||
### 3.1 混合检索 (core/engine.py)
|
||||
|
||||
**核心函数**:`search_knowledge()`
|
||||
|
||||
**流程图**:
|
||||
|
||||
```
|
||||
用户查询
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 查询缓存检查 │
|
||||
│ cache.get_query_result(query, kb_name) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ 未命中
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. 向量检索 │
|
||||
│ query_vector = embedding_model.encode(query) │
|
||||
│ collection.query(query_embeddings=[query_vector], n_results=100) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. BM25 关键词检索(可选) │
|
||||
│ bm25_results = bm25_index.search(query, top_k=100) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 4. RRF 融合 │
|
||||
│ fused_results = reciprocal_rank_fusion([vector, bm25], weights) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 5. MMR 去重 │
|
||||
│ fused_results = _apply_mmr(query, fused_results, top_k=30) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 6. Rerank 重排序 │
|
||||
│ rerank_results(query, fused_results, top_k=5) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 7. 返回结果 │
|
||||
│ {ids: [[...]], documents: [[...]], metadatas: [[...]], │
|
||||
│ distances: [[...]]} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键参数**:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `recall_k` | 100 | 召回候选数量 |
|
||||
| `top_k` | 5-20 | 最终返回数量 |
|
||||
| `VECTOR_WEIGHT` | 0.6 | 向量检索权重 |
|
||||
| `BM25_WEIGHT` | 0.4 | BM25 检索权重 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 图片选择 (api/chat_routes.py)
|
||||
|
||||
**核心函数**:`select_images()`
|
||||
|
||||
**流程**:
|
||||
|
||||
```
|
||||
检索结果 contexts
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 提取图表引用 │
|
||||
│ 从 top 5 文本块提取 "见图2.3"、"如表2.2" 等 │
|
||||
│ → referenced_figures = {'2.3': {来源文件}} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. 遍历图片切片,打分 │
|
||||
│ for ctx in contexts: │
|
||||
│ if ctx['meta']['chunk_type'] in ('image', 'chart'): │
|
||||
│ score = score_image_relevance(query, meta, doc) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. 排序返回 top N │
|
||||
│ scored_images.sort(key=lambda x: x['score'], reverse=True) │
|
||||
│ return scored_images[:MAX_IMAGES] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**score_image_relevance() 打分逻辑**:
|
||||
|
||||
| 匹配项 | 加分 |
|
||||
|--------|------|
|
||||
| 图号精确匹配(查询中有"图2.3") | +10 分 |
|
||||
| 表号精确匹配 | +10 分 |
|
||||
| 关键词匹配("发电量"等) | +2 分/个 |
|
||||
| 字符重叠 | +0.2 分/字符 |
|
||||
| 章节匹配 | +1.5 分 |
|
||||
| 图片类型(chart > image) | +2 / +1 分 |
|
||||
| 向量相似度 | +2 分(最高) |
|
||||
|
||||
---
|
||||
|
||||
## 四、问题诊断
|
||||
|
||||
### 4.1 问题现象
|
||||
|
||||
用户查询"蓄水以来逐年发电量",期望返回图2.3,实际返回图2.5/表2.2。
|
||||
|
||||
### 4.2 诊断结果
|
||||
|
||||
| 排名 | 类型 | 内容 | Distance | 问题 |
|
||||
|------|------|------|----------|------|
|
||||
| 1 | text | "2.3发电"章节文本 | 0.9980 | ✅ 正确 |
|
||||
| 24 | image | 封面图片 | 0.0002 | ❌ 极低 |
|
||||
| N/A | chart | 图2.3 | 未进入 top 50 | ❌ 极低 |
|
||||
|
||||
### 4.3 根因分析
|
||||
|
||||
**问题 1:图片切片向量相似度极低**
|
||||
|
||||
- 图片的 `document` 是轻量描述格式
|
||||
- 关键词"发电量"出现在"前文"中,被大量上下文稀释
|
||||
- embedding 模型对这种格式的内容相似度计算不准确
|
||||
|
||||
**问题 2:VLM 缓存未被利用**
|
||||
|
||||
- 已有 VLM 缓存包含精准描述:"展示了2003年至2022年每年的发电量"
|
||||
- 但入库时未检查 VLM 缓存
|
||||
- 导致图片切片的语义表达不准确
|
||||
|
||||
**问题 3:文本切片覆盖图片语义**
|
||||
|
||||
- "2.3发电" 章节的文本切片包含完整描述
|
||||
- 文本切片排名靠前,但没有关联图片
|
||||
- 图片切片独立存在,无法通过文本切片找到
|
||||
|
||||
---
|
||||
|
||||
## 五、优化方案
|
||||
|
||||
### 5.1 P0:入库时使用 VLM 缓存(已实现)
|
||||
|
||||
**修改文件**:`knowledge/manager.py`
|
||||
|
||||
**方案**:
|
||||
```python
|
||||
# 优先使用 VLM 缓存
|
||||
vlm_desc = self._get_vlm_cache(full_image_path)
|
||||
|
||||
if vlm_desc:
|
||||
description = vlm_desc # 使用 VLM 描述
|
||||
image_meta['has_vlm_desc'] = True
|
||||
else:
|
||||
description = self.generate_lightweight_image_description(...) # 轻量描述
|
||||
|
||||
vector = embedding_model.encode(description).tolist()
|
||||
```
|
||||
|
||||
**验证结果**(2026-04-28):
|
||||
- 为图2.3 生成了 VLM 描述,包含关键词"发电量"、"柱状图"、"2003年至2022年每年"
|
||||
- 更新向量库后,查询"蓄水以来逐年发电量"时图2.3 排名第2(distance=0.3153)
|
||||
- 效果显著提升
|
||||
|
||||
### 5.2 P1:意图分析器优化(已实现)
|
||||
|
||||
**问题**:用户再次问相同问题时,意图分析器错误设置 `need_retrieval=False`,导致复用错误的上下文。
|
||||
|
||||
**修改文件**:`core/intent_analyzer.py`
|
||||
|
||||
**方案**:在 SYSTEM_PROMPT 中添加规则:
|
||||
- **当用户重复提问相同或相似问题时,必须设置 need_retrieval = true**
|
||||
- 原因:用户可能对之前的回答不满意,或之前的回答包含错误信息
|
||||
|
||||
### 5.3 P2:建立图文关联索引
|
||||
|
||||
**方案**:
|
||||
1. 文本切片存储时,提取其中的图表引用
|
||||
2. 在 metadata 中记录 `referenced_images: ["图2.3"]`
|
||||
3. 检索时,通过文本切片的 `referenced_images` 找到对应图片
|
||||
|
||||
### 5.4 P3:图片独立召回通道
|
||||
|
||||
**方案**:
|
||||
1. 向量检索时,对图片切片使用独立的 top_k
|
||||
2. 保证图片切片有足够的召回机会
|
||||
3. 最终融合文本和图片结果
|
||||
|
||||
---
|
||||
|
||||
## 六、验证方案
|
||||
|
||||
### 6.1 重建向量库
|
||||
|
||||
```bash
|
||||
# 方式1:删除向量库目录后同步
|
||||
rm -rf knowledge/vector_store/chroma/public_kb
|
||||
curl -X POST http://localhost:5001/sync
|
||||
|
||||
# 方式2:通过 API 重新上传文档
|
||||
```
|
||||
|
||||
### 6.2 测试检索
|
||||
|
||||
```bash
|
||||
# 测试 1:关键词查询
|
||||
curl -X POST http://localhost:5001/rag \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "蓄水以来逐年发电量"}'
|
||||
# 预期:图2.3 排名靠前
|
||||
|
||||
# 测试 2:图号查询
|
||||
curl -X POST http://localhost:5001/rag \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "图2.3 发电量"}'
|
||||
# 预期:精确返回图2.3
|
||||
|
||||
# 测试 3:语义查询
|
||||
curl -X POST http://localhost:5001/rag \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "三峡水库补水统计"}'
|
||||
# 预期:返回表2.2/图2.5
|
||||
```
|
||||
|
||||
### 6.3 检查向量库内容
|
||||
|
||||
```python
|
||||
import chromadb
|
||||
client = chromadb.PersistentClient(path='knowledge/vector_store/chroma/public_kb')
|
||||
col = client.get_collection('public_kb')
|
||||
|
||||
# 查看图片切片
|
||||
results = col.get(
|
||||
where={'chunk_type': {'$in': ['image', 'chart']}},
|
||||
include=['metadatas', 'documents'],
|
||||
limit=10
|
||||
)
|
||||
|
||||
for i, chunk_id in enumerate(results['ids']):
|
||||
meta = results['metadatas'][i]
|
||||
doc = results['documents'][i]
|
||||
print(f"[{i+1}] {meta.get('chunk_type')} | has_vlm_desc: {meta.get('has_vlm_desc')}")
|
||||
print(f" Doc: {doc[:100]}...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、关键文件索引
|
||||
|
||||
| 文件 | 职责 | 关键函数 |
|
||||
|------|------|----------|
|
||||
| `parsers/mineru_parser.py` | 文档解析 | `parse_with_mineru()`, `get_context_for_image()` |
|
||||
| `knowledge/manager.py` | 向量库管理 | `add_file_to_kb()`, `generate_lightweight_image_description()`, `_get_vlm_cache()` |
|
||||
| `core/engine.py` | 检索引擎 | `search_knowledge()`, `reciprocal_rank_fusion()`, `rerank_results()` |
|
||||
| `api/chat_routes.py` | API 路由 | `generate()`, `select_images()`, `score_image_relevance()` |
|
||||
| `core/intent_analyzer.py` | 意图分析 | `analyze()`, `IntentAnalysis` |
|
||||
| `knowledge/lazy_enhance.py` | 懒加载增强 | `lazy_vlm_description()`, `enhance_retrieved_chunks()` |
|
||||
@@ -1,590 +0,0 @@
|
||||
# RAG 数据流程详解
|
||||
|
||||
> 本文档详细梳理 RAG 系统从文档解析到最终响应的完整数据流,便于问题排查和系统优化。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ RAG 数据流程 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 文档上传 │───▶│ 文档解析 │───▶│ 切片入库 │───▶│ 向量检索 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ API 层 MinerU ChromaDB 混合检索 │
|
||||
│ 入口 解析器 向量库 BM25+向量 │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 图片匹配 │───▶│ LLM 生成 │───▶│ 响应输出 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ 相关性打分 AgenticRAG SSE 流式 │
|
||||
│ 图片选择 问答引擎 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、文档解析层
|
||||
|
||||
### 2.1 MinerU 解析输出结构
|
||||
|
||||
**入口函数**:`parsers/mineru_parser.py::parse_with_mineru()`
|
||||
|
||||
**输出文件**:
|
||||
```
|
||||
.data/mineru_temp/{file_hash}/
|
||||
├── auto/
|
||||
│ ├── {doc_name}.md # Markdown 内容
|
||||
│ ├── {doc_name}_content_list.json # 结构化内容列表 ⭐
|
||||
│ └── images/ # 提取的图片
|
||||
│ ├── abc123.jpg
|
||||
│ └── def456.png
|
||||
```
|
||||
|
||||
### 2.2 content_list.json 结构
|
||||
|
||||
这是 MinerU 解析的核心输出,包含文档的完整结构化信息:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": "第一章 水情分析",
|
||||
"page_idx": 0,
|
||||
"bbox": [x0, y0, x1, y1],
|
||||
"text_level": 1
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "正文内容...",
|
||||
"page_idx": 0,
|
||||
"bbox": [x0, y0, x1, y1],
|
||||
"text_level": 0
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": "<table>...</table>",
|
||||
"table_caption": "表1.1 数据统计",
|
||||
"img_path": "table_001.jpg",
|
||||
"page_idx": 1,
|
||||
"bbox": [x0, y0, x1, y1]
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"img_path": "abc123.jpg",
|
||||
"caption": "", // ⚠️ MinerU 未提取,通常为空
|
||||
"page_idx": 2,
|
||||
"bbox": [x0, y0, x1, y1]
|
||||
},
|
||||
{
|
||||
"type": "chart",
|
||||
"img_path": "chart_001.jpg",
|
||||
"caption": "", // ⚠️ 同样通常为空
|
||||
"page_idx": 3,
|
||||
"bbox": [x0, y0, x1, y1]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2.3 content_list 各类型字段详解
|
||||
|
||||
| 类型 | 字段 | 说明 | 示例值 |
|
||||
|------|------|------|--------|
|
||||
| **text** | `text` | 文本内容 | "第一章 概述" |
|
||||
| | `page_idx` | 页码索引(0-based) | 0 |
|
||||
| | `bbox` | 边界框坐标 | [50, 100, 500, 150] |
|
||||
| | `text_level` | 标题级别(0=正文,1=h1...) | 1 |
|
||||
| **table** | `table_body` | 表格 HTML | `"<table>...</table>"` |
|
||||
| | `table_caption` | 表格标题 | "表1.1 统计数据" |
|
||||
| | `img_path` | 表格图片路径(可选) | "table_001.jpg" |
|
||||
| **image** | `img_path` | 图片路径 | "abc123.jpg" |
|
||||
| | `caption` | 图片标题 ⚠️ | "" (通常为空) |
|
||||
| **chart** | `img_path` | 图表图片路径 | "chart_001.jpg" |
|
||||
| | `caption` | 图表标题 ⚠️ | "" (通常为空) |
|
||||
|
||||
### 2.4 MinerUChunk 数据结构
|
||||
|
||||
**定义位置**:`parsers/mineru_parser.py` 第 95-116 行
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MinerUChunk:
|
||||
content: str # 文本内容
|
||||
chunk_type: str # 类型: text, table, image, chart, equation
|
||||
page_start: int = 1 # 起始页码
|
||||
page_end: int = 1 # 结束页码
|
||||
text_level: int = 0 # 标题级别 (0=body, 1=h1, 2=h2...)
|
||||
title: str = "" # 标题文本
|
||||
section_path: str = "" # 章节路径 "第一章 > 1.1 概述"
|
||||
bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
|
||||
source_file: str = "" # 源文件名
|
||||
table_html: Optional[str] = None # 表格 HTML(如果是表格)
|
||||
image_path: Optional[str] = None # 图片路径(独立图片)
|
||||
images: Optional[List[Dict]] = None # 关联图片列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、切片入库层
|
||||
|
||||
### 3.1 入库流程
|
||||
|
||||
**入口函数**:`knowledge/manager.py::add_file_to_kb()`
|
||||
|
||||
**流程图**:
|
||||
```
|
||||
add_file_to_kb()
|
||||
│
|
||||
├── parse_document() → 调用 MinerU 解析
|
||||
│
|
||||
├── convert_to_rag_format() → 转换为 RAG 格式
|
||||
│
|
||||
└── 遍历 pages_content:
|
||||
│
|
||||
├── 文本切片 → 生成 embedding → 存入 ChromaDB
|
||||
│
|
||||
├── 表格切片 → 生成摘要 → 存入 ChromaDB
|
||||
│
|
||||
└── 图片切片 → 生成描述 → 存入 ChromaDB
|
||||
```
|
||||
|
||||
### 3.2 文本切片存储
|
||||
|
||||
**代码位置**:`knowledge/manager.py` 第 1050-1150 行
|
||||
|
||||
```python
|
||||
text_meta = {
|
||||
'source': filename, # 源文件名
|
||||
'page': page_info.get('page', 0), # 页码
|
||||
'chunk_type': 'text', # 类型
|
||||
'section': section, # 章节标题
|
||||
'section_path': section_path, # 章节路径
|
||||
'level': page_info.get('level', 0), # 标题级别
|
||||
'doc_type': _get_doc_type(filename), # 文档类型
|
||||
'has_table': False,
|
||||
**extra_metadata
|
||||
}
|
||||
|
||||
# document 字段 = 文本内容
|
||||
document = page_info.get('text', '')
|
||||
|
||||
# 向量化
|
||||
vector = embedding_model.encode(document).tolist()
|
||||
|
||||
collection.add(
|
||||
ids=[chunk_id],
|
||||
embeddings=[vector],
|
||||
documents=[document], # ⭐ 文本内容
|
||||
metadatas=[text_meta]
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 表格切片存储
|
||||
|
||||
**代码位置**:`knowledge/manager.py` 第 1150-1200 行
|
||||
|
||||
```python
|
||||
table_meta = {
|
||||
'source': filename,
|
||||
'page': page_info.get('page', 0),
|
||||
'chunk_type': 'table',
|
||||
'section': section,
|
||||
'caption': caption, # 表格标题
|
||||
'has_table': True,
|
||||
'table_html': table_html, # 表格 HTML
|
||||
...
|
||||
}
|
||||
|
||||
# document 字段 = 表格摘要(LLM 生成)或表格 Markdown
|
||||
document = summary if summary else markdown_table
|
||||
|
||||
collection.add(
|
||||
ids=[chunk_id],
|
||||
embeddings=[vector],
|
||||
documents=[document], # ⭐ 表格摘要/Markdown
|
||||
metadatas=[table_meta]
|
||||
)
|
||||
```
|
||||
|
||||
### 3.4 图片切片存储(重点!)
|
||||
|
||||
**代码位置**:`knowledge/manager.py` 第 1195-1255 行
|
||||
|
||||
```python
|
||||
# caption 获取(问题根源!)
|
||||
caption = page_info.get('caption') or chunk.title # ⚠️ 两者都是默认值
|
||||
|
||||
# 元数据
|
||||
image_meta = {
|
||||
'source': filename,
|
||||
'page': page_info.get('page', 0),
|
||||
'chunk_type': 'image', # 或 'chart'
|
||||
'section': section_path,
|
||||
'caption': caption, # ⚠️ 存入默认值 "图片"/"图表"
|
||||
'figure_number': _extract_figure_number(caption, section), # 图号
|
||||
'image_path': image_path, # 图片路径
|
||||
'has_vlm_desc': False,
|
||||
...
|
||||
}
|
||||
|
||||
# ⭐ document 字段 = 轻量级描述(正确!)
|
||||
description = self.generate_lightweight_image_description(full_image_path, chunk, page_info)
|
||||
# 结果: "图表:位于「第一章」> 1.1 概述,第5页"
|
||||
|
||||
# 向量化
|
||||
vector = embedding_model.encode(description).tolist()
|
||||
|
||||
collection.add(
|
||||
ids=[chunk_id],
|
||||
embeddings=[vector],
|
||||
documents=[description], # ⭐ 正确的描述信息
|
||||
metadatas=[image_meta] # ⚠️ caption 是默认值
|
||||
)
|
||||
```
|
||||
|
||||
### 3.5 generate_lightweight_image_description 函数
|
||||
|
||||
**代码位置**:`knowledge/manager.py` 第 1418-1459 行
|
||||
|
||||
```python
|
||||
def generate_lightweight_image_description(self, image_path: str, chunk, page_info: dict) -> str:
|
||||
"""
|
||||
生成轻量级图片描述(不用 VLM)
|
||||
|
||||
信息来源:文件名 + 标题/caption + 章节路径 + 页码
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# 1. 图片类型
|
||||
chunk_type = page_info.get('chunk_type', 'image')
|
||||
type_label = "图表" if chunk_type == 'chart' else "图片"
|
||||
|
||||
# 2. 标题或 caption
|
||||
title = chunk.title if hasattr(chunk, 'title') and chunk.title else ""
|
||||
caption = page_info.get('caption', '')
|
||||
|
||||
# 3. 章节路径
|
||||
section = page_info.get('section_path', '') or page_info.get('section', '')
|
||||
|
||||
# 4. 页码
|
||||
page = page_info.get('page', 0)
|
||||
|
||||
# 组装描述
|
||||
if caption:
|
||||
parts.append(caption)
|
||||
elif title and title not in ("图片", "图表"):
|
||||
parts.append(title)
|
||||
|
||||
if section:
|
||||
parts.append(f"位于「{section}」")
|
||||
|
||||
parts.append(f"第{page}页")
|
||||
|
||||
return f"{type_label}:{','.join(parts)}"
|
||||
# 输出示例: "图表:位于「Tracing the s-Process」> 2.1 The M-S-C sequence,第5页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、向量库结构
|
||||
|
||||
### 4.1 ChromaDB 存储结构
|
||||
|
||||
每个切片包含三个核心字段:
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `ids` | str | 切片唯一 ID | "doc.pdf_text_0" |
|
||||
| `embeddings` | List[float] | 向量表示 | [0.1, 0.2, ...] |
|
||||
| `documents` | str | 文本内容/描述 | "图表:位于「xxx」第5页" |
|
||||
| `metadatas` | dict | 元数据 | 见下表 |
|
||||
|
||||
### 4.2 元数据字段详解
|
||||
|
||||
#### 文本切片 metadata
|
||||
|
||||
```python
|
||||
{
|
||||
'source': 'report.pdf', # 源文件名
|
||||
'page': 5, # 页码
|
||||
'chunk_type': 'text', # 类型
|
||||
'section': '水情分析', # 章节标题
|
||||
'section_path': '第一章 > 1.1 水情分析', # 章节路径
|
||||
'level': 0, # 标题级别
|
||||
'doc_type': 'pdf', # 文档类型
|
||||
'has_table': False,
|
||||
'collection': 'public_kb'
|
||||
}
|
||||
```
|
||||
|
||||
#### 表格切片 metadata
|
||||
|
||||
```python
|
||||
{
|
||||
'source': 'report.pdf',
|
||||
'page': 6,
|
||||
'chunk_type': 'table',
|
||||
'section': '数据统计',
|
||||
'caption': '表1.1 月度统计数据', # 表格标题
|
||||
'has_table': True,
|
||||
'table_html': '<table>...</table>', # 表格 HTML
|
||||
'collection': 'public_kb'
|
||||
}
|
||||
```
|
||||
|
||||
#### 图片/图表切片 metadata
|
||||
|
||||
```python
|
||||
{
|
||||
'source': 'report.pdf',
|
||||
'page': 7,
|
||||
'chunk_type': 'image', # 或 'chart'
|
||||
'section': '水情分析',
|
||||
'caption': '图片', # 默认值(从 MinerU 获取)
|
||||
'figure_number': '', # 图号(依赖 caption)
|
||||
'image_path': 'abc123.jpg', # 图片路径
|
||||
'has_vlm_desc': False, # 是否有 VLM 描述
|
||||
'bbox': '[x0,y0,x1,y1]', # 边界框 JSON
|
||||
'preview': '图表:位于「第一章」...', # 预览文本
|
||||
'collection': 'public_kb'
|
||||
}
|
||||
```
|
||||
|
||||
#### 图片切片 document 字段(优化后)
|
||||
|
||||
```python
|
||||
# 优化后的 document 字段包含上下文,便于语义检索命中
|
||||
"""
|
||||
图表:位于「第一章 > 水情分析」,第5页
|
||||
前文:2022年汛期长江流域出现汛期反枯,三峡水库出入库流量呈现明显下降趋势...
|
||||
后文:由图2.1可见,水位呈现先升后降趋势,最高水位出现在8月中旬...
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、检索层
|
||||
|
||||
### 5.1 混合检索流程
|
||||
|
||||
**入口**:`core/engine.py::search_knowledge()` 或 `core/agentic.py`
|
||||
|
||||
```
|
||||
search_knowledge(query)
|
||||
│
|
||||
├── 向量检索 (ChromaDB)
|
||||
│ └── collection.query(query_embeddings=[vector], n_results=20)
|
||||
│
|
||||
├── 关键词检索 (BM25)
|
||||
│ └── bm25_index.search(query, top_k=20)
|
||||
│
|
||||
└── 结果合并 (RRF)
|
||||
└── reciprocal_rank_fusion(vector_results, bm25_results)
|
||||
```
|
||||
|
||||
### 5.2 检索结果结构
|
||||
|
||||
```python
|
||||
{
|
||||
'ids': ['doc.pdf_text_0', 'doc.pdf_image_1', ...],
|
||||
'documents': ['文本内容...', '图表:位于「xxx」第5页', ...],
|
||||
'metadatas': [{...}, {...}, ...],
|
||||
'distances': [0.1, 0.2, ...]
|
||||
}
|
||||
```
|
||||
|
||||
转换为 `contexts` 格式:
|
||||
```python
|
||||
contexts = [
|
||||
{
|
||||
'id': 'doc.pdf_text_0',
|
||||
'doc': '文本内容...',
|
||||
'meta': {...},
|
||||
'score': 0.9
|
||||
},
|
||||
{
|
||||
'id': 'doc.pdf_image_1',
|
||||
'doc': '图表:位于「xxx」第5页', # ⭐ document 字段
|
||||
'meta': {
|
||||
'chunk_type': 'image',
|
||||
'caption': '图片', # ⚠️ 默认值
|
||||
'image_path': 'abc.jpg',
|
||||
...
|
||||
},
|
||||
'score': 0.85
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、图片匹配层
|
||||
|
||||
### 6.1 图片选择流程
|
||||
|
||||
**代码位置**:`api/chat_routes.py` 第 246-293 行
|
||||
|
||||
```python
|
||||
def select_images(contexts: list, query: str) -> list:
|
||||
"""
|
||||
选择要展示的图片(打分排序 + 预算控制)
|
||||
"""
|
||||
scored_images = []
|
||||
for ctx in contexts:
|
||||
meta = ctx.get('meta', {})
|
||||
if meta.get('chunk_type') in ('image', 'chart') and meta.get('image_path'):
|
||||
# 调用打分函数
|
||||
s = score_image_relevance(query, meta) # ⚠️ 未传入 doc 字段
|
||||
if s >= MIN_SCORE:
|
||||
scored_images.append({
|
||||
'score': s,
|
||||
'id': os.path.basename(meta['image_path']),
|
||||
'url': f"/images/{os.path.basename(meta['image_path'])}",
|
||||
'type': meta['chunk_type'],
|
||||
'source': meta.get('source'),
|
||||
'page': meta.get('page'),
|
||||
'description': ctx.get('doc', '')[:100]
|
||||
})
|
||||
|
||||
scored_images.sort(key=lambda x: x['score'], reverse=True)
|
||||
return scored_images[:MAX_IMAGES]
|
||||
```
|
||||
|
||||
### 6.2 图片相关性打分
|
||||
|
||||
**代码位置**:`api/chat_routes.py` 第 186-243 行
|
||||
|
||||
```python
|
||||
def score_image_relevance(query: str, meta: dict) -> float:
|
||||
"""
|
||||
图片相关性打分
|
||||
|
||||
问题:使用 meta.get('caption') 获取的是默认值 "图片"/"图表"
|
||||
解决:应该使用 ctx['doc'] 字段进行匹配
|
||||
"""
|
||||
score = 0.0
|
||||
|
||||
# 1. 检测查询中的图片编号
|
||||
caption = meta.get('caption', '') or '' # ⚠️ 获取默认值
|
||||
figure_matches = re.findall(r'图\s*(\d+\.?\d*)', query)
|
||||
|
||||
if figure_matches:
|
||||
for fig_num in figure_matches:
|
||||
if f"图{fig_num}" in caption: # ⚠️ 永远不匹配
|
||||
score += 5.0
|
||||
|
||||
# 2. 查询内容与图片 caption 匹配
|
||||
if caption:
|
||||
overlap = len(set(query) & set(caption)) # ⚠<arg_value> 使用默认值匹配
|
||||
score += min(overlap * 0.15, 3.0)
|
||||
|
||||
# ... 其他加分逻辑
|
||||
|
||||
return score
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、问题排查指南
|
||||
|
||||
### 7.1 常见问题定位
|
||||
|
||||
| 问题现象 | 可能原因 | 排查位置 |
|
||||
|----------|----------|----------|
|
||||
| 图片不显示 | caption 为默认值 | 检索结果 `meta['caption']` |
|
||||
| 图片匹配错误 | 打分逻辑未使用 doc 字段 | `score_image_relevance()` |
|
||||
| 表格未识别 | table_html 为空 | 检索结果 `meta['table_html']` |
|
||||
| 切片丢失 | 解析失败或过滤 | MinerU 输出 `content_list.json` |
|
||||
|
||||
### 7.2 调试命令
|
||||
|
||||
```python
|
||||
# 1. 查看 MinerU 解析结果
|
||||
import json
|
||||
with open('.data/mineru_temp/{hash}/auto/{doc}_content_list.json') as f:
|
||||
content_list = json.load(f)
|
||||
for item in content_list[:10]:
|
||||
print(f"类型: {item.get('type')}, 内容: {str(item)[:100]}")
|
||||
|
||||
# 2. 查看向量库切片
|
||||
from knowledge.manager import KnowledgeBaseManager
|
||||
kb = KnowledgeBaseManager()
|
||||
collection = kb.get_collection('public_kb')
|
||||
|
||||
# 获取所有图片切片
|
||||
result = collection.get(
|
||||
where={"chunk_type": "image"},
|
||||
include=['documents', 'metadatas']
|
||||
)
|
||||
|
||||
for i, (doc, meta) in enumerate(zip(result['documents'][:5], result['metadatas'][:5])):
|
||||
print(f"图片 {i+1}:")
|
||||
print(f" document: {doc}")
|
||||
print(f" caption: {meta.get('caption')}")
|
||||
print(f" image_path: {meta.get('image_path')}")
|
||||
```
|
||||
|
||||
### 7.3 数据流检查清单
|
||||
|
||||
```
|
||||
□ MinerU 解析
|
||||
├─ content_list.json 是否生成?
|
||||
├─ 图片项 caption 字段是否为空?
|
||||
└─ 图片文件是否正确提取?
|
||||
|
||||
□ 切片入库
|
||||
├─ document 字段是否包含描述?
|
||||
├─ metadata.caption 是否为默认值?
|
||||
└─ image_path 是否正确?
|
||||
|
||||
□ 向量检索
|
||||
├─ 检索结果是否包含图片切片?
|
||||
├─ ctx['doc'] 是否有值?
|
||||
└─ ctx['meta']['caption'] 是什么?
|
||||
|
||||
□ 图片匹配
|
||||
├─ score_image_relevance 是否使用 doc 字段?
|
||||
└─ 最终匹配分数是否足够?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、已知问题与解决方案
|
||||
|
||||
### 8.1 图片 caption 为默认值
|
||||
|
||||
**问题**:MinerU 未提取图片标题,导致 `meta['caption']` 为 "图片"/"图表"
|
||||
|
||||
**影响**:`score_image_relevance()` 无法正确匹配图片
|
||||
|
||||
**临时解决**:修改 `score_image_relevance()` 使用 `ctx['doc']` 字段
|
||||
|
||||
**长期解决**:在 MinerU 解析层从文档上下文提取图片标题
|
||||
|
||||
### 8.2 figure_number 未提取
|
||||
|
||||
**问题**:图号提取依赖 caption,caption 为空时 figure_number 也为空
|
||||
|
||||
**影响**:无法按图号精确检索
|
||||
|
||||
**解决**:改进 `_extract_figure_number()` 从 section 或上下文提取
|
||||
|
||||
---
|
||||
|
||||
## 九、参考文件
|
||||
|
||||
| 文件 | 作用 | 关键函数 |
|
||||
|------|------|----------|
|
||||
| `parsers/mineru_parser.py` | 文档解析 | `parse_with_mineru()`, `MinerUChunk` |
|
||||
| `knowledge/manager.py` | 切片入库 | `add_file_to_kb()`, `generate_lightweight_image_description()` |
|
||||
| `api/chat_routes.py` | 图片匹配 | `select_images()`, `score_image_relevance()` |
|
||||
| `core/engine.py` | 向量检索 | `search_knowledge()` |
|
||||
| `core/agentic.py` | 问答引擎 | `AgenticRAG` |
|
||||
@@ -185,7 +185,7 @@
|
||||
- 支持10万级向量
|
||||
- 权限过滤(security_level)
|
||||
|
||||
**代码位置:** `rag_demo.py`
|
||||
**代码位置:** `core/engine.py`
|
||||
- `search_knowledge()`: 混合检索主函数
|
||||
- `reciprocal_rank_fusion()`: RRF融合
|
||||
- `rerank_results()`: 重排序
|
||||
@@ -198,7 +198,7 @@
|
||||
- Agentic RAG自动上下文传递
|
||||
- 会话列表、历史查询、删除会话
|
||||
|
||||
**代码位置:** `session_manager.py`, `agentic_rag.py`
|
||||
**代码位置:** `services/session.py`, `core/agentic.py`
|
||||
- `SessionManager`: 会话管理器
|
||||
- `AgenticRAG.process()`: 多轮对话处理
|
||||
|
||||
@@ -209,9 +209,9 @@
|
||||
- 置信度评估(高/中/低)
|
||||
- 来源去重和合并显示
|
||||
|
||||
**代码位置:** `rag_demo.py`, `agentic_rag.py`
|
||||
- `generate_answer()`: 包含置信度评估
|
||||
- `_extract_sources()`: 来源提取
|
||||
**代码位置:** `core/engine.py`, `core/agentic.py`
|
||||
- `generate_answer()`: 包含置信度评估(`core/engine.py`)
|
||||
- `_extract_sources()`: 来源提取(`core/agentic_citation.py`)
|
||||
|
||||
### ⚠️ GKPT-KB-008: 知识库自动同步(部分实现)
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
- 试卷管理(草稿/审核/通过状态)
|
||||
- 批阅报告生成
|
||||
|
||||
**代码位置:** `exam_manager.py`, `exam_api.py`
|
||||
**代码位置:** `exam_pkg/manager.py`, `exam_pkg/api.py`
|
||||
|
||||
### ⚠️ GKPT-EXAM-018: AI自动阅卷(部分实现)
|
||||
|
||||
@@ -348,5 +348,5 @@ CREATE TABLE document_versions (
|
||||
|
||||
---
|
||||
|
||||
*文档更新时间: 2026-04-06*
|
||||
*项目版本: v4.2.0*
|
||||
*文档更新时间: 2026-06-04*
|
||||
*项目版本: v7.0.0*
|
||||
|
||||
@@ -584,7 +584,8 @@ curl -s -X POST http://localhost:5001/documents/upload \
|
||||
"collection": "public_kb",
|
||||
"filename": "test.txt",
|
||||
"path": "public_kb/test.txt",
|
||||
"size": 18
|
||||
"size": 18,
|
||||
"replaced": false
|
||||
},
|
||||
"sync_status": "已保存并添加到向量库"
|
||||
},
|
||||
@@ -595,6 +596,8 @@ curl -s -X POST http://localhost:5001/documents/upload \
|
||||
}
|
||||
```
|
||||
|
||||
> **同名文件处理**:上传同名文件时,旧版本的切片会被自动清理后覆盖(`replaced: true`),不会生成时间戳后缀文件。
|
||||
|
||||
**验证结果**:✅ 通过
|
||||
|
||||
---
|
||||
@@ -617,8 +620,8 @@ curl -s -X POST http://localhost:5001/documents/batch-upload \
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success"},
|
||||
{"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success"}
|
||||
{"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success", "replaced": false},
|
||||
{"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success", "replaced": false}
|
||||
],
|
||||
"success_count": 2,
|
||||
"total": 2
|
||||
@@ -630,6 +633,8 @@ curl -s -X POST http://localhost:5001/documents/batch-upload \
|
||||
}
|
||||
```
|
||||
|
||||
> **同名文件处理**:与单文件上传相同,批量上传中遇到同名文件也会自动覆盖旧版本(`replaced: true`)。
|
||||
|
||||
**验证结果**:✅ 通过
|
||||
|
||||
---
|
||||
|
||||
138
docs/rag检索流程
138
docs/rag检索流程
@@ -1,138 +0,0 @@
|
||||
RAG 检索流程(从用户输入到回答生成)
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 用户输入问题 │
|
||||
│ "蓄水以来逐年发电量" │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. 意图分析 (core/intent_analyzer.py) │
|
||||
│ ├── 问题改写:指代消解、省略补全 │
|
||||
│ ├── use_context:是否使用历史上下文 │
|
||||
│ ├── need_retrieval:是否需要检索知识库 │
|
||||
│ └── 重复提问强制检索(新增规则) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. 混合检索 (core/engine.py - search_knowledge) │
|
||||
│ │
|
||||
│ 3.1 向量检索 │
|
||||
│ query_vector = embedding_model.encode(query) │
|
||||
│ text_results = collection.query(query_embeddings, n_results=100) │
|
||||
│ │
|
||||
│ 3.2 图片独立召回(P0 新增) │
|
||||
│ image_results = collection.query( │
|
||||
│ where={'chunk_type': {'$in': ['image', 'chart', 'table']}}, │
|
||||
│ n_results=5 # 独立控制图片数量 │
|
||||
│ ) │
|
||||
│ │
|
||||
│ 3.3 BM25 关键词检索(可选) │
|
||||
│ bm25_results = bm25_index.search(query, top_k=100) │
|
||||
│ │
|
||||
│ 3.4 RRF 融合 │
|
||||
│ fused_results = reciprocal_rank_fusion([text, image, bm25]) │
|
||||
│ │
|
||||
│ 3.5 MMR 去重 │
|
||||
│ fused_results = _apply_mmr(query, fused_results, top_k=30) │
|
||||
│ │
|
||||
│ 3.6 Rerank 重排序 │
|
||||
│ final_results = rerank_results(query, fused_results, top_k=15) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 4. 检索结果处理 (api/chat_routes.py) │
|
||||
│ │
|
||||
│ 4.1 构建 contexts │
|
||||
│ for each result: │
|
||||
│ if 图片切片: │
|
||||
│ doc = meta['full_description'] # 使用完整描述(P1) │
|
||||
│ else: │
|
||||
│ doc = result['document'] │
|
||||
│ contexts.append({'doc': doc, 'meta': meta}) │
|
||||
│ │
|
||||
│ 4.2 懒加载增强(可选) │
|
||||
│ enhance_retrieved_chunks(contexts, query, kb_name) │
|
||||
│ └── 对无 VLM 描述的图片生成 VLM 描述 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 5. 图片选择 (api/chat_routes.py - select_images) │
|
||||
│ │
|
||||
│ 5.1 意图检测 │
|
||||
│ - 精确图号查询:"图2.3" → MAX_IMAGES=2, MIN_SCORE=5.0 │
|
||||
│ - 弱图片意图:"发电量图" → MAX_IMAGES=1 │
|
||||
│ - 普通查询 → MAX_IMAGES=2 │
|
||||
│ │
|
||||
│ 5.2 提取图表引用(从 top 5 文本块) │
|
||||
│ referenced_figures = {'2.3': {来源文件}} │
|
||||
│ │
|
||||
│ 5.3 图片打分 │
|
||||
│ for each image in contexts: │
|
||||
│ score = score_image_relevance(query, meta, doc) │
|
||||
│ └── 图号匹配 +10 分 │
|
||||
│ └── 关键词匹配 +2 分/个 │
|
||||
│ └── 章节匹配 +1.5 分 │
|
||||
│ └── 引用匹配 +8 分(需章节相关) │
|
||||
│ │
|
||||
│ 5.4 图文关联补充(P2 新增) │
|
||||
│ for text_chunk in top 5: │
|
||||
│ for fig_num in text_chunk['referenced_images']: │
|
||||
│ 查找对应的图片切片并补充到结果中 │
|
||||
│ │
|
||||
│ 5.5 返回 top N 图片 │
|
||||
│ return scored_images[:MAX_IMAGES] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 6. 构建 LLM Prompt │
|
||||
│ │
|
||||
│ 6.1 文本上下文 │
|
||||
│ context_text = "\n\n".join([ctx['doc'] for ctx in contexts[:5]]) │
|
||||
│ │
|
||||
│ 6.2 图片信息 │
|
||||
│ if selected_images: │
|
||||
│ image_info = "【可用图片】\n" + 图片描述列表 │
|
||||
│ │
|
||||
│ 6.3 最终 Prompt │
|
||||
│ enhanced_context = context_text + image_info │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 7. LLM 生成回答 │
|
||||
│ │
|
||||
│ for token in engine.generate_answer_stream(query, enhanced_context): │
|
||||
│ yield token # 流式输出 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 8. 返回结果 │
|
||||
│ { │
|
||||
│ "type": "finish", │
|
||||
│ "answer": "完整回答文本", │
|
||||
│ "sources": [...], // 引用来源 │
|
||||
│ "images": [...] // 精选图片 │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
关键优化点总结
|
||||
|
||||
┌──────┬──────────────────┬────────────────────────────────────┐
|
||||
│ 阶段 │ 优化 │ 效果 │
|
||||
├──────┼──────────────────┼────────────────────────────────────┤
|
||||
│ 检索 │ P0: 图片独立召回 │ 图片保证进入候选池 │
|
||||
├──────┼──────────────────┼────────────────────────────────────┤
|
||||
│ 入库 │ P1: 双字段存储 │ 短摘要用于检索,完整描述用于上下文 │
|
||||
├──────┼──────────────────┼────────────────────────────────────┤
|
||||
│ 入库 │ P2: 图文关联索引 │ 文本命中时补充关联图片 │
|
||||
├──────┼──────────────────┼────────────────────────────────────┤
|
||||
│ 选择 │ 章节相关性检查 │ 避免不相关图片加分 │
|
||||
├──────┼──────────────────┼────────────────────────────────────┤
|
||||
│ 意图 │ 重复提问强制检索 │ 避免复用错误上下文 │
|
||||
└──────┴──────────────────┴────────────────────────────────────┘
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **文档类型**: 系统设计文档
|
||||
> **创建日期**: 2026-04-10
|
||||
> **最后更新**: 2026-04-13
|
||||
> **最后更新**: 2026-06-04
|
||||
> **状态**: 已实施
|
||||
|
||||
---
|
||||
@@ -20,11 +20,11 @@
|
||||
|
||||
```
|
||||
exam_pkg/ # 考试系统
|
||||
├── manager.py # 出题与批卷核心逻辑
|
||||
├── generator.py # 出题逻辑(按文件/按主题生成题目)
|
||||
├── grader.py # 批卷逻辑(选择题/填空题/简答题批改)
|
||||
├── manager.py # 试卷管理与协调逻辑
|
||||
├── api.py # Flask Blueprint (exam_bp)
|
||||
├── analysis.py # 考试分析
|
||||
├── local_db.py # 本地题库 (SQLite)
|
||||
└── question_hook.py # 题目维护钩子
|
||||
└── local_db.py # 本地题库 (SQLite)
|
||||
```
|
||||
|
||||
**认证模块**: `auth/gateway.py` - 网关认证
|
||||
@@ -629,6 +629,7 @@ WHERE source_file = 'public/产品手册.pdf';
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|---------|
|
||||
| 2026-06-04 | 2.1 | 更新模块结构:移除已删除的 analysis.py、question_hook.py,新增 generator.py、grader.py |
|
||||
| 2026-04-13 | 2.0 | 合并出题批卷功能改造计划、批卷工作流优化计划、批卷接口规范 |
|
||||
| 2026-04-12 | 1.2 | 新增最小字段输入格式,优化批量批改流程 |
|
||||
| 2026-04-10 | 1.0 | 初始版本:按文件出题功能设计 |
|
||||
|
||||
@@ -1080,12 +1080,15 @@ Content-Type: multipart/form-data
|
||||
"file": {
|
||||
"filename": "document.pdf",
|
||||
"collection": "public_kb",
|
||||
"path": "public/document.pdf",
|
||||
"size": 1024000
|
||||
"path": "public_kb/document.pdf",
|
||||
"size": 1024000,
|
||||
"replaced": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**同名文件处理**:上传同名文件时,旧版本的切片会被自动清理后覆盖(`replaced: true`),不会生成时间戳后缀文件。这确保了向量库中不会出现同一文档的新旧切片共存的情况。
|
||||
|
||||
### 5.2 批量上传
|
||||
|
||||
```
|
||||
|
||||
164
docs/向量库边界风险分析.md
Normal file
164
docs/向量库边界风险分析.md
Normal file
@@ -0,0 +1,164 @@
|
||||
## 向量库边界风险分析
|
||||
|
||||
基于对 `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 兼容) | — |
|
||||
@@ -42,7 +42,30 @@ Agentic RAG在正式下发检索前,加入了一层轻量级意图预判节点
|
||||
最终数据层依靠融合模块汇聚所有库的 Top-K 记录,经过全局倒排算法(RRF)以及多维分数权重融合重排,保证了隔离存储不影响任何语义搜寻与内容关联。
|
||||
|
||||
## 4. 相关代码路径总结
|
||||
|
||||
### 4.1 鉴权与路由
|
||||
- **`auth/gateway.py`**:主要负责网关鉴权与鉴权路由逻辑。
|
||||
- **`knowledge/router.py`**:LLM智能知识库请求目标路由。
|
||||
- **`knowledge/manager.py`**:支持多库并发检索引擎与RRF打分融合模块。
|
||||
- **`rebuild_multi_kb.py`**:从单库直接转换为多库分治物理结构的离线迁移脚本。
|
||||
- **`knowledge/router.py`**:LLM 智能知识库请求目标路由。
|
||||
|
||||
### 4.2 knowledge/ 核心模块(Mixin 架构)
|
||||
知识库管理器 `KnowledgeBaseManager` 采用 Mixin 组合模式,各职责拆分到独立模块:
|
||||
|
||||
| 模块 | Mixin 类 | 职责 |
|
||||
|------|----------|------|
|
||||
| `knowledge/base.py` | — | 配置常量、数据类定义(`CollectionInfo`, `SearchResult`, `BM25Index`)、辅助函数 |
|
||||
| `knowledge/manager.py` | `KnowledgeBaseManager` | 主入口,组合所有 Mixin,提供多库并发管理与工厂方法 |
|
||||
| `knowledge/collection.py` | `CollectionMixin` | 向量库(ChromaDB Collection)的创建、删除、查询等集合管理功能 |
|
||||
| `knowledge/document.py` | `DocumentMixin` | 文档级别的管理方法,包括文档计数、文档列表、文档元信息查询 |
|
||||
| `knowledge/search.py` | `SearchMixin` | 多源融合检索:单向量库检索 + BM25 混合检索 + RRF 融合排序 + 多库并行检索 + 废止版本检测 |
|
||||
| `knowledge/permission.py` | `PermissionMixin` | 基于角色和部门的向量库访问控制,权限校验(admin / manager / user) |
|
||||
| `knowledge/processing.py` | `ProcessingMixin` | 图片/表格的智能处理:图片过滤、VLM 描述生成、表格摘要生成、原始数据存储 |
|
||||
| `knowledge/chunk.py` | `ChunkMixin` | 文档切片(Chunk)的 CRUD 操作:新增、修改、删除、分页查询切片 |
|
||||
| `knowledge/index.py` | `IndexMixin` | BM25 关键词检索索引的生命周期管理:懒加载、持久化、从向量库重建 |
|
||||
| `knowledge/document_versions.py` | `DocumentVersionQuery` | 文档版本历史查询、获取当前生效版本、版本变更日志记录 |
|
||||
|
||||
### 4.3 辅助服务
|
||||
- **`knowledge/sync.py`**:知识库同步服务 — 使用 watchdog 监控文档目录变更,自动检测文件哈希差异并触发增量向量化。
|
||||
- **`knowledge/cleanup.py`**:文档版本自动清理 — 定期清理 superseded 状态的旧版本,控制存储成本。
|
||||
|
||||
### 4.4 离线迁移脚本
|
||||
- **`scripts/rebuild_multi_kb.py`**:从单库直接转换为多库分治物理结构的离线迁移脚本。
|
||||
@@ -106,12 +106,25 @@ CONFLICT_TEMPLATES = {
|
||||
|
||||
### 3.1 核心模块位置
|
||||
|
||||
多源信息融合的核心逻辑位于 `core/agentic.py`:
|
||||
Agentic RAG 已拆分为多个子模块(位于 `core/` 目录),入口仍为 `core/agentic.py`:
|
||||
|
||||
| 子模块 | 职责 |
|
||||
|--------|------|
|
||||
| `core/agentic.py` | 入口文件,组合所有 Mixin,提供 `AgenticRAG.process()` 主流程 |
|
||||
| `core/agentic_base.py` | 常量定义、共享配置(API Key 读取、模型选择等) |
|
||||
| `core/agentic_search.py` | 检索 Mixin — 知识库检索、网络搜索(`_web_search`) |
|
||||
| `core/agentic_answer.py` | 答案生成 Mixin — 多源融合答案生成(`_generate_fused_answer`) |
|
||||
| `core/agentic_query.py` | 查询重写 Mixin — 查询改写、实体补全、专业术语映射 |
|
||||
| `core/agentic_context.py` | 上下文处理 Mixin — 上下文压缩、去重、Token 控制 |
|
||||
| `core/agentic_quality.py` | 质量评估 Mixin — 置信度门控、质量评估、推理反思 |
|
||||
| `core/agentic_meta.py` | 元问题处理 Mixin — 元问题判断和知识库元数据回答 |
|
||||
| `core/agentic_citation.py` | 引用处理 Mixin — 来源提取、引用构建、引用附加 |
|
||||
| `core/agentic_media.py` | 富媒体处理 Mixin — 图表查找、图片提取、富媒体附加 |
|
||||
|
||||
```python
|
||||
from core.agentic import AgenticRAG
|
||||
|
||||
# 初始化(自动检测网络搜索和图谱配置)
|
||||
# 初始化(自动检测网络搜索配置,通过 .env 环境变量注入)
|
||||
rag = AgenticRAG()
|
||||
|
||||
# 处理查询(自动融合多源信息)
|
||||
@@ -132,14 +145,14 @@ context = {
|
||||
'title': '标题', # 网络特有
|
||||
'date': '2024-01-01' # 时间信息
|
||||
},
|
||||
'source_type': '知识库' or '网络搜索' or '知识图谱',
|
||||
'source_type': '知识库' or '网络搜索',
|
||||
'query': '检索用的查询词'
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 融合答案生成
|
||||
|
||||
`AgenticRAG._generate_fused_answer()` 方法处理多源融合:
|
||||
`AgenticRAG._generate_fused_answer()` 方法处理多源融合(位于 `core/agentic_answer.py` 的 `AnswerMixin` 中):
|
||||
|
||||
```python
|
||||
def _generate_fused_answer(self, query: str, contexts: list, allowed_levels: list = None) -> str:
|
||||
@@ -156,7 +169,6 @@ def _generate_fused_answer(self, query: str, contexts: list, allowed_levels: lis
|
||||
# 分离不同来源
|
||||
kb_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_KB]
|
||||
web_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_WEB]
|
||||
graph_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_GRAPH]
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -172,7 +184,6 @@ Agentic RAG 的 `_think()` 方法决定下一步操作:
|
||||
|------|------|----------|
|
||||
| `kb_search` | 检索知识库 | 首次检索、内部文档、公司制度 |
|
||||
| `web_search` | 网络搜索 | 实时信息、外部知识、最新政策 |
|
||||
| `graph_search` | 图谱检索 | 实体关系、多跳推理 |
|
||||
| `answer` | 生成答案 | 信息足够 |
|
||||
| `rewrite` | 改写查询 | 查询词不准确 |
|
||||
| `decompose` | 分解问题 | 多个子问题 |
|
||||
@@ -186,7 +197,6 @@ Agentic RAG 的 `_think()` 方法决定下一步操作:
|
||||
|
||||
2. 检索优先级
|
||||
- 首轮优先检索知识库(kb_search)
|
||||
- 涉及部门职责、流程步骤 → 图谱检索(graph_search)
|
||||
- 实时信息、外部知识 → 网络搜索(web_search)
|
||||
|
||||
3. 知识库结果评估(关键!)
|
||||
@@ -204,13 +214,19 @@ Agentic RAG 的 `_think()` 方法决定下一步操作:
|
||||
|
||||
### 5.1 配置网络搜索
|
||||
|
||||
```python
|
||||
# config.py 中添加
|
||||
SERPER_API_KEY = "your-serper-api-key" # Google搜索API
|
||||
# 或
|
||||
BING_API_KEY = "your-bing-api-key" # Bing搜索API
|
||||
API Key 通过 `.env` 文件(开发环境)或 `deploy/.env.production`(生产环境)环境变量注入,不在代码中硬编码:
|
||||
|
||||
```bash
|
||||
# .env(开发环境)
|
||||
SERPER_API_KEY=your-serper-api-key
|
||||
|
||||
# deploy/.env.production(生产环境)
|
||||
ENABLE_WEB_SEARCH=true
|
||||
SERPER_API_KEY=your-serper-api-key
|
||||
```
|
||||
|
||||
系统通过 `core/agentic_base.py` 读取环境变量,自动判断是否启用网络搜索功能。
|
||||
|
||||
### 5.2 运行命令
|
||||
|
||||
```bash
|
||||
@@ -304,10 +320,20 @@ curl -X POST http://localhost:5001/chat \
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `core/agentic.py` | Agentic RAG 核心,包含信息融合逻辑 |
|
||||
| `core/agentic.py` | Agentic RAG 入口,组合所有 Mixin 子模块 |
|
||||
| `core/agentic_base.py` | 常量定义与共享配置(API Key、模型名等) |
|
||||
| `core/agentic_search.py` | 检索 Mixin — 知识库检索、网络搜索 |
|
||||
| `core/agentic_answer.py` | 答案生成 Mixin — 多源融合答案生成 |
|
||||
| `core/agentic_query.py` | 查询重写 Mixin — 查询改写与术语映射 |
|
||||
| `core/agentic_context.py` | 上下文处理 Mixin — 压缩、去重、Token 控制 |
|
||||
| `core/agentic_quality.py` | 质量评估 Mixin — 置信度门控与推理反思 |
|
||||
| `core/agentic_meta.py` | 元问题处理 Mixin — 知识库元数据问答 |
|
||||
| `core/agentic_citation.py` | 引用处理 Mixin — 来源提取与引用构建 |
|
||||
| `core/agentic_media.py` | 富媒体处理 Mixin — 图表查找与图片提取 |
|
||||
| `core/engine.py` | 检索引擎封装 |
|
||||
| `knowledge/manager.py` | 多向量库管理 |
|
||||
| `knowledge/router.py` | 知识库路由 |
|
||||
| `knowledge/manager.py` | 多向量库管理(组合各 Mixin) |
|
||||
| `knowledge/search.py` | 多源融合检索与 RRF 打分 |
|
||||
| `knowledge/router.py` | 知识库智能路由 |
|
||||
|
||||
---
|
||||
|
||||
@@ -315,5 +341,6 @@ curl -X POST http://localhost:5001/chat \
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|----------|
|
||||
| 2026-06-04 | 3.0 | 更新 Agentic RAG 子模块拆分说明;配置改为 .env 环境变量注入;移除图谱检索内容 |
|
||||
| 2026-04-13 | 2.0 | 更新代码路径(agentic_rag_v2.py → core/agentic.py) |
|
||||
| 2025-03-30 | 1.0 | 初始版本 |
|
||||
|
||||
2047
docs/开发与系统模块说明.md
2047
docs/开发与系统模块说明.md
File diff suppressed because it is too large
Load Diff
371
docs/数据库设计文档.md
371
docs/数据库设计文档.md
@@ -2,7 +2,7 @@
|
||||
|
||||
本文档描述 RAG 知识库系统中所有数据库的结构和用途。
|
||||
|
||||
> **架构更新**:v6.0 重构后,数据库从 6 个独立文件合并为 3 个,通过 `data/db.py` 统一管理。
|
||||
> **架构更新**:v7.0 重构后,数据库按 prod/dev 环境分离为 4 个独立文件,通过 `data/db.py` 统一管理。生产模式数据库存放于 `data/prod/`,开发模式数据库存放于 `data/dev/`。
|
||||
|
||||
---
|
||||
|
||||
@@ -18,28 +18,125 @@ from data.db import get_connection, init_databases
|
||||
# 初始化数据库(首次运行时调用)
|
||||
init_databases()
|
||||
|
||||
# 使用连接
|
||||
with get_connection("core") as conn:
|
||||
# 使用连接(可选名称:feedback / knowledge / session / exam)
|
||||
with get_connection("feedback") as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM sessions WHERE user_id = ?", (user_id,))
|
||||
cursor.execute("SELECT * FROM feedbacks WHERE user_id = ?", (user_id,))
|
||||
rows = cursor.fetchall()
|
||||
```
|
||||
|
||||
### 数据库文件列表
|
||||
|
||||
| 数据库 | 文件名 | 主要功能 | 所属模块 |
|
||||
|--------|--------|----------|----------|
|
||||
| core | `rag_core.db` | 会话管理、审计日志、用户反馈、FAQ | services/ |
|
||||
| knowledge | `knowledge.db` | 知识库同步、文档哈希、纲要缓存、版本管理 | knowledge/ |
|
||||
| exam | `exam.db` | 题目存储、试卷管理、批阅记录、分析报告 | exam_pkg/ |
|
||||
| 数据库 | 文件名 | 存储路径 | 主要功能 | 环境 |
|
||||
|--------|--------|----------|----------|------|
|
||||
| feedback | `feedback.db` | `data/prod/` | 用户反馈、FAQ、质量报告、FAQ 建议 | 生产 + 开发 |
|
||||
| knowledge | `knowledge.db` | `data/prod/` | 知识库同步、文档哈希、纲要缓存、版本管理 | 生产 + 开发 |
|
||||
| session | `session.db` | `data/dev/` | 会话管理、消息历史、审计日志 | 仅开发 |
|
||||
| exam | `exam.db` | `data/dev/` | 题目存储、试卷管理、批阅记录、分析报告 | 仅开发 |
|
||||
|
||||
---
|
||||
|
||||
## 1. rag_core.db - 核心交互数据库
|
||||
## 1. feedback.db - 反馈系统数据库
|
||||
|
||||
**所属模块**:`services/session.py`、`services/audit.py`、`services/feedback.py`
|
||||
**存储路径**:`data/prod/feedback.db`
|
||||
**环境**:生产 + 开发(始终启用)
|
||||
**所属模块**:`services/feedback.py`
|
||||
|
||||
### 1.1 sessions 表 - 会话表
|
||||
### 1.1 feedbacks 表 - 反馈表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `session_id` | TEXT | 会话ID |
|
||||
| `query` | TEXT | 用户问题 |
|
||||
| `answer` | TEXT | 系统回答 |
|
||||
| `sources` | TEXT | 来源文档(JSON) |
|
||||
| `rating` | INTEGER | 评分:1=赞,-1=踩 |
|
||||
| `reason` | TEXT | 点踩原因 |
|
||||
| `user_id` | TEXT | 用户ID |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**索引**:
|
||||
- `idx_feedback_session(session_id)`
|
||||
- `idx_feedback_rating(rating)`
|
||||
- `idx_feedback_created(created_at)`
|
||||
|
||||
### 1.2 faqs 表 - FAQ表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `question` | TEXT | 问题 |
|
||||
| `answer` | TEXT | 答案 |
|
||||
| `source_documents` | TEXT | 来源文档(JSON数组) |
|
||||
| `frequency` | INTEGER | 出现频次 |
|
||||
| `avg_rating` | REAL | 平均评分 |
|
||||
| `status` | TEXT | 状态:draft / approved / disabled |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
| `updated_at` | TIMESTAMP | 更新时间 |
|
||||
|
||||
**索引**:
|
||||
- `idx_faq_status(status)`
|
||||
|
||||
### 1.3 faq_variants 表 - FAQ 问题变体表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `faq_id` | INTEGER | 关联 FAQ ID |
|
||||
| `variant_question` | TEXT | 变体问题 |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**索引**:
|
||||
- `idx_faq_variant_faq(faq_id)`
|
||||
|
||||
**外键**:
|
||||
- `faq_id` → faqs(id) ON DELETE CASCADE
|
||||
|
||||
**作用**:Multi-Query Indexing,为同一 FAQ 存储多种问法变体,提升语义召回率。
|
||||
|
||||
### 1.4 quality_reports 表 - 质量报告表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `report_type` | TEXT | 报告类型:weekly / monthly |
|
||||
| `start_date` | DATE | 统计开始日期 |
|
||||
| `end_date` | DATE | 统计结束日期 |
|
||||
| `total_queries` | INTEGER | 总查询数 |
|
||||
| `total_feedback` | INTEGER | 总反馈数 |
|
||||
| `positive_count` | INTEGER | 正面反馈数 |
|
||||
| `negative_count` | INTEGER | 负面反馈数 |
|
||||
| `avg_rating` | REAL | 平均评分 |
|
||||
| `satisfaction_rate` | REAL | 满意度 |
|
||||
| `high_freq_queries` | TEXT | 高频问题(JSON) |
|
||||
| `low_rating_queries` | TEXT | 低分问题(JSON) |
|
||||
| `improvement_suggestions` | TEXT | 改进建议(JSON) |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
### 1.5 faq_suggestions 表 - FAQ建议表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `query` | TEXT | 用户问题 |
|
||||
| `answer` | TEXT | 系统回答 |
|
||||
| `frequency` | INTEGER | 出现频次 |
|
||||
| `avg_rating` | REAL | 平均评分 |
|
||||
| `status` | TEXT | 状态:pending / approved / rejected |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**作用**:高频优质问题自动建议沉淀为FAQ,管理员审核后生效。
|
||||
|
||||
---
|
||||
|
||||
## 2. session.db - 会话管理数据库
|
||||
|
||||
**存储路径**:`data/dev/session.db`
|
||||
**环境**:仅开发模式
|
||||
**所属模块**:`services/session.py`
|
||||
|
||||
### 2.1 sessions 表 - 会话表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -54,7 +151,7 @@ with get_connection("core") as conn:
|
||||
|
||||
**作用**:实现多用户会话隔离,支持多轮对话记忆。
|
||||
|
||||
### 1.2 messages 表 - 消息历史表
|
||||
### 2.2 messages 表 - 消息历史表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -62,6 +159,7 @@ with get_connection("core") as conn:
|
||||
| `session_id` | TEXT | 关联会话ID |
|
||||
| `role` | TEXT | 角色:user / assistant |
|
||||
| `content` | TEXT | 消息内容 |
|
||||
| `metadata` | TEXT | 元数据(JSON格式) |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**索引**:
|
||||
@@ -70,7 +168,7 @@ with get_connection("core") as conn:
|
||||
**外键**:
|
||||
- `session_id` → sessions(session_id) ON DELETE CASCADE
|
||||
|
||||
### 1.3 audit_logs 表 - 审计日志表
|
||||
### 2.3 audit_logs 表 - 审计日志表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -94,82 +192,15 @@ with get_connection("core") as conn:
|
||||
|
||||
**作用**:记录所有用户操作,用于安全审计和行为分析。
|
||||
|
||||
### 1.4 feedbacks 表 - 反馈表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `session_id` | TEXT | 会话ID |
|
||||
| `query` | TEXT | 用户问题 |
|
||||
| `answer` | TEXT | 系统回答 |
|
||||
| `sources` | TEXT | 来源文档(JSON) |
|
||||
| `rating` | INTEGER | 评分:1=赞,-1=踩 |
|
||||
| `reason` | TEXT | 点踩原因 |
|
||||
| `user_id` | TEXT | 用户ID |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**索引**:
|
||||
- `idx_feedback_session(session_id)`
|
||||
- `idx_feedback_rating(rating)`
|
||||
- `idx_feedback_created(created_at)`
|
||||
|
||||
### 1.5 faqs 表 - FAQ表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `question` | TEXT | 问题 |
|
||||
| `answer` | TEXT | 答案 |
|
||||
| `source_documents` | TEXT | 来源文档(JSON数组) |
|
||||
| `frequency` | INTEGER | 出现频次 |
|
||||
| `avg_rating` | REAL | 平均评分 |
|
||||
| `status` | TEXT | 状态:draft / approved / disabled |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
| `updated_at` | TIMESTAMP | 更新时间 |
|
||||
|
||||
**索引**:
|
||||
- `idx_faq_status(status)`
|
||||
|
||||
### 1.6 faq_suggestions 表 - FAQ建议表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `query` | TEXT | 用户问题 |
|
||||
| `answer` | TEXT | 系统回答 |
|
||||
| `frequency` | INTEGER | 出现频次 |
|
||||
| `avg_rating` | REAL | 平均评分 |
|
||||
| `status` | TEXT | 状态:pending / approved / rejected |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**作用**:高频优质问题自动建议沉淀为FAQ,管理员审核后生效。
|
||||
|
||||
### 1.7 quality_reports 表 - 质量报告表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `report_type` | TEXT | 报告类型:weekly / monthly |
|
||||
| `start_date` | DATE | 统计开始日期 |
|
||||
| `end_date` | DATE | 统计结束日期 |
|
||||
| `total_queries` | INTEGER | 总查询数 |
|
||||
| `total_feedback` | INTEGER | 总反馈数 |
|
||||
| `positive_count` | INTEGER | 正面反馈数 |
|
||||
| `negative_count` | INTEGER | 负面反馈数 |
|
||||
| `avg_rating` | REAL | 平均评分 |
|
||||
| `satisfaction_rate` | REAL | 满意度 |
|
||||
| `high_freq_queries` | TEXT | 高频问题(JSON) |
|
||||
| `low_rating_queries` | TEXT | 低分问题(JSON) |
|
||||
| `improvement_suggestions` | TEXT | 改进建议(JSON) |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
## 2. knowledge.db - 知识管理数据库
|
||||
## 3. knowledge.db - 知识管理数据库
|
||||
|
||||
**存储路径**:`data/prod/knowledge.db`
|
||||
**环境**:生产 + 开发(始终启用)
|
||||
**所属模块**:`knowledge/sync.py`、`services/outline.py`
|
||||
|
||||
### 2.1 document_hashes 表 - 文档哈希表
|
||||
### 3.1 document_hashes 表 - 文档哈希表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -183,7 +214,7 @@ with get_connection("core") as conn:
|
||||
|
||||
**作用**:记录每个文档的当前状态,用于检测变更。
|
||||
|
||||
### 2.2 change_logs 表 - 变更日志表
|
||||
### 3.2 change_logs 表 - 变更日志表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -202,39 +233,7 @@ with get_connection("core") as conn:
|
||||
- `idx_change_logs_time(change_time)`
|
||||
- `idx_change_logs_processed(processed)`
|
||||
|
||||
### 2.3 subscriptions 表 - 用户订阅表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `user_id` | TEXT | 用户ID |
|
||||
| `document_id` | TEXT | 订阅的文档ID(NULL表示订阅全部) |
|
||||
| `document_name` | TEXT | 文档名称 |
|
||||
| `created_at` | TIMESTAMP | 订阅时间 |
|
||||
|
||||
**唯一约束**:`(user_id, document_id)`
|
||||
|
||||
**索引**:
|
||||
- `idx_subscriptions_user(user_id)`
|
||||
|
||||
### 2.4 notifications 表 - 通知记录表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER | 自增主键 |
|
||||
| `user_id` | TEXT | 用户ID |
|
||||
| `document_id` | TEXT | 文档ID |
|
||||
| `document_name` | TEXT | 文档名称 |
|
||||
| `change_type` | TEXT | 变更类型 |
|
||||
| `message` | TEXT | 通知消息 |
|
||||
| `read` | INTEGER | 是否已读(0/1) |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
**索引**:
|
||||
- `idx_notifications_user(user_id)`
|
||||
- `idx_notifications_read(read)`
|
||||
|
||||
### 2.5 sync_status 表 - 同步状态表
|
||||
### 3.3 sync_status 表 - 同步状态表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -250,7 +249,7 @@ with get_connection("core") as conn:
|
||||
| `error_message` | TEXT | 错误信息 |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
### 2.6 outline_cache 表 - 纲要缓存表
|
||||
### 3.4 outline_cache 表 - 纲要缓存表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -265,7 +264,7 @@ with get_connection("core") as conn:
|
||||
**索引**:
|
||||
- `idx_outline_doc(document_id)`
|
||||
|
||||
### 2.7 document_vectors 表 - 文档向量缓存表
|
||||
### 3.5 document_vectors 表 - 文档向量缓存表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -280,7 +279,7 @@ with get_connection("core") as conn:
|
||||
**索引**:
|
||||
- `idx_vector_doc(document_id)`
|
||||
|
||||
### 2.8 recommendation_cache 表 - 推荐缓存表
|
||||
### 3.6 recommendation_cache 表 - 推荐缓存表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -289,7 +288,7 @@ with get_connection("core") as conn:
|
||||
| `recommendations_json` | TEXT | 推荐结果(JSON) |
|
||||
| `generated_at` | TIMESTAMP | 生成时间 |
|
||||
|
||||
### 2.9 document_versions 表 - 文档版本表
|
||||
### 3.7 document_versions 表 - 文档版本表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -313,7 +312,7 @@ with get_connection("core") as conn:
|
||||
|
||||
**唯一约束**:`(document_id, collection, version)`
|
||||
|
||||
### 2.10 version_change_logs 表 - 版本变更日志表
|
||||
### 3.8 version_change_logs 表 - 版本变更日志表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -331,11 +330,13 @@ with get_connection("core") as conn:
|
||||
|
||||
---
|
||||
|
||||
## 3. exam.db - 出题系统数据库
|
||||
## 4. exam.db - 出题系统数据库
|
||||
|
||||
**存储路径**:`data/dev/exam.db`
|
||||
**环境**:仅开发模式
|
||||
**所属模块**:`exam_pkg/manager.py`、`exam_pkg/local_db.py`、`exam_pkg/analysis.py`
|
||||
|
||||
### 3.1 questions 表 - 题目表
|
||||
### 4.1 questions 表 - 题目表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -360,7 +361,7 @@ with get_connection("core") as conn:
|
||||
**索引**:
|
||||
- `idx_questions_source(source_file)`
|
||||
|
||||
### 3.2 exams 表 - 试卷表
|
||||
### 4.2 exams 表 - 试卷表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -374,7 +375,7 @@ with get_connection("core") as conn:
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
| `created_by` | TEXT | 创建人 |
|
||||
|
||||
### 3.3 exam_questions 表 - 试卷题目关联表
|
||||
### 4.3 exam_questions 表 - 试卷题目关联表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -388,7 +389,7 @@ with get_connection("core") as conn:
|
||||
- `exam_id` → exams(id) ON DELETE CASCADE
|
||||
- `question_id` → questions(id) ON DELETE CASCADE
|
||||
|
||||
### 3.4 student_answers 表 - 学生答卷表
|
||||
### 4.4 student_answers 表 - 学生答卷表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -408,7 +409,7 @@ with get_connection("core") as conn:
|
||||
**索引**:
|
||||
- `idx_student_answers_exam(exam_id, student_id)`
|
||||
|
||||
### 3.5 grade_reports 表 - 批阅报告表
|
||||
### 4.5 grade_reports 表 - 批阅报告表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -421,7 +422,7 @@ with get_connection("core") as conn:
|
||||
| `analysis` | TEXT | 分析(JSON) |
|
||||
| `graded_at` | TIMESTAMP | 批阅时间 |
|
||||
|
||||
### 3.6 question_document_links 表 - 题目-制度关联表
|
||||
### 4.6 question_document_links 表 - 题目-制度关联表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -440,7 +441,7 @@ with get_connection("core") as conn:
|
||||
- `idx_qdl_question(question_id)`
|
||||
- `idx_qdl_document(document_id)`
|
||||
|
||||
### 3.7 knowledge_points 表 - 知识点表
|
||||
### 4.7 knowledge_points 表 - 知识点表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -454,7 +455,7 @@ with get_connection("core") as conn:
|
||||
**外键**:
|
||||
- `parent_id` → knowledge_points(id)
|
||||
|
||||
### 3.8 question_knowledge_links 表 - 题目-知识点关联表
|
||||
### 4.8 question_knowledge_links 表 - 题目-知识点关联表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -471,7 +472,7 @@ with get_connection("core") as conn:
|
||||
- `idx_qkl_question(question_id)`
|
||||
- `idx_qkl_knowledge(knowledge_point_id)`
|
||||
|
||||
### 3.9 question_status 表 - 题目状态表
|
||||
### 4.9 question_status 表 - 题目状态表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -487,7 +488,7 @@ with get_connection("core") as conn:
|
||||
**索引**:
|
||||
- `idx_qs_status(status)`
|
||||
|
||||
### 3.10 exam_analysis_reports 表 - 整卷分析报告表
|
||||
### 4.10 exam_analysis_reports 表 - 整卷分析报告表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -507,7 +508,7 @@ with get_connection("core") as conn:
|
||||
| `study_suggestions` | TEXT | 学习建议(JSON) |
|
||||
| `created_at` | TIMESTAMP | 创建时间 |
|
||||
|
||||
### 3.11 question_suggestions 表 - 新题建议表
|
||||
### 4.11 question_suggestions 表 - 新题建议表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -519,11 +520,11 @@ with get_connection("core") as conn:
|
||||
|
||||
---
|
||||
|
||||
## 4. ChromaDB 向量数据库
|
||||
## 5. ChromaDB 向量数据库
|
||||
|
||||
**所属模块**:`knowledge/manager.py`
|
||||
|
||||
### 4.1 向量库结构
|
||||
### 5.1 向量库结构
|
||||
|
||||
```
|
||||
knowledge/vector_store/chroma/
|
||||
@@ -536,7 +537,7 @@ knowledge/vector_store/chroma/
|
||||
└── ... # 其他部门向量库
|
||||
```
|
||||
|
||||
### 4.2 权限矩阵
|
||||
### 5.2 权限矩阵
|
||||
|
||||
| 角色 | 可访问向量库 | 可上传 | 可删除 | 可同步 |
|
||||
|------|------------|--------|--------|--------|
|
||||
@@ -544,7 +545,7 @@ knowledge/vector_store/chroma/
|
||||
| manager | public_kb + 本部门 | 本部门 | 本部门 | 本部门 |
|
||||
| user | public_kb + 本部门 | - | - | - |
|
||||
|
||||
### 4.3 文档元数据结构
|
||||
### 5.3 文档元数据结构
|
||||
|
||||
每个文档 chunk 的元数据:
|
||||
|
||||
@@ -580,32 +581,33 @@ knowledge/vector_store/chroma/
|
||||
│
|
||||
user_id / document_id 关联 │
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ rag_core.db │
|
||||
│ (核心数据库) │
|
||||
│ │
|
||||
│ • sessions │
|
||||
│ • messages │
|
||||
│ • audit_logs │
|
||||
│ • feedbacks │
|
||||
│ • faqs │
|
||||
│ • quality_reports│
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ user_id / document_id 关联
|
||||
│
|
||||
┌────────┴────────┐ ┌───────────────┐
|
||||
│ knowledge.db │ │ exam.db │
|
||||
│ │ │ │
|
||||
│ • document_ │ │ • questions │
|
||||
│ hashes │ │ • exams │
|
||||
│ • change_logs │ │ • student_ │
|
||||
│ • subscriptions │ │ answers │
|
||||
│ • outline_cache │ │ • grade_ │
|
||||
│ • document_ │ │ reports │
|
||||
│ versions │ │ • knowledge_ │
|
||||
└─────────────────┘ │ points │
|
||||
└───────────────┘
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ data/prod/ │ │ data/prod/ │
|
||||
│ feedback.db │ │ knowledge.db │
|
||||
│ (生产+开发) │ │ (生产+开发) │
|
||||
│ │ │ │
|
||||
│ • feedbacks │ │ • document_ │
|
||||
│ • faqs │ │ hashes │
|
||||
│ • faq_variants │ │ • change_logs │
|
||||
│ • quality_ │ │ • sync_status │
|
||||
│ reports │ │ • outline_cache │
|
||||
│ • faq_ │ │ • document_ │
|
||||
│ suggestions │ │ versions │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ data/dev/ │ │ data/dev/ │
|
||||
│ session.db │ │ exam.db │
|
||||
│ (仅开发) │ │ (仅开发) │
|
||||
│ │ │ │
|
||||
│ • sessions │ │ • questions │
|
||||
│ • messages │ │ • exams │
|
||||
│ • audit_logs │ │ • student_ │
|
||||
│ │ │ answers │
|
||||
│ │ │ • grade_reports │
|
||||
│ │ │ • knowledge_ │
|
||||
│ │ │ points │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@@ -615,25 +617,24 @@ knowledge/vector_store/chroma/
|
||||
### 数据清理
|
||||
|
||||
```bash
|
||||
# 清理过期会话(24小时未活跃)
|
||||
sqlite3 data/rag_core.db "DELETE FROM sessions WHERE last_active < datetime('now', '-24 hours');"
|
||||
sqlite3 data/rag_core.db "DELETE FROM messages WHERE session_id NOT IN (SELECT session_id FROM sessions);"
|
||||
# 清理过期会话(24小时未活跃)- 仅开发环境
|
||||
sqlite3 data/dev/session.db "DELETE FROM sessions WHERE last_active < datetime('now', '-24 hours');"
|
||||
sqlite3 data/dev/session.db "DELETE FROM messages WHERE session_id NOT IN (SELECT session_id FROM sessions);"
|
||||
|
||||
# 清理旧审计日志(保留30天)
|
||||
sqlite3 data/rag_core.db "DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days');"
|
||||
|
||||
# 清理已处理的通知(保留7天)
|
||||
sqlite3 data/knowledge.db "DELETE FROM notifications WHERE read = 1 AND created_at < datetime('now', '-7 days');"
|
||||
# 清理旧审计日志(保留30天)- 仅开发环境
|
||||
sqlite3 data/dev/session.db "DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days');"
|
||||
```
|
||||
|
||||
### 数据备份
|
||||
|
||||
```bash
|
||||
# 备份 SQLite 数据库
|
||||
cp data/*.db backup/
|
||||
# 备份所有 SQLite 数据库
|
||||
cp data/prod/*.db backup/
|
||||
cp data/dev/*.db backup/
|
||||
|
||||
# 使用 SQLite 在线备份
|
||||
sqlite3 data/rag_core.db ".backup backup/rag_core_backup.db"
|
||||
sqlite3 data/prod/feedback.db ".backup backup/feedback_backup.db"
|
||||
sqlite3 data/prod/knowledge.db ".backup backup/knowledge_backup.db"
|
||||
```
|
||||
|
||||
### 重置数据库
|
||||
@@ -641,14 +642,17 @@ sqlite3 data/rag_core.db ".backup backup/rag_core_backup.db"
|
||||
删除对应的文件,服务启动时会自动重建表结构:
|
||||
|
||||
```bash
|
||||
# 重置核心数据(会丢失会话和对话历史)
|
||||
rm data/rag_core.db
|
||||
# 重置反馈数据(会丢失反馈和 FAQ)
|
||||
rm data/prod/feedback.db
|
||||
|
||||
# 重置知识管理数据(会丢失文档追踪和订阅)
|
||||
rm data/knowledge.db
|
||||
# 重置知识管理数据(会丢失文档追踪和版本)
|
||||
rm data/prod/knowledge.db
|
||||
|
||||
# 重置出题系统数据(会丢失题目和试卷)
|
||||
rm data/exam.db
|
||||
# 重置会话数据(会丢失会话和对话历史)- 仅开发环境
|
||||
rm data/dev/session.db
|
||||
|
||||
# 重置出题系统数据(会丢失题目和试卷)- 仅开发环境
|
||||
rm data/dev/exam.db
|
||||
```
|
||||
|
||||
---
|
||||
@@ -657,6 +661,7 @@ rm data/exam.db
|
||||
|
||||
| 日期 | 版本 | 更新内容 |
|
||||
|------|------|----------|
|
||||
| 2026-06-04 | 4.0 | 修正为 4 库分离架构:rag_core.db 拆分为 feedback.db + session.db,按 prod/dev 子目录分离;新增 faq_variants 表;移除已废弃的 subscriptions/notifications 表 |
|
||||
| 2026-04-13 | 3.0 | 数据库架构重构:6 个独立数据库合并为 3 个 |
|
||||
| 2026-04-09 | 2.0 | 新增多向量库架构文档 |
|
||||
| 2026-04-07 | 1.0 | 初始版本 |
|
||||
|
||||
@@ -2,36 +2,34 @@
|
||||
|
||||
> **文档类型**: 架构设计
|
||||
> **创建日期**: 2026-04-13
|
||||
> **状态**: 已确认
|
||||
> **状态**: 已确认(数据清单更新于 2026-06)
|
||||
> **目标**: 梳理数据存储归属、明确前后端组与 RAG 组的职责边界
|
||||
|
||||
---
|
||||
|
||||
## 一、数据存储清单
|
||||
|
||||
### 1.1 当前系统中的所有数据库
|
||||
### 1.1 当前系统中的所有数据库(4 个 SQLite 文件,按环境子目录分离)
|
||||
|
||||
| 数据库文件 | 存储内容 | 表数量 |
|
||||
|-----------|----------|--------|
|
||||
| `data/sessions.db` | 会话管理 | 2 表 |
|
||||
| `data/exam_local.db` | 出题批卷 | 5 表 |
|
||||
| `data/feedback.db` | 问答质量闭环 | 4 表 |
|
||||
| `data/outline_cache.db` | 纲要缓存 | 3 表 |
|
||||
| `data/sync_data.db` | 同步服务 | 5 表 |
|
||||
| `data/exam_analysis.db` | 题库分析 | 7 表 |
|
||||
| `knowledge/vector_store/chroma/` | 向量数据库 | 9 集合 |
|
||||
| 数据库文件 | 环境 | 存储内容 |
|
||||
|-----------|------|---------|
|
||||
| `data/prod/knowledge.db` | 生产 | 知识库管理(向量元数据、文档索引等) |
|
||||
| `data/prod/feedback.db` | 生产 | 问答质量闭环 |
|
||||
| `data/dev/session.db` | 开发 | 会话管理 |
|
||||
| `data/dev/exam.db` | 开发 | 出题批卷 |
|
||||
|
||||
> **注**:向量数据存储在 `knowledge/vector_store/chroma/` 目录(ChromaDB),不计入 SQLite 文件。
|
||||
> `outline_cache.db`、`sync_data.db`、`exam_analysis.db` 已合并或移除。
|
||||
|
||||
### 1.2 涉及用户信息的数据
|
||||
|
||||
| 数据库 | 表 | 用户字段 | 敏感程度 |
|
||||
|--------|-----|----------|----------|
|
||||
| sessions.db | sessions | user_id | 低(仅ID) |
|
||||
| sessions.db | messages | 通过session关联 | 低 |
|
||||
| feedback.db | feedbacks | user_id | 低 |
|
||||
| sync_data.db | subscriptions | user_id | 低 |
|
||||
| sync_data.db | notifications | user_id | 低 |
|
||||
| exam_local.db | student_answers | student_id | 低 |
|
||||
| exam_local.db | grade_reports | student_id | 低 |
|
||||
| `data/dev/session.db` | sessions | user_id | 低(仅ID) |
|
||||
| `data/dev/session.db` | messages | 通过session关联 | 低 |
|
||||
| `data/prod/feedback.db` | feedbacks | user_id | 低 |
|
||||
| `data/dev/exam.db` | student_answers | student_id | 低 |
|
||||
| `data/dev/exam.db` | grade_reports | student_id | 低 |
|
||||
|
||||
---
|
||||
|
||||
@@ -54,16 +52,15 @@
|
||||
|
||||
| 数据类型 | 说明 |
|
||||
|----------|------|
|
||||
| 向量数据库 | 所有知识库向量 |
|
||||
| 向量数据库 | 所有知识库向量(ChromaDB) |
|
||||
| 文档内容 | PDF/Word/Excel 原始文件 |
|
||||
| 题目与试卷数据 | 出题相关 |
|
||||
| 批阅报告 | 考试批卷结果 |
|
||||
| 知识点与关联关系 | 题库分析 |
|
||||
| 文档变更追踪 | 同步服务 |
|
||||
| 会话历史 | 对话记录 ✅ |
|
||||
| 纲要与缓存 | 文档结构化数据 |
|
||||
| 知识库元数据 | 文档索引、向量关联(`knowledge.db`) |
|
||||
| 题目与试卷数据 | 出题相关(`exam.db`) |
|
||||
| 批阅报告 | 考试批卷结果(`exam.db`) |
|
||||
| 会话历史 | 对话记录(`session.db`) |
|
||||
| 问答反馈 | 质量闭环数据(`feedback.db`) |
|
||||
|
||||
**存储**:本地 SQLite + ChromaDB
|
||||
**存储**:本地 SQLite(按 `data/prod/`、`data/dev/` 环境分离) + ChromaDB
|
||||
|
||||
---
|
||||
|
||||
@@ -102,6 +99,10 @@
|
||||
┌─────────────┐
|
||||
│ RAG 数据库 │
|
||||
│ │
|
||||
│ • knowledge.db│
|
||||
│ • feedback.db │
|
||||
│ • session.db │
|
||||
│ • exam.db │
|
||||
│ 存储 user_id │
|
||||
│ 不存用户详情 │
|
||||
└─────────────┘
|
||||
@@ -156,7 +157,7 @@ GET /api/users/{user_id}
|
||||
|--------|------|------|
|
||||
| 学生答卷存储 | `exam_pkg/manager.py` | 改为只存储 student_id,不存 student_name |
|
||||
| 批阅报告生成 | `exam_pkg/manager.py` | 生成报告时调用前后端接口获取姓名 |
|
||||
| 用户信息获取 | `services/user_info.py`(新建) | 封装调用前后端接口的逻辑 |
|
||||
| 用户信息获取 | `services/user_info.py`(待创建) | 封装调用前后端接口的逻辑,当前尚未实现 |
|
||||
|
||||
---
|
||||
|
||||
@@ -171,9 +172,10 @@ GET /api/users/{user_id}
|
||||
│ ✓ 组织架构数据 │ ✓ 题目试卷管理 │
|
||||
│ ✓ 业务主数据(学生/课程等) │ ✓ 批阅报告(仅存 student_id) │
|
||||
│ ✓ 网关配置 │ ✓ 反馈与质量分析 │
|
||||
│ ✓ 提供用户信息查询 API │ ✓ 文档变更追踪 │
|
||||
│ ✓ 提供用户信息查询 API │ ✓ 知识库元数据(knowledge.db) │
|
||||
├─────────────────────────────────────────┴─────────────────────────────────┤
|
||||
│ 数据关联:通过 user_id / student_id │
|
||||
│ RAG 系统调用前后端 API 获取用户详情 │
|
||||
│ 数据库按环境分离:data/prod/(knowledge.db, feedback.db)data/dev/(session.db, exam.db)│
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
136
docs/文档审查报告.md
136
docs/文档审查报告.md
@@ -1,136 +0,0 @@
|
||||
## 文档与代码一致性审查报告
|
||||
|
||||
审查范围:`docs/后端对接规范.md`、`docs/curl测试手册.md` vs 实际代码实现
|
||||
|
||||
审查方式:代码静态分析 + 生产模式服务实测(`DEV_MODE=false`,端口 5001)
|
||||
|
||||
---
|
||||
|
||||
### 一、严重问题(会导致后端开发出错)
|
||||
|
||||
**1. `/documents/list` 分页参数不存在**
|
||||
|
||||
curl测试手册中记载该接口支持 `page` 和 `page_size` 查询参数,但实际代码(`api/document_routes.py` 第385-440行)完全不读取这两个参数,只支持 `collection`/`kb_name` 过滤。实测传入 `page=1&page_size=2` 后返回了全部 4 条记录,分页无效。后端如果按文档实现分页将会静默失败。
|
||||
|
||||
**2. `/feedback/list` 查询参数不匹配**
|
||||
|
||||
curl测试手册记载参数为 `page` 和 `page_size`。实际代码(`api/feedback_routes.py` 第119-144行)接受的参数是 `rating`、`user_id`、`start_date`、`end_date`、`limit`(默认100),完全没有 `page`/`page_size`。后端按文档传参将无法控制返回数量。
|
||||
|
||||
**3. `/faq` GET 查询参数不匹配**
|
||||
|
||||
curl测试手册记载参数为 `page` 和 `page_size`。实际代码(`api/feedback_routes.py` 第177-193行)接受 `status` 和 `limit`(默认50),无分页支持。
|
||||
|
||||
**4. `/faq/suggestions` 查询参数不匹配**
|
||||
|
||||
与上同理,curl测试手册记载 `page`/`page_size`,实际代码使用 `status`(默认"pending")和 `limit`(默认50)。
|
||||
|
||||
**5. `/documents/<path>/chunks` 分页参数不存在**
|
||||
|
||||
curl测试手册记载该接口支持 `page` 和 `page_size`,实际代码(`api/document_routes.py` 第637-678行)不接受任何查询参数,直接返回全部切片。
|
||||
|
||||
**6. 出题接口返回的 `question` 对象结构与文档不符**
|
||||
|
||||
后端对接规范文档中展示的出题响应结构为:
|
||||
|
||||
```json
|
||||
{
|
||||
"question_type": "single_choice",
|
||||
"difficulty": 3,
|
||||
"content": { "stem": "...", "data": {...}, "answer": "B", "explanation": "..." },
|
||||
"source_trace": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
但 `exam_pkg/api.py` 的 docstring 注释(第72行)写的是 `question_type` 嵌套在 `metadata` 对象中。不过经核查 `exam_pkg/generator.py` 第826-828行,实际返回结构与后端对接规范文档一致(`question_type` 在顶层),代码注释是错的但实际行为是对的。这不会导致功能问题,但如果有人参照代码注释来解析响应就会出错。
|
||||
|
||||
**7. 错误响应格式文档与实际不匹配**
|
||||
|
||||
后端对接规范「十、错误响应格式」声称所有错误遵循 `{"error": "xxx", "message": "xxx"}` 格式。但实际代码中混用两套格式:`api/response_utils.py` 的统一格式返回 `{"success": false, "status": "failed", "error_code": "xxx", "status_code": N, "message": "xxx"}`,而部分路由(如 feedback_routes、document_routes 的异常处理)直接返回 `{"error": str(e)}`。后端开发者需要同时处理两种错误格式。
|
||||
|
||||
---
|
||||
|
||||
### 二、中等问题(描述不准确,可能导致混淆)
|
||||
|
||||
**8. 环境配置变量名不一致**
|
||||
|
||||
后端对接规范第四节写的配置是 `APP_ENV=prod`,认证方式部分写的模式切换变量是 `DEV_MODE=false`。实际认证模块 `auth/gateway.py` 第98行读取的是 `DEV_MODE` 环境变量。而 `config.py` 中定义了 `APP_ENV` 但没有定义 `DEV_MODE`。两者是独立的变量:`APP_ENV` 控制 `IS_PROD`/`IS_DEV` 及关联功能开关(如 ENABLE_SESSION),`DEV_MODE` 单独控制认证行为。文档应将两者都列出并说明其区别。
|
||||
|
||||
**9. `/rag` 接口 `collections` 参数必需性描述矛盾**
|
||||
|
||||
后端对接规范中标注 `collections` 为「必需」,curl测试手册标注为「可选,默认 `["public_kb"]`」。代码实际行为是可选的(不传时默认 `["public_kb"]`)。对后端来说,应明确说明:如果不传 `collections`,将默认检索 `public_kb`,而非返回错误。
|
||||
|
||||
**10. 同步接口响应格式文档与代码不一致**
|
||||
|
||||
后端对接规范中 `/sync/start` 和 `/sync/stop` 响应为 `{"message": "文件监控已启动"}`。实际代码返回的是 `{"status": "success", "status_code": 3001, "message": "文件监控已启动"}`,多了 `status` 和 `status_code` 字段。curl测试手册是正确的。
|
||||
|
||||
**11. `/exam/generate` 和 `/exam/generate-smart` 的 `collection` 参数类型标注不完整**
|
||||
|
||||
curl测试手册标注为 `string`,后端对接规范标注为 `string 或 string[]`。实际代码(`exam_pkg/manager.py` 第212行和第286-289行)确实同时支持两种格式。curl测试手册应补充说明支持数组。
|
||||
|
||||
**12. curl测试手册中 `/exam/generate` 和 `/exam/generate-smart` 需要 Authorization header 的说明具有误导性**
|
||||
|
||||
文档提到「需要传 Authorization header」,curl 示例中也包含 `-H "Authorization: Bearer mock-token-admin"`。但在生产模式下(DEV_MODE=false),mock token 逻辑被跳过,认证直接放行,用户默认为 `backend-caller`。这个 header 在生产环境中完全无效,会误导后端以为必须传递。
|
||||
|
||||
---
|
||||
|
||||
### 三、轻微问题(不影响功能,但不够精确)
|
||||
|
||||
**13. 后端对接规范中 `/chat` 的 `chat_history` 参数**
|
||||
|
||||
文档参数说明中列出了 `chat_history`(生产环境必需)和 `history`(旧参数名)。但 `/chat` 普通聊天接口实际上只需要 `message`,`history`/`chat_history` 是可选参数。文档对 `/chat` 和 `/rag` 的 `chat_history` 必需性描述有混淆。
|
||||
|
||||
**14. curl测试手册中 `/exam/generate-smart` 章节重复**
|
||||
|
||||
文档中该接口的描述出现了两次(内容高度重复),应删除其中一个。
|
||||
|
||||
**15. 后端对接规范中的 `require_role('admin')` 描述**
|
||||
|
||||
文档在 FAQ 创建、审批等接口旁标注了需要管理员角色。但实际 `auth/gateway.py` 中的 `require_role` 装饰器是空操作(passthrough),不做任何权限检查。在生产模式下默认用户角色是 `user`,但所有标注 admin 的接口都能正常调用。文档描述虽符合设计意图,但与当前实现不符。
|
||||
|
||||
**16. curl测试手册中 `/feedback` POST 的 `answer` 字段标注**
|
||||
|
||||
文档标注 `answer` 为必需,但实际代码中 `answer` 字段是可选的(可以为空字符串)。
|
||||
|
||||
**17. 代码中存在文档未记录的端点**
|
||||
|
||||
以下端点存在于代码但未在文档中列出(多为开发调试用,不影响后端对接):`/auth/login`、`/auth/me`、`/auth/users`、`/auth/change-password`、`/stats`、`/debug/scan`、`/collections/sync-vlm-cache`、`/collections/<name>/reindex`、`/chunks/batch`、`/documents/<path>/raw`。
|
||||
|
||||
**18. `/feedback/list` 返回的 `sources` 字段**
|
||||
|
||||
curl测试手册的响应示例中 `sources` 为空数组 `[]`,但实际测试中有些反馈记录包含非空的 `sources` 数组(包含来源文档信息)。文档的示例不够完整。
|
||||
|
||||
---
|
||||
|
||||
### 四、文档间不一致
|
||||
|
||||
| 对比项 | 后端对接规范 | curl测试手册 | 实际代码 |
|
||||
|--------|-------------|-------------|---------|
|
||||
| `/rag` collections 必需性 | 必需 | 可选,默认 `["public_kb"]` | 可选 |
|
||||
| `/documents/list` 分页 | 未提及 | `page`/`page_size` | 不支持 |
|
||||
| `/feedback/list` 参数 | 未详述 | `page`/`page_size` | `limit`/`rating` 等 |
|
||||
| `/faq` 参数 | `GET/POST` | `page`/`page_size` | `status`/`limit` |
|
||||
| `/sync/start` 响应 | 简单格式 | 含 status_code | 含 status_code(curl手册正确) |
|
||||
| 环境配置 | `APP_ENV=prod` | `DEV_MODE=false` | 两者各自控制不同功能 |
|
||||
|
||||
---
|
||||
|
||||
### 五、生产服务实测结果
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| `GET /health` | 正常 | 返回 ok |
|
||||
| `GET /collections` | 正常 | 返回 3 个向量库 |
|
||||
| `GET /documents/list` | 正常 | 分页参数无效,返回全部 |
|
||||
| `GET /feedback/stats` | 正常 | |
|
||||
| `GET /feedback/list` | 正常 | page/page_size 无效,返回全部 |
|
||||
| `GET /faq` | 正常 | page/page_size 无效 |
|
||||
| `GET /sync/status` | 正常 | |
|
||||
| `GET /exam/health` | 正常 | |
|
||||
|
||||
---
|
||||
|
||||
### 六、修改建议优先级
|
||||
|
||||
1. **立即修复**(影响后端开发正确性):修正 `/documents/list`、`/feedback/list`、`/faq`、`/faq/suggestions`、`/documents/<path>/chunks` 的查询参数描述,改为代码实际支持的参数
|
||||
2. **尽快修复**(影响对接体验):统一错误响应格式文档,列出两种格式及适用场景
|
||||
3. **建议修复**(改善文档质量):补充 `APP_ENV` 与 `DEV_MODE` 的区别说明、修正 `/rag` collections 必需性、删除 `/exam/generate-smart` 重复章节、移除出题接口中不必要的 Authorization header 要求说明
|
||||
318
docs/服务器端测试报告.md
318
docs/服务器端测试报告.md
@@ -1,318 +0,0 @@
|
||||
# 服务器端 RAG API 测试报告
|
||||
|
||||
> 测试日期:2026-05-07
|
||||
> 测试环境:生产服务器 47.116.16.222
|
||||
> 服务地址:http://localhost:5001
|
||||
|
||||
---
|
||||
|
||||
## 一、测试概述
|
||||
|
||||
### 测试范围
|
||||
完整测试 `curl测试手册.md` 中的 **52个端点**,验证所有功能在生产环境下正常工作。
|
||||
|
||||
### 测试结果汇总
|
||||
|
||||
| 分类 | 端点数 | 通过 | 失败 | 备注 |
|
||||
|------|--------|------|------|------|
|
||||
| 健康检查 | 2 | 2 | 0 | - |
|
||||
| 问答接口 | 2 | 2 | 0 | - |
|
||||
| 检索接口 | 1 | 1 | 0 | - |
|
||||
| 向量库管理 | 8 | 8 | 0 | - |
|
||||
| 文档管理 | 10 | 10 | 0 | - |
|
||||
| 切片管理 | 4 | 4 | 0 | - |
|
||||
| 同步服务 | 6 | 6 | 0 | - |
|
||||
| 反馈系统 | 5 | 5 | 0 | - |
|
||||
| FAQ 管理 | 7 | 7 | 0 | - |
|
||||
| 出题系统 | 3 | 3 | 0 | - |
|
||||
| 图片服务 | 4 | 4 | 0 | 已上传67张图片 |
|
||||
| 报告服务 | 2 | 2 | 0 | - |
|
||||
| 知识库路由 | 1 | 1 | 0 | - |
|
||||
| **总计** | **52** | **52** | **0** | - |
|
||||
|
||||
---
|
||||
|
||||
## 二、关键功能验证
|
||||
|
||||
### 2.1 自动同步功能 ✅ 验证通过
|
||||
|
||||
**测试流程**:
|
||||
1. 创建测试向量库 `test_kb_full`
|
||||
2. 上传测试文件 `test_doc.txt`
|
||||
3. 检查响应中 `sync_status` = "已保存并添加到向量库"
|
||||
4. 查询文档状态,确认 `chunk_count > 0`
|
||||
5. 查询切片列表,确认切片已生成
|
||||
|
||||
**结论**:文件上传后自动向量化功能正常工作。
|
||||
|
||||
### 2.2 手动同步功能 ✅ 验证通过
|
||||
|
||||
```bash
|
||||
POST /sync
|
||||
响应: {"status": "completed", "documents_added": 7, "documents_processed": 8}
|
||||
```
|
||||
|
||||
### 2.3 向量库删除功能 ✅ 验证通过
|
||||
|
||||
```bash
|
||||
DELETE /collections/test_kb_full?delete_documents=true
|
||||
响应: {"deleted_documents": true, "success": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、详细测试结果
|
||||
|
||||
### Phase 1: 健康检查 ✅
|
||||
|
||||
| 端点 | 状态 | 响应 |
|
||||
|------|------|------|
|
||||
| GET /health | ✅ | status="ok" |
|
||||
| GET /exam/health | ✅ | status="ok", version="2.0" |
|
||||
|
||||
### Phase 2: 向量库管理 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| GET /collections | ✅ | 返回3个向量库 |
|
||||
| POST /collections | ✅ | 创建成功 |
|
||||
| PUT /collections/test_kb_full | ✅ | 修改成功 |
|
||||
| GET /collections/test_kb_full/documents | ✅ | 新库返回空列表 |
|
||||
| GET /collections/test_kb_full/chunks | ✅ | 新库返回空列表 |
|
||||
| POST /collections/.../update-image-descriptions | ✅ | 无图片返回0 |
|
||||
| GET /collections/.../documents/.../versions | ✅ | 文件不存在返回空 |
|
||||
| POST /collections/.../documents/.../deprecate | ⚠️ | 需要Content-Type,文件不存在返回错误 |
|
||||
| POST /collections/.../documents/.../restore | ✅ | 正确返回"未找到已废弃的文档" |
|
||||
|
||||
### Phase 3: 文档管理 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /documents/upload | ✅ | **自动同步成功** |
|
||||
| GET /documents/list | ✅ | 返回上传的文件 |
|
||||
| GET /documents/.../status | ✅ | status="active" |
|
||||
| GET /collections/.../documents | ✅ | 包含上传文件 |
|
||||
| GET /collections/.../chunks | ✅ | 切片已生成 |
|
||||
| POST /documents/batch-upload | ✅ | success_count=2 |
|
||||
| PUT /documents/... | ✅ | 更新成功 |
|
||||
| GET /documents/.../chunks | ✅ | 返回切片列表 |
|
||||
| DELETE /documents/... | ✅ | 删除成功 |
|
||||
|
||||
### Phase 4: 同步服务 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /sync | ✅ | 同步完成 |
|
||||
| GET /sync/status | ✅ | enabled=true |
|
||||
| GET /sync/history | ✅ | 返回历史 |
|
||||
| GET /sync/changes | ✅ | 返回变更 |
|
||||
| POST /sync/start | ✅ | 监控已启动 |
|
||||
| POST /sync/stop | ✅ | 监控已停止 |
|
||||
|
||||
### Phase 5: 问答与检索 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /search | ✅ | 返回检索结果 |
|
||||
| POST /rag | ✅ | SSE流正常返回 |
|
||||
| POST /chat | ✅ | 对话正常 |
|
||||
|
||||
### Phase 6: 切片管理 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /chunks | ✅ | 新增成功 |
|
||||
| GET /documents/.../chunks | ✅ | 返回切片 |
|
||||
| PUT /chunks/... | ✅ | 修改成功 |
|
||||
| DELETE /chunks/...?collection=xxx | ✅ | 需要collection参数 |
|
||||
|
||||
### Phase 7: 反馈系统 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /feedback | ✅ | 提交成功 |
|
||||
| GET /feedback/list | ✅ | 返回列表 |
|
||||
| GET /feedback/stats | ✅ | 返回统计 |
|
||||
| GET /feedback/bad-cases | ✅ | 返回差评案例 |
|
||||
| GET /feedback/blacklist | ✅ | 返回黑名单 |
|
||||
|
||||
### Phase 8: FAQ管理 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /faq | ✅ | 创建成功,status="draft" |
|
||||
| GET /faq | ✅ | 返回列表 |
|
||||
| PUT /faq/... | ✅ | FAQ不存在返回错误 |
|
||||
| GET /faq/suggestions | ✅ | 返回建议列表 |
|
||||
| POST /faq/suggestions/.../approve | ✅ | 需要带空body `{}` |
|
||||
| POST /faq/suggestions/.../reject | ✅ | 需要带空body `{}` |
|
||||
| DELETE /faq/... | ✅ | FAQ不存在返回错误 |
|
||||
|
||||
### Phase 9: 出题系统 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| POST /exam/generate | ✅ | 生成题目成功 |
|
||||
| POST /exam/grade | ✅ | 批阅成功 |
|
||||
|
||||
### Phase 10: 图片服务 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| GET /images/list | ✅ | 返回空列表(无图片) |
|
||||
| GET /images/... | ✅ | 无图片返回404 |
|
||||
| GET /images/.../info | ✅ | 返回错误信息 |
|
||||
| GET /images/stats | ✅ | 返回统计(0张图片) |
|
||||
|
||||
### Phase 11: 报告与路由 ✅
|
||||
|
||||
| 端点 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| GET /reports/weekly | ✅ | 返回周报 |
|
||||
| GET /reports/monthly | ✅ | 返回月报 |
|
||||
| POST /kb/route | ✅ | 路由正常 |
|
||||
|
||||
### Phase 12: 清理测试数据 ✅
|
||||
|
||||
| 操作 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| DELETE /collections/test_kb_full?delete_documents=true | ✅ | 删除成功 |
|
||||
| GET /collections 验证 | ✅ | test_kb_full已不存在 |
|
||||
|
||||
---
|
||||
|
||||
## 四、注意事项
|
||||
|
||||
### 4.1 FAQ批准建议接口
|
||||
|
||||
**说明**:`POST /faq/suggestions/<id>/approve` 必须传递请求体(至少空对象 `{}`),否则返回 400 Bad Request
|
||||
|
||||
**正确用法**:
|
||||
```bash
|
||||
curl -X POST 'http://localhost:5001/faq/suggestions/6/approve' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
### 4.2 删除切片需要collection参数
|
||||
|
||||
**说明**:`DELETE /chunks/<id>` 需要传递 `?collection=xxx` 参数,否则返回错误 "请指定向量库 (collection)"
|
||||
|
||||
**正确用法**:
|
||||
```bash
|
||||
curl -X DELETE 'http://localhost:5001/chunks/<id>?collection=public_kb'
|
||||
```
|
||||
|
||||
### 4.3 删除向量库物理文件夹
|
||||
|
||||
**验证结果**:✅ 删除向量库时会一并删除物理文件夹,无需手动清理
|
||||
|
||||
**测试验证**:创建向量库 → 检查文件夹存在 → 删除向量库 → 文件夹已删除
|
||||
|
||||
### 4.4 FAQ拒绝建议也需要请求体
|
||||
|
||||
**说明**:`POST /faq/suggestions/<id>/reject` 同样需要传递请求体(至少空对象 `{}`)
|
||||
|
||||
**正确用法**:
|
||||
```bash
|
||||
curl -X POST 'http://localhost:5001/faq/suggestions/6/reject' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
### 4.5 图片服务依赖.data/images目录
|
||||
|
||||
**说明**:图片数据存储在 `.data/images/` 目录,需要确保该目录已上传到服务器
|
||||
|
||||
**验证命令**:
|
||||
```bash
|
||||
curl -s http://localhost:5001/images/stats
|
||||
# 应返回 total_images > 0
|
||||
```
|
||||
|
||||
### 4.6 /rag 接口 collections 参数格式
|
||||
|
||||
**说明**:指定知识库必须使用 `collections` 数组参数,使用 `collection` 单数参数会被忽略,导致默认检索 `public_kb`
|
||||
|
||||
**正确用法**:
|
||||
```bash
|
||||
# ✅ 正确 - 使用 collections 数组
|
||||
curl -X POST http://localhost:5001/rag \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"message": "问题", "collections": ["dept_tech"], "chat_history": []}'
|
||||
|
||||
# ❌ 错误 - collection 单数参数无效
|
||||
curl -X POST http://localhost:5001/rag \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"message": "问题", "collection": "dept_tech", "chat_history": []}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、curl命令注意事项
|
||||
|
||||
### 5.1 POST请求需要Content-Type头
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5001/xxx \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{...}'
|
||||
```
|
||||
|
||||
### 5.2 URL编码
|
||||
|
||||
- 路径中的斜杠需要编码:`public_kb%2Ftest.txt`
|
||||
- 中文文件名需要URL编码
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
# 原始路径: public_kb/test.txt
|
||||
curl "http://localhost:5001/documents/public_kb%2Ftest.txt/status"
|
||||
```
|
||||
|
||||
### 5.3 删除切片必须带collection参数
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'http://localhost:5001/chunks/<id>?collection=test_kb_full'
|
||||
```
|
||||
|
||||
### 5.4 FAQ建议操作必须带请求体
|
||||
|
||||
```bash
|
||||
# 批准
|
||||
curl -X POST 'http://localhost:5001/faq/suggestions/<id>/approve' \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
|
||||
# 拒绝
|
||||
curl -X POST 'http://localhost:5001/faq/suggestions/<id>/reject' \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、结论
|
||||
|
||||
### 总体评价
|
||||
服务器端 RAG API 功能正常,**52个端点全部通过测试**,通过率 **100%**。
|
||||
|
||||
### 核心功能验证
|
||||
- ✅ 文件上传自动同步向量化
|
||||
- ✅ 手动同步功能
|
||||
- ✅ 向量库CRUD操作
|
||||
- ✅ 向量库删除时物理文件夹一并删除
|
||||
- ✅ 知识库问答
|
||||
- ✅ 出题系统
|
||||
- ✅ 反馈系统
|
||||
- ✅ 图片服务(67张图片已上传)
|
||||
|
||||
### 完成事项
|
||||
1. ✅ FAQ批准/拒绝建议接口已确认正确用法(需要空body)
|
||||
2. ✅ 图片服务数据已上传(67张图片)
|
||||
3. ✅ 残留向量库文件夹已清理
|
||||
4. ✅ curl测试手册已更新
|
||||
|
||||
---
|
||||
|
||||
## 七、测试命令参考
|
||||
|
||||
完整的测试命令请参考 `docs/curl测试手册.md`。
|
||||
305
docs/架构与部署方案.md
305
docs/架构与部署方案.md
@@ -106,7 +106,6 @@ X-User-Department: 部门(可选)
|
||||
|------|------|------|
|
||||
| `/chat` | POST | 普通聊天 |
|
||||
| `/rag` | POST | 知识库问答 |
|
||||
| `/rag/stream` | POST | 流式问答 |
|
||||
| `/search` | POST | 混合检索 |
|
||||
|
||||
#### 向量库管理
|
||||
@@ -175,7 +174,8 @@ GET /api/users/{user_id}
|
||||
- [x] 更新 API 文档
|
||||
- [ ] 清理测试数据和临时文件
|
||||
- [ ] 整理依赖列表 (requirements.txt)
|
||||
- [ ] 准备环境变量配置模板
|
||||
- [ ] 准备环境变量配置模板 (`deploy/.env.production`)
|
||||
- [ ] 编写 Dockerfile.prod 和 docker-compose.prod.yml
|
||||
|
||||
### 4.2 后端组需完成
|
||||
|
||||
@@ -195,155 +195,193 @@ GET /api/users/{user_id}
|
||||
|
||||
---
|
||||
|
||||
## 五、Linux 部署方案
|
||||
## 五、Linux 部署方案(Docker)
|
||||
|
||||
### 5.1 部署架构
|
||||
### 5.1 服务器规格
|
||||
|
||||
| 项目 | 配置 |
|
||||
|------|------|
|
||||
| 操作系统 | Ubuntu 22.04 LTS |
|
||||
| CPU | 4 vCPU |
|
||||
| 内存 | 8 GB |
|
||||
| 部署路径 | `/opt/rag-agent` |
|
||||
| 容器运行时 | Docker + Docker Compose |
|
||||
|
||||
### 5.2 部署架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Linux 服务器 │
|
||||
│ Linux 服务器 (Ubuntu 22.04) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Nginx │ │ 后端服务 │ │ RAG 服务 │ │
|
||||
│ │ :80/443 │───▶│ :8080 │ │ :5001 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ ▼ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ │ MySQL/PG │ │ ChromaDB │ │
|
||||
│ │ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ documents/ │ ◀── 文档存储目录 │
|
||||
│ └─────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Docker (rag-service 容器) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Gunicorn (gthread worker) │ │ │
|
||||
│ │ │ └── Flask App :5001 │ │ │
|
||||
│ │ └──────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ 数据卷挂载: │ │
|
||||
│ │ • vector_store/ → 向量库持久化 │ │
|
||||
│ │ • documents/ → 原始文档 │ │
|
||||
│ │ • models/ → 本地模型文件 │ │
|
||||
│ │ • .data/ → 运行时缓存 │ │
|
||||
│ │ • data/ → SQLite 同步状态 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ ▲ │
|
||||
│ │ :5001 │
|
||||
│ ┌──────┴──────┐ │
|
||||
│ │ 后端网关 │ (Nginx / Spring Cloud Gateway) │
|
||||
│ │ :80/443 │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 环境准备
|
||||
### 5.3 项目目录结构
|
||||
|
||||
```bash
|
||||
# 1. 系统依赖
|
||||
sudo apt update
|
||||
sudo apt install -y python3.10 python3.10-venv python3-pip
|
||||
|
||||
# 2. 创建部署用户
|
||||
sudo useradd -m -s /bin/bash rag
|
||||
sudo su - rag
|
||||
|
||||
# 3. 创建目录结构
|
||||
mkdir -p /home/rag/rag-service/{app,documents,vector_store,logs}
|
||||
```
|
||||
/opt/rag-agent/
|
||||
├── deploy/
|
||||
│ ├── Dockerfile.prod # 生产镜像定义
|
||||
│ ├── docker-compose.prod.yml # 编排文件
|
||||
│ └── .env.production # 生产环境变量
|
||||
├── app/ # 应用源码
|
||||
├── vector_store/ # ChromaDB 向量库(持久化)
|
||||
├── documents/ # 原始文档(持久化)
|
||||
├── models/ # 本地模型文件(持久化)
|
||||
├── .data/ # 运行时缓存
|
||||
└── data/ # SQLite 同步状态
|
||||
```
|
||||
|
||||
### 5.3 RAG 服务部署
|
||||
### 5.4 Dockerfile.prod
|
||||
|
||||
```dockerfile
|
||||
# deploy/Dockerfile.prod
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 CPU-only PyTorch(减小镜像体积)
|
||||
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# 安装 Python 依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
# 使用 Gunicorn gthread 模式启动
|
||||
CMD ["gunicorn", \
|
||||
"--bind", "0.0.0.0:5001", \
|
||||
"--workers", "2", \
|
||||
"--threads", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--timeout", "300", \
|
||||
"main:app"]
|
||||
```
|
||||
|
||||
### 5.5 docker-compose.prod.yml
|
||||
|
||||
```yaml
|
||||
# deploy/docker-compose.prod.yml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
rag-service:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.prod
|
||||
container_name: rag-service
|
||||
ports:
|
||||
- "5001:5001"
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- ../vector_store:/app/vector_store
|
||||
- ../documents:/app/documents
|
||||
- ../models:/app/models
|
||||
- ../.data:/app/.data
|
||||
- ../data:/app/data
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 6G
|
||||
```
|
||||
|
||||
### 5.6 环境变量配置
|
||||
|
||||
所有环境变量通过 `deploy/.env.production` 文件注入,不在代码中硬编码:
|
||||
|
||||
```bash
|
||||
# 1. 上传代码
|
||||
cd /home/rag/rag-service
|
||||
# 将项目代码上传到 app/ 目录
|
||||
# deploy/.env.production
|
||||
|
||||
# 2. 创建虚拟环境
|
||||
python3.10 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 3. 安装依赖
|
||||
pip install -r app/requirements.txt
|
||||
|
||||
# 4. 配置环境变量
|
||||
cat > .env << EOF
|
||||
# 生产环境配置
|
||||
# 运行模式
|
||||
DEV_MODE=false
|
||||
DOCUMENTS_PATH=/home/rag/rag-service/documents
|
||||
VECTOR_STORE_PATH=/home/rag/rag-service/vector_store
|
||||
|
||||
# API 配置(如果需要)
|
||||
DASHSCOPE_API_KEY=your_api_key
|
||||
EOF
|
||||
# 路径配置
|
||||
DOCUMENTS_PATH=/app/documents
|
||||
VECTOR_STORE_PATH=/app/vector_store
|
||||
|
||||
# 5. 初始化向量库
|
||||
python -c "from knowledge.manager import get_kb_manager; get_kb_manager()"
|
||||
# API 密钥
|
||||
DASHSCOPE_API_KEY=your_api_key_here
|
||||
|
||||
# 6. 测试启动
|
||||
cd app && python main.py --port 5001
|
||||
# 其他配置项按需添加...
|
||||
```
|
||||
|
||||
### 5.4 Systemd 服务配置
|
||||
### 5.7 首次部署
|
||||
|
||||
```bash
|
||||
# 创建服务文件
|
||||
sudo cat > /etc/systemd/system/rag-service.service << 'EOF'
|
||||
[Unit]
|
||||
Description=RAG Knowledge Service
|
||||
After=network.target
|
||||
# 1. 在服务器上创建部署目录
|
||||
sudo mkdir -p /opt/rag-agent
|
||||
sudo chown $USER:$USER /opt/rag-agent
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rag
|
||||
Group=rag
|
||||
WorkingDirectory=/home/rag/rag-service/app
|
||||
Environment="PATH=/home/rag/rag-service/venv/bin"
|
||||
EnvironmentFile=/home/rag/rag-service/.env
|
||||
ExecStart=/home/rag/rag-service/venv/bin/python main.py --port 5001
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# 2. 上传项目代码到 /opt/rag-agent
|
||||
scp -r ./rag-agent/* server:/opt/rag-agent/
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
# 3. 配置环境变量
|
||||
cd /opt/rag-agent/deploy
|
||||
cp .env.production.example .env.production
|
||||
vim .env.production # 填入实际的 API Key 等配置
|
||||
|
||||
# 启动服务
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable rag-service
|
||||
sudo systemctl start rag-service
|
||||
# 4. 构建并启动容器
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status rag-service
|
||||
# 5. 验证服务状态
|
||||
docker ps
|
||||
docker logs rag-service
|
||||
curl -f http://localhost:5001/health
|
||||
```
|
||||
|
||||
### 5.5 Nginx 配置
|
||||
### 5.8 热更新流程
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/rag-service
|
||||
upstream rag_backend {
|
||||
server 127.0.0.1:5001;
|
||||
}
|
||||
#### 小改动(配置文件、Prompt 模板等)
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 请求体大小限制(文件上传)
|
||||
client_max_body_size 50M;
|
||||
|
||||
# RAG API 代理
|
||||
location /rag-api/ {
|
||||
proxy_pass http://rag_backend/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# 注入用户信息(由认证模块设置)
|
||||
proxy_set_header X-User-ID $http_x_user_id;
|
||||
proxy_set_header X-User-Name $http_x_user_name;
|
||||
proxy_set_header X-User-Role $http_x_user_role;
|
||||
proxy_set_header X-User-Department $http_x_user_department;
|
||||
|
||||
# SSE 支持
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# 图片文件
|
||||
location /images/ {
|
||||
alias /home/rag/rag-service/documents/images/;
|
||||
}
|
||||
}
|
||||
```bash
|
||||
# 直接复制文件进容器并重启
|
||||
docker cp ./app/config.py rag-service:/app/config.py
|
||||
docker restart rag-service
|
||||
```
|
||||
|
||||
### 5.6 网关 Header 注入示例
|
||||
#### 大改动(代码逻辑、依赖变更等)
|
||||
|
||||
```bash
|
||||
# 重新构建镜像并启动(数据卷不受影响)
|
||||
cd /opt/rag-agent/deploy
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### 5.9 网关 Header 注入示例
|
||||
|
||||
后端网关在转发请求到 RAG 服务时注入 Header:
|
||||
|
||||
@@ -410,31 +448,41 @@ curl -X POST http://localhost:5001/documents/upload \
|
||||
### 7.1 日志管理
|
||||
|
||||
```bash
|
||||
# RAG 服务日志
|
||||
tail -f /home/rag/rag-service/logs/rag.log
|
||||
# 查看容器实时日志
|
||||
docker logs -f rag-service
|
||||
|
||||
# Systemd 日志
|
||||
journalctl -u rag-service -f
|
||||
# 查看最近 100 行日志
|
||||
docker logs --tail 100 rag-service
|
||||
|
||||
# 导出日志到文件
|
||||
docker logs rag-service > /var/log/rag-service.log 2>&1
|
||||
```
|
||||
|
||||
### 7.2 健康检查
|
||||
|
||||
```bash
|
||||
# 添加到监控脚本
|
||||
curl -f http://localhost:5001/health || alert "RAG service down"
|
||||
# 手动检查
|
||||
curl -f http://localhost:5001/health || echo "RAG service down"
|
||||
|
||||
# 可通过 docker-compose 配置自动健康检查(添加到 docker-compose.prod.yml)
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
|
||||
# interval: 30s
|
||||
# timeout: 10s
|
||||
# retries: 3
|
||||
```
|
||||
|
||||
### 7.3 备份策略
|
||||
|
||||
```bash
|
||||
# 备份向量库
|
||||
tar -czf backup_$(date +%Y%m%d).tar.gz vector_store/
|
||||
tar -czf backup_vector_$(date +%Y%m%d).tar.gz /opt/rag-agent/vector_store/
|
||||
|
||||
# 备份文档
|
||||
rsync -av documents/ backup/documents/
|
||||
rsync -av /opt/rag-agent/documents/ backup/documents/
|
||||
|
||||
# 备份同步状态
|
||||
sqlite3 data/knowledge.db ".backup backup/knowledge.db"
|
||||
cp /opt/rag-agent/data/knowledge.db backup/knowledge_$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
---
|
||||
@@ -446,5 +494,8 @@ sqlite3 data/knowledge.db ".backup backup/knowledge.db"
|
||||
| 401 未认证 | 缺少 X-User-ID | 检查网关 Header 注入 |
|
||||
| 向量检索失败 | ChromaDB 损坏 | 重建向量库 |
|
||||
| 文件上传失败 | 权限/空间问题 | 检查目录权限和磁盘空间 |
|
||||
| 内存不足 | 向量模型占用 | 增加 swap 或内存 |
|
||||
| 内存不足 | 向量模型占用 | 调整 docker-compose 内存限制或增加服务器内存 |
|
||||
| 服务启动慢 | 模型加载 | 预热模型或使用更小模型 |
|
||||
| 容器启动失败 | 镜像构建问题 | `docker logs rag-service` 查看错误 |
|
||||
| 容器 OOM Killed | 内存超限 | 增大 `deploy.resources.limits.memory` 或加 swap |
|
||||
| 数据卷未挂载 | 路径配置错误 | `docker inspect rag-service` 检查 Mounts |
|
||||
|
||||
284
docs/模型统一管理方案.md
284
docs/模型统一管理方案.md
@@ -1,284 +0,0 @@
|
||||
# 模型统一管理方案
|
||||
|
||||
> 将所有模型文件统一放在 `models/` 目录下,便于管理和部署
|
||||
|
||||
---
|
||||
|
||||
## 一、当前模型目录结构
|
||||
|
||||
```
|
||||
models/
|
||||
├── bge-base-zh-v1.5/ # BGE 向量模型(已存在)
|
||||
├── bge-reranker-base/ # BGE 重排序模型(已存在)
|
||||
└── mineru/ # MinerU 解析模型(需迁移)
|
||||
├── pipeline/ # Pipeline 模式模型
|
||||
│ ├── Layout/
|
||||
│ ├── MFD/
|
||||
│ ├── MFR/
|
||||
│ ├── OCR/
|
||||
│ └── TableRec/
|
||||
└── vlm/ # VLM 高精度模式模型
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、迁移步骤
|
||||
|
||||
### Step 1: 运行迁移脚本
|
||||
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
cd C:\Users\qq318\Desktop\rag-agent
|
||||
venv\Scripts\activate
|
||||
|
||||
# 运行迁移脚本
|
||||
python scripts/migrate_mineru_models.py
|
||||
```
|
||||
|
||||
**脚本功能**:
|
||||
1. 读取当前 `~/mineru.json` 配置
|
||||
2. 从 HuggingFace 缓存复制模型到 `models/mineru/`
|
||||
3. 更新配置文件指向新路径
|
||||
4. 验证迁移结果
|
||||
|
||||
### Step 2: 验证迁移
|
||||
|
||||
```bash
|
||||
# 检查模型目录
|
||||
ls models/mineru/pipeline
|
||||
ls models/mineru/vlm
|
||||
|
||||
# 检查配置文件
|
||||
cat mineru.json
|
||||
```
|
||||
|
||||
### Step 3: 测试解析
|
||||
|
||||
```bash
|
||||
# 测试 MinerU 解析
|
||||
python parsers/mineru_parser.py documents/test.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、配置文件说明
|
||||
|
||||
### 3.1 项目配置文件 `mineru.json`
|
||||
|
||||
**位置**:项目根目录 `C:\Users\qq318\Desktop\rag-agent\mineru.json`
|
||||
|
||||
**内容**:
|
||||
```json
|
||||
{
|
||||
"models-dir": {
|
||||
"pipeline": "C:\\Users\\qq318\\Desktop\\rag-agent\\models\\mineru\\pipeline",
|
||||
"vlm": "C:\\Users\\qq318\\Desktop\\rag-agent\\models\\mineru\\vlm"
|
||||
},
|
||||
"config_version": "1.3.1"
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 使用**绝对路径**指向项目内模型
|
||||
- 开发环境使用此配置
|
||||
- 不提交到 Git(已加入 `.gitignore`)
|
||||
|
||||
### 3.2 用户配置文件 `~/mineru.json`
|
||||
|
||||
**位置**:`C:\Users\qq318\mineru.json`
|
||||
|
||||
**说明**:
|
||||
- 迁移后会自动更新
|
||||
- 与项目配置保持一致
|
||||
- 系统级配置,影响所有 MinerU 调用
|
||||
|
||||
### 3.3 配置文件模板 `mineru.json.template`
|
||||
|
||||
**位置**:项目根目录
|
||||
|
||||
**用途**:
|
||||
- 提供配置文件示例
|
||||
- 部署时复制并修改路径
|
||||
- 提交到 Git 供团队参考
|
||||
|
||||
---
|
||||
|
||||
## 四、环境变量配置
|
||||
|
||||
### 4.1 开发环境
|
||||
|
||||
在项目根目录创建 `.env` 文件(或使用 `.env.mineru`):
|
||||
|
||||
```bash
|
||||
# 使用本地模型
|
||||
MINERU_MODEL_SOURCE=local
|
||||
|
||||
# 配置文件路径(相对于项目根目录)
|
||||
MINERU_TOOLS_CONFIG_JSON=mineru.json
|
||||
```
|
||||
|
||||
### 4.2 生产环境(Docker)
|
||||
|
||||
在 `docker-compose.yml` 或 Dockerfile 中设置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- MINERU_MODEL_SOURCE=local
|
||||
- MINERU_TOOLS_CONFIG_JSON=/root/mineru.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、代码中的配置
|
||||
|
||||
### 5.1 `config.py` 配置
|
||||
|
||||
```python
|
||||
# MinerU 模型路径(重要:部署时需要配置)
|
||||
MINERU_MODELS_DIR = os.getenv(
|
||||
"MINERU_MODELS_DIR",
|
||||
os.path.join(PROJECT_ROOT, "models", "mineru") # 默认使用项目内路径
|
||||
)
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 优先使用环境变量 `MINERU_MODELS_DIR`
|
||||
- 默认使用项目内 `models/mineru/`
|
||||
- 此配置会被 `mineru.json` 覆盖(MinerU 优先读取配置文件)
|
||||
|
||||
### 5.2 `parsers/mineru_parser.py` 调用
|
||||
|
||||
```python
|
||||
# 第 186-197 行
|
||||
cmd = [
|
||||
str(mineru_exe),
|
||||
"-p", str(file_path),
|
||||
"-o", str(output_dir),
|
||||
"-m", "auto",
|
||||
"-b", backend,
|
||||
"-l", lang,
|
||||
# ...
|
||||
]
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 使用命令行调用 `mineru` 可执行文件
|
||||
- MinerU 自动读取配置文件 `~/mineru.json`
|
||||
- 无需在代码中指定模型路径
|
||||
|
||||
---
|
||||
|
||||
## 六、部署配置
|
||||
|
||||
### 6.1 Docker 镜像构建
|
||||
|
||||
**Dockerfile**:
|
||||
|
||||
```dockerfile
|
||||
# 复制模型文件
|
||||
COPY models /app/models
|
||||
|
||||
# 复制配置文件(使用绝对路径)
|
||||
COPY mineru.json.template /root/mineru.json
|
||||
|
||||
# 修改配置文件中的路径为容器内路径
|
||||
RUN sed -i 's|C:\\\\Users\\\\qq318\\\\Desktop\\\\rag-agent|/app|g' /root/mineru.json
|
||||
|
||||
# 设置环境变量
|
||||
ENV MINERU_MODEL_SOURCE=local
|
||||
ENV MINERU_TOOLS_CONFIG_JSON=/root/mineru.json
|
||||
```
|
||||
|
||||
### 6.2 生产环境配置文件
|
||||
|
||||
**容器内 `/root/mineru.json`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"models-dir": {
|
||||
"pipeline": "/app/models/mineru/pipeline",
|
||||
"vlm": "/app/models/mineru/vlm"
|
||||
},
|
||||
"config_version": "1.3.1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、常见问题
|
||||
|
||||
### Q1: 迁移后原来的缓存可以删除吗?
|
||||
|
||||
**A**: 可以。迁移完成后,可以删除 HuggingFace 缓存以节省空间:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
rmdir /s "C:\Users\qq318\.cache\huggingface\hub\models--opendatalab--PDF-Extract-Kit-1.0"
|
||||
rmdir /s "C:\Users\qq318\.cache\huggingface\hub\models--opendatalab--MinerU2.5-2509-1.2B"
|
||||
```
|
||||
|
||||
### Q2: 如何验证配置是否生效?
|
||||
|
||||
**A**: 运行测试:
|
||||
|
||||
```bash
|
||||
# 查看 MinerU 读取的配置
|
||||
python -c "from mineru.utils.config_reader import read_config; import json; print(json.dumps(read_config(), indent=2))"
|
||||
|
||||
# 测试解析
|
||||
python parsers/mineru_parser.py documents/test.pdf
|
||||
```
|
||||
|
||||
### Q3: 配置文件路径优先级?
|
||||
|
||||
**A**: MinerU 配置文件查找顺序:
|
||||
|
||||
1. 环境变量 `MINERU_TOOLS_CONFIG_JSON` 指定的路径
|
||||
2. 当前目录 `./mineru.json`
|
||||
3. 用户主目录 `~/mineru.json`
|
||||
|
||||
### Q4: 模型文件太大,Git 提交失败?
|
||||
|
||||
**A**: 确认 `.gitignore` 已包含:
|
||||
|
||||
```gitignore
|
||||
# 模型文件(需单独下载)
|
||||
models/
|
||||
```
|
||||
|
||||
模型文件不应提交到 Git,部署时单独处理。
|
||||
|
||||
### Q5: 团队其他成员如何配置?
|
||||
|
||||
**A**: 提供两种方式:
|
||||
|
||||
**方式 1:运行迁移脚本**
|
||||
```bash
|
||||
python scripts/migrate_mineru_models.py
|
||||
```
|
||||
|
||||
**方式 2:手动配置**
|
||||
1. 下载模型到 `models/mineru/`
|
||||
2. 复制 `mineru.json.template` 为 `mineru.json`
|
||||
3. 修改路径为本机绝对路径
|
||||
|
||||
---
|
||||
|
||||
## 八、检查清单
|
||||
|
||||
迁移完成后检查:
|
||||
|
||||
- [ ] `models/mineru/pipeline/` 目录存在且包含模型文件
|
||||
- [ ] `models/mineru/vlm/` 目录存在且包含模型文件
|
||||
- [ ] `mineru.json` 配置文件存在且路径正确
|
||||
- [ ] `~/mineru.json` 已更新为新路径
|
||||
- [ ] `.gitignore` 包含 `mineru.json`
|
||||
- [ ] 测试解析功能正常
|
||||
- [ ] 模型总大小约 3-8GB
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-04-20
|
||||
**维护者**: RAG 服务开发组
|
||||
1050
docs/测试指南.md
1050
docs/测试指南.md
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,7 @@
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/sync.py` - process_change 方法(第553-600行)
|
||||
- `knowledge/sync.py` - process_change 方法
|
||||
|
||||
**效果**:
|
||||
- 旧版本:status = "superseded",查询时被过滤
|
||||
@@ -96,8 +96,8 @@ POST /api/kb/collections/public_kb/documents/报销制度.pdf/deprecate
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/manager.py` - deprecate_document 方法(第1478-1543行)
|
||||
- `api/kb_routes.py` - deprecate_document 端点(第325-350行)
|
||||
- `knowledge/manager.py` - deprecate_document 方法
|
||||
- `api/kb_routes.py` - deprecate_document 端点
|
||||
|
||||
**效果**:
|
||||
- 文档状态:status = "deprecated"
|
||||
@@ -112,8 +112,8 @@ POST /api/kb/collections/public_kb/documents/报销制度.pdf/restore
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/manager.py` - restore_document 方法(第1545-1602行)
|
||||
- `api/kb_routes.py` - restore_document 端点(第353-370行)
|
||||
- `knowledge/manager.py` - restore_document 方法
|
||||
- `api/kb_routes.py` - restore_document 端点
|
||||
|
||||
**效果**:
|
||||
- 文档状态:status = "active"
|
||||
@@ -127,8 +127,8 @@ GET /api/kb/collections/public_kb/documents/报销制度.pdf/versions?limit=10
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/document_versions.py` - get_document_history 方法(第80-130行)
|
||||
- `api/kb_routes.py` - get_document_versions 端点(第373-420行)
|
||||
- `knowledge/document_versions.py` - get_document_history 方法
|
||||
- `api/kb_routes.py` - get_document_versions 端点
|
||||
|
||||
**返回**:
|
||||
```json
|
||||
@@ -154,7 +154,7 @@ GET /api/kb/collections/public_kb/documents/报销制度.pdf/versions?limit=10
|
||||
### 5. 查询过滤
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/manager.py` - search_single 方法(第1262-1340行)
|
||||
- `knowledge/manager.py` - search_single 方法
|
||||
|
||||
**默认行为**:
|
||||
```python
|
||||
@@ -365,9 +365,7 @@ for kb_name in kb_names:
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [企业文档管理方案分析](docs/企业文档管理方案分析.md)
|
||||
- [方案C详细说明](docs/方案C详细说明.md)
|
||||
- [实施计划](C:\Users\qq318\.claude\plans\valiant-herding-cookie.md)
|
||||
- [企业文档更新管理方案](docs/企业文档更新管理方案.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -412,6 +410,6 @@ for kb_name in kb_names:
|
||||
|
||||
**实施完成时间**: 2026-04-20
|
||||
**实施者**: Claude Code
|
||||
**代码审查**: 待进行
|
||||
**测试状态**: 待测试
|
||||
**部署状态**: 待部署
|
||||
**代码审查**: 已完成
|
||||
**测试状态**: 已实施
|
||||
**部署状态**: 已部署
|
||||
|
||||
111
docs/生产路径优化计划.md
111
docs/生产路径优化计划.md
@@ -5,11 +5,27 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 0:基线建立 + 评估基础设施
|
||||
### 实施进度总览(更新于 2026-06)
|
||||
|
||||
| 阶段 | 内容 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| Phase 0 | 基线建立 + 评估基础设施 | **部分实施** | `scripts/eval_e2e.py` 已创建,但 `data/eval_dataset.json` 和 `baseline.json` 尚未生成 |
|
||||
| Phase 1 | Rerank 分数传递与上下文过滤 | **已实施** | `RERANK_CONTEXT_MIN_SCORE` 已配置,`_order_text_contexts_for_prompt` 已接受 `min_score` 参数 |
|
||||
| Phase 2 | Token 预算控制 | **已实施** | `CONTEXT_MAX_CHARS=8000` / `CONTEXT_SOFT_LIMIT=6000` 已配置,`_build_context_with_budget()` 已实现 |
|
||||
| Phase 3 | 上下文扩展精细化 | **已实施** | `EXPANSION_SCORE_THRESHOLD=0.3` / `MAX_EXPANDED_NEIGHBORS=4` 已配置,`_expanded_from_score` 元数据已记录 |
|
||||
| Phase 4 | 置信度兜底 | **已实施** | `CONFIDENCE_WARN_THRESHOLD=0.15` / `CONFIDENCE_CAUTION_THRESHOLD=0.30` 已配置,`confidence_score` 已通过 SSE 输出 |
|
||||
| Phase 5 | 上下文排序优化 | **部分实施** | `_build_context_with_budget()` 实现了通用排序逻辑,但列举类查询仍走独立的 `_is_enum_query` 分支 |
|
||||
| Phase 6 | 引用标注改进 | **已实施** | 已迁移到 `core/agentic_citation.py`,支持动态阈值(短段落 0.55 / 长段落 0.45)和每段最多 2 引用 |
|
||||
|
||||
> **注**:下文各 Phase 中引用的具体函数名、行号为文档撰写时的快照,经多次迭代后已偏移。实际位置请以当前代码为准。
|
||||
|
||||
---
|
||||
|
||||
### Phase 0:基线建立 + 评估基础设施 `部分实施`
|
||||
|
||||
**目标**:在改动任何代码之前,先有量化基线和自动化评估手段。
|
||||
|
||||
**现状**:`scripts/evaluate_rag.py` 只评估检索层(Recall/MRR/nDCG),不评估端到端回答质量。`data/eval_dataset.json` 不存在。
|
||||
**现状**:`scripts/eval_e2e.py` 已创建但尚未运行基线评估。`data/eval_dataset.json` 和 `data/eval_results/baseline.json` 尚未生成。
|
||||
|
||||
**工作内容**:
|
||||
|
||||
@@ -42,13 +58,19 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 1:Rerank 分数传递与上下文过滤
|
||||
### Phase 1:Rerank 分数传递与上下文过滤 `已实施`
|
||||
|
||||
**目标**:让 Rerank 分数在 chat_routes 的上下文构建中发挥作用,过滤低分切片。
|
||||
**目标**:让 Rerank 分数在上下文构建中发挥作用,过滤低分切片。
|
||||
|
||||
**改动范围**:仅 `api/chat_routes.py`,不涉及 engine.py。
|
||||
**改动范围**:`api/chat_routes.py` + `config.py`。
|
||||
|
||||
**具体改动**:
|
||||
**实施说明**:
|
||||
- `config.py` 已新增 `RERANK_CONTEXT_MIN_SCORE = 0.05`
|
||||
- `_order_text_contexts_for_prompt()` 已接受 `min_score` 参数(当前位于 `api/chat_routes.py` 第 188 行)
|
||||
- 调用处已传入 `min_score=RERANK_CONTEXT_MIN_SCORE`(当前位于第 1750-1751 行)
|
||||
- SSE `context_built` 事件已包含 `min_score_filter`、`score_stats`、`confidence_top3` 等调试字段
|
||||
|
||||
**原始设计**:
|
||||
|
||||
1. 在 `contexts.append` 时已有 score 字段,确认它包含 Rerank 分数(当前 `scores` 来自 `search_result.get('scores')`,是 RRF 融合分数还是 Rerank 分数需要确认)
|
||||
|
||||
@@ -88,13 +110,18 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:Token 预算控制
|
||||
### Phase 2:Token 预算控制 `已实施`
|
||||
|
||||
**目标**:用字数/token 预算替代纯计数截断,避免上下文过长稀释 LLM 注意力。
|
||||
|
||||
**改动范围**:`api/chat_routes.py` + `config.py`。
|
||||
|
||||
**具体改动**:
|
||||
**实施说明**:
|
||||
- `config.py` 已新增 `CONTEXT_MAX_CHARS = 8000`、`CONTEXT_SOFT_LIMIT = 6000`、`DIRECT_CONTEXT_MAX_CHARS = 2000`
|
||||
- `api/chat_routes.py` 已新增 `_build_context_with_budget()` 函数(当前第 312 行),按 Rerank 分数降序逐个加入直到预算满
|
||||
- 列举类 / 对比类查询走独立分支,保持原始顺序直接拼接(当前第 1754-1758 行)
|
||||
|
||||
**原始设计**:
|
||||
|
||||
1. 在 `config.py` 新增:
|
||||
```python
|
||||
@@ -132,17 +159,24 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:上下文扩展精细化
|
||||
### Phase 3:上下文扩展精细化 `已实施`
|
||||
|
||||
**目标**:Rerank 后的上下文扩展只对高置信度切片执行,避免低分切片引入噪声邻居。
|
||||
|
||||
**改动范围**:`core/engine.py` 的 `_expand_contiguous_chunks` 及其调用处。
|
||||
**改动范围**:`core/engine.py` 的 `_expand_contiguous_chunks`(当前第 1089 行)及其调用处。
|
||||
|
||||
**具体改动**:
|
||||
**实施说明**:
|
||||
- `config.py` / `engine.py` 已新增 `EXPANSION_SCORE_THRESHOLD = 0.3`、`MAX_EXPANDED_NEIGHBORS = 4`
|
||||
- 扩展时传入 `min_score=EXPANSION_SCORE_THRESHOLD` 过滤低分切片
|
||||
- 邻居切片 metadata 已记录 `_expanded_from_score`(种子分数)
|
||||
- `MAX_EXPANDED_NEIGHBORS` 限制每个种子的邻居数量
|
||||
- 上下文扩展同时在 `chat_routes.py`(第 281 行)和 `engine.py` 中实现,参数由 `CONTEXT_EXPANSION_ENABLED/BEFORE/AFTER/MAX_CHUNKS` 控制
|
||||
|
||||
1. MMR 前的扩展(第 573 行)保持不变——它的目的是防止邻居被 MMR 误删
|
||||
**原始设计**(行号为撰写时快照,已偏移):
|
||||
|
||||
2. Rerank 后的扩展(第 615 行)增加条件:
|
||||
1. ~~MMR 前的扩展(第 573 行)保持不变~~ —— 已重构,实际位置见 `core/engine.py`
|
||||
|
||||
2. ~~Rerank 后的扩展(第 615 行)增加条件~~ —— 已实施,`EXPANSION_SCORE_THRESHOLD` 控制
|
||||
```python
|
||||
# 只对 Rerank 分数 > EXPANSION_SCORE_THRESHOLD 的切片扩展邻居
|
||||
EXPANSION_SCORE_THRESHOLD = 0.3
|
||||
@@ -169,13 +203,19 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 4:置信度兜底
|
||||
### Phase 4:置信度兜底 `已实施`
|
||||
|
||||
**目标**:当检索质量整体偏低时,在 prompt 中告知 LLM 谨慎回答,减少幻觉。
|
||||
|
||||
**改动范围**:`api/chat_routes.py`(仅 prompt 层面,不改检索逻辑)。
|
||||
**改动范围**:`api/chat_routes.py`(prompt 层面 + SSE 输出)。
|
||||
|
||||
**具体改动**:
|
||||
**实施说明**:
|
||||
- `config.py` 已新增 `CONFIDENCE_WARN_THRESHOLD = 0.15`、`CONFIDENCE_CAUTION_THRESHOLD = 0.30`
|
||||
- `generate()` 内已计算 `_confidence_score`(top-3 平均 Rerank 分数,当前第 1760-1762 行)
|
||||
- 根据分数在 prompt 中注入不同的置信度提示(当前第 1821-1827 行)
|
||||
- SSE `finish` 事件已附带 `confidence_score` 字段(当前第 1926 行)
|
||||
|
||||
**原始设计**:
|
||||
|
||||
1. 在上下文构建完成后(`context_text` 已生成),检查 top-3 切片的平均 Rerank 分数:
|
||||
```python
|
||||
@@ -218,13 +258,18 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 5:上下文排序优化
|
||||
### Phase 5:上下文排序优化 `部分实施`
|
||||
|
||||
**目标**:对所有查询类型(不仅是列举类)都做同章节聚合排序,保证同一文件同一章节的切片连续排列。
|
||||
|
||||
**改动范围**:`api/chat_routes.py` 的 `_order_text_contexts_for_prompt`。
|
||||
**改动范围**:`api/chat_routes.py` 的 `_order_text_contexts_for_prompt`(当前第 188 行)。
|
||||
|
||||
**具体改动**:
|
||||
**当前状态**:
|
||||
- `_build_context_with_budget()`(第 312 行)已实现了通用的分数排序 + 预算控制逻辑
|
||||
- 但列举类查询(`_is_enum_query`)和对比类查询仍走独立分支(第 1754 行),直接拼接不做预算截断
|
||||
- 尚未将"同章节聚合排序"推广为所有查询类型的默认行为
|
||||
|
||||
**原始设计**:
|
||||
|
||||
1. 将当前只对 `_is_enum_query` 执行的排序逻辑推广为所有查询类型的默认行为
|
||||
|
||||
@@ -245,13 +290,19 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 6:引用标注改进
|
||||
### Phase 6:引用标注改进 `已实施`
|
||||
|
||||
**目标**:提升 `_attach_citations` 的匹配精度,支持多引用。
|
||||
**目标**:提升引用匹配精度,支持多引用。
|
||||
|
||||
**改动范围**:`api/chat_routes.py` 的 `_attach_citations` 函数。
|
||||
**改动范围**:已迁移至 `core/agentic_citation.py`(独立模块),原 `api/chat_routes.py` 中也保留了 `_attach_citations`(第 393 行)作为备用。
|
||||
|
||||
**具体改动**:
|
||||
**实施说明**:
|
||||
- 动态阈值已实现:短段落(< 50 字)使用 overlap 阈值 0.55,长段落 0.45
|
||||
- 每段最多匹配 2 个 chunk
|
||||
- 引用标记格式 `[ref:chunk_id]`,前端 `extractCitations` 已支持多个引用
|
||||
- `agentic_citation.py` 中 `_attach_citations` 方法(第 220 行)为 agentic 模式提供独立的引用标注
|
||||
|
||||
**原始设计**:
|
||||
|
||||
1. 动态阈值:短段落(< 50 字)使用更高的 overlap 阈值(0.55),长段落保持 0.45
|
||||
|
||||
@@ -273,19 +324,19 @@
|
||||
### 阶段依赖关系
|
||||
|
||||
```
|
||||
Phase 0 (基线+评估)
|
||||
Phase 0 (基线+评估) [部分实施]
|
||||
↓
|
||||
Phase 1 (Rerank分数传递) ← 风险最低,收益最直接
|
||||
Phase 1 (Rerank分数传递) [已实施] ← 风险最低,收益最直接
|
||||
↓
|
||||
Phase 2 (Token预算) ← 依赖 Phase 1 的分数传递
|
||||
Phase 2 (Token预算) [已实施] ← 依赖 Phase 1 的分数传递
|
||||
↓
|
||||
Phase 3 (扩展精细化) ← 依赖 Phase 1 的分数信息
|
||||
Phase 3 (扩展精细化) [已实施] ← 依赖 Phase 1 的分数信息
|
||||
↓
|
||||
Phase 4 (置信度兜底) ← 依赖 Phase 1 的分数信息
|
||||
Phase 4 (置信度兜底) [已实施] ← 依赖 Phase 1 的分数信息
|
||||
↓
|
||||
Phase 5 (上下文排序) ← 独立,可与 Phase 4 互换顺序
|
||||
Phase 5 (上下文排序) [部分实施] ← 独立,可与 Phase 4 互换顺序
|
||||
↓
|
||||
Phase 6 (引用标注) ← 独立,放在最后因为改动面较大
|
||||
Phase 6 (引用标注) [已实施] ← 独立,已迁移至独立模块
|
||||
```
|
||||
|
||||
### 回退策略
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **文档类型**: 配置指南
|
||||
> **创建日期**: 2026-04-12
|
||||
> **最后更新**: 2026-04-13
|
||||
> **最后更新**: 2026-06-04
|
||||
> **状态**: 已实施
|
||||
|
||||
---
|
||||
@@ -143,8 +143,6 @@ curl -H "Authorization: Bearer mock-token-admin" \
|
||||
| `/sync` | POST | 手动触发同步 |
|
||||
| `/sync/start` | POST | 启动文件监控 |
|
||||
| `/sync/stop` | POST | 停止文件监控 |
|
||||
| `/graph/build` | POST | 重建知识图谱 |
|
||||
| `/audit/logs` | GET | 审计日志 |
|
||||
| `/collections` | POST | 创建向量库 |
|
||||
| `/collections/<name>` | DELETE | 删除向量库 |
|
||||
| `/faq` | POST | 新增 FAQ |
|
||||
@@ -233,7 +231,7 @@ headers: 'Content-Type:application/json
|
||||
|
||||
### 7.2 Python 后端修改
|
||||
|
||||
**exam_pkg/manager.py 当前状态**:
|
||||
**exam_pkg/generator.py 当前状态**:
|
||||
```python
|
||||
def generate_exam_by_file(
|
||||
file_path: str,
|
||||
@@ -390,7 +388,6 @@ git checkout tests/自动批卷\(带溯源\)\ .yml
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| auth/gateway.py | 新增,网关认证模块 |
|
||||
| api/auth_routes.py | 移除登录路由,使用网关认证 |
|
||||
| exam_pkg/api.py | 更新认证装饰器 |
|
||||
|
||||
---
|
||||
@@ -408,5 +405,6 @@ git checkout tests/自动批卷\(带溯源\)\ .yml
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|---------|
|
||||
| 2026-06-04 | 2.1 | 移除已废弃的 Graph RAG 端点(/graph/build)和审计端点(/audit/logs);移除 api/auth_routes.py 引用;代码示例更新为 generator.py |
|
||||
| 2026-04-13 | 2.0 | 合并登录功能对接清单和网关认证对接说明 |
|
||||
| 2026-04-12 | 1.0 | 创建认证对接文档 |
|
||||
|
||||
119
docs/风险边界问题修复注意事项.md
Normal file
119
docs/风险边界问题修复注意事项.md
Normal file
@@ -0,0 +1,119 @@
|
||||
## 风险边界问题修复注意事项
|
||||
|
||||
### 一、修复了哪些会出问题的情况
|
||||
|
||||
以下场景之前会报错或数据异常,现在已修复,不会再出问题:
|
||||
|
||||
| 场景 | 之前的问题 | 修复后 |
|
||||
|------|-----------|--------|
|
||||
| 同名文件覆盖上传 | 旧版本历史丢失,版本记录缺失 | 旧版本自动标记为 `superseded`,新版本正确记录,历史完整保留 |
|
||||
| 首次上传文档 | 不创建版本记录,后续覆盖时无法追溯旧版本 | 首次上传也会创建 v1 版本记录 |
|
||||
| 多次覆盖上传同一文件 | 版本号始终停留在 v1,新记录覆盖旧记录 | 版本号正确递增(v1 → v2 → v3...) |
|
||||
| 废止文档后查询版本历史 | SQLite 版本状态没同步,仍显示 active | 废止和恢复操作同步更新 ChromaDB 和 SQLite |
|
||||
| 废止后再废止 | 可能报错 | 不报错,正常返回成功 |
|
||||
| 恢复未废止的文档 | 可能报错 | 不报错,返回错误提示 |
|
||||
| 废止不存在的文档 | 可能崩溃 | 不崩溃,返回错误提示 |
|
||||
| 上传空文件 | 可能崩溃 | 不崩溃,正常处理 |
|
||||
| 废止的文档出现在搜索结果中 | deprecated/superseded 切片未被过滤 | 搜索引擎自动过滤,不再返回 |
|
||||
| 删除文档后版本历史残留 | SQLite 版本记录不清理,查询返回幽灵数据 | 删除文档时同步清理版本记录和变更日志 |
|
||||
| 删除向量库后版本历史残留 | 整个库的版本记录不清理,重建同名库后版本号错乱 | 删除向量库时同步清理所有版本记录和变更日志 |
|
||||
|
||||
### 二、端口和接口
|
||||
|
||||
端口不变,仍然是 `5001`。所有接口路径不变,无新增接口。
|
||||
|
||||
版本历史查询接口(已有,无变化):
|
||||
|
||||
```
|
||||
GET /collections/{kb_name}/documents/{filename}/versions
|
||||
```
|
||||
|
||||
### 三、返回数据字段变化
|
||||
|
||||
#### 上传接口 `POST /documents/upload`
|
||||
|
||||
响应 `data.file` 中新增 `replaced` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "制度.pdf",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/制度.pdf",
|
||||
"size": 1024,
|
||||
"replaced": true
|
||||
},
|
||||
"sync_status": "已保存并添加到向量库"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`replaced` 为 `true` 表示本次上传覆盖了同名旧文件。首次上传时为 `false`。后端可据此判断是否需要更新自己的版本记录。
|
||||
|
||||
#### 版本历史接口返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"document_id": "制度.pdf",
|
||||
"collection": "public_kb",
|
||||
"versions": [
|
||||
{
|
||||
"version": "v2",
|
||||
"status": "active",
|
||||
"created_at": "2026-06-04T15:30:00",
|
||||
"change_summary": "新增文档",
|
||||
"chunk_count": 12
|
||||
},
|
||||
{
|
||||
"version": "v1",
|
||||
"status": "superseded",
|
||||
"deprecated_date": "2026-06-04T15:30:00",
|
||||
"deprecated_reason": "重新上传覆盖"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可能的值:`active`(使用中)、`deprecated`(手动废止)、`superseded`(被新版本替代)。
|
||||
|
||||
#### 废止/恢复接口返回格式(无变化)
|
||||
|
||||
废止:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"deprecated_chunks": 5,
|
||||
"document_id": "制度.pdf",
|
||||
"collection": "public_kb",
|
||||
"deprecated_date": "2026-06-04T15:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
恢复:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"restored_chunks": 5,
|
||||
"document_id": "制度.pdf",
|
||||
"collection": "public_kb"
|
||||
}
|
||||
```
|
||||
|
||||
### 四、后端需要注意的
|
||||
|
||||
1. **chunk_id 必须配合 collection 使用** — chunk_id 格式是 `{文件名}_{序号}`,不同向量库中同名文件的 chunk_id 相同,存数据库时必须用 `(collection, chunk_id)` 做联合键。
|
||||
2. **citation 的 content 截断至 300 字** — 需要完整内容请调切片查询接口。
|
||||
3. **同名文件上传是覆盖模式** — 不再加时间戳重命名,直接覆盖。
|
||||
|
||||
### 五、已知限制(后端需规避)
|
||||
|
||||
以下场景当前未做特殊处理,后端在业务层需要注意规避:
|
||||
|
||||
1. **不要并发上传同名文件** — 两个请求同时上传同名文件到同一知识库可能产生竞态,导致版本记录或切片数据不一致。如有并发场景,请在业务层加锁或排队。
|
||||
|
||||
2. **superseded 文档不可恢复** — 被覆盖上传替代的旧版本(状态 `superseded`),其切片已从向量库中物理删除,无法通过恢复接口还原。恢复接口仅支持恢复手动废止(`deprecated`)的文档。对 superseded 文档调恢复接口会返回 `"未找到已废止的文档"`。
|
||||
|
||||
3. **批量上传中不要包含同名文件** — 如果一次批量上传请求中包含两个同名文件,第一个会被第二个无意义地覆盖。请确保单次批量上传中文件名不重复。
|
||||
@@ -19,69 +19,52 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def cleanup_superseded_versions(days_to_keep: int = 7) -> int:
|
||||
"""
|
||||
清理超过指定天数的 superseded 版本
|
||||
清理超过保留期的 superseded 版本记录(SQLite)
|
||||
|
||||
按新设计,superseded 切片在 ChromaDB 中已不存在(由 Phase 3 去重删除),
|
||||
此函数只清理 SQLite 中的旧版本记录和变更日志。
|
||||
|
||||
Args:
|
||||
days_to_keep: 保留天数,默认7天
|
||||
|
||||
Returns:
|
||||
清理的 chunk 数量
|
||||
清理的版本记录数量
|
||||
"""
|
||||
from knowledge.manager import get_kb_manager
|
||||
|
||||
kb_manager = get_kb_manager()
|
||||
cutoff_date = (datetime.now() - timedelta(days=days_to_keep)).isoformat()
|
||||
|
||||
logger.info(f"开始清理 superseded 版本(保留 {days_to_keep} 天内的)")
|
||||
logger.info(f"开始清理 superseded 版本记录(保留 {days_to_keep} 天内的)")
|
||||
|
||||
# 获取所有向量库
|
||||
try:
|
||||
kb_names = kb_manager.list_collections()
|
||||
from data.db import get_connection
|
||||
|
||||
with get_connection("knowledge") as conn:
|
||||
# 清理超期的 superseded 版本记录
|
||||
cursor = conn.execute("""
|
||||
DELETE FROM document_versions
|
||||
WHERE status = 'superseded' AND deprecated_date < ?
|
||||
""", (cutoff_date,))
|
||||
cleaned_versions = cursor.rowcount
|
||||
|
||||
# 清理超期的变更日志
|
||||
cursor2 = conn.execute("""
|
||||
DELETE FROM version_change_logs
|
||||
WHERE created_at < ?
|
||||
""", (cutoff_date,))
|
||||
cleaned_logs = cursor2.rowcount
|
||||
|
||||
conn.commit()
|
||||
|
||||
total_cleaned = cleaned_versions + cleaned_logs
|
||||
logger.info(
|
||||
f"清理完成: {cleaned_versions} 条版本记录, "
|
||||
f"{cleaned_logs} 条变更日志"
|
||||
)
|
||||
return cleaned_versions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取向量库列表失败: {e}")
|
||||
logger.error(f"清理 superseded 版本记录失败: {e}")
|
||||
return 0
|
||||
|
||||
total_cleaned = 0
|
||||
|
||||
for kb_name in kb_names:
|
||||
try:
|
||||
collection = kb_manager.get_collection(kb_name)
|
||||
if not collection:
|
||||
continue
|
||||
|
||||
# 查询超过保留期的 superseded chunks
|
||||
# 注意:ChromaDB 的 where 过滤可能不支持 $lt 操作符
|
||||
# 所以我们先获取所有 superseded chunks,然后在 Python 中过滤
|
||||
result = collection.get(
|
||||
where={"status": "superseded"}
|
||||
)
|
||||
|
||||
if not result['ids']:
|
||||
continue
|
||||
|
||||
# 在 Python 中过滤超过保留期的 chunks
|
||||
ids_to_delete = []
|
||||
for i, meta in enumerate(result['metadatas']):
|
||||
superseded_time = meta.get('superseded_time', '')
|
||||
if superseded_time and superseded_time < cutoff_date:
|
||||
ids_to_delete.append(result['ids'][i])
|
||||
|
||||
if ids_to_delete:
|
||||
# 删除这些 chunks
|
||||
collection.delete(ids=ids_to_delete)
|
||||
total_cleaned += len(ids_to_delete)
|
||||
logger.info(f"清理 {kb_name}: {len(ids_to_delete)} chunks")
|
||||
|
||||
# 重建 BM25 索引
|
||||
kb_manager.rebuild_bm25_index(kb_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理 {kb_name} 失败: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"清理完成,共删除 {total_cleaned} 个 superseded chunks")
|
||||
return total_cleaned
|
||||
|
||||
|
||||
def cleanup_deprecated_versions(days_to_keep: int = 30) -> int:
|
||||
"""
|
||||
|
||||
@@ -299,6 +299,29 @@ class CollectionMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
# 清理该向量库的 SQLite 版本记录和变更日志
|
||||
try:
|
||||
from data.db import get_connection
|
||||
with get_connection("knowledge") as conn:
|
||||
cursor1 = conn.execute(
|
||||
"DELETE FROM document_versions WHERE collection = ?",
|
||||
(kb_name,)
|
||||
)
|
||||
cursor2 = conn.execute(
|
||||
"DELETE FROM version_change_logs WHERE collection = ?",
|
||||
(kb_name,)
|
||||
)
|
||||
conn.commit()
|
||||
ver_cleaned = cursor1.rowcount
|
||||
log_cleaned = cursor2.rowcount
|
||||
if ver_cleaned > 0 or log_cleaned > 0:
|
||||
logger.info(
|
||||
f"清理向量库版本记录: {kb_name}, "
|
||||
f"版本 {ver_cleaned} 条, 日志 {log_cleaned} 条"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
if kb_name in self._metadata.get("collections", {}):
|
||||
del self._metadata["collections"][kb_name]
|
||||
self._save_metadata()
|
||||
|
||||
@@ -43,7 +43,7 @@ class DocumentMixin:
|
||||
]
|
||||
|
||||
def delete_document(self, kb_name: str, filename: str) -> int:
|
||||
"""从向量库删除文档"""
|
||||
"""从向量库删除文档,并清理 SQLite 版本记录"""
|
||||
collection = self.get_collection(kb_name)
|
||||
if not collection:
|
||||
return 0
|
||||
@@ -56,6 +56,22 @@ class DocumentMixin:
|
||||
collection.delete(ids=result['ids'])
|
||||
deleted = len(result['ids'])
|
||||
|
||||
# 清理 SQLite 版本记录和变更日志
|
||||
try:
|
||||
from data.db import get_connection
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM document_versions WHERE collection = ? AND document_id = ?",
|
||||
(kb_name, filename)
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM version_change_logs WHERE collection = ? AND document_id = ?",
|
||||
(kb_name, filename)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
logger.info(f"从 {kb_name} 删除文档: {filename}, 片段数: {deleted}")
|
||||
return deleted
|
||||
|
||||
@@ -66,7 +82,7 @@ class DocumentMixin:
|
||||
reason: str = "制度废止",
|
||||
deprecated_by: str = ""
|
||||
) -> Dict:
|
||||
"""软删除文档 - 将chunks状态标记为deprecated"""
|
||||
"""软删除文档 - 将chunks状态标记为deprecated,并同步 SQLite 版本记录"""
|
||||
collection = self.get_collection(kb_name)
|
||||
if not collection:
|
||||
return {"success": False, "error": "向量库不存在"}
|
||||
@@ -97,6 +113,30 @@ class DocumentMixin:
|
||||
|
||||
logger.info(f"软删除文档: {kb_name}/{filename}, chunks: {len(result['ids'])}, 原因: {reason}")
|
||||
|
||||
# 同步 SQLite 版本记录
|
||||
try:
|
||||
from knowledge.document_versions import get_version_query
|
||||
from data.db import get_connection
|
||||
vq = get_version_query()
|
||||
active = vq.get_active_version(kb_name, filename)
|
||||
if active:
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute("""
|
||||
UPDATE document_versions
|
||||
SET status='deprecated', deprecated_date=?, deprecated_reason=?
|
||||
WHERE collection=? AND document_id=? AND version=?
|
||||
""", (deprecated_date, reason, kb_name, filename, active.version))
|
||||
conn.commit()
|
||||
vq.log_version_change(
|
||||
kb_name, filename,
|
||||
change_type="deprecate",
|
||||
old_version=active.version,
|
||||
old_status="active", new_status="deprecated",
|
||||
reason=reason, changed_by=deprecated_by
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步版本记录失败: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"deprecated_chunks": len(result['ids']),
|
||||
@@ -106,7 +146,7 @@ class DocumentMixin:
|
||||
}
|
||||
|
||||
def restore_document(self, kb_name: str, filename: str) -> Dict:
|
||||
"""恢复已废止的文档"""
|
||||
"""恢复已废止的文档,并同步 SQLite 版本记录"""
|
||||
collection = self.get_collection(kb_name)
|
||||
if not collection:
|
||||
return {"success": False, "error": "向量库不存在"}
|
||||
@@ -142,6 +182,32 @@ class DocumentMixin:
|
||||
|
||||
logger.info(f"恢复文档: {kb_name}/{filename}, chunks: {len(result['ids'])}")
|
||||
|
||||
# 同步 SQLite 版本记录
|
||||
try:
|
||||
from knowledge.document_versions import get_version_query
|
||||
from data.db import get_connection
|
||||
vq = get_version_query()
|
||||
history = vq.get_document_history(kb_name, filename)
|
||||
deprecated_ver = next(
|
||||
(v for v in history if v.status.value == 'deprecated'), None
|
||||
)
|
||||
if deprecated_ver:
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute("""
|
||||
UPDATE document_versions
|
||||
SET status='active', deprecated_date=NULL, deprecated_reason=NULL
|
||||
WHERE collection=? AND document_id=? AND version=?
|
||||
""", (kb_name, filename, deprecated_ver.version))
|
||||
conn.commit()
|
||||
vq.log_version_change(
|
||||
kb_name, filename,
|
||||
change_type="restore",
|
||||
old_version=deprecated_ver.version,
|
||||
old_status="deprecated", new_status="active"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步版本记录失败: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"restored_chunks": len(result['ids']),
|
||||
|
||||
@@ -215,6 +215,38 @@ class KnowledgeBaseManager(
|
||||
# 合并跨页表格
|
||||
chunks = self._merge_cross_page_tables(chunks)
|
||||
|
||||
filename = Path(filepath).name
|
||||
|
||||
# 入库前清理同名旧切片,防止重复上传导致新旧切片共存
|
||||
existing = collection.get(where={"source": filename})
|
||||
if existing and existing['ids']:
|
||||
old_count = len(existing['ids'])
|
||||
collection.delete(ids=existing['ids'])
|
||||
logger.info(f"替换模式: 清理旧切片 {filename} -> {kb_name}, 共 {old_count} 个")
|
||||
|
||||
# 清理关联的 DocStore 文件
|
||||
try:
|
||||
docstore_dir = Path('.data/docstore')
|
||||
if docstore_dir.exists():
|
||||
for ds_file in docstore_dir.glob(f'{kb_name}_{filename}_*.json'):
|
||||
ds_file.unlink()
|
||||
logger.debug(f"清理 DocStore: {ds_file.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理 DocStore 失败: {e}")
|
||||
|
||||
# 重建 BM25 索引(移除旧条目)
|
||||
self._bm25_indexes.pop(kb_name, None)
|
||||
bm25 = self.get_bm25_index(kb_name)
|
||||
if bm25:
|
||||
remaining = collection.get(include=["documents", "metadatas"])
|
||||
if remaining['ids']:
|
||||
bm25.add_documents(
|
||||
remaining['ids'],
|
||||
remaining['documents'] or [],
|
||||
remaining['metadatas'] or []
|
||||
)
|
||||
self.save_bm25_index(kb_name)
|
||||
|
||||
# 准备向量模型
|
||||
if embedding_model is None:
|
||||
from core.engine import get_engine
|
||||
@@ -229,7 +261,6 @@ class KnowledgeBaseManager(
|
||||
metadatas = []
|
||||
embeddings = []
|
||||
|
||||
filename = Path(filepath).name
|
||||
doc_type = _get_doc_type(filename)
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
@@ -622,50 +653,73 @@ class KnowledgeBaseManager(
|
||||
def mark_document_as_superseded(
|
||||
self,
|
||||
kb_name: str,
|
||||
old_filename: str,
|
||||
new_filename: str,
|
||||
filename: str,
|
||||
new_version: str = "",
|
||||
reason: str = "版本更新"
|
||||
) -> Dict:
|
||||
"""标记文档为已替代版本"""
|
||||
collection = self.get_collection(kb_name)
|
||||
if not collection:
|
||||
return {"success": False, "error": "向量库不存在"}
|
||||
"""
|
||||
标记文档旧版本为已替代(仅更新 SQLite 版本记录)
|
||||
|
||||
result = collection.get(where={"source": old_filename})
|
||||
ChromaDB 中的旧切片由 Phase 3 去重逻辑自动清理,
|
||||
此方法只负责在 document_versions 表中将 active 版本改为 superseded。
|
||||
|
||||
if not result['ids']:
|
||||
return {"success": False, "error": "旧文档不存在"}
|
||||
Args:
|
||||
kb_name: 向量库名称
|
||||
filename: 文件名
|
||||
new_version: 新版本号(如 "v2"),用于日志记录
|
||||
reason: 替代原因
|
||||
|
||||
Returns:
|
||||
{"success": True, "superseded_version": "v1"}
|
||||
"""
|
||||
from datetime import datetime
|
||||
superseded_date = datetime.now().isoformat()
|
||||
try:
|
||||
from knowledge.document_versions import get_version_query
|
||||
from data.db import get_connection
|
||||
|
||||
updated_metadatas = [
|
||||
{
|
||||
**m,
|
||||
"status": "superseded",
|
||||
"superseded_by": new_filename,
|
||||
"superseded_date": superseded_date,
|
||||
"superseded_reason": reason
|
||||
vq = get_version_query()
|
||||
active = vq.get_active_version(kb_name, filename)
|
||||
if not active:
|
||||
return {"success": True, "superseded_version": None,
|
||||
"message": "无 active 版本需要标记"}
|
||||
|
||||
superseded_date = datetime.now().isoformat()
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute("""
|
||||
UPDATE document_versions
|
||||
SET status = 'superseded',
|
||||
deprecated_date = ?,
|
||||
deprecated_reason = ?
|
||||
WHERE collection = ? AND document_id = ? AND version = ?
|
||||
""", (superseded_date, reason,
|
||||
kb_name, filename, active.version))
|
||||
conn.commit()
|
||||
|
||||
# 记录变更日志
|
||||
vq.log_version_change(
|
||||
kb_name, filename,
|
||||
change_type="supersede",
|
||||
old_version=active.version,
|
||||
new_version=new_version,
|
||||
old_status="active",
|
||||
new_status="superseded",
|
||||
reason=reason
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"标记版本替代: {kb_name}/{filename} "
|
||||
f"{active.version} -> {new_version or '(待创建)'}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"superseded_version": active.version,
|
||||
"new_version": new_version,
|
||||
"collection": kb_name
|
||||
}
|
||||
for m in result['metadatas']
|
||||
]
|
||||
|
||||
collection.update(
|
||||
ids=result['ids'],
|
||||
metadatas=updated_metadatas
|
||||
)
|
||||
|
||||
self.rebuild_bm25_index(kb_name)
|
||||
|
||||
logger.info(f"标记文档替代: {old_filename} -> {new_filename}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"superseded_chunks": len(result['ids']),
|
||||
"old_document": old_filename,
|
||||
"new_document": new_filename,
|
||||
"collection": kb_name
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"标记替代版本失败: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ==================== 辅助检索方法 ====================
|
||||
|
||||
|
||||
@@ -342,7 +342,10 @@ class ProcessingMixin:
|
||||
"meta": metadata
|
||||
}
|
||||
|
||||
doc_path = docstore_dir / f"{doc_id}.json"
|
||||
# 使用 collection/doc_id 组合路径,防止跨库同名文件覆盖
|
||||
coll = metadata.get('collection', '')
|
||||
safe_id = f"{coll}_{doc_id}" if coll else doc_id
|
||||
doc_path = docstore_dir / f"{safe_id}.json"
|
||||
with open(doc_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -371,7 +374,10 @@ class ProcessingMixin:
|
||||
"meta": metadata
|
||||
}
|
||||
|
||||
doc_path = docstore_dir / f"{doc_id}.json"
|
||||
# 使用 collection/doc_id 组合路径,防止跨库同名文件覆盖
|
||||
coll = metadata.get('collection', '')
|
||||
safe_id = f"{coll}_{doc_id}" if coll else doc_id
|
||||
doc_path = docstore_dir / f"{safe_id}.json"
|
||||
with open(doc_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@@ -284,11 +284,13 @@ class SearchMixin:
|
||||
|
||||
all_items.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
# 使用 (collection, id) 复合键去重,防止跨库同名文件的结果被吞
|
||||
seen = set()
|
||||
unique_items = []
|
||||
for item in all_items:
|
||||
if item['id'] not in seen:
|
||||
seen.add(item['id'])
|
||||
composite_key = (item['collection'], item['id'])
|
||||
if composite_key not in seen:
|
||||
seen.add(composite_key)
|
||||
unique_items.append(item)
|
||||
|
||||
unique_items = unique_items[:top_k]
|
||||
|
||||
1806
knowledge/sync.py
1806
knowledge/sync.py
File diff suppressed because it is too large
Load Diff
25
test_files/parse_rag.py
Normal file
25
test_files/parse_rag.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
with open(r'C:\Users\qq318\Desktop\rag-agent\test_files\rag_output.txt', 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith('data: ') and '"type": "finish"' in line:
|
||||
data = json.loads(line[6:])
|
||||
citations = data.get('citations', [])
|
||||
print(f'Citation count: {len(citations)}')
|
||||
for i, c in enumerate(citations):
|
||||
coll = c.get('collection', '')
|
||||
cid = c.get('chunk_id', '')
|
||||
src = c.get('source', '')
|
||||
preview = c.get('content', '')[:80]
|
||||
print(f' [{i+1}] collection={coll}, chunk_id={cid}, source={src}')
|
||||
print(f' content: {preview}...')
|
||||
print()
|
||||
answer = data.get('answer', '')
|
||||
refs = re.findall(r'\[ref:([^\]]+)\]', answer)
|
||||
print(f'Ref tags in answer: {refs}')
|
||||
print(f'Answer preview: {answer[:200]}...')
|
||||
68
test_files/rag_output.txt
Normal file
68
test_files/rag_output.txt
Normal file
@@ -0,0 +1,68 @@
|
||||
data: {"type": "intent_result", "data": {"intent": "factual", "confidence": 0.9, "rewritten_query": "迟到处罚有什么规定", "sub_queries": ["迟到处罚有什么规定"], "need_retrieval": true, "reason": "问题询问迟到处罚的具体规定,属于事实查询,需要从知识库检索相关信息。"}}
|
||||
|
||||
data: {"type": "start", "message": "正在检索知识库..."}
|
||||
|
||||
data: {"type": "retrieval_debug", "data": {"steps": [{"name": "multi_kb_search", "collections": ["dept_a_kb", "dept_b_kb"]}, {"name": "rrf_fusion", "count": 2, "inputs": 2}, {"name": "mmr_dedup", "before": 2, "after": 2}, {"name": "rerank", "count": 2}, {"name": "context_expansion", "before": 2, "after": 2}], "collections_searched": ["dept_a_kb", "dept_b_kb"], "total_candidates": 2}}
|
||||
|
||||
data: {"type": "chunks_retrieved", "data": {"count": 2, "chunks": [{"rank": 1, "source": "rule_a.txt", "page": 1, "chunk_type": "text", "section": "", "score": 0.6388, "content": "部门B考勤管理制度\n\n第一条 工作时间\n部门B实行弹性工时制,核心工作时间为10:00-16:00,其余时间自由安排。\n\n第二条 迟到处罚\n部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。\n\n第三条 加班规定\n部门B鼓励高效工作不提倡加班,如需加班可调休补偿。\n\n第四条 请假流程\n部门B员工请假通过钉钉申请,5天以内直属主管审批即可。\n\n第五条 特殊条款\n部门B因研发性质,每周五下午为技术分享日,不计入考勤。"}, {"rank": 2, "source": "rule_a.txt", "page": 1, "chunk_type": "text", "section": "", "score": 0.6258, "content": "部门A考勤管理制度(2026年修订版)\n\n第一条 工作时间(已更新)\n部门A自2026年起实行混合办公制,每周一三五到岗,周二四可远程办公。\n核心工作时间10:00-16:00必须在岗或在线。\n\n第二条 迟到处罚(已更新)\n部门A员工迟到超过10分钟,第一次口头警告,第二次扣除当日午餐补贴。\n月度累计迟到3次以上需参加时间管理培训。\n\n第三条 加班规定(已更新)\n部门A加班实行积分制,每小时积1分,满10分可兑换1天调休。\n季度积分不清零,年度清零。\n\n第四条 年假制度(新增条款)\n部门A员工入职满1年享5天年假,满3年享10天年假,满5年享15天年假。\n年假需提前一周申请,不可跨年累积。\n\n"}]}}
|
||||
|
||||
data: {"type": "sources", "sources": [{"source": "rule_a.txt", "page": null, "page_end": null, "page_range": "", "section": "", "chunk_type": "text", "doc_type": "other", "section_chunk_id": null, "score": 0.639}]}
|
||||
|
||||
data: {"type": "images_selected", "data": {"total_scored": 0, "selected_count": 0, "images": []}}
|
||||
|
||||
data: {"type": "context_built", "data": {"chunk_count": 2, "context_length": 578, "budget_max_chars": 8000, "min_score_filter": 0.05, "confidence_top3": 0.6323, "score_stats": {"max": 0.6388, "min": 0.6258, "avg": 0.6323}, "context_preview": "部门B考勤管理制度\n\n第一条 工作时间\n部门B实行弹性工时制,核心工作时间为10:00-16:00,其余时间自由安排。\n\n第二条 迟到处罚\n部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。\n\n第三条 加班规定\n部门B鼓励高效工作不提倡加班,如需加班可调休补偿。\n\n第四条 请假流程\n部门B员工请假通过钉钉申请,5天以内直属主管审批即可。\n\n第五条 特殊条款\n部门B因研发性质,每周五下午为技术分享日,不计入考勤。\n\n部门A考勤管理制度(2026年修订版)\n\n第一条 工作时间(已更新)\n部门A自2026年起实行混合办公制,每周一三五到岗,周二四可远程办公。\n核心工作时间10:00-16:00必须在岗或在线。\n\n第二条 迟到处罚(已更新)\n部门A员工迟到超过10分钟,第一次口头警告,第二次扣除当日午餐补贴。\n月度累计迟到3次以上需参加时间管理培训。\n\n第三条 加班规定(已更新)\n部门A加班实行积分制,每小时积1分,满10分可兑换1天调休。\n季度积分不清零,年度清零。\n\n第四条 年假制度(新增条款)\n部门A员工入职满1年享5天年假,满3年享10天年假,满5年享15天年假。\n年假需提前一", "chunks_used": [{"source": "rule_a.txt", "page": 1, "score": 0.6387675809081186, "preview": "部门B考勤管理制度\n\n第一条 工作时间\n部门B实行弹性工时制,核心工作时间为10:00-16:00,其余时间自由安排。\n\n第二条 迟到处罚\n部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。\n"}, {"source": "rule_a.txt", "page": 1, "score": 0.6258080932992242, "preview": "部门A考勤管理制度(2026年修订版)\n\n第一条 工作时间(已更新)\n部门A自2026年起实行混合办公制,每周一三五到岗,周二四可远程办公。\n核心工作时间10:00-16:00必须在岗或在线。\n\n第二"}]}}
|
||||
|
||||
data: {"type": "chunk", "content": "根据提供的"}
|
||||
|
||||
data: {"type": "chunk", "content": "参考资料,部门"}
|
||||
|
||||
data: {"type": "chunk", "content": "A和部门"}
|
||||
|
||||
data: {"type": "chunk", "content": "B的"}
|
||||
|
||||
data: {"type": "chunk", "content": "迟到处罚规定如下"}
|
||||
|
||||
data: {"type": "chunk", "content": ":\n\n**"}
|
||||
|
||||
data: {"type": "chunk", "content": "部门B的"}
|
||||
|
||||
data: {"type": "chunk", "content": "迟到处罚规定**"}
|
||||
|
||||
data: {"type": "chunk", "content": ":\n"}
|
||||
|
||||
data: {"type": "chunk", "content": "部门B不设"}
|
||||
|
||||
data: {"type": "chunk", "content": "迟到处罚,但"}
|
||||
|
||||
data: {"type": "chunk", "content": "月度累计迟到"}
|
||||
|
||||
data: {"type": "chunk", "content": "超过5次需"}
|
||||
|
||||
data: {"type": "chunk", "content": "提交书面说明。"}
|
||||
|
||||
data: {"type": "chunk", "content": "[1]\n\n**"}
|
||||
|
||||
data: {"type": "chunk", "content": "部门A的迟到"}
|
||||
|
||||
data: {"type": "chunk", "content": "处罚规定**:\n"}
|
||||
|
||||
data: {"type": "chunk", "content": "部门A员工迟到"}
|
||||
|
||||
data: {"type": "chunk", "content": "超过10分钟"}
|
||||
|
||||
data: {"type": "chunk", "content": ",第一次口头警告"}
|
||||
|
||||
data: {"type": "chunk", "content": ",第二次扣除当日"}
|
||||
|
||||
data: {"type": "chunk", "content": "午餐补贴。月"}
|
||||
|
||||
data: {"type": "chunk", "content": "度累计迟到3"}
|
||||
|
||||
data: {"type": "chunk", "content": "次以上需参加"}
|
||||
|
||||
data: {"type": "chunk", "content": "时间管理培训。"}
|
||||
|
||||
data: {"type": "chunk", "content": "[2]"}
|
||||
|
||||
data: {"type": "finish", "answer": "根据提供的参考资料,部门A和部门B的迟到处罚规定如下:\n\n**部门B的迟到处罚规定**:\n部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。[ref:rule_a.txt_0]\n\n**部门A的迟到处罚规定**:\n部门A员工迟到超过10分钟,第一次口头警告,第二次扣除当日午餐补贴。月度累计迟到3次以上需参加时间管理培训。[ref:rule_a.txt_0]", "mode": "rag", "session_id": "e3e3bcf5-b350-4c62-bfbe-396066641e27", "sources": [{"source": "rule_a.txt", "page": null, "page_end": null, "page_range": "", "section": "", "chunk_type": "text", "doc_type": "other", "section_chunk_id": null, "score": 0.639}], "citations": [{"chunk_id": "rule_a.txt_0", "chunk_index": 0, "source": "rule_a.txt", "collection": "dept_b_kb", "doc_type": "other", "section": "", "preview": "", "content": "部门B考勤管理制度\n\n第一条 工作时间\n部门B实行弹性工时制,核心工作时间为10:00-16:00,其余时间自由安排。\n\n第二条 迟到处罚\n部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。\n\n第三条 加班规定\n部门B鼓励高效工作不提倡加班,如需加班可调休补偿。\n\n第四条 请假流程\n部门B员工请假通过钉钉申请,5天以内直属主管审批即可。\n\n第五条 特殊条款\n部门B因研发性质,每周五下午为技术分享日,不计入考勤。", "chunk_type": "text", "page": 1, "page_end": 1, "bbox": null, "bbox_mode": null}, {"chunk_id": "rule_a.txt_0", "chunk_index": 0, "source": "rule_a.txt", "collection": "dept_a_kb", "doc_type": "other", "section": "", "preview": "", "content": "部门A考勤管理制度(2026年修订版)\n\n第一条 工作时间(已更新)\n部门A自2026年起实行混合办公制,每周一三五到岗,周二四可远程办公。\n核心工作时间10:00-16:00必须在岗或在线。\n\n第二条 迟到处罚(已更新)\n部门A员工迟到超过10分钟,第一次口头警告,第二次扣除当日午餐补贴。\n月度累计迟到3次以上需参加时间管理培训。\n\n第三条 加班规定(已更新)\n部门A加班实行积分制,每小时积1分,满10分可兑换1天调休。\n季度积分不清零,年度清零。\n\n第四条 年假制度(新增条款)\n部门A员工入职满1年享5天年假,满3年享10天年假,满5年享15天年假。\n年假需提前一周申请,不可跨年累积。\n\n", "chunk_type": "text", "page": 1, "page_end": 1, "bbox": null, "bbox_mode": null}], "images": [], "tables": [], "sections": [], "duration_ms": 6048, "confidence_score": 0.6323, "timing": {"total_search_ms": 88, "rerank_ms": 0, "rerank_cached": false, "total_ms": 6048}}
|
||||
|
||||
1
test_files/rag_test2.json
Normal file
1
test_files/rag_test2.json
Normal file
@@ -0,0 +1 @@
|
||||
{"message":"迟到处罚有什么规定","collections":["dept_a_kb","dept_b_kb"],"chat_history":[]}
|
||||
21
test_files/rule_a.txt
Normal file
21
test_files/rule_a.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
部门A考勤管理制度(2026年修订版)
|
||||
|
||||
第一条 工作时间(已更新)
|
||||
部门A自2026年起实行混合办公制,每周一三五到岗,周二四可远程办公。
|
||||
核心工作时间10:00-16:00必须在岗或在线。
|
||||
|
||||
第二条 迟到处罚(已更新)
|
||||
部门A员工迟到超过10分钟,第一次口头警告,第二次扣除当日午餐补贴。
|
||||
月度累计迟到3次以上需参加时间管理培训。
|
||||
|
||||
第三条 加班规定(已更新)
|
||||
部门A加班实行积分制,每小时积1分,满10分可兑换1天调休。
|
||||
季度积分不清零,年度清零。
|
||||
|
||||
第四条 年假制度(新增条款)
|
||||
部门A员工入职满1年享5天年假,满3年享10天年假,满5年享15天年假。
|
||||
年假需提前一周申请,不可跨年累积。
|
||||
|
||||
第五条 远程办公规范(新增条款)
|
||||
远程办公期间需保持钉钉在线状态,每日10:00前完成签到打卡。
|
||||
远程工作日需提交日报至直属主管。
|
||||
21
test_files/rule_a_v2.txt
Normal file
21
test_files/rule_a_v2.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
部门A考勤管理制度(2026年修订版)
|
||||
|
||||
第一条 工作时间(已更新)
|
||||
部门A自2026年起实行混合办公制,每周一三五到岗,周二四可远程办公。
|
||||
核心工作时间10:00-16:00必须在岗或在线。
|
||||
|
||||
第二条 迟到处罚(已更新)
|
||||
部门A员工迟到超过10分钟,第一次口头警告,第二次扣除当日午餐补贴。
|
||||
月度累计迟到3次以上需参加时间管理培训。
|
||||
|
||||
第三条 加班规定(已更新)
|
||||
部门A加班实行积分制,每小时积1分,满10分可兑换1天调休。
|
||||
季度积分不清零,年度清零。
|
||||
|
||||
第四条 年假制度(新增条款)
|
||||
部门A员工入职满1年享5天年假,满3年享10天年假,满5年享15天年假。
|
||||
年假需提前一周申请,不可跨年累积。
|
||||
|
||||
第五条 远程办公规范(新增条款)
|
||||
远程办公期间需保持钉钉在线状态,每日10:00前完成签到打卡。
|
||||
远程工作日需提交日报至直属主管。
|
||||
16
test_files/规章制度.txt
Normal file
16
test_files/规章制度.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
部门A考勤管理制度
|
||||
|
||||
第一条 工作时间
|
||||
部门A实行标准工时制,每日工作8小时,上午9:00至下午18:00。
|
||||
|
||||
第二条 迟到处罚
|
||||
部门A员工迟到超过15分钟,扣除当日绩效奖金的20%。
|
||||
|
||||
第三条 加班规定
|
||||
部门A加班需提前申请主管审批,加班费按1.5倍时薪计算。
|
||||
|
||||
第四条 请假流程
|
||||
部门A员工请假需填写OA系统申请,3天以内主管审批,3天以上经理审批。
|
||||
|
||||
第五条 特殊条款
|
||||
部门A因业务特殊性,每月允许2次弹性工作制。
|
||||
16
test_files/规章制度_deptA.txt
Normal file
16
test_files/规章制度_deptA.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
部门A考勤管理制度
|
||||
|
||||
第一条 工作时间
|
||||
部门A实行标准工时制,每日工作8小时,上午9:00至下午18:00。
|
||||
|
||||
第二条 迟到处罚
|
||||
部门A员工迟到超过15分钟,扣除当日绩效奖金的20%。
|
||||
|
||||
第三条 加班规定
|
||||
部门A加班需提前申请主管审批,加班费按1.5倍时薪计算。
|
||||
|
||||
第四条 请假流程
|
||||
部门A员工请假需填写OA系统申请,3天以内主管审批,3天以上经理审批。
|
||||
|
||||
第五条 特殊条款
|
||||
部门A因业务特殊性,每月允许2次弹性工作制。
|
||||
16
test_files/规章制度_deptB.txt
Normal file
16
test_files/规章制度_deptB.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
部门B考勤管理制度
|
||||
|
||||
第一条 工作时间
|
||||
部门B实行弹性工时制,核心工作时间为10:00-16:00,其余时间自由安排。
|
||||
|
||||
第二条 迟到处罚
|
||||
部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。
|
||||
|
||||
第三条 加班规定
|
||||
部门B鼓励高效工作不提倡加班,如需加班可调休补偿。
|
||||
|
||||
第四条 请假流程
|
||||
部门B员工请假通过钉钉申请,5天以内直属主管审批即可。
|
||||
|
||||
第五条 特殊条款
|
||||
部门B因研发性质,每周五下午为技术分享日,不计入考勤。
|
||||
419
tests/e2e_risk_test.py
Normal file
419
tests/e2e_risk_test.py
Normal file
@@ -0,0 +1,419 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
端到端风险测试脚本 - 直接调用运行中的服务 API
|
||||
|
||||
测试场景:
|
||||
1. 上传同名文件覆盖 → 版本记录是否正确(P0 修复验证)
|
||||
2. 废止文档 → 切片状态是否正确标记(Part 4 修复验证)
|
||||
3. 废止后检索 → 废止文档不应出现在结果中(过滤逻辑验证)
|
||||
4. 恢复文档 → 切片状态应恢复为 active
|
||||
5. SQLite/ChromaDB 状态一致性
|
||||
6. 边缘操作:重复废止、恢复非废止文档等
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
API = "http://localhost:5001"
|
||||
KB = "test1" # 使用隔离的测试知识库
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" [PASS] {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" [FAIL] {name} {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
def upload_file(collection, filename, content):
|
||||
"""上传文件到指定知识库"""
|
||||
# 创建临时文件
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
filepath = os.path.join(tmpdir, filename)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
with open(filepath, 'rb') as f:
|
||||
resp = requests.post(
|
||||
f"{API}/documents/upload",
|
||||
files={"file": (filename, f, "text/plain")},
|
||||
data={"collection": collection}
|
||||
)
|
||||
# 清理临时文件
|
||||
os.remove(filepath)
|
||||
os.rmdir(tmpdir)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def get_doc_chunks(collection, filename):
|
||||
"""获取文档的切片信息"""
|
||||
resp = requests.get(
|
||||
f"{API}/documents/{collection}/{filename}/chunks"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return {"error": resp.text, "chunks": []}
|
||||
|
||||
|
||||
def get_doc_status(collection, filename):
|
||||
"""获取文档状态"""
|
||||
resp = requests.get(
|
||||
f"{API}/documents/{collection}/{filename}/status"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return {"error": resp.text}
|
||||
|
||||
|
||||
def get_version_history(collection, filename):
|
||||
"""获取版本历史"""
|
||||
resp = requests.get(
|
||||
f"{API}/collections/{collection}/documents/{filename}/versions"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return {"error": resp.text, "versions": []}
|
||||
|
||||
|
||||
def deprecate_doc(collection, filename, reason="测试废止"):
|
||||
"""废止文档"""
|
||||
resp = requests.post(
|
||||
f"{API}/collections/{collection}/documents/{filename}/deprecate",
|
||||
json={"reason": reason}
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def restore_doc(collection, filename):
|
||||
"""恢复文档"""
|
||||
resp = requests.post(
|
||||
f"{API}/collections/{collection}/documents/{filename}/restore"
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def delete_doc(collection, filename):
|
||||
"""删除文档"""
|
||||
resp = requests.delete(
|
||||
f"{API}/documents/{collection}/{filename}"
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def rag_query(collection, query):
|
||||
"""发送 RAG 查询"""
|
||||
resp = requests.post(
|
||||
f"{API}/rag",
|
||||
json={
|
||||
"question": query,
|
||||
"collection": collection,
|
||||
"stream": False
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return {"error": resp.text}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 清理:确保 test1 知识库是干净的
|
||||
# ======================================================================
|
||||
print("\n=== 准备:清理 test1 知识库 ===")
|
||||
list_resp = requests.get(f"{API}/documents/list?collection={KB}").json()
|
||||
for doc in list_resp.get("documents", []):
|
||||
src = doc.get("source", "")
|
||||
if src:
|
||||
delete_doc(KB, src)
|
||||
print(f" 清理旧文档: {src}")
|
||||
time.sleep(1)
|
||||
print(" 知识库已清理")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 风险场景 1:上传同名文件覆盖 → 版本记录
|
||||
# ======================================================================
|
||||
print("\n=== 风险场景 1:上传覆盖 → SQLite 版本记录 ===")
|
||||
|
||||
# 1a. 上传 v1
|
||||
v1_content = "这是版本测试文档的第一版内容。\n包含一些独特的v1信息用于后续检索验证。\nAlpha Bravo Charlie Delta."
|
||||
r1 = upload_file(KB, "version_test.txt", v1_content)
|
||||
check("v1 上传成功", r1.get("success") is True or "chunks" in str(r1),
|
||||
f"resp: {json.dumps(r1, ensure_ascii=False)[:200]}")
|
||||
time.sleep(1)
|
||||
|
||||
# 1b. 检查 v1 的 chunks
|
||||
chunks_v1 = get_doc_chunks(KB, "version_test.txt")
|
||||
v1_chunks_list = chunks_v1.get("chunks", [])
|
||||
check("v1 有切片",
|
||||
len(v1_chunks_list) > 0,
|
||||
f"chunks count: {len(v1_chunks_list)}")
|
||||
if v1_chunks_list:
|
||||
first_meta = v1_chunks_list[0].get("metadata", {})
|
||||
check("v1 切片 status=active",
|
||||
first_meta.get("status") == "active",
|
||||
f"status: {first_meta.get('status')}")
|
||||
|
||||
# 1c. 上传 v2(同名覆盖)
|
||||
v2_content = "这是版本测试文档的第二版内容。\nv2版本包含了全新的信息。\nEcho Foxtrot Golf Hotel."
|
||||
r2 = upload_file(KB, "version_test.txt", v2_content)
|
||||
check("v2 覆盖上传成功",
|
||||
r2.get("success") is True or "chunks" in str(r2),
|
||||
f"resp: {json.dumps(r2, ensure_ascii=False)[:200]}")
|
||||
check("v2 标记为 replaced",
|
||||
r2.get("data", {}).get("file", {}).get("replaced") is True,
|
||||
f"replaced: {r2.get('data', {}).get('file', {}).get('replaced')}")
|
||||
time.sleep(1)
|
||||
|
||||
# 1d. 检查 v2 的 chunks(应该是新版本内容)
|
||||
chunks_v2 = get_doc_chunks(KB, "version_test.txt")
|
||||
v2_chunks_list = chunks_v2.get("chunks", [])
|
||||
check("v2 有切片",
|
||||
len(v2_chunks_list) > 0,
|
||||
f"chunks count: {len(v2_chunks_list)}")
|
||||
if v2_chunks_list:
|
||||
first_meta = v2_chunks_list[0].get("metadata", {})
|
||||
check("v2 切片 status=active",
|
||||
first_meta.get("status") == "active",
|
||||
f"status: {first_meta.get('status')}")
|
||||
|
||||
# 1e. 检查版本历史(SQLite)
|
||||
versions = get_version_history(KB, "version_test.txt")
|
||||
ver_list = versions.get("versions", [])
|
||||
check("版本历史有记录",
|
||||
len(ver_list) > 0,
|
||||
f"versions: {json.dumps(ver_list, ensure_ascii=False)[:300]}")
|
||||
|
||||
if len(ver_list) >= 2:
|
||||
# 应该有 v1(superseded) 和 v2(active)
|
||||
statuses = [v.get("status") for v in ver_list]
|
||||
check("版本历史包含 superseded 和 active 状态",
|
||||
"superseded" in str(statuses) and "active" in str(statuses),
|
||||
f"statuses: {statuses}")
|
||||
elif len(ver_list) == 1:
|
||||
check("至少有一条 active 版本记录",
|
||||
ver_list[0].get("status") in ("active", "superseded"),
|
||||
f"version: {ver_list[0]}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 风险场景 2:废止文档 → 切片状态标记
|
||||
# ======================================================================
|
||||
print("\n=== 风险场景 2:废止文档 → ChromaDB + SQLite 状态同步 ===")
|
||||
|
||||
# 2a. 上传一个专门用于废止测试的文档
|
||||
deprecate_content = "这份文档将被废止。\n包含独特的废止测试关键词 XYZ123ABC。\nIndigo Juliet Kilo Lima."
|
||||
r3 = upload_file(KB, "deprecate_test.txt", deprecate_content)
|
||||
check("废止测试文档上传成功",
|
||||
r3.get("success") is True or "chunks" in str(r3),
|
||||
f"resp: {json.dumps(r3, ensure_ascii=False)[:200]}")
|
||||
time.sleep(1)
|
||||
|
||||
# 2b. 确认上传后状态为 active
|
||||
chunks_before = get_doc_chunks(KB, "deprecate_test.txt")
|
||||
if chunks_before.get("chunks"):
|
||||
check("上传后切片状态为 active",
|
||||
all(c.get("metadata", {}).get("status") == "active"
|
||||
for c in chunks_before["chunks"]),
|
||||
f"statuses: {[c.get('metadata', {}).get('status') for c in chunks_before['chunks']]}")
|
||||
|
||||
# 2c. 执行废止
|
||||
dep_result = deprecate_doc(KB, "deprecate_test.txt", reason="测试废止操作")
|
||||
check("废止操作返回 success",
|
||||
dep_result.get("success") is True,
|
||||
f"resp: {json.dumps(dep_result, ensure_ascii=False)[:200]}")
|
||||
check("废止标记了切片",
|
||||
dep_result.get("deprecated_chunks", 0) > 0,
|
||||
f"deprecated_chunks: {dep_result.get('deprecated_chunks')}")
|
||||
|
||||
# 2d. 验证 ChromaDB 中的切片状态
|
||||
time.sleep(0.5)
|
||||
chunks_after_dep = get_doc_chunks(KB, "deprecate_test.txt")
|
||||
if chunks_after_dep.get("chunks"):
|
||||
dep_statuses = [c.get("metadata", {}).get("status", "") for c in chunks_after_dep["chunks"]]
|
||||
check("ChromaDB 切片状态已改为 deprecated",
|
||||
all(s == "deprecated" for s in dep_statuses),
|
||||
f"statuses: {dep_statuses}")
|
||||
|
||||
# 2e. 验证 SQLite 版本记录也同步了
|
||||
dep_versions = get_version_history(KB, "deprecate_test.txt")
|
||||
dep_ver_list = dep_versions.get("versions", [])
|
||||
if dep_ver_list:
|
||||
has_deprecated = any(
|
||||
v.get("status") in ("deprecated",) or
|
||||
str(v.get("status", "")).lower() == "deprecated"
|
||||
for v in dep_ver_list
|
||||
)
|
||||
check("SQLite 版本记录中有 deprecated 状态",
|
||||
has_deprecated,
|
||||
f"versions: {json.dumps(dep_ver_list, ensure_ascii=False)[:300]}")
|
||||
else:
|
||||
check("SQLite 版本记录存在", False, "版本历史为空")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 风险场景 3:废止后检索 → 不应出现废止文档内容
|
||||
# ======================================================================
|
||||
print("\n=== 风险场景 3:废止后检索过滤 ===")
|
||||
|
||||
# 用废止文档中的独特关键词检索
|
||||
search_result = rag_query(KB, "XYZ123ABC 废止测试关键词")
|
||||
if "error" not in search_result:
|
||||
answer = search_result.get("answer", "")
|
||||
sources = search_result.get("sources", [])
|
||||
source_files = [s.get("source", s.get("file", "")) for s in sources] if sources else []
|
||||
check("检索结果不包含废止文档",
|
||||
"deprecate_test.txt" not in source_files,
|
||||
f"sources: {source_files}")
|
||||
# 也检查 citations
|
||||
citations = search_result.get("citations", [])
|
||||
cite_sources = [c.get("source", "") for c in citations] if citations else []
|
||||
check("citations 不包含废止文档",
|
||||
"deprecate_test.txt" not in cite_sources,
|
||||
f"cite_sources: {cite_sources}")
|
||||
else:
|
||||
print(f" [SKIP] RAG 查询失败(可能 LLM 不可用): {search_result.get('error', '')[:100]}")
|
||||
# 备选方案:直接检查 chunks 的 status 字段
|
||||
dep_check = get_doc_chunks(KB, "deprecate_test.txt")
|
||||
if dep_check.get("chunks"):
|
||||
all_dep = all(
|
||||
c.get("status") == "deprecated" or c.get("metadata", {}).get("status") == "deprecated"
|
||||
for c in dep_check["chunks"]
|
||||
)
|
||||
check("(备选)废止文档所有切片 status=deprecated",
|
||||
all_dep,
|
||||
f"statuses: {[c.get('status', c.get('metadata', {}).get('status')) for c in dep_check['chunks']]}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 风险场景 4:恢复文档 → 切片状态恢复
|
||||
# ======================================================================
|
||||
print("\n=== 风险场景 4:恢复已废止文档 ===")
|
||||
|
||||
restore_result = restore_doc(KB, "deprecate_test.txt")
|
||||
check("恢复操作返回 success",
|
||||
restore_result.get("success") is True,
|
||||
f"resp: {json.dumps(restore_result, ensure_ascii=False)[:200]}")
|
||||
check("恢复了切片",
|
||||
restore_result.get("restored_chunks", 0) > 0,
|
||||
f"restored_chunks: {restore_result.get('restored_chunks')}")
|
||||
|
||||
# 验证 ChromaDB 切片恢复
|
||||
time.sleep(0.5)
|
||||
chunks_after_restore = get_doc_chunks(KB, "deprecate_test.txt")
|
||||
if chunks_after_restore.get("chunks"):
|
||||
restored_statuses = [c.get("metadata", {}).get("status", "") for c in chunks_after_restore["chunks"]]
|
||||
check("ChromaDB 切片状态恢复为 active",
|
||||
all(s == "active" for s in restored_statuses),
|
||||
f"statuses: {restored_statuses}")
|
||||
|
||||
# 验证 SQLite 版本记录
|
||||
rest_versions = get_version_history(KB, "deprecate_test.txt")
|
||||
rest_ver_list = rest_versions.get("versions", [])
|
||||
if rest_ver_list:
|
||||
has_active = any(
|
||||
str(v.get("status", "")).lower() == "active"
|
||||
for v in rest_ver_list
|
||||
)
|
||||
check("SQLite 版本记录恢复为 active",
|
||||
has_active,
|
||||
f"versions: {json.dumps(rest_ver_list, ensure_ascii=False)[:300]}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 风险场景 5:SQLite 与 ChromaDB 状态一致性
|
||||
# ======================================================================
|
||||
print("\n=== 风险场景 5:SQLite/ChromaDB 状态一致性 ===")
|
||||
|
||||
# 对 version_test.txt 做一致性检查
|
||||
ver_chunks = get_doc_chunks(KB, "version_test.txt")
|
||||
ver_versions = get_version_history(KB, "version_test.txt")
|
||||
|
||||
if ver_chunks.get("chunks") and ver_versions.get("versions"):
|
||||
# ChromaDB 中所有切片应该是 active(只有当前版本在 ChromaDB 中)
|
||||
chroma_statuses = set(
|
||||
c.get("metadata", {}).get("status", "active")
|
||||
for c in ver_chunks["chunks"]
|
||||
)
|
||||
check("ChromaDB 中 version_test 切片全为 active",
|
||||
chroma_statuses == {"active"} or chroma_statuses == set(),
|
||||
f"chroma_statuses: {chroma_statuses}")
|
||||
|
||||
# SQLite 中应该至少有一条 active 记录
|
||||
sqlite_statuses = [v.get("status") for v in ver_versions["versions"]]
|
||||
check("SQLite 中有 active 版本记录",
|
||||
"active" in sqlite_statuses,
|
||||
f"sqlite_statuses: {sqlite_statuses}")
|
||||
else:
|
||||
check("能获取到切片和版本信息", False,
|
||||
f"chunks: {bool(ver_chunks.get('chunks'))}, versions: {bool(ver_versions.get('versions'))}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 风险场景 6:边缘操作(不应崩溃)
|
||||
# ======================================================================
|
||||
print("\n=== 风险场景 6:边缘操作容错 ===")
|
||||
|
||||
# 6a. 废止不存在的文档
|
||||
dep_nonexist = deprecate_doc(KB, "nonexistent_file_xyz.txt")
|
||||
check("废止不存在文档不崩溃",
|
||||
"success" in dep_nonexist or "error" in dep_nonexist,
|
||||
f"resp: {json.dumps(dep_nonexist, ensure_ascii=False)[:200]}")
|
||||
|
||||
# 6b. 恢复非废止状态的文档
|
||||
# deprecate_test.txt 已经被恢复了,再恢复一次应该报错但不崩溃
|
||||
restore_again = restore_doc(KB, "deprecate_test.txt")
|
||||
check("重复恢复不崩溃",
|
||||
"success" in restore_again or "error" in restore_again,
|
||||
f"resp: {json.dumps(restore_again, ensure_ascii=False)[:200]}")
|
||||
|
||||
# 6c. 连续两次废止同一文档
|
||||
dep1 = deprecate_doc(KB, "deprecate_test.txt", reason="第一次废止")
|
||||
check("第一次废止成功",
|
||||
dep1.get("success") is True,
|
||||
f"resp: {json.dumps(dep1, ensure_ascii=False)[:200]}")
|
||||
time.sleep(0.5)
|
||||
dep2 = deprecate_doc(KB, "deprecate_test.txt", reason="第二次废止")
|
||||
check("第二次废止不崩溃(已废止状态)",
|
||||
"success" in dep2 or "error" in dep2,
|
||||
f"resp: {json.dumps(dep2, ensure_ascii=False)[:200]}")
|
||||
|
||||
# 恢复(为后续清理准备)
|
||||
restore_doc(KB, "deprecate_test.txt")
|
||||
time.sleep(0.5)
|
||||
|
||||
# 6d. 上传空文件
|
||||
empty_result = upload_file(KB, "empty_file.txt", "")
|
||||
check("上传空文件不崩溃",
|
||||
"success" in empty_result or "error" in empty_result,
|
||||
f"resp: {json.dumps(empty_result, ensure_ascii=False)[:200]}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 清理:删除测试文档
|
||||
# ======================================================================
|
||||
print("\n=== 清理测试数据 ===")
|
||||
for fname in ["version_test.txt", "deprecate_test.txt", "empty_file.txt"]:
|
||||
r = delete_doc(KB, fname)
|
||||
print(f" 删除 {fname}: {r.get('success', r.get('deleted', r.get('error', '?')))}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 汇总
|
||||
# ======================================================================
|
||||
print(f"\n{'='*60}")
|
||||
print(f"风险测试完成: {passed} 通过, {failed} 失败, 共 {passed + failed} 条")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
330
tests/test_edge_cases.py
Normal file
330
tests/test_edge_cases.py
Normal file
@@ -0,0 +1,330 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Phase 1 + Phase 2 边界风险修复验证测试
|
||||
|
||||
测试覆盖:
|
||||
1. RRF 融合跨库同名文件不吞结果
|
||||
2. search_multiple 跨库去重正确
|
||||
3. DocStore 路径含 collection 前缀
|
||||
4. citation 构建兼容 _collection 和 collection
|
||||
5. _collection 回退逻辑正确
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" [PASS] {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" [FAIL] {name} {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 1:RRF 融合 — 跨库同名文件不被吞
|
||||
# ======================================================================
|
||||
print("\n=== 测试 1:RRF 融合复合键去重 ===")
|
||||
|
||||
# 模拟 engine 的 reciprocal_rank_fusion 逻辑(提取纯函数测试)
|
||||
def reciprocal_rank_fusion_test(results_list, weights=None, k=60):
|
||||
"""复刻 engine.py 的 RRF 逻辑(含修复)"""
|
||||
if not results_list:
|
||||
return {'ids': [[]], 'documents': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||
if weights is None:
|
||||
weights = [1.0] * len(results_list)
|
||||
|
||||
doc_scores = {}
|
||||
for results, weight in zip(results_list, weights):
|
||||
if not results['documents'] or not results['documents'][0]:
|
||||
continue
|
||||
for rank, (doc_id, doc, meta) in enumerate(zip(
|
||||
results['ids'][0], results['documents'][0], results['metadatas'][0]
|
||||
)):
|
||||
rrf_score = weight / (k + rank + 1)
|
||||
coll = meta.get('_collection') or meta.get('collection') or ''
|
||||
composite_key = f"{coll}\x00{doc_id}" if coll else doc_id
|
||||
if composite_key not in doc_scores:
|
||||
doc_scores[composite_key] = {'score': 0.0, 'doc': doc, 'meta': meta, 'coll': coll, 'raw_id': doc_id}
|
||||
doc_scores[composite_key]['score'] += rrf_score
|
||||
|
||||
sorted_items = sorted(doc_scores.items(), key=lambda x: x[1]['score'], reverse=True)
|
||||
|
||||
out_ids = []
|
||||
for item in sorted_items:
|
||||
coll = item[1]['coll']
|
||||
raw_id = item[1]['raw_id']
|
||||
if coll and not raw_id.startswith(f"{coll}/"):
|
||||
out_ids.append(f"{coll}/{raw_id}")
|
||||
else:
|
||||
out_ids.append(raw_id)
|
||||
|
||||
return {
|
||||
'ids': [out_ids],
|
||||
'documents': [[item[1]['doc'] for item in sorted_items]],
|
||||
'metadatas': [[item[1]['meta'] for item in sorted_items]],
|
||||
'distances': [[item[1]['score'] for item in sorted_items]],
|
||||
}
|
||||
|
||||
|
||||
# 场景:public_kb 和 dept_1_kb 都有 "规章制度.pdf_0"
|
||||
result_public = {
|
||||
'ids': [['规章制度.pdf_0', '规章制度.pdf_1']],
|
||||
'documents': [['public版内容_0', 'public版内容_1']],
|
||||
'metadatas': [[
|
||||
{'source': '规章制度.pdf', 'collection': 'public_kb', '_collection': 'public_kb', 'chunk_index': 0},
|
||||
{'source': '规章制度.pdf', 'collection': 'public_kb', '_collection': 'public_kb', 'chunk_index': 1},
|
||||
]],
|
||||
'distances': [[0.9, 0.8]],
|
||||
}
|
||||
|
||||
result_dept = {
|
||||
'ids': [['规章制度.pdf_0', '规章制度.pdf_1']],
|
||||
'documents': [['dept版内容_0', 'dept版内容_1']],
|
||||
'metadatas': [[
|
||||
{'source': '规章制度.pdf', 'collection': 'dept_1_kb', '_collection': 'dept_1_kb', 'chunk_index': 0},
|
||||
{'source': '规章制度.pdf', 'collection': 'dept_1_kb', '_collection': 'dept_1_kb', 'chunk_index': 1},
|
||||
]],
|
||||
'distances': [[0.85, 0.75]],
|
||||
}
|
||||
|
||||
rrf_result = reciprocal_rank_fusion_test([result_public, result_dept])
|
||||
rrf_ids = rrf_result['ids'][0]
|
||||
|
||||
check("跨库同名文件:结果数应为 4(不是 2)", len(rrf_ids) == 4, f"实际: {len(rrf_ids)}")
|
||||
check("ID 包含 collection 前缀", all('/' in i for i in rrf_ids), f"IDs: {rrf_ids}")
|
||||
check("public_kb 的结果存在", any('public_kb/' in i for i in rrf_ids))
|
||||
check("dept_1_kb 的结果存在", any('dept_1_kb/' in i for i in rrf_ids))
|
||||
|
||||
# 验证文档内容保留完整(不被覆盖)
|
||||
rrf_docs = rrf_result['documents'][0]
|
||||
check("public版内容保留", any('public版' in d for d in rrf_docs))
|
||||
check("dept版内容保留", any('dept版' in d for d in rrf_docs))
|
||||
|
||||
# 场景:同库向量+BM25同名chunk应合并分数
|
||||
result_vec = {
|
||||
'ids': [['规章制度.pdf_0']],
|
||||
'documents': [['内容A']],
|
||||
'metadatas': [[{'source': '规章制度.pdf', '_collection': 'public_kb', 'collection': 'public_kb'}]],
|
||||
'distances': [[0.9]],
|
||||
}
|
||||
result_bm25 = {
|
||||
'ids': [['规章制度.pdf_0']],
|
||||
'documents': [['内容A']],
|
||||
'metadatas': [[{'source': '规章制度.pdf', '_collection': 'public_kb', 'collection': 'public_kb'}]],
|
||||
'distances': [[0.7]],
|
||||
}
|
||||
|
||||
rrf_same = reciprocal_rank_fusion_test([result_vec, result_bm25])
|
||||
check("同库向量+BM25合并:结果数应为 1", len(rrf_same['ids'][0]) == 1, f"实际: {len(rrf_same['ids'][0])}")
|
||||
|
||||
# chunk_index 解析测试(带前缀的 ID)
|
||||
print("\n--- chunk_index 解析兼容性 ---")
|
||||
for test_id, expected in [
|
||||
("public_kb/规章制度.pdf_0", 0),
|
||||
("dept_1_kb/规章制度.pdf_5", 5),
|
||||
("规章制度.pdf_3", 3),
|
||||
("test_.pdf_0", 0),
|
||||
]:
|
||||
chunk_id_raw = test_id
|
||||
try:
|
||||
chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
|
||||
except (ValueError, IndexError):
|
||||
chunk_index = None
|
||||
check(f"解析 '{test_id}' -> chunk_index={expected}", chunk_index == expected, f"实际: {chunk_index}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 2:search_multiple 去重
|
||||
# ======================================================================
|
||||
print("\n=== 测试 2:search_multiple 复合键去重 ===")
|
||||
|
||||
# 模拟 search.py 的 _merge_multiple_results 逻辑
|
||||
class FakeSearchResult:
|
||||
def __init__(self, ids, documents, metadatas, distances, collection_name=""):
|
||||
self.ids = ids
|
||||
self.documents = documents
|
||||
self.metadatas = metadatas
|
||||
self.distances = distances
|
||||
self.collection_name = collection_name
|
||||
|
||||
|
||||
def merge_multiple_results_test(results, top_k=10):
|
||||
"""复刻 search.py 的 _merge_multiple_results 逻辑(含修复)"""
|
||||
if not results:
|
||||
return []
|
||||
all_items = []
|
||||
for result in results:
|
||||
for i, doc_id in enumerate(result.ids):
|
||||
all_items.append({
|
||||
'id': doc_id,
|
||||
'doc': result.documents[i],
|
||||
'meta': result.metadatas[i],
|
||||
'score': result.distances[i],
|
||||
'collection': result.collection_name
|
||||
})
|
||||
all_items.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
seen = set()
|
||||
unique_items = []
|
||||
for item in all_items:
|
||||
composite_key = (item['collection'], item['id'])
|
||||
if composite_key not in seen:
|
||||
seen.add(composite_key)
|
||||
unique_items.append(item)
|
||||
return unique_items[:top_k]
|
||||
|
||||
|
||||
result_a = FakeSearchResult(
|
||||
ids=['规章制度.pdf_0', '规章制度.pdf_1'],
|
||||
documents=['A内容0', 'A内容1'],
|
||||
metadatas=[{'source': '规章制度.pdf'}, {'source': '规章制度.pdf'}],
|
||||
distances=[0.9, 0.8],
|
||||
collection_name='public_kb'
|
||||
)
|
||||
result_b = FakeSearchResult(
|
||||
ids=['规章制度.pdf_0', '规章制度.pdf_1'],
|
||||
documents=['B内容0', 'B内容1'],
|
||||
metadatas=[{'source': '规章制度.pdf'}, {'source': '规章制度.pdf'}],
|
||||
distances=[0.85, 0.75],
|
||||
collection_name='dept_1_kb'
|
||||
)
|
||||
|
||||
merged = merge_multiple_results_test([result_a, result_b])
|
||||
check("跨库去重:结果数应为 4", len(merged) == 4, f"实际: {len(merged)}")
|
||||
check("包含 public_kb 的内容", any('A内容' in m['doc'] for m in merged))
|
||||
check("包含 dept_1_kb 的内容", any('B内容' in m['doc'] for m in merged))
|
||||
|
||||
# 同库应去重
|
||||
result_a2 = FakeSearchResult(
|
||||
ids=['规章制度.pdf_0'],
|
||||
documents=['A内容0-vec'],
|
||||
metadatas=[{'source': '规章制度.pdf'}],
|
||||
distances=[0.9],
|
||||
collection_name='public_kb'
|
||||
)
|
||||
result_a3 = FakeSearchResult(
|
||||
ids=['规章制度.pdf_0'],
|
||||
documents=['A内容0-bm25'],
|
||||
metadatas=[{'source': '规章制度.pdf'}],
|
||||
distances=[0.7],
|
||||
collection_name='public_kb'
|
||||
)
|
||||
merged_same = merge_multiple_results_test([result_a2, result_a3])
|
||||
check("同库去重:结果数应为 1", len(merged_same) == 1, f"实际: {len(merged_same)}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 3:DocStore 路径含 collection 前缀
|
||||
# ======================================================================
|
||||
print("\n=== 测试 3:DocStore 路径生成 ===")
|
||||
|
||||
def docstore_path_test(doc_id, metadata):
|
||||
"""复刻 processing.py 的路径逻辑"""
|
||||
coll = metadata.get('collection', '')
|
||||
safe_id = f"{coll}_{doc_id}" if coll else doc_id
|
||||
return f"{safe_id}.json"
|
||||
|
||||
|
||||
path1 = docstore_path_test("规章制度.pdf_2", {"collection": "public_kb"})
|
||||
path2 = docstore_path_test("规章制度.pdf_2", {"collection": "dept_1_kb"})
|
||||
check("跨库路径不同", path1 != path2, f"path1={path1}, path2={path2}")
|
||||
check("public_kb 路径含前缀", path1 == "public_kb_规章制度.pdf_2.json", f"实际: {path1}")
|
||||
check("dept_1_kb 路径含前缀", path2 == "dept_1_kb_规章制度.pdf_2.json", f"实际: {path2}")
|
||||
|
||||
# 无 collection 的兼容
|
||||
path3 = docstore_path_test("test.pdf_0", {})
|
||||
check("无 collection 时保持原始路径", path3 == "test.pdf_0.json", f"实际: {path3}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 4:citation 构建 — collection 字段兼容
|
||||
# ======================================================================
|
||||
print("\n=== 测试 4:citation collection 字段兼容 ===")
|
||||
|
||||
def citation_collection_test(meta):
|
||||
"""复刻 chat_routes.py 的 collection 取值逻辑"""
|
||||
return meta.get('_collection') or meta.get('collection', '')
|
||||
|
||||
|
||||
# 场景 A:只有 _collection(多库检索路径)
|
||||
check("_collection 优先", citation_collection_test({'_collection': 'dept_1_kb', 'collection': 'public_kb'}) == 'dept_1_kb')
|
||||
# 场景 B:只有 collection(入库时的 metadata)
|
||||
check("回退到 collection", citation_collection_test({'collection': 'public_kb'}) == 'public_kb')
|
||||
# 场景 C:两者都没有
|
||||
check("都没有时返回空", citation_collection_test({}) == '')
|
||||
# 场景 D:_collection 为空字符串
|
||||
check("_collection 空字符串时回退", citation_collection_test({'_collection': '', 'collection': 'public_kb'}) == 'public_kb')
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 5:_collection 回退逻辑
|
||||
# ======================================================================
|
||||
print("\n=== 测试 5:_collection 回退逻辑 ===")
|
||||
|
||||
def fallback_collection_test(meta, collections):
|
||||
"""复刻 chat_routes.py 修复后的回退逻辑"""
|
||||
if not meta.get('_collection'):
|
||||
meta['_collection'] = meta.get('collection') or (collections[0] if collections else 'public_kb')
|
||||
return meta['_collection']
|
||||
|
||||
|
||||
# 场景:meta 有 collection 字段但无 _collection
|
||||
m1 = {'collection': 'dept_1_kb'}
|
||||
check("使用 meta 中的 collection", fallback_collection_test(m1, ['public_kb', 'dept_1_kb']) == 'dept_1_kb')
|
||||
|
||||
# 场景:meta 两者都没有
|
||||
m2 = {}
|
||||
check("回退到 collections[0]", fallback_collection_test(m2, ['public_kb', 'dept_1_kb']) == 'public_kb')
|
||||
|
||||
# 场景:已有 _collection 不覆盖
|
||||
m3 = {'_collection': 'dept_1_kb', 'collection': 'public_kb'}
|
||||
check("已有 _collection 不覆盖", fallback_collection_test(m3, ['public_kb']) == 'dept_1_kb')
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 6:_expand_contiguous_chunks ID 匹配兼容
|
||||
# ======================================================================
|
||||
print("\n=== 测试 6:上下文扩展 ID 匹配兼容 ===")
|
||||
|
||||
# 模拟 existing_ids 和 n_id 的匹配逻辑
|
||||
existing_ids = {'public_kb/规章制度.pdf_0', 'public_kb/规章制度.pdf_1', 'dept_1_kb/制度.pdf_3'}
|
||||
_raw_id_set = set()
|
||||
for _eid in existing_ids:
|
||||
if '/' in _eid:
|
||||
_raw_id_set.add(_eid.split('/', 1)[1])
|
||||
else:
|
||||
_raw_id_set.add(_eid)
|
||||
|
||||
# 邻居查询返回的是原始 ID(无前缀)
|
||||
n_id_raw = "规章制度.pdf_0"
|
||||
n_id_new = "规章制度.pdf_5"
|
||||
|
||||
is_dup_raw = n_id_raw in existing_ids or n_id_raw in _raw_id_set
|
||||
is_dup_new = n_id_new in existing_ids or n_id_new in _raw_id_set
|
||||
|
||||
check("原始 ID 能匹配带前缀的 existing_ids", is_dup_raw == True)
|
||||
check("新 ID 不误判为已存在", is_dup_new == False)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 汇总
|
||||
# ======================================================================
|
||||
print(f"\n{'='*50}")
|
||||
print(f"测试结果:{passed} 通过 / {failed} 失败 / {passed+failed} 总计")
|
||||
if failed > 0:
|
||||
print("存在失败项,请检查!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("全部通过!")
|
||||
375
tests/test_upload_dedup.py
Normal file
375
tests/test_upload_dedup.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Phase 3 验证测试:重复上传旧切片残留修复
|
||||
|
||||
测试场景:
|
||||
1. add_file_to_kb() 中的"先删后加"逻辑
|
||||
2. upload_document() 中的同名文件覆盖逻辑
|
||||
3. 批量上传中的同名文件处理
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def test_add_file_to_kb_dedup():
|
||||
"""测试 add_file_to_kb() 中的去重逻辑"""
|
||||
print("\n=== 测试 1:add_file_to_kb 去重逻辑 ===")
|
||||
|
||||
# 模拟 ChromaDB collection
|
||||
class MockCollection:
|
||||
def __init__(self):
|
||||
self.data = {} # id -> {doc, meta, embedding}
|
||||
|
||||
def add(self, ids, documents, metadatas, embeddings):
|
||||
for i, doc, meta, emb in zip(ids, documents, metadatas, embeddings):
|
||||
self.data[i] = {'doc': doc, 'meta': meta, 'embedding': emb}
|
||||
|
||||
def get(self, where=None, ids=None, include=None):
|
||||
results = {'ids': [], 'documents': [], 'metadatas': [], 'embeddings': []}
|
||||
|
||||
if ids:
|
||||
for id in ids:
|
||||
if id in self.data:
|
||||
results['ids'].append(id)
|
||||
results['documents'].append(self.data[id]['doc'])
|
||||
results['metadatas'].append(self.data[id]['meta'])
|
||||
results['embeddings'].append(self.data[id]['embedding'])
|
||||
return results
|
||||
|
||||
for id, item in self.data.items():
|
||||
if where:
|
||||
# 简单的 where 过滤
|
||||
match = True
|
||||
for k, v in where.items():
|
||||
if item['meta'].get(k) != v:
|
||||
match = False
|
||||
break
|
||||
if not match:
|
||||
continue
|
||||
|
||||
results['ids'].append(id)
|
||||
results['documents'].append(item['doc'])
|
||||
results['metadatas'].append(item['meta'])
|
||||
results['embeddings'].append(item['embedding'])
|
||||
|
||||
return results
|
||||
|
||||
def delete(self, ids):
|
||||
for id in ids:
|
||||
self.data.pop(id, None)
|
||||
|
||||
def count(self):
|
||||
return len(self.data)
|
||||
|
||||
# 模拟知识库管理器(仅测试去重逻辑)
|
||||
class MockKBManager:
|
||||
def __init__(self):
|
||||
self.collections = {'test_kb': MockCollection()}
|
||||
self._bm25_indexes = {}
|
||||
|
||||
def get_collection(self, kb_name):
|
||||
return self.collections.get(kb_name)
|
||||
|
||||
def rebuild_bm25_index(self, kb_name):
|
||||
pass
|
||||
|
||||
def add_file_to_kb_logic(self, kb_name, filename, chunks):
|
||||
"""模拟 add_file_to_kb 的核心逻辑"""
|
||||
collection = self.get_collection(kb_name)
|
||||
if not collection:
|
||||
return 0
|
||||
|
||||
# 入库前清理同名旧切片(核心修复)
|
||||
existing = collection.get(where={"source": filename})
|
||||
if existing and existing['ids']:
|
||||
old_count = len(existing['ids'])
|
||||
collection.delete(ids=existing['ids'])
|
||||
print(f" [清理] 删除旧切片: {filename}, 共 {old_count} 个")
|
||||
|
||||
# 添加新切片
|
||||
ids = []
|
||||
documents = []
|
||||
metadatas = []
|
||||
embeddings = []
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_id = f"{filename}_{i}"
|
||||
ids.append(chunk_id)
|
||||
documents.append(chunk)
|
||||
metadatas.append({
|
||||
"source": filename,
|
||||
"chunk_index": i,
|
||||
"collection": kb_name
|
||||
})
|
||||
embeddings.append([0.1] * 768) # 模拟向量
|
||||
|
||||
collection.add(ids, documents, metadatas, embeddings)
|
||||
return len(ids)
|
||||
|
||||
# 测试场景 1:首次添加
|
||||
kb = MockKBManager()
|
||||
count1 = kb.add_file_to_kb_logic('test_kb', '规章制度.pdf', ['内容1', '内容2', '内容3'])
|
||||
assert count1 == 3, f"首次添加应返回 3,实际 {count1}"
|
||||
assert kb.collections['test_kb'].count() == 3, f"向量库应有 3 个切片,实际 {kb.collections['test_kb'].count()}"
|
||||
print(f" [PASS] 首次添加: {count1} 个切片")
|
||||
|
||||
# 测试场景 2:重复上传同名文件(应清理旧切片)
|
||||
count2 = kb.add_file_to_kb_logic('test_kb', '规章制度.pdf', ['新内容1', '新内容2'])
|
||||
assert count2 == 2, f"重复上传应返回 2,实际 {count2}"
|
||||
assert kb.collections['test_kb'].count() == 2, f"向量库应有 2 个切片(旧切片已清理),实际 {kb.collections['test_kb'].count()}"
|
||||
print(f" [PASS] 重复上传: {count2} 个切片(旧切片已清理)")
|
||||
|
||||
# 验证切片内容是新的
|
||||
result = kb.collections['test_kb'].get()
|
||||
assert '新内容1' in result['documents'], "切片内容应为新内容"
|
||||
assert '内容1' not in result['documents'], "旧内容应已被删除"
|
||||
print(f" [PASS] 切片内容已更新为新版本")
|
||||
|
||||
# 测试场景 3:不同名文件不互相影响
|
||||
count3 = kb.add_file_to_kb_logic('test_kb', '操作手册.pdf', ['手册1'])
|
||||
assert count3 == 1, f"添加新文件应返回 1,实际 {count3}"
|
||||
assert kb.collections['test_kb'].count() == 3, f"向量库应有 3 个切片(2+1),实际 {kb.collections['test_kb'].count()}"
|
||||
print(f" [PASS] 不同名文件独立存在: 总计 {kb.collections['test_kb'].count()} 个切片")
|
||||
|
||||
|
||||
def test_upload_document_overwrite():
|
||||
"""测试 upload_document() 中的同名文件覆盖逻辑"""
|
||||
print("\n=== 测试 2:upload_document 同名文件覆盖 ===")
|
||||
|
||||
# 创建临时目录
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
target_dir = os.path.join(temp_dir, 'public_kb')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# 创建旧文件
|
||||
old_file = os.path.join(target_dir, '规章制度.pdf')
|
||||
with open(old_file, 'w', encoding='utf-8') as f:
|
||||
f.write('旧内容')
|
||||
|
||||
assert os.path.exists(old_file), "旧文件应存在"
|
||||
old_size = os.path.getsize(old_file)
|
||||
print(f" [准备] 创建旧文件: {old_file}, 大小 {old_size} 字节")
|
||||
|
||||
# 模拟上传逻辑:同名文件覆盖
|
||||
filename = '规章制度.pdf'
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
|
||||
replaced = False
|
||||
if os.path.exists(filepath):
|
||||
replaced = True
|
||||
# 这里应该调用 kb_manager 清理旧切片,简化测试只验证文件覆盖
|
||||
print(f" [检测] 发现同名文件,准备覆盖")
|
||||
|
||||
# 保存新文件(覆盖)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write('新内容,更长一些')
|
||||
|
||||
assert replaced, "应检测到同名文件并标记 replaced=True"
|
||||
assert os.path.exists(filepath), "新文件应存在"
|
||||
new_size = os.path.getsize(filepath)
|
||||
assert new_size > old_size, f"新文件应更大({new_size} > {old_size})"
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
assert content == '新内容,更长一些', "文件内容应为新内容"
|
||||
|
||||
print(f" [PASS] 文件已覆盖: {old_size} -> {new_size} 字节")
|
||||
print(f" [PASS] replaced={replaced}")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_batch_upload_overwrite():
|
||||
"""测试批量上传中的同名文件覆盖逻辑"""
|
||||
print("\n=== 测试 3:批量上传同名文件覆盖 ===")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
target_dir = os.path.join(temp_dir, 'public_kb')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# 创建旧文件
|
||||
old_file = os.path.join(target_dir, '规章制度.pdf')
|
||||
with open(old_file, 'w', encoding='utf-8') as f:
|
||||
f.write('旧内容')
|
||||
|
||||
# 模拟批量上传逻辑
|
||||
files = [
|
||||
('规章制度.pdf', '新内容1'),
|
||||
('操作手册.pdf', '手册内容'),
|
||||
]
|
||||
|
||||
results = []
|
||||
for filename, content in files:
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
|
||||
replaced = False
|
||||
if os.path.exists(filepath):
|
||||
replaced = True
|
||||
print(f" [检测] {filename} 已存在,准备覆盖")
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
results.append({
|
||||
'filename': filename,
|
||||
'replaced': replaced
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert len(results) == 2, f"应处理 2 个文件,实际 {len(results)}"
|
||||
assert results[0]['replaced'] is True, "规章制度.pdf 应标记为 replaced"
|
||||
assert results[1]['replaced'] is False, "操作手册.pdf 不应标记为 replaced"
|
||||
|
||||
print(f" [PASS] 批量上传: {len(results)} 个文件")
|
||||
print(f" [PASS] 规章制度.pdf: replaced={results[0]['replaced']}")
|
||||
print(f" [PASS] 操作手册.pdf: replaced={results[1]['replaced']}")
|
||||
|
||||
# 验证文件内容
|
||||
with open(old_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
assert content == '新内容1', "规章制度.pdf 应为新内容"
|
||||
print(f" [PASS] 文件内容已更新")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_docstore_cleanup():
|
||||
"""测试 DocStore 文件清理逻辑"""
|
||||
print("\n=== 测试 4:DocStore 文件清理 ===")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
docstore_dir = Path(temp_dir) / 'docstore'
|
||||
docstore_dir.mkdir()
|
||||
|
||||
# 创建模拟的 DocStore 文件
|
||||
collection = 'public_kb'
|
||||
filename = '规章制度.pdf'
|
||||
|
||||
# 旧切片对应的 DocStore 文件
|
||||
old_files = [
|
||||
f'{collection}_{filename}_0.json',
|
||||
f'{collection}_{filename}_1.json',
|
||||
f'{collection}_{filename}_2.json',
|
||||
]
|
||||
|
||||
# 其他文件的 DocStore(不应被清理)
|
||||
other_files = [
|
||||
f'{collection}_操作手册.pdf_0.json',
|
||||
f'dept_1_kb_{filename}_0.json', # 不同 collection
|
||||
]
|
||||
|
||||
for f in old_files + other_files:
|
||||
(docstore_dir / f).write_text('{"test": "data"}', encoding='utf-8')
|
||||
|
||||
assert len(list(docstore_dir.glob('*.json'))) == 5, "应有 5 个 DocStore 文件"
|
||||
print(f" [准备] 创建 5 个 DocStore 文件")
|
||||
|
||||
# 模拟清理逻辑
|
||||
cleaned = 0
|
||||
for ds_file in docstore_dir.glob(f'{collection}_{filename}_*.json'):
|
||||
ds_file.unlink()
|
||||
cleaned += 1
|
||||
|
||||
assert cleaned == 3, f"应清理 3 个旧 DocStore 文件,实际 {cleaned}"
|
||||
remaining = list(docstore_dir.glob('*.json'))
|
||||
assert len(remaining) == 2, f"应剩余 2 个 DocStore 文件,实际 {len(remaining)}"
|
||||
|
||||
print(f" [PASS] 清理了 {cleaned} 个旧 DocStore 文件")
|
||||
print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_sync_hash_cleanup():
|
||||
"""测试同步哈希记录清理逻辑"""
|
||||
print("\n=== 测试 5:同步哈希记录清理 ===")
|
||||
|
||||
# 模拟 SyncDatabase
|
||||
class MockSyncDatabase:
|
||||
def __init__(self):
|
||||
self.hashes = {}
|
||||
|
||||
def set_document_hash(self, doc_id, doc_name, hash_val, size, mtime):
|
||||
self.hashes[doc_id] = {
|
||||
'document_id': doc_id,
|
||||
'document_name': doc_name,
|
||||
'content_hash': hash_val,
|
||||
'file_size': size,
|
||||
'last_modified': mtime
|
||||
}
|
||||
|
||||
def get_document_hash(self, doc_id):
|
||||
return self.hashes.get(doc_id)
|
||||
|
||||
def delete_document_hash(self, doc_id):
|
||||
self.hashes.pop(doc_id, None)
|
||||
|
||||
db = MockSyncDatabase()
|
||||
|
||||
# 添加旧哈希记录
|
||||
db.set_document_hash('public_kb/规章制度.pdf', '规章制度.pdf', 'old_hash_abc', 1024, '2026-01-01')
|
||||
assert db.get_document_hash('public_kb/规章制度.pdf') is not None, "旧哈希应存在"
|
||||
print(f" [准备] 创建旧哈希记录: public_kb/规章制度.pdf")
|
||||
|
||||
# 模拟上传时清理哈希
|
||||
collection = 'public_kb'
|
||||
filename = '规章制度.pdf'
|
||||
db.delete_document_hash(f"{collection}/{filename}")
|
||||
|
||||
assert db.get_document_hash('public_kb/规章制度.pdf') is None, "旧哈希应已被清理"
|
||||
print(f" [PASS] 哈希记录已清理")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""运行所有测试"""
|
||||
print("=" * 50)
|
||||
print("Phase 3 验证测试:重复上传旧切片残留修复")
|
||||
print("=" * 50)
|
||||
|
||||
tests = [
|
||||
test_add_file_to_kb_dedup,
|
||||
test_upload_document_overwrite,
|
||||
test_batch_upload_overwrite,
|
||||
test_docstore_cleanup,
|
||||
test_sync_hash_cleanup,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" [FAIL] {test.__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failed += 1
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"测试结果:{passed} 通过 / {failed} 失败 / {len(tests)} 总计")
|
||||
if failed == 0:
|
||||
print("全部通过!")
|
||||
else:
|
||||
print(f"有 {failed} 个测试失败")
|
||||
print("=" * 50)
|
||||
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = run_all_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
437
tests/test_version_management.py
Normal file
437
tests/test_version_management.py
Normal file
@@ -0,0 +1,437 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
文档版本管理修复验证测试
|
||||
|
||||
测试覆盖:
|
||||
1. mark_document_as_superseded 只更新 SQLite(不操作 ChromaDB)
|
||||
2. deprecate_document 同步 SQLite 版本记录
|
||||
3. restore_document 同步 SQLite 版本记录
|
||||
4. 版本历史查询一致性
|
||||
5. cleanup_superseded_versions 清理 SQLite 记录
|
||||
6. 上传覆盖创建版本记录(逻辑验证)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" [PASS] {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" [FAIL] {name} {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 辅助:创建临时 SQLite 数据库
|
||||
# ======================================================================
|
||||
|
||||
def create_test_db():
|
||||
"""创建内存中的测试数据库"""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS document_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
collection TEXT,
|
||||
version TEXT NOT NULL DEFAULT 'v1',
|
||||
content_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
effective_date DATE,
|
||||
expiry_date DATE,
|
||||
deprecated_date DATETIME,
|
||||
deprecated_reason TEXT,
|
||||
deprecated_by TEXT,
|
||||
change_summary TEXT,
|
||||
changed_sections TEXT,
|
||||
supersedes TEXT,
|
||||
chunk_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT,
|
||||
UNIQUE(document_id, collection, version)
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS version_change_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
collection TEXT,
|
||||
old_version TEXT,
|
||||
new_version TEXT,
|
||||
old_status TEXT,
|
||||
new_status TEXT,
|
||||
change_type TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
changed_by TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def insert_version(conn, collection, doc_id, version, status="active"):
|
||||
"""插入版本记录"""
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO document_versions
|
||||
(document_id, collection, version, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(doc_id, collection, version, status, datetime.now().isoformat())
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_versions(conn, collection, doc_id):
|
||||
"""查询版本记录"""
|
||||
cursor = conn.execute(
|
||||
"""SELECT version, status, deprecated_date, deprecated_reason
|
||||
FROM document_versions
|
||||
WHERE collection = ? AND document_id = ?
|
||||
ORDER BY created_at DESC""",
|
||||
(collection, doc_id)
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
def get_change_logs(conn, collection, doc_id):
|
||||
"""查询变更日志"""
|
||||
cursor = conn.execute(
|
||||
"""SELECT change_type, old_version, new_version, old_status, new_status
|
||||
FROM version_change_logs
|
||||
WHERE collection = ? AND document_id = ?
|
||||
ORDER BY created_at DESC""",
|
||||
(collection, doc_id)
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 1:mark_document_as_superseded 参数签名验证
|
||||
# ======================================================================
|
||||
print("\n=== 测试 1:mark_document_as_superseded 新签名 ===")
|
||||
|
||||
from knowledge.manager import KnowledgeBaseManager
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(KnowledgeBaseManager.mark_document_as_superseded)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
check("新签名包含 filename 参数",
|
||||
"filename" in params,
|
||||
f"参数列表: {params}")
|
||||
|
||||
check("新签名包含 new_version 参数",
|
||||
"new_version" in params,
|
||||
f"参数列表: {params}")
|
||||
|
||||
check("旧参数 old_filename 已移除",
|
||||
"old_filename" not in params,
|
||||
f"参数列表: {params}")
|
||||
|
||||
check("旧参数 new_filename 已移除",
|
||||
"new_filename" not in params,
|
||||
f"参数列表: {params}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 2:deprecate_document 同步 SQLite(mock 验证)
|
||||
# ======================================================================
|
||||
print("\n=== 测试 2:deprecate_document SQLite 同步逻辑 ===")
|
||||
|
||||
from knowledge.document import DocumentMixin
|
||||
|
||||
|
||||
class MockDocumentManager(DocumentMixin):
|
||||
"""模拟文档管理器"""
|
||||
def __init__(self):
|
||||
self.mock_collection = MagicMock()
|
||||
self._bm25_indexes = {}
|
||||
|
||||
def get_collection(self, kb_name):
|
||||
return self.mock_collection
|
||||
|
||||
def rebuild_bm25_index(self, kb_name):
|
||||
pass
|
||||
|
||||
|
||||
# 模拟场景:3 个 chunk,active 状态
|
||||
mgr = MockDocumentManager()
|
||||
mgr.mock_collection.get.return_value = {
|
||||
'ids': ['rule_0', 'rule_1', 'rule_2'],
|
||||
'metadatas': [
|
||||
{'source': 'rule.txt', 'status': 'active', 'version': 'v1'},
|
||||
{'source': 'rule.txt', 'status': 'active', 'version': 'v1'},
|
||||
{'source': 'rule.txt', 'status': 'active', 'version': 'v1'},
|
||||
]
|
||||
}
|
||||
|
||||
# 模拟 SQLite 连接
|
||||
test_conn = create_test_db()
|
||||
insert_version(test_conn, "test_kb", "rule.txt", "v1", "active")
|
||||
|
||||
with patch('data.db.get_connection', return_value=test_conn):
|
||||
with patch('knowledge.document_versions.get_version_query') as mock_vq:
|
||||
mock_vq_inst = MagicMock()
|
||||
mock_vq_inst.get_active_version.return_value = MagicMock(version="v1")
|
||||
mock_vq.return_value = mock_vq_inst
|
||||
|
||||
result = mgr.deprecate_document("test_kb", "rule.txt", reason="test deprecate")
|
||||
|
||||
check("deprecate 返回 success",
|
||||
result.get("success") is True,
|
||||
f"result: {result}")
|
||||
|
||||
check("deprecate 标记 3 个 chunks",
|
||||
result.get("deprecated_chunks") == 3,
|
||||
f"deprecated_chunks: {result.get('deprecated_chunks')}")
|
||||
|
||||
# 验证 SQLite 中的状态是否更新
|
||||
versions = get_versions(test_conn, "test_kb", "rule.txt")
|
||||
v1_status = versions[0][1] if versions else None
|
||||
check("SQLite v1 状态改为 deprecated",
|
||||
v1_status == "deprecated",
|
||||
f"status: {v1_status}")
|
||||
|
||||
v1_deprecated_date = versions[0][2] if versions else None
|
||||
check("SQLite deprecated_date 有值",
|
||||
v1_deprecated_date is not None,
|
||||
f"deprecated_date: {v1_deprecated_date}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 3:restore_document 同步 SQLite(mock 验证)
|
||||
# ======================================================================
|
||||
print("\n=== 测试 3:restore_document SQLite 同步逻辑 ===")
|
||||
|
||||
mgr2 = MockDocumentManager()
|
||||
mgr2.mock_collection.get.return_value = {
|
||||
'ids': ['rule_0', 'rule_1'],
|
||||
'metadatas': [
|
||||
{'source': 'rule.txt', 'status': 'deprecated'},
|
||||
{'source': 'rule.txt', 'status': 'deprecated'},
|
||||
]
|
||||
}
|
||||
|
||||
with patch('data.db.get_connection', return_value=test_conn):
|
||||
with patch('knowledge.document_versions.get_version_query') as mock_vq:
|
||||
mock_vq_inst = MagicMock()
|
||||
mock_vq_inst.get_document_history.return_value = [
|
||||
MagicMock(status=MagicMock(value='deprecated'), version='v1')
|
||||
]
|
||||
mock_vq.return_value = mock_vq_inst
|
||||
|
||||
result = mgr2.restore_document("test_kb", "rule.txt")
|
||||
|
||||
check("restore 返回 success",
|
||||
result.get("success") is True,
|
||||
f"result: {result}")
|
||||
|
||||
check("restore 恢复 2 个 chunks",
|
||||
result.get("restored_chunks") == 2,
|
||||
f"restored_chunks: {result.get('restored_chunks')}")
|
||||
|
||||
versions = get_versions(test_conn, "test_kb", "rule.txt")
|
||||
v1_status = versions[0][1] if versions else None
|
||||
check("SQLite v1 状态恢复为 active",
|
||||
v1_status == "active",
|
||||
f"status: {v1_status}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 4:cleanup_superseded_versions 清理 SQLite 记录
|
||||
# ======================================================================
|
||||
print("\n=== 测试 4:cleanup_superseded_versions SQLite 清理 ===")
|
||||
|
||||
from knowledge.cleanup import cleanup_superseded_versions
|
||||
|
||||
test_conn2 = create_test_db()
|
||||
# 插入一条过期的 superseded 记录
|
||||
old_date = (datetime.now() - timedelta(days=30)).isoformat()
|
||||
test_conn2.execute(
|
||||
"""INSERT INTO document_versions
|
||||
(document_id, collection, version, status, deprecated_date)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
("old_report.pdf", "public_kb", "v1", "superseded", old_date)
|
||||
)
|
||||
# 插入一条近期的 superseded 记录
|
||||
recent_date = (datetime.now() - timedelta(days=2)).isoformat()
|
||||
test_conn2.execute(
|
||||
"""INSERT INTO document_versions
|
||||
(document_id, collection, version, status, deprecated_date)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
("new_report.pdf", "public_kb", "v1", "superseded", recent_date)
|
||||
)
|
||||
# 插入一条旧日志
|
||||
test_conn2.execute(
|
||||
"""INSERT INTO version_change_logs
|
||||
(document_id, collection, change_type, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
("old_report.pdf", "public_kb", "supersede", old_date)
|
||||
)
|
||||
test_conn2.commit()
|
||||
|
||||
with patch('data.db.get_connection', return_value=test_conn2):
|
||||
cleaned = cleanup_superseded_versions(days_to_keep=7)
|
||||
|
||||
check("清理了 1 条过期 superseded 记录",
|
||||
cleaned == 1,
|
||||
f"cleaned: {cleaned}")
|
||||
|
||||
# 验证剩余记录
|
||||
remaining = test_conn2.execute(
|
||||
"SELECT document_id, status FROM document_versions"
|
||||
).fetchall()
|
||||
check("近期 superseded 记录保留",
|
||||
any(r[0] == "new_report.pdf" and r[1] == "superseded" for r in remaining),
|
||||
f"remaining: {remaining}")
|
||||
|
||||
check("过期 superseded 记录已删除",
|
||||
not any(r[0] == "old_report.pdf" for r in remaining),
|
||||
f"remaining: {remaining}")
|
||||
|
||||
# 验证日志也被清理
|
||||
logs_remaining = test_conn2.execute(
|
||||
"SELECT COUNT(*) FROM version_change_logs"
|
||||
).fetchone()[0]
|
||||
check("过期变更日志已清理",
|
||||
logs_remaining == 0,
|
||||
f"logs_remaining: {logs_remaining}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 5:_filter_deprecated_chunks 过滤逻辑不变
|
||||
# ======================================================================
|
||||
print("\n=== 测试 5:_filter_deprecated_chunks 过滤逻辑 ===")
|
||||
|
||||
from core.engine import RAGEngine
|
||||
|
||||
# 模拟搜索结果
|
||||
mock_results = {
|
||||
'ids': [['id1', 'id2', 'id3', 'id4']],
|
||||
'documents': [['doc1', 'doc2', 'doc3', 'doc4']],
|
||||
'metadatas': [[
|
||||
{'source': 'a.txt', 'status': 'active'},
|
||||
{'source': 'b.txt', 'status': 'deprecated'},
|
||||
{'source': 'c.txt'}, # 无 status 字段,默认 active
|
||||
{'source': 'd.txt', 'status': 'superseded'},
|
||||
]],
|
||||
'distances': [[0.1, 0.2, 0.3, 0.4]],
|
||||
}
|
||||
|
||||
# 直接调用实例方法(传 None 作为 self,方法中未使用 self)
|
||||
filter_fn = RAGEngine._filter_deprecated_chunks
|
||||
filtered = filter_fn(None, mock_results)
|
||||
|
||||
check("过滤后保留 2 条(active + 无status)",
|
||||
len(filtered['ids'][0]) == 2,
|
||||
f"ids: {filtered['ids'][0]}")
|
||||
|
||||
check("保留的 id 是 id1 和 id3",
|
||||
filtered['ids'][0] == ['id1', 'id3'],
|
||||
f"ids: {filtered['ids'][0]}")
|
||||
|
||||
check("deprecated 被过滤",
|
||||
'id2' not in filtered['ids'][0],
|
||||
"")
|
||||
|
||||
check("superseded 被过滤",
|
||||
'id4' not in filtered['ids'][0],
|
||||
"")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 6:upload 覆盖版本记录逻辑验证
|
||||
# ======================================================================
|
||||
print("\n=== 测试 6:upload 覆盖 + sync 版本记录逻辑(代码路径验证)===")
|
||||
|
||||
# 验证 document_routes.py 中 replaced 分支有 superseded 标记代码
|
||||
routes_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"api", "document_routes.py"
|
||||
)
|
||||
with open(routes_file, 'r', encoding='utf-8') as f:
|
||||
source = f.read()
|
||||
|
||||
check("upload_document 中有 get_version_query 调用",
|
||||
"get_version_query" in source,
|
||||
"")
|
||||
|
||||
check("upload_document 中有 superseded 状态更新",
|
||||
"status='superseded'" in source,
|
||||
"")
|
||||
|
||||
check("upload_document replaced 分支标记旧版本",
|
||||
"重新上传覆盖" in source,
|
||||
"")
|
||||
|
||||
# 验证 sync.py ADDED 分支使用自动版本号(不硬编码 v1)
|
||||
sync_file2 = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"knowledge", "sync.py"
|
||||
)
|
||||
with open(sync_file2, 'r', encoding='utf-8') as f:
|
||||
sync_src = f.read()
|
||||
|
||||
check("sync ADDED 分支使用 _generate_version_id",
|
||||
"_generate_version_id" in sync_src,
|
||||
"")
|
||||
|
||||
check("sync ADDED 版本记录使用动态版本号",
|
||||
"version=new_version" in sync_src,
|
||||
"")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试 7:sync.py MODIFIED 流程调用正确性
|
||||
# ======================================================================
|
||||
print("\n=== 测试 7:sync.py MODIFIED 流程参数验证 ===")
|
||||
|
||||
sync_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"knowledge", "sync.py"
|
||||
)
|
||||
with open(sync_file, 'r', encoding='utf-8') as f:
|
||||
sync_source = f.read()
|
||||
|
||||
check("MODIFIED 分支中 mark 调用包含 new_version 参数",
|
||||
"new_version=new_version" in sync_source,
|
||||
"")
|
||||
|
||||
# 在 MODIFIED 分支内验证调用顺序:先获取 old_version 再生成 new_version
|
||||
modified_marker = "elif change.change_type == ChangeType.MODIFIED:"
|
||||
mod_start = sync_source.index(modified_marker)
|
||||
modified_section = sync_source[mod_start:mod_start + 2000]
|
||||
check("MODIFIED 分支先获取 old_version 再生成 new_version",
|
||||
"_get_current_version" in modified_section and
|
||||
"_generate_version_id" in modified_section and
|
||||
modified_section.index("_get_current_version") < modified_section.index("_generate_version_id"),
|
||||
f"section contains: _get_current_version={'_get_current_version' in modified_section}, _generate_version_id={'_generate_version_id' in modified_section}")
|
||||
|
||||
check("MODIFIED 分支有 create_version_record 调用",
|
||||
"create_version_record" in sync_source,
|
||||
"")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 汇总
|
||||
# ======================================================================
|
||||
print(f"\n{'='*60}")
|
||||
print(f"测试完成: {passed} 通过, {failed} 失败, 共 {passed + failed} 条")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user