From cb75b9b2743c3c7bd3b99311f1083bfce55187c0 Mon Sep 17 00:00:00 2001 From: lacerate551 <128470311+lacerate551@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:58:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(boundary):=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E5=BA=93=E8=BE=B9=E7=95=8C=E9=97=AE=E9=A2=98=E3=80=81=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=AE=A1=E7=90=86=E5=8F=8A=E5=88=A0=E9=99=A4=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多库检索与存储修复: - 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 - 更新多篇现有文档 --- api/chat_routes.py | 35 +- api/document_routes.py | 123 +- core/engine.py | 34 +- docs/Agentic_RAG完整指南.md | 139 +- docs/Agent工具调用与MCP协议详解.md | 350 ----- docs/MinerU模型部署指南.md | 467 +++---- docs/OPTIMIZATION_PROGRESS.md | 50 - docs/RAG幻觉问题优化方案.md | 649 --------- docs/RAG数据流程.md | 609 +++++++++ docs/RAG数据流程完整分析.md | 448 ------ docs/RAG数据流程详解.md | 590 -------- docs/RAG需求负责清单.md | 16 +- docs/curl测试手册.md | 11 +- docs/rag检索流程 | 138 -- docs/出题批卷系统设计.md | 11 +- docs/后端对接规范.md | 7 +- docs/向量库边界风险分析.md | 164 +++ docs/多向量库实现权限划分.md | 29 +- docs/多源信息融合指南.md | 57 +- docs/开发与系统模块说明.md | 2047 +++++++++++++++------------- docs/数据库设计文档.md | 371 ++--- docs/数据归属与协作方案.md | 58 +- docs/文档审查报告.md | 136 -- docs/服务器端测试报告.md | 318 ----- docs/架构与部署方案.md | 305 +++-- docs/模型统一管理方案.md | 284 ---- docs/测试指南.md | 1050 +++++++------- docs/版本管理实施完成报告.md | 26 +- docs/生产路径优化计划.md | 111 +- docs/认证与权限配置指南.md | 8 +- docs/风险边界问题修复注意事项.md | 119 ++ knowledge/cleanup.py | 83 +- knowledge/collection.py | 23 + knowledge/document.py | 72 +- knowledge/manager.py | 128 +- knowledge/processing.py | 10 +- knowledge/search.py | 6 +- knowledge/sync.py | 1806 ++++++++++++------------ test_files/parse_rag.py | 25 + test_files/rag_output.txt | 68 + test_files/rag_test2.json | 1 + test_files/rule_a.txt | 21 + test_files/rule_a_v2.txt | 21 + test_files/规章制度.txt | 16 + test_files/规章制度_deptA.txt | 16 + test_files/规章制度_deptB.txt | 16 + tests/e2e_risk_test.py | 419 ++++++ tests/test_edge_cases.py | 330 +++++ tests/test_upload_dedup.py | 375 +++++ tests/test_version_management.py | 437 ++++++ 50 files changed, 6385 insertions(+), 6248 deletions(-) delete mode 100644 docs/Agent工具调用与MCP协议详解.md delete mode 100644 docs/OPTIMIZATION_PROGRESS.md delete mode 100644 docs/RAG幻觉问题优化方案.md create mode 100644 docs/RAG数据流程.md delete mode 100644 docs/RAG数据流程完整分析.md delete mode 100644 docs/RAG数据流程详解.md delete mode 100644 docs/rag检索流程 create mode 100644 docs/向量库边界风险分析.md delete mode 100644 docs/文档审查报告.md delete mode 100644 docs/服务器端测试报告.md delete mode 100644 docs/模型统一管理方案.md create mode 100644 docs/风险边界问题修复注意事项.md create mode 100644 test_files/parse_rag.py create mode 100644 test_files/rag_output.txt create mode 100644 test_files/rag_test2.json create mode 100644 test_files/rule_a.txt create mode 100644 test_files/rule_a_v2.txt create mode 100644 test_files/规章制度.txt create mode 100644 test_files/规章制度_deptA.txt create mode 100644 test_files/规章制度_deptB.txt create mode 100644 tests/e2e_risk_test.py create mode 100644 tests/test_edge_cases.py create mode 100644 tests/test_upload_dedup.py create mode 100644 tests/test_version_management.py diff --git a/api/chat_routes.py b/api/chat_routes.py index dde2f72..ec0adb5 100644 --- a/api/chat_routes.py +++ b/api/chat_routes.py @@ -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') diff --git a/api/document_routes.py b/api/document_routes.py index c1a2601..5860c1e 100644 --- a/api/document_routes.py +++ b/api/document_routes.py @@ -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({ diff --git a/core/engine.py b/core/engine.py index 27dc162..5092344 100644 --- a/core/engine.py +++ b/core/engine.py @@ -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]], diff --git a/docs/Agentic_RAG完整指南.md b/docs/Agentic_RAG完整指南.md index d16e137..5e5d833 100644 --- a/docs/Agentic_RAG完整指南.md +++ b/docs/Agentic_RAG完整指南.md @@ -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. 检索后:质量评估与自我迭代 diff --git a/docs/Agent工具调用与MCP协议详解.md b/docs/Agent工具调用与MCP协议详解.md deleted file mode 100644 index e6f7ea5..0000000 --- a/docs/Agent工具调用与MCP协议详解.md +++ /dev/null @@ -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) diff --git a/docs/MinerU模型部署指南.md b/docs/MinerU模型部署指南.md index e2c4fa8..f60b306 100644 --- a/docs/MinerU模型部署指南.md +++ b/docs/MinerU模型部署指南.md @@ -35,7 +35,11 @@ MinerU 配置文件查找顺序: **Windows**:`C:\Users\\mineru.json` **Linux**:`/root/mineru.json` 或 `/home//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= +DASHSCOPE_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= + +# 网络搜索(按需开启) +ENABLE_WEB_SEARCH=false +SERPER_API_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=`,无需在本地部署模型。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 -o -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/) \ No newline at end of file diff --git a/docs/OPTIMIZATION_PROGRESS.md b/docs/OPTIMIZATION_PROGRESS.md deleted file mode 100644 index 8dd5b35..0000000 --- a/docs/OPTIMIZATION_PROGRESS.md +++ /dev/null @@ -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批处理 | diff --git a/docs/RAG幻觉问题优化方案.md b/docs/RAG幻觉问题优化方案.md deleted file mode 100644 index 709e395..0000000 --- a/docs/RAG幻觉问题优化方案.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/docs/RAG数据流程.md b/docs/RAG数据流程.md new file mode 100644 index 0000000..d362e65 --- /dev/null +++ b/docs/RAG数据流程.md @@ -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()` | diff --git a/docs/RAG数据流程完整分析.md b/docs/RAG数据流程完整分析.md deleted file mode 100644 index 23599b8..0000000 --- a/docs/RAG数据流程完整分析.md +++ /dev/null @@ -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()` | diff --git a/docs/RAG数据流程详解.md b/docs/RAG数据流程详解.md deleted file mode 100644 index f57d30e..0000000 --- a/docs/RAG数据流程详解.md +++ /dev/null @@ -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_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_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': '...
', # 表格 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)) # ⚠ 使用默认值匹配 - 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` | diff --git a/docs/RAG需求负责清单.md b/docs/RAG需求负责清单.md index 1f90090..cce6d63 100644 --- a/docs/RAG需求负责清单.md +++ b/docs/RAG需求负责清单.md @@ -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* diff --git a/docs/curl测试手册.md b/docs/curl测试手册.md index 5d78b78..acddc73 100644 --- a/docs/curl测试手册.md +++ b/docs/curl测试手册.md @@ -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`)。 + **验证结果**:✅ 通过 --- diff --git a/docs/rag检索流程 b/docs/rag检索流程 deleted file mode 100644 index 0101e8d..0000000 --- a/docs/rag检索流程 +++ /dev/null @@ -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: 图文关联索引 │ 文本命中时补充关联图片 │ - ├──────┼──────────────────┼────────────────────────────────────┤ - │ 选择 │ 章节相关性检查 │ 避免不相关图片加分 │ - ├──────┼──────────────────┼────────────────────────────────────┤ - │ 意图 │ 重复提问强制检索 │ 避免复用错误上下文 │ - └──────┴──────────────────┴────────────────────────────────────┘ \ No newline at end of file diff --git a/docs/出题批卷系统设计.md b/docs/出题批卷系统设计.md index 1d16ab1..35e37c1 100644 --- a/docs/出题批卷系统设计.md +++ b/docs/出题批卷系统设计.md @@ -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 | 初始版本:按文件出题功能设计 | diff --git a/docs/后端对接规范.md b/docs/后端对接规范.md index b2c2255..fa73807 100644 --- a/docs/后端对接规范.md +++ b/docs/后端对接规范.md @@ -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 批量上传 ``` diff --git a/docs/向量库边界风险分析.md b/docs/向量库边界风险分析.md new file mode 100644 index 0000000..8bc3cbe --- /dev/null +++ b/docs/向量库边界风险分析.md @@ -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 兼容) | — | diff --git a/docs/多向量库实现权限划分.md b/docs/多向量库实现权限划分.md index f8fc113..a4672f7 100644 --- a/docs/多向量库实现权限划分.md +++ b/docs/多向量库实现权限划分.md @@ -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`**:从单库直接转换为多库分治物理结构的离线迁移脚本。 \ No newline at end of file +- **`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`**:从单库直接转换为多库分治物理结构的离线迁移脚本。 \ No newline at end of file diff --git a/docs/多源信息融合指南.md b/docs/多源信息融合指南.md index 68522b1..ce845ba 100644 --- a/docs/多源信息融合指南.md +++ b/docs/多源信息融合指南.md @@ -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 | 初始版本 | diff --git a/docs/开发与系统模块说明.md b/docs/开发与系统模块说明.md index eef9ff5..58edfa4 100644 --- a/docs/开发与系统模块说明.md +++ b/docs/开发与系统模块说明.md @@ -1,925 +1,1122 @@ -# 开发与系统模块说明 - -> 本文档由原《开发文档》与《模块说明》合并而成,涵盖开发环境配置、技术栈、架构以及细粒度模块说明。 - -## 第一部分:开发文档体系 - -# RAG 知识库问答系统 - 开发文档 - -> **项目版本**: v7.0.0 -> **更新日期**: 2026-04-18 -> **文档用途**: 架构说明、技术栈、部署指南 - -> **API 接口文档**: 详见 [后端对接规范.md](./后端对接规范.md) - ---- - -## 一、项目概述 - -### 1.1 项目定位 - -本项目是智能出题系统的**核心知识服务层**,为上层 Dify 工作流提供知识检索能力。系统通过 RAG(检索增强生成)技术,实现基于企业制度文档的智能问答,支持: - -- **知识库问答**:基于向量检索 + BM25 + Rerank 的混合检索 -- **Agentic RAG**:智能问答流程(Query Rewriting、Context Compression、Answer Grounding) -- **多轮对话**:会话历史管理、代词消解 -- **图谱推理**:基于 Neo4j 的多跳关系查询(可选) -- **网络搜索**:实时信息获取(可选,需配置 Serper API) - -### 1.2 系统架构 - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 前端应用层 │ -│ (chat-ui/ 开发测试界面) │ -└───────────────────────────────┬─────────────────────────────────────┘ - │ HTTP API / SSE - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ API 服务层 (api/) │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ -│ │ /chat │ │ /rag │ │ /sessions │ │ /search │ │ -│ │ 智能聊天 │ │ SSE 流式问答│ │ 会话管理 │ │ 混合检索 │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────┬──────┘ │ -└─────────┼────────────────┼────────────────┼───────────────┼────────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 核心能力层 (core/) │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Agentic RAG (agentic.py) │ │ -│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │ │ -│ │ │ Query │ │ 检索层 │ │ Context │ │ Answer │ │ │ -│ │ │ Rewriting │ │向量+BM25 │ │Compression│ │ Grounding│ │ │ -│ │ │ 统一入口 │ │ +Rerank │ │ Token控制 │ │ 幻觉闭环 │ │ │ -│ │ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 数据存储层 │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ -│ │ ChromaDB │ │ .data/ │ │ SQLite │ │ documents/ │ │ -│ │ 向量数据库 │ │ 图片存储 │ │ 会话数据 │ │ 文档源 │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### 1.3 技术栈 - -| 层级 | 技术 | 说明 | -|------|------|------| -| API 服务 | Flask + Flask-CORS | RESTful API,SSE 流式返回 | -| 文档解析 | MinerU 3.0+ | PDF/DOCX/PPTX/图片统一解析 | -| 向量检索 | ChromaDB + BGE-base-zh | 本地向量数据库 + 嵌入模型 | -| 关键词检索 | BM25 + jieba | 中文分词 + 倒排索引 | -| 重排序 | BGE-reranker-base | CrossEncoder 精排 | -| 大模型 | Qwen (通义千问) | 问答生成、实体提取 | -| 数据库 | SQLite | 会话管理、审计日志 | - ---- - -## 二、项目结构 - -``` -├── main.py # 统一启动入口 -├── config.py # API 配置(需自行创建) -├── requirements.txt # 依赖列表 -│ -├── api/ # API 路由层(Flask Blueprint) -│ ├── __init__.py # create_app() 应用工厂 -│ ├── chat_routes.py # /chat, /rag (SSE), /search -│ ├── session_routes.py # /sessions, /history -│ ├── auth_routes.py # /health, /auth/me -│ ├── kb_routes.py # /collections -│ ├── document_routes.py # /documents/upload -│ ├── sync_routes.py # /sync -│ ├── image_routes.py # /images/ -│ └── feedback_routes.py # /feedback -│ -├── core/ # RAG 核心引擎 -│ ├── agentic.py # AgenticRAG 智能问答 -│ ├── engine.py # 检索引擎封装 -│ ├── bm25_index.py # BM25 索引 -│ ├── chunker.py # 文本分块 -│ ├── query_classifier.py # 查询分类器 -│ ├── confidence_gate.py # 置信度门控 -│ ├── quality_assessor.py # 质量评估器 -│ ├── loop_guard.py # 循环防护 -│ └── reasoning_reflector.py # 推理反思器 -│ -├── parsers/ # 文档解析器 -│ ├── mineru_parser.py # MinerU 统一解析 (PDF/DOCX/PPTX/图片) -│ ├── excel_parser.py # Excel 专属管道 -│ └── image_extractor.py # 图片噪音过滤 -│ -├── knowledge/ # 知识库管理 -│ ├── manager.py # 多向量库管理器 -│ ├── router.py # 知识库路由器 -│ └── sync.py # 同步服务 -│ -├── services/ # 业务服务 -│ ├── session.py # 会话管理 (SQLite) -│ ├── audit.py # 审计日志 -│ └── feedback.py # 反馈系统 -│ -├── auth/ # 认证与安全 -│ ├── gateway.py # 网关认证 (DEV_MODE mock token) -│ └── security.py # 安全防护 -│ -├── data/ # SQLite 数据库 -│ ├── db.py # 统一数据访问层 -│ ├── rag_core.db # 会话/审计数据 -│ └── knowledge.db # 知识管理数据 -│ -├── .data/ # 运行时数据 -│ ├── files/images/ # 提取的图片 -│ └── mineru_output/ # MinerU 解析输出 -│ -├── chat-ui/ # 前端测试界面 -│ ├── index.html # 主页面 -│ ├── app.js # 主逻辑 -│ └── api-test.js # API 测试面板 -│ -├── docs/ # 文档 -│ ├── 后端对接规范.md # API 接口规范 (主要) -│ ├── 开发文档.md # 本文档 -│ └── ... -│ -├── scripts/ # 工具脚本 -│ └── analyze_chunks.py # 切片分析 -│ -├── tools/ # 开发工具 -│ └── export_chunks.py # 导出切片 -│ -└── exam_pkg/ # 出题系统(可选) - ├── manager.py # 出题与批卷 - └── api.py # Flask Blueprint -``` - ---- - -## 三、Agentic RAG 流程 - -### 3.1 完整流程图 - -``` -用户问题 (query) - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 1. Query Rewriting(统一入口) │ -│ - 有历史对话 → 强制改写(消歧) │ -│ - 短查询 (<10字符) → 强制改写(扩展) │ -│ - 其他 → LLM 判断是否需要改写 │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 2. 查询分类 (QueryClassifier) │ -│ - FACT: 事实查询 → 直接检索 │ -│ - COMPARISON: 比较查询 → 分解检索 │ -│ - META: 元问题 → 直接回答 │ -│ - REALTIME: 实时信息 → 网络搜索(可选) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 3. 检索流程 │ -│ - 向量检索 + BM25 + Rerank │ -│ - 置信度门控检查 (threshold=0.3) │ -│ - 多维质量评估 (相关性/完整性/准确性/覆盖率) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 4. Context Compression │ -│ - Rerank 过滤 (score < 0.3 丢弃) │ -│ - 去重 (相同来源+页码只保留一个) │ -│ - Token 控制 (max=3500 tokens, max=20 条) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 5. 答案生成 │ -│ - 多源融合 (知识库 + 网络 + 图谱) │ -│ - 来源标注 │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 6. Answer Grounding(幻觉闭环) │ -│ - 幻觉检测 (推理反思器) │ -│ - 发现幻觉 → 补充检索 → 重新生成 │ -│ - 最多重试 1 次 │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 7. 输出 │ -│ - answer: 回答内容 │ -│ - sources: 来源列表(已去重,含页码范围) │ -│ - images/tables: 富媒体信息 │ -│ - session_id: 会话ID(用于多轮对话) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 3.2 关键配置参数 - -| 参数 | 值 | 说明 | -|------|-----|------| -| `MAX_CONTEXT_TOKENS` | 3500 | 上下文最大 token 数 | -| `MAX_CONTEXT_COUNT` | 20 | 上下文最大条数 | -| `RERANK_THRESHOLD` | 0.3 | Rerank 过滤阈值 | -| `MAX_GROUNDING_RETRY` | 1 | 幻觉修正最多重试次数 | -| `max_iterations` | 3 | 最大迭代检索次数 | - ---- - -## 四、API 接口 - -> **详细 API 文档**: 详见 [后端对接规范.md](./后端对接规范.md) - -### 核心接口概览 - -| 接口 | 方法 | 说明 | -|------|------|------| -| `/chat` | POST | 智能聊天 | -| `/rag` | POST | 知识库问答(SSE 流式) | -| `/search` | POST | 混合检索(供 Dify 调用) | -| `/sessions` | GET | 会话列表 | -| `/history/` | GET | 会话历史 | -| `/collections` | GET | 向量库列表 | -| `/images/` | GET | 获取图片 | -| `/sync` | POST | 触发同步 | -| `/health` | GET | 健康检查 | - ---- - -## 五、开发环境配置 - -### 5.1 环境准备 - -```powershell -# 创建虚拟环境 -python -m venv venv -.\venv\Scripts\Activate.ps1 - -# 安装依赖 -pip install -r requirements.txt -``` - -### 5.2 配置文件 - -复制 `config.example.py` 为 `config.py`: - -```python -# config.py - 必需配置 - -# 通义千问 API(必需) -DASHSCOPE_API_KEY = "your-api-key" -DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" -DASHSCOPE_MODEL = "qwen-flash" # 文本模型 -DASHSCOPE_VL_MODEL = "qwen-vl-plus" # 视觉模型(图片描述) - -# 兼容变量 -API_KEY = DASHSCOPE_API_KEY -BASE_URL = DASHSCOPE_BASE_URL -MODEL = DASHSCOPE_MODEL - -# 文档路径 -DOCUMENTS_PATH = "./documents" - -# 开发模式(支持 mock 用户) -DEV_MODE = True -``` - -### 5.3 开发模式特性 - -| 特性 | 说明 | -|------|------| -| Mock 用户 | 支持 `mock-token-admin` 等模拟 token | -| 本地登录 | `/auth/login` 接口支持用户名密码登录 | -| 会话存储 | SQLite 本地存储,无需外部数据库 | -| 前端界面 | `http://localhost:5001` 直接访问测试 | - -**模拟用户列表**: - -| 用户名 | 密码 | 角色 | -|--------|------|------| -| admin | admin123 | admin | -| manager | manager123 | manager | -| user | test123 | user | - ---- - -## 六、运行命令 - -### 6.1 启动服务 - -```powershell -# 激活虚拟环境 -.\venv\Scripts\Activate.ps1 - -# 启动服务 -python main.py # 端口 5001 -python main.py --port 8080 # 指定端口 -``` - -### 6.2 同步知识库 - -```powershell -# 通过 API 触发同步 -curl -X POST http://localhost:5001/sync - -# 或通过前端界面操作 -``` - ---- - -## 七、会话管理 - -### 7.1 多轮对话流程 - -``` -首次对话: -POST /rag { "message": "出差补助标准", "collections": ["public_kb"] } - ↓ -finish 事件返回 session_id - ↓ -前端保存 session_id - -后续对话: -POST /rag { "message": "它有什么限制", "session_id": "xxx", "collections": ["public_kb"] } - ↓ -RAG 服务自动从 SQLite 加载历史 - ↓ -Query Rewriting: "它" → "出差补助" - ↓ -生成带上下文的回答 -``` - -### 7.2 会话相关 API - -| 接口 | 说明 | -|------|------| -| `GET /sessions` | 获取用户会话列表 | -| `GET /history/` | 获取会话历史 | -| `DELETE /session/` | 删除会话 | - ---- - -## 八、部署指南 - -### 8.1 生产环境建议 - -| 项目 | 建议 | -|------|------| -| DEV_MODE | 设置为 `false` | -| WSGI 服务器 | gunicorn 或 uWSGI | -| 反向代理 | Nginx | -| HTTPS | 配置 SSL 证书 | - -### 8.2 职责边界 - -| 后端负责 | RAG 服务负责 | -|----------|--------------| -| 用户认证 | 知识库问答 | -| 权限判断 | 向量检索 | -| 会话管理(生产) | 返回溯源 | -| 消息存储 | 文档处理 | - ---- - -## 九、错误码说明 - -| 状态码 | 说明 | 处理建议 | -|--------|------|----------| -| 200 | 成功 | - | -| 400 | 请求参数错误 | 检查请求体格式 | -| 401 | 未认证 | 检查 Header 认证信息 | -| 403 | 权限不足 | 检查用户角色权限 | -| 404 | 资源不存在 | 检查 session_id 或资源路径 | -| 500 | 服务器内部错误 | 查看服务日志 | - ---- - -## 十、相关文档 - -- [后端对接规范.md](./后端对接规范.md) - API 接口规范(主要) -- [数据库设计文档.md](./数据库设计文档.md) - 数据库结构 -- [模块说明.md](./模块说明.md) - 模块详细说明 -- [Agentic_RAG完整指南.md](./Agentic_RAG完整指南.md) - Agentic RAG 详解 - - ---- - -## 第二部分:模块规范体系 - -# 项目模块说明文档 (v6.1.0) - -> **注**:本项目经过大规模重构,采用模块化架构。当前版本已包含细粒度多向量库权限控制、文档生命周期跟踪、本地化自动出题系统及FAQ问答闭环反馈收集。 - -## 项目架构概览 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ API 服务层 │ -│ main.py (入口) │ -│ (Flask 应用工厂,整合所有 Blueprint,提供 REST API) │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ │ │ │ │ │ │ - ▼ ▼ ▼ ▼ ▼ ▼ ▼ -┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐ -│ RAG 核心 ││ 图谱模块 ││ 出题系统 ││ 安全模块 ││ 同步服务 ││ 反馈闭环 ││ 纲要生成 │ -│core/ ││graph/ ││exam_pkg/ ││auth/ ││knowledge/││services/ ││services/ │ -│agentic.py││graph_ ││manager.py││gateway.py││sync.py ││feedback.py││outline.py│ -│engine.py ││manager.py││api.py ││security.py││ ││ ││ │ -│bm25_ ││entity_ ││local_db.py││ ││ ││ ││ │ -│index.py ││extractor ││analysis.py││ ││ ││ ││ │ -│chunker.py││graph_rag ││question_ ││ ││ ││ ││ │ -│ ││graph_ ││hook.py ││ ││ ││ ││ │ -│ ││build.py ││ ││ ││ ││ ││ │ -└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘ - │ │ - ▼ │ -┌──────────────────────────┐ │ -│ 多向量库管理 │ │ -│ knowledge/manager.py │ │ -│ knowledge/router.py │ │ -└──────────────────────────┘ │ - ┌─────────────────────┼─────────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │会话管理 │ │题库分析 │ │ 审计日志 │ - │services/ │ │exam_pkg/ │ │services/ │ - │session.py│ │analysis.py│ │audit.py │ - └──────────┘ └──────────┘ └──────────┘ -``` - ---- - -## 目录结构 - -``` -项目根目录/ -├── main.py # ✨ 统一启动入口(推荐) -├── config.py # API 配置(不提交) -├── config.example.py # API 配置模板 -├── requirements.txt # 依赖列表 -│ -├── api/ # API 路由层(Flask Blueprint) -│ ├── __init__.py # create_app() 应用工厂 -│ ├── chat_routes.py # /chat, /rag, /rag/stream, /search -│ ├── session_routes.py # /sessions, /history, /session, /clear -│ ├── auth_routes.py # /stats, /health, /auth/me -│ ├── audit_routes.py # /audit/logs -│ ├── kb_routes.py # /collections, /documents/sync, /kb/route -│ ├── document_routes.py # /documents/upload, /documents/list, 版本管理 -│ ├── sync_routes.py # /sync, /subscribe, /notifications -│ ├── graph_routes.py # /graph/search, /graph/build, /graph/stats -│ ├── question_routes.py # /questions/*, /knowledge-points -│ ├── outline_routes.py # /outline/*, /recommend/* -│ ├── feedback_routes.py # /feedback/*, /reports/*, /faq/* -│ └── image_routes.py # 图片相关接口 -│ -├── core/ # RAG 核心引擎 -│ ├── __init__.py -│ ├── agentic.py # AgenticRAG 智能问答 -│ ├── engine.py # 检索引擎封装 -│ ├── bm25_index.py # BM25 关键词索引 -│ ├── chunker.py # 语义分块器 -│ ├── query_classifier.py # 查询分类器 -│ ├── confidence_gate.py # 置信度门控 -│ ├── quality_assessor.py # 质量评估器 -│ ├── reasoning_reflector.py # 推理反思器 -│ └── loop_guard.py # 循环防护 -│ -├── parsers/ # 文档解析器 -│ ├── __init__.py -│ ├── mineru_parser.py # MinerU 统一解析(PDF/DOCX/PPTX/图片) -│ ├── pdf_mineru.py # MinerU PDF 兼容别名 -│ ├── excel_parser.py # Excel 解析(Pandas 管道) -│ ├── txt_parser.py # TXT 解析 -│ └── image_extractor.py # 图片提取器 -│ -├── knowledge/ # 知识库管理模块 -│ ├── __init__.py -│ ├── manager.py # 多向量库管理器 -│ ├── router.py # 知识库路由器 -│ ├── sync.py # 同步服务 -│ ├── lifecycle.py # 文档生命周期 -│ ├── diff.py # 文档差异分析 -│ └── vector_store/ # 向量数据库与BM25索引 -│ ├── chroma/ # ChromaDB存储 -│ └── bm25/ # BM25索引存储 -│ -├── exam_pkg/ # 考试系统 -│ ├── __init__.py -│ ├── manager.py # 出题与批卷 -│ ├── api.py # Flask Blueprint (exam_bp) -│ ├── analysis.py # 考试分析 -│ ├── local_db.py # 本地题库 -│ └── question_hook.py # 题目维护钩子 -│ -├── services/ # 业务服务 -│ ├── __init__.py -│ ├── session.py # 会话管理 -│ ├── audit.py # 审计日志 -│ ├── feedback.py # 反馈质量闭环 -│ ├── outline.py # 纲要生成与推荐 -│ └── user_info.py # 用户信息服务 -│ -├── auth/ # 认证与安全 -│ ├── __init__.py -│ ├── gateway.py # 网关认证 -│ └── security.py # 输入/输出安全 -│ -├── data/ # SQLite 数据库 -│ ├── __init__.py -│ ├── db.py # 统一数据访问层 -│ ├── rag_core.db # 核心数据(会话、审计、反馈) -│ ├── knowledge.db # 知识管理(同步、大纲、版本) -│ └── exam.db # 出题系统(题目、试卷、批卷) -│ -├── graph/ # 知识图谱 -│ ├── __init__.py -│ ├── graph_manager.py # Neo4j 图谱管理 -│ ├── entity_extractor.py # 实体提取器 -│ ├── graph_rag.py # 图谱增强检索 -│ └── graph_build.py # 图谱构建工具 -│ -├── documents/ # 知识库文档目录 -├── models/ # 本地模型目录 -├── scripts/ # 工具脚本 -│ ├── migrate_version_status.py # 版本状态迁移 -│ ├── rebuild_multi_kb.py # 重建多向量库 -│ ├── run_exam.py # 运行考试 -│ └── test_rag_questions.py # RAG问题测试 -├── tests/ # 测试 -├── chat-ui/ # 前端界面 -├── venv/ # 虚拟环境 -│ -``` - -> **注意**: 根目录下的 `.py` 文件大多已迁移至子包,保留仅为向后兼容。 -> 新代码请使用子包路径导入,如 `from auth.gateway import require_gateway_auth`。 - ---- - -## 模块详细说明 - -### 一、API 路由层 (api/) - -#### 1. `api/__init__.py` - 应用工厂 - -**职责**:创建并配置 Flask 应用,注册所有 Blueprint - -**主要功能**: -- 初始化共享服务(SessionManager、AuditLogger、AgenticRAG) -- 注册所有 API Blueprint -- 可选模块按需加载 - -**使用方式**: -```python -from api import create_app - -app = create_app() -app.run(host='0.0.0.0', port=5001) -``` - -#### 2. API Blueprint 分组 - -| Blueprint | 文件 | 端点前缀 | 主要功能 | -|-----------|------|----------|----------| -| `auth_bp` | auth_routes.py | - | /stats, /health, /auth/me | -| `session_bp` | session_routes.py | - | /sessions, /history, /session, /clear | -| `audit_bp` | audit_routes.py | - | /audit/logs | -| `chat_bp` | chat_routes.py | - | /chat, /rag, /rag/stream, /search | -| `kb_bp` | kb_routes.py | - | /collections, /documents/sync, /kb/route | -| `document_bp` | document_routes.py | - | /documents/upload, /documents/list | -| `sync_bp` | sync_routes.py | - | /sync, /subscribe, /notifications | -| `graph_bp` | graph_routes.py | - | /graph/search, /graph/build, /graph/stats | -| `question_bp` | question_routes.py | - | /questions/*, /knowledge-points | -| `outline_bp` | outline_routes.py | - | /outline/*, /recommend/* | -| `feedback_bp` | feedback_routes.py | - | /feedback/*, /reports/*, /faq/* | -| `image_bp` | image_routes.py | - | 图片上传、处理相关接口 | -| `exam_bp` | exam_pkg/api.py | /exam | 出题系统相关接口 | - ---- - -### 二、核心 RAG 模块 (core/) - -#### 3. `core/agentic.py` - Agentic RAG 核心 - -**职责**:智能问答的 Agent 决策引擎 - -**主要功能**: -- Agent 决策循环(检索、改写、分解、回答) -- 网络搜索集成(Serper API) -- 图谱检索集成 -- 多源结果融合 -- SSE 流式输出 - -**关键类/函数**: -| 类/函数 | 说明 | -|---------|------| -| `AgenticRAG` | 主类,封装所有 Agent 功能 | -| `process()` | 处理用户查询 | -| `chat_search()` | 聊天搜索(支持网络搜索) | -| `simple_query()` | 简化调用接口 | - -**使用方式**: -```python -from core.agentic import AgenticRAG, simple_query - -# 完整模式 -rag = AgenticRAG() -result = rag.process("出差补助标准是什么?") - -# 简化模式 -result = simple_query("出差补助标准") -``` - -#### 4. `core/engine.py` - 检索引擎封装 - -**职责**:统一的检索引擎接口 - -**主要功能**: -- 向量检索 -- BM25 关键词检索 -- 混合检索 + Rerank - -#### 5. `core/bm25_index.py` - BM25 索引管理 - -**职责**:BM25 关键词索引的构建和查询 - -#### 6. `core/query_classifier.py` - 查询分类器 - -**职责**:对用户查询进行意图分类,辅助选择合适的检索策略 - -#### 7. `core/confidence_gate.py` - 置信度门控 - -**职责**:基于置信度判断是否需要额外的检索或改写 - -#### 8. `core/quality_assessor.py` - 质量评估器 - -**职责**:评估检索结果和生成回答的质量 - -#### 9. `core/reasoning_reflector.py` - 推理反思器 - -**职责**:对推理过程进行反思和优化 - -#### 10. `core/loop_guard.py` - 循环防护 - -**职责**:防止 Agent 陷入无限循环,控制最大迭代次数 - ---- - -### 三、知识库管理模块 (knowledge/) - -#### 11. `knowledge/manager.py` - 多向量库管理器 - -**职责**:多向量库的创建、管理和检索 - -**主要功能**: -- 多向量库创建与管理(public_kb + dept_xxx) -- 每个向量库独立的 BM25 索引 -- 并行检索多个向量库 -- RRF 融合结果 - -**使用方式**: -```python -from knowledge.manager import get_kb_manager - -kb_manager = get_kb_manager() - -# 创建向量库 -kb_manager.create_collection('dept_finance', display_name='财务部知识库') - -# 检索 -results = kb_manager.search_multiple(['public_kb', 'dept_finance'], query_vector) -``` - -#### 12. `knowledge/router.py` - 知识库路由器 - -**职责**:根据查询意图和用户权限智能选择目标向量库 - -**主要功能**: -- 规则匹配(关键词识别部门) -- LLM 意图分析(复杂查询) -- 权限过滤 - -#### 13. `knowledge/sync.py` - 知识库同步服务 - -**职责**:自动检测文档变更并触发增量更新 - ---- - -### 四、数据库模块 (data/) - -#### 14. `data/db.py` - 统一数据访问层 - -**职责**:集中管理所有数据库连接 - -**主要功能**: -- 统一数据库路径配置 -- 连接池管理(上下文管理器) -- WAL 模式 + 外键约束 -- 自动事务管理 - -**数据库架构**: -| 数据库 | 主要功能 | -|--------|----------| -| `rag_core.db` | 会话、审计、反馈、FAQ | -| `knowledge.db` | 同步、大纲、文档版本 | -| `exam.db` | 题目、试卷、批卷、分析 | - -**使用方式**: -```python -from data.db import get_connection, init_databases - -# 初始化数据库 -init_databases() - -# 使用连接 -with get_connection("core") as conn: - cursor = conn.cursor() - cursor.execute("SELECT * FROM sessions WHERE user_id = ?", (user_id,)) - rows = cursor.fetchall() -``` - ---- - -### 五、出题系统模块 (exam_pkg/) - -#### 15. `exam_pkg/manager.py` - 出题核心逻辑 - -**职责**:试卷生成、保存、批阅的核心业务逻辑 - -**主要功能**: -- 调用 Dify 工作流生成试卷 -- 试卷 CRUD 操作 -- 审核流程管理 -- 自动批阅与报告生成 - -#### 16. `exam_pkg/api.py` - 出题系统 API - -**职责**:出题系统的 Flask Blueprint - -**API 端点**: -| 端点 | 方法 | 说明 | -|------|------|------| -| `/exam/generate-by-file` | POST | 按文件生成题目(带溯源) | -| `/exam/generate` | POST | 生成试卷 | -| `/exam/list` | GET | 获取试卷列表 | -| `/exam/` | GET/PUT/DELETE | 试卷 CRUD | -| `/exam/grade-from-mysql` | POST | 基于 MySQL 数据批卷 | - -#### 17. `exam_pkg/analysis.py` - 题库分析模块 - -**职责**:题目与制度文档关联、知识点分析 - ---- - -### 六、服务模块 (services/) - -#### 18. `services/session.py` - 会话管理 - -**职责**:多用户对话历史管理 - -**主要功能**: -- 会话创建与管理 -- 消息历史存储 -- 上下文压缩 -- 会话过期清理 - -**使用方式**: -```python -from services.session import SessionManager - -sm = SessionManager() -session_id = sm.create_session("user_123") -sm.add_message(session_id, "user", "出差补助标准是什么?") -history = sm.get_history(session_id) -``` - -#### 19. `services/audit.py` - 审计日志 - -**职责**:用户操作审计 - -**主要功能**: -- 记录查询日志 -- 记录检索结果 -- 日志查询 - -#### 20. `services/feedback.py` - 反馈服务 - -**职责**:用户反馈收集与 FAQ 自动沉淀 - -#### 21. `services/outline.py` - 纲要生成器 - -**职责**:自动生成文档结构纲要 - ---- - -### 七、认证与安全模块 (auth/) - -#### 22. `auth/gateway.py` - 网关认证 - -**职责**:网关注入的 Header 认证与多向量库权限控制 - -**主要功能**: -- 从 Header 读取用户信息(X-User-ID、X-User-Role、X-User-Department) -- 角色映射 -- 多向量库权限控制 -- `@require_gateway_auth` 装饰器 - -**网关注入的 Header**: -| Header | 说明 | -|--------|------| -| `X-User-ID` | 用户唯一标识 | -| `X-User-Name` | 用户名 | -| `X-User-Role` | 用户角色 | -| `X-User-Department` | 部门 | - -#### 23. `auth/security.py` - 安全防护 - -**职责**:Prompt 注入防护 - ---- - -### 八、图谱模块 (graph/) - -> **注意**:图谱模块为可选功能,需在 `config.py` 中配置 `USE_GRAPH_RAG=True` 和 Neo4j 连接。 - -#### 24. `graph/graph_manager.py` - 图谱管理器 - -**职责**:Neo4j 图数据库管理 - -#### 25. `graph/entity_extractor.py` - 实体提取器 - -**职责**:使用 LLM 从文本提取实体和关系 - -#### 26. `graph/graph_rag.py` - 图谱 RAG - -**职责**:图谱增强检索 - -#### 27. `graph/graph_build.py` - 图谱构建工具 - -**使用方式**: -```bash -python -m graph.graph_build --stats -python -m graph.graph_build --file documents/xxx.pdf -``` - ---- - -## 数据库文件说明 - -| 文件名 | 主要功能 | 详细文档 | -|--------|----------|----------| -| `data/rag_core.db` | 会话管理、审计日志、用户反馈、FAQ | [数据库设计文档.md](./数据库设计文档.md) | -| `data/knowledge.db` | 知识库同步、文档哈希、纲要缓存、版本管理 | [数据库设计文档.md](./数据库设计文档.md) | -| `data/exam.db` | 题目存储、试卷管理、批阅记录、分析报告 | [数据库设计文档.md](./数据库设计文档.md) | -| `knowledge/vector_store/` | 多向量库存储(ChromaDB) | [多向量库实现权限划分.md](./多向量库实现权限划分.md) | - ---- - -## 运行命令 - -```powershell -# ✨ 推荐方式 - 新入口 -python main.py # 启动 API 服务(端口 5001) -python main.py --port 8080 # 指定端口 - -# 旧入口(仍可用) -python main.py # 启动统一网关与大模型 API 服务 -python scripts/test_rag_questions.py # 自动化问答自评估测试 -python scripts/rebuild_multi_kb.py # 强制重建各个部门/集合维度的知识库 -``` - ---- - -## 相关文档 - -- [API接口文档.md](./API接口文档.md) - REST API 详细说明 -- [数据库设计文档.md](./数据库设计文档.md) - 所有数据库结构 -- [认证与权限配置指南.md](./认证与权限配置指南.md) - 网关认证说明 -- [多向量库实现权限划分.md](./多向量库实现权限划分.md) - 权限架构说明 - ---- - -## 最后更新 - -- 文档版本:v6.1.0 -- 更新时间:2026-04-16 -- 主要更新: - - 补充 core/ 目录新增模块(query_classifier、confidence_gate、quality_assessor、reasoning_reflector、loop_guard) - - 补充 api/ 目录新增的 image_routes.py - - 补充 parsers/ 目录新增的 image_extractor.py - - 修正 knowledge/ 目录重复描述,合并 vector_store 子目录说明 - - 补充 scripts/ 目录实际存在的脚本文件 - - 重新编号所有模块说明章节 +# 开发与系统模块说明 + +> 本文档由原《开发文档》与《模块说明》合并而成,涵盖开发环境配置、技术栈、架构以及细粒度模块说明。 + +## 第一部分:开发文档体系 + +# RAG 知识库问答系统 - 开发文档 + +> **项目版本**: v7.0.0 +> **更新日期**: 2026-06-04 +> **文档用途**: 架构说明、技术栈、部署指南 + +> **API 接口文档**: 详见 [后端对接规范.md](./后端对接规范.md) + +--- + +## 一、项目概述 + +### 1.1 项目定位 + +本项目是智能出题系统的**核心知识服务层**,为上层 Dify 工作流提供知识检索能力。系统通过 RAG(检索增强生成)技术,实现基于企业制度文档的智能问答,支持: + +- **知识库问答**:基于向量检索 + BM25 + Rerank 的混合检索 +- **Agentic RAG**:智能问答流程(Query Rewriting、Context Compression、Answer Grounding) +- **多轮对话**:会话历史管理、代词消解 +- **网络搜索**:实时信息获取(可选,需配置 Serper API) + +### 1.2 系统架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 前端应用层 │ +│ (chat-ui/ 开发测试界面) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ HTTP API / SSE + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ API 服务层 (api/) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ /chat │ │ /rag │ │ /sessions │ │ /search │ │ +│ │ 智能聊天 │ │ SSE 流式问答│ │ 会话管理 │ │ 混合检索 │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────┬──────┘ │ +└─────────┼────────────────┼────────────────┼───────────────┼────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 核心能力层 (core/) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Agentic RAG (agentic.py) │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │ │ +│ │ │ Query │ │ 检索层 │ │ Context │ │ Answer │ │ │ +│ │ │ Rewriting │ │向量+BM25 │ │Compression│ │ Grounding│ │ │ +│ │ │ 统一入口 │ │ +Rerank │ │ Token控制 │ │ 幻觉闭环 │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 数据存储层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ ChromaDB │ │ .data/ │ │ SQLite │ │ documents/ │ │ +│ │ 向量数据库 │ │ 图片存储 │ │ 会话数据 │ │ 文档源 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| API 服务 | Flask + Flask-CORS | RESTful API,SSE 流式返回 | +| 文档解析 | MinerU 3.0+ | PDF/DOCX/PPTX/图片统一解析 | +| 向量检索 | ChromaDB + BGE-base-zh | 本地向量数据库 + 嵌入模型 | +| 关键词检索 | BM25 + jieba | 中文分词 + 倒排索引 | +| 重排序 | BGE-reranker-base | CrossEncoder 精排 | +| 大模型 | Qwen (通义千问) | 问答生成、查询改写、意图分析 | +| 数据库 | SQLite | 会话管理、知识管理 | + +--- + +## 二、项目结构 + +``` +├── main.py # 统一启动入口 +├── config.py # API 配置(需自行创建) +├── requirements.txt # 依赖列表 +│ +├── api/ # API 路由层(Flask Blueprint) +│ ├── __init__.py # create_app() 应用工厂 +│ ├── chat_routes.py # /chat, /rag (SSE), /search +│ ├── session_routes.py # /sessions, /history +│ ├── auth_routes.py # /health, /auth/me +│ ├── kb_routes.py # /collections +│ ├── document_routes.py # /documents/upload +│ ├── sync_routes.py # /sync +│ ├── image_routes.py # /images/ +│ ├── feedback_routes.py # /feedback +│ └── response_utils.py # 统一响应格式工具 +│ +├── core/ # RAG 核心引擎 +│ ├── agentic.py # AgenticRAG 智能问答(兼容入口) +│ ├── agentic_base.py # 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 # 元信息与状态管理 +│ ├── engine.py # 检索引擎封装 +│ ├── bm25_index.py # BM25 索引 +│ ├── chunker.py # 文本分块 +│ ├── query_classifier.py # 查询分类器 +│ ├── intent_analyzer.py # 意图分析器 +│ ├── confidence_gate.py # 置信度门控 +│ ├── quality_assessor.py # 质量评估器 +│ ├── loop_guard.py # 循环防护 +│ └── reasoning_reflector.py # 推理反思器 +│ +├── parsers/ # 文档解析器 +│ ├── mineru_parser.py # MinerU 统一解析 (PDF/DOCX/PPTX/图片) +│ ├── excel_parser.py # Excel 专属管道 +│ └── image_extractor.py # 图片噪音过滤 +│ +├── knowledge/ # 知识库管理 +│ ├── manager.py # 多向量库管理器 +│ ├── router.py # 知识库路由器 +│ ├── sync.py # 同步服务 +│ ├── document.py # 文档管理 +│ ├── collection.py # 集合管理 +│ ├── search.py # 检索接口 +│ ├── permission.py # 权限控制 +│ ├── processing.py # 处理流水线 +│ ├── chunk.py # 切片管理 +│ └── ... # 更多模块见第二部分 +│ +├── services/ # 业务服务 +│ ├── session.py # 会话管理 (SQLite) +│ ├── feedback.py # 反馈系统 +│ └── outline.py # 纲要生成 +│ +├── auth/ # 认证与安全 +│ ├── gateway.py # 网关认证 (DEV_MODE mock token) +│ └── security.py # 安全防护 +│ +├── repositories/ # 数据仓库层 +│ ├── session_repo.py # 会话仓库(抽象接口) +│ ├── sqlite_session_repo.py # SQLite 实现 +│ └── stateless_session_repo.py # 无状态实现 +│ +├── data/ # SQLite 数据库 +│ ├── db.py # 统一数据访问层 +│ ├── rag_core.db # 会话/反馈数据 +│ └── knowledge.db # 知识管理数据 +│ +├── .data/ # 运行时数据 +│ ├── files/images/ # 提取的图片 +│ └── mineru_output/ # MinerU 解析输出 +│ +├── chat-ui/ # 前端测试界面 +│ ├── index.html # 主页面 +│ ├── app.js # 主逻辑 +│ └── api-test.js # API 测试面板 +│ +├── deploy/ # 部署配置 +│ ├── Dockerfile.prod # 生产环境 Docker +│ ├── docker-compose.prod.yml # 生产环境 Compose +│ ├── gunicorn.conf.py # Gunicorn 配置 +│ ├── nginx.conf # Nginx 配置 +│ └── wsgi.py # WSGI 入口 +│ +├── docs/ # 文档 +│ ├── 后端对接规范.md # API 接口规范 (主要) +│ ├── 开发文档.md # 本文档 +│ └── ... +│ +├── scripts/ # 工具脚本 +│ └── analyze_chunks.py # 切片分析 +│ +├── tools/ # 开发工具 +│ ├── chunk_analyzer.py # 切片分析 +│ ├── chunk_metrics.py # 指标统计 +│ ├── llm_evaluator.py # LLM 评估 +│ └── export_chunks.py # 导出切片 +│ +└── exam_pkg/ # 出题系统(可选) + ├── generator.py # 试题生成 + ├── grader.py # 评分批阅 + ├── manager.py # 出题管理 + ├── local_db.py # 本地题库 + └── api.py # Flask Blueprint +``` + +--- + +## 三、Agentic RAG 流程 + +### 3.1 完整流程图 + +``` +用户问题 (query) + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. Query Rewriting(统一入口) │ +│ - 有历史对话 → 强制改写(消歧) │ +│ - 短查询 (<10字符) → 强制改写(扩展) │ +│ - 其他 → LLM 判断是否需要改写 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 查询分类 (QueryClassifier) │ +│ - FACT: 事实查询 → 直接检索 │ +│ - COMPARISON: 比较查询 → 分解检索 │ +│ - META: 元问题 → 直接回答 │ +│ - REALTIME: 实时信息 → 网络搜索(可选) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 检索流程 │ +│ - 向量检索 + BM25 + Rerank │ +│ - 置信度门控检查 (threshold=0.3) │ +│ - 多维质量评估 (相关性/完整性/准确性/覆盖率) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. Context Compression │ +│ - Rerank 过滤 (score < 0.3 丢弃) │ +│ - 去重 (相同来源+页码只保留一个) │ +│ - Token 控制 (max=3500 tokens, max=20 条) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 5. 答案生成 │ +│ - 多源融合 (知识库 + 网络) │ +│ - 来源标注 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 6. Answer Grounding(幻觉闭环) │ +│ - 幻觉检测 (推理反思器) │ +│ - 发现幻觉 → 补充检索 → 重新生成 │ +│ - 最多重试 1 次 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 7. 输出 │ +│ - answer: 回答内容 │ +│ - sources: 来源列表(已去重,含页码范围) │ +│ - images/tables: 富媒体信息 │ +│ - session_id: 会话ID(用于多轮对话) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 关键配置参数 + +| 参数 | 值 | 说明 | +|------|-----|------| +| `MAX_CONTEXT_TOKENS` | 3500 | 上下文最大 token 数 | +| `MAX_CONTEXT_COUNT` | 20 | 上下文最大条数 | +| `RERANK_THRESHOLD` | 0.3 | Rerank 过滤阈值 | +| `MAX_GROUNDING_RETRY` | 1 | 幻觉修正最多重试次数 | +| `max_iterations` | 3 | 最大迭代检索次数 | + +--- + +## 四、API 接口 + +> **详细 API 文档**: 详见 [后端对接规范.md](./后端对接规范.md) + +### 核心接口概览 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/chat` | POST | 智能聊天 | +| `/rag` | POST | 知识库问答(SSE 流式) | +| `/search` | POST | 混合检索(供 Dify 调用) | +| `/sessions` | GET | 会话列表 | +| `/history/` | GET | 会话历史 | +| `/collections` | GET | 向量库列表 | +| `/images/` | GET | 获取图片 | +| `/sync` | POST | 触发同步 | +| `/health` | GET | 健康检查 | + +--- + +## 五、开发环境配置 + +### 5.1 环境准备 + +```powershell +# 创建虚拟环境 +python -m venv venv +.\venv\Scripts\Activate.ps1 + +# 安装依赖 +pip install -r requirements.txt +``` + +### 5.2 配置文件 + +复制 `config.example.py` 为 `config.py`: + +```python +# config.py - 必需配置 + +# 通义千问 API(必需) +DASHSCOPE_API_KEY = "your-api-key" +DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +DASHSCOPE_MODEL = "qwen-flash" # 文本模型 +DASHSCOPE_VL_MODEL = "qwen-vl-plus" # 视觉模型(图片描述) + +# 兼容变量 +API_KEY = DASHSCOPE_API_KEY +BASE_URL = DASHSCOPE_BASE_URL +MODEL = DASHSCOPE_MODEL + +# 文档路径 +DOCUMENTS_PATH = "./documents" + +# 开发模式(支持 mock 用户) +DEV_MODE = True +``` + +### 5.3 开发模式特性 + +| 特性 | 说明 | +|------|------| +| Mock 用户 | 支持 `mock-token-admin` 等模拟 token | +| 本地登录 | `/auth/login` 接口支持用户名密码登录 | +| 会话存储 | SQLite 本地存储,无需外部数据库 | +| 前端界面 | `http://localhost:5001` 直接访问测试 | + +**模拟用户列表**: + +| 用户名 | 密码 | 角色 | +|--------|------|------| +| admin | admin123 | admin | +| manager | manager123 | manager | +| user | test123 | user | + +--- + +## 六、运行命令 + +### 6.1 启动服务 + +```powershell +# 激活虚拟环境 +.\venv\Scripts\Activate.ps1 + +# 启动服务 +python main.py # 端口 5001 +python main.py --port 8080 # 指定端口 +``` + +### 6.2 同步知识库 + +```powershell +# 通过 API 触发同步 +curl -X POST http://localhost:5001/sync + +# 或通过前端界面操作 +``` + +--- + +## 七、会话管理 + +### 7.1 多轮对话流程 + +``` +首次对话: +POST /rag { "message": "出差补助标准", "collections": ["public_kb"] } + ↓ +finish 事件返回 session_id + ↓ +前端保存 session_id + +后续对话: +POST /rag { "message": "它有什么限制", "session_id": "xxx", "collections": ["public_kb"] } + ↓ +RAG 服务自动从 SQLite 加载历史 + ↓ +Query Rewriting: "它" → "出差补助" + ↓ +生成带上下文的回答 +``` + +### 7.2 会话相关 API + +| 接口 | 说明 | +|------|------| +| `GET /sessions` | 获取用户会话列表 | +| `GET /history/` | 获取会话历史 | +| `DELETE /session/` | 删除会话 | + +--- + +## 八、部署指南 + +### 8.1 生产环境建议 + +| 项目 | 建议 | +|------|------| +| DEV_MODE | 设置为 `false` | +| WSGI 服务器 | gunicorn 或 uWSGI | +| 反向代理 | Nginx | +| HTTPS | 配置 SSL 证书 | + +### 8.2 职责边界 + +| 后端负责 | RAG 服务负责 | +|----------|--------------| +| 用户认证 | 知识库问答 | +| 权限判断 | 向量检索 | +| 会话管理(生产) | 返回溯源 | +| 消息存储 | 文档处理 | + +--- + +## 九、错误码说明 + +| 状态码 | 说明 | 处理建议 | +|--------|------|----------| +| 200 | 成功 | - | +| 400 | 请求参数错误 | 检查请求体格式 | +| 401 | 未认证 | 检查 Header 认证信息 | +| 403 | 权限不足 | 检查用户角色权限 | +| 404 | 资源不存在 | 检查 session_id 或资源路径 | +| 500 | 服务器内部错误 | 查看服务日志 | + +--- + +## 十、相关文档 + +- [后端对接规范.md](./后端对接规范.md) - API 接口规范(主要) +- [数据库设计文档.md](./数据库设计文档.md) - 数据库结构 +- [Agentic_RAG完整指南.md](./Agentic_RAG完整指南.md) - Agentic RAG 详解 + + +--- + +## 第二部分:模块规范体系 + +# 项目模块说明文档 (v7.0.0) + +> **注**:本项目经过大规模重构,采用模块化架构。当前版本已包含细粒度多向量库权限控制、文档生命周期跟踪、本地化自动出题系统、FAQ问答闭环反馈收集,以及 Agentic RAG 细粒度拆分模块。图谱模块(graph/)已完全移除。 + +## 项目架构概览 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ API 服务层 │ +│ main.py (入口) │ +│ (Flask 应用工厂,整合所有 Blueprint,提供 REST API) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ +┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐ +│ RAG 核心 ││ 知识库 ││ 出题系统 ││ 安全模块 ││ 同步服务 ││ 反馈闭环 ││ 纲要生成 │ +│core/ ││knowledge/││exam_pkg/ ││auth/ ││knowledge/││services/ ││services/ │ +│agentic_* ││manager.py││generator ││gateway.py││sync.py ││feedback.py││outline.py│ +│engine.py ││search.py ││grader.py ││security.py│ ││ ││ │ +│bm25_ ││document.py││manager.py│ ││ ││ ││ │ +│index.py ││chunk.py ││local_db.py│ ││ ││ ││ │ +│chunker.py││permission││api.py ││ ││ ││ ││ │ +│ ││processing││ ││ ││ ││ ││ │ +└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘ + │ │ + ▼ │ +┌──────────────────────────┐ │ +│ 多向量库管理 │ │ +│ knowledge/manager.py │ │ +│ knowledge/router.py │ │ +│ knowledge/collection.py │ │ +└──────────────────────────┘ │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │会话管理 │ │题库生成 │ │会话仓库 │ + │services/ │ │exam_pkg/ │ │repos/ │ + │session.py│ │generator │ │session_ │ + └──────────┘ └──────────┘ │repo.py │ + └──────────┘ +``` + +--- + +## 目录结构 + +``` +项目根目录/ +├── main.py # ✨ 统一启动入口(推荐) +├── config.py # API 配置(不提交) +├── config.example.py # API 配置模板 +├── requirements.txt # 依赖列表 +├── requirements-prod.txt # 生产环境依赖 +│ +├── api/ # API 路由层(Flask Blueprint) +│ ├── __init__.py # create_app() 应用工厂 +│ ├── chat_routes.py # /chat, /rag, /rag/stream, /search +│ ├── session_routes.py # /sessions, /history, /session, /clear +│ ├── auth_routes.py # /stats, /health, /auth/me +│ ├── audit_routes.py # /audit/logs +│ ├── kb_routes.py # /collections, /documents/sync, /kb/route +│ ├── document_routes.py # /documents/upload, /documents/list, 版本管理 +│ ├── sync_routes.py # /sync, /subscribe, /notifications +│ ├── feedback_routes.py # /feedback/*, /reports/*, /faq/* +│ ├── image_routes.py # 图片相关接口 +│ └── response_utils.py # 统一响应格式工具 +│ +├── core/ # RAG 核心引擎 +│ ├── __init__.py +│ ├── agentic.py # AgenticRAG 智能问答(兼容入口) +│ ├── agentic_base.py # 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 # 元信息与状态管理 +│ ├── engine.py # 检索引擎封装 +│ ├── bm25_index.py # BM25 关键词索引 +│ ├── chunker.py # 语义分块器 +│ ├── query_classifier.py # 查询分类器 +│ ├── intent_analyzer.py # 意图分析器 +│ ├── query_decomposer.py # 查询分解器 +│ ├── query_expansion.py # 查询扩展 +│ ├── confidence_gate.py # 置信度门控 +│ ├── quality_assessor.py # 质量评估器 +│ ├── reasoning_reflector.py # 推理反思器 +│ ├── loop_guard.py # 循环防护 +│ ├── mmr.py # MMR 多样性排序 +│ ├── cache.py # 通用缓存 +│ ├── semantic_cache.py # 语义缓存 +│ ├── adaptive_topk.py # 自适应 TopK 选取 +│ ├── llm_budget.py # LLM Token 预算管理 +│ ├── llm_utils.py # LLM 调用工具 +│ ├── status_codes.py # 状态码定义 +│ └── constants.py # 全局常量 +│ +├── parsers/ # 文档解析器 +│ ├── __init__.py +│ ├── mineru_parser.py # MinerU 统一解析(PDF/DOCX/PPTX/图片) +│ ├── pdf_mineru.py # MinerU PDF 兼容别名 +│ ├── excel_parser.py # Excel 解析(Pandas 管道) +│ ├── txt_parser.py # TXT 解析 +│ └── image_extractor.py # 图片提取器 +│ +├── knowledge/ # 知识库管理模块 +│ ├── __init__.py +│ ├── base.py # 知识库基类与公共定义 +│ ├── manager.py # 多向量库管理器 +│ ├── router.py # 知识库路由器 +│ ├── collection.py # 向量库集合管理 +│ ├── document.py # 文档管理(增删改查) +│ ├── document_versions.py # 文档版本管理 +│ ├── search.py # 知识库检索接口 +│ ├── permission.py # 权限控制 +│ ├── processing.py # 文档处理流水线 +│ ├── chunk.py # 切片管理 +│ ├── index.py # 索引管理 +│ ├── sync.py # 同步服务 +│ ├── cleanup.py # 清理与回收 +│ ├── lazy_enhance.py # 延迟增强(按需优化) +│ └── vector_store/ # 向量数据库与BM25索引 +│ ├── chroma/ # ChromaDB存储 +│ └── bm25/ # BM25索引存储 +│ +├── repositories/ # 数据仓库层 +│ ├── __init__.py +│ ├── session_repo.py # 会话仓库(抽象接口) +│ ├── sqlite_session_repo.py # SQLite 会话仓库实现 +│ └── stateless_session_repo.py # 无状态会话仓库实现 +│ +├── exam_pkg/ # 考试系统 +│ ├── __init__.py +│ ├── generator.py # 试题生成器 +│ ├── grader.py # 评分与批阅 +│ ├── manager.py # 出题与批卷管理 +│ ├── local_db.py # 本地题库 +│ └── api.py # Flask Blueprint (exam_bp) +│ +├── services/ # 业务服务 +│ ├── __init__.py +│ ├── session.py # 会话管理 +│ ├── feedback.py # 反馈质量闭环 +│ └── outline.py # 纲要生成与推荐 +│ +├── auth/ # 认证与安全 +│ ├── __init__.py +│ ├── gateway.py # 网关认证 +│ └── security.py # 输入/输出安全 +│ +├── storage/ # 文件存储服务 +│ ├── __init__.py +│ ├── file_fetcher.py # 文件获取 +│ └── file_provider.py # 文件提供 +│ +├── data/ # SQLite 数据库 +│ ├── __init__.py +│ ├── db.py # 统一数据访问层 +│ ├── rag_core.db # 核心数据(会话、反馈) +│ └── knowledge.db # 知识管理(同步、大纲、版本) +│ +├── tools/ # 开发与分析工具 +│ ├── chunk_analyzer.py # 切片质量分析 +│ ├── chunk_metrics.py # 切片指标统计 +│ ├── chunk_report.py # 切片报告生成 +│ ├── llm_evaluator.py # LLM 评估器 +│ ├── export_chunks.py # 导出切片 +│ ├── clean_vector_store.py # 清理向量库 +│ ├── rebuild_pdf_vectors.py # 重建 PDF 向量 +│ └── upload_test_files.py # 上传测试文件 +│ +├── deploy/ # 部署配置 +│ ├── Dockerfile # 开发环境 Docker +│ ├── Dockerfile.prod # 生产环境 Docker +│ ├── docker-compose.yml # 开发环境 Compose +│ ├── docker-compose.prod.yml # 生产环境 Compose +│ ├── gunicorn.conf.py # Gunicorn 配置 +│ ├── nginx.conf # Nginx 配置 +│ └── wsgi.py # WSGI 入口 +│ +├── documents/ # 知识库文档目录 +├── models/ # 本地模型目录 +├── scripts/ # 工具脚本 +│ ├── analyze_chunks.py # 切片分析 +│ ├── analyze_content_list.py # 内容列表分析 +│ ├── check_tables.py # 数据表检查 +│ ├── compare_embedding_models.py # 嵌入模型对比 +│ ├── eval_e2e.py # 端到端评估 +│ ├── evaluate_answer.py # 答案评估 +│ ├── evaluate_rag.py # RAG 评估 +│ ├── fix_image_paths.py # 图片路径修复 +│ ├── migrate_add_metadata.py # 元数据迁移 +│ ├── migrate_split_databases.py # 数据库拆分迁移 +│ ├── migrate_version_status.py # 版本状态迁移 +│ ├── rebuild_multi_kb.py # 重建多向量库 +│ └── test_rag_questions.py # RAG问题测试 +├── tests/ # 测试 +├── chat-ui/ # 前端界面 +├── dev-ui/ # 开发前端(Vite + Vue) +├── venv/ # 虚拟环境 +│ +``` + +> **注意**: 根目录下的 `.py` 文件大多已迁移至子包,保留仅为向后兼容。 +> 新代码请使用子包路径导入,如 `from auth.gateway import require_gateway_auth`。 + +--- + +## 模块详细说明 + +### 一、API 路由层 (api/) + +#### 1. `api/__init__.py` - 应用工厂 + +**职责**:创建并配置 Flask 应用,注册所有 Blueprint + +**主要功能**: +- 初始化共享服务(SessionManager、AuditLogger、AgenticRAG) +- 注册所有 API Blueprint +- 可选模块按需加载 + +**使用方式**: +```python +from api import create_app + +app = create_app() +app.run(host='0.0.0.0', port=5001) +``` + +#### 2. API Blueprint 分组 + +| Blueprint | 文件 | 端点前缀 | 主要功能 | +|-----------|------|----------|----------| +| `auth_bp` | auth_routes.py | - | /stats, /health, /auth/me | +| `session_bp` | session_routes.py | - | /sessions, /history, /session, /clear | +| `audit_bp` | audit_routes.py | - | /audit/logs | +| `chat_bp` | chat_routes.py | - | /chat, /rag, /rag/stream, /search | +| `kb_bp` | kb_routes.py | - | /collections, /documents/sync, /kb/route | +| `document_bp` | document_routes.py | - | /documents/upload, /documents/list | +| `sync_bp` | sync_routes.py | - | /sync, /subscribe, /notifications | +| `feedback_bp` | feedback_routes.py | - | /feedback/*, /reports/*, /faq/* | +| `image_bp` | image_routes.py | - | 图片上传、处理相关接口 | +| `exam_bp` | exam_pkg/api.py | /exam | 出题系统相关接口 | + +**工具模块**: +| 文件 | 说明 | +|------|------| +| `response_utils.py` | 统一响应格式封装(成功/错误/流式响应构造) | + +--- + +### 二、核心 RAG 模块 (core/) + +#### 3. `core/agentic.py` - Agentic RAG 兼容入口 + +**职责**:智能问答的 Agent 决策引擎(向后兼容入口,内部委托至细粒度模块) + +**主要功能**: +- Agent 决策循环(检索、改写、分解、回答) +- 网络搜索集成(Serper API) +- 多源结果融合 +- SSE 流式输出 + +**关键类/函数**: +| 类/函数 | 说明 | +|---------|------| +| `AgenticRAG` | 主类,封装所有 Agent 功能 | +| `process()` | 处理用户查询 | +| `chat_search()` | 聊天搜索(支持网络搜索) | +| `simple_query()` | 简化调用接口 | + +**使用方式**: +```python +from core.agentic import AgenticRAG, simple_query + +# 完整模式 +rag = AgenticRAG() +result = rag.process("出差补助标准是什么?") + +# 简化模式 +result = simple_query("出差补助标准") +``` + +#### 4. Agentic 细粒度拆分模块 + +> v7.0.0 将 `agentic.py` 的职责拆分为以下独立模块,各司其职: + +| 模块 | 职责 | +|------|------| +| `agentic_base.py` | Agentic 基类与公共逻辑(配置注入、依赖初始化) | +| `agentic_search.py` | 智能检索模块(向量检索 + BM25 + Rerank 编排) | +| `agentic_answer.py` | 答案生成模块(多源融合、流式输出) | +| `agentic_citation.py` | 引用与来源标注(来源去重、页码范围合并) | +| `agentic_context.py` | 上下文压缩与管理(Token 控制、去重、截断) | +| `agentic_query.py` | 查询改写与处理(代词消解、查询扩展) | +| `agentic_media.py` | 富媒体处理(图片/表格提取与标注) | +| `agentic_quality.py` | 质量评估与幻觉检测(Answer Grounding) | +| `agentic_meta.py` | 元信息与状态管理(计时、统计、调试信息) | + +#### 5. `core/engine.py` - 检索引擎封装 + +**职责**:统一的检索引擎接口 + +**主要功能**: +- 向量检索 +- BM25 关键词检索 +- 混合检索 + Rerank + +#### 6. `core/bm25_index.py` - BM25 索引管理 + +**职责**:BM25 关键词索引的构建和查询 + +#### 7. `core/query_classifier.py` - 查询分类器 + +**职责**:对用户查询进行意图分类,辅助选择合适的检索策略 + +#### 8. `core/intent_analyzer.py` - 意图分析器 + +**职责**:深度意图分析,识别查询类型(事实/比较/元问题/实时信息) + +#### 9. `core/query_decomposer.py` - 查询分解器 + +**职责**:将复杂查询分解为多个子查询并行检索 + +#### 10. `core/query_expansion.py` - 查询扩展 + +**职责**:基于 LLM 对查询进行语义扩展,提升召回率 + +#### 11. `core/confidence_gate.py` - 置信度门控 + +**职责**:基于置信度判断是否需要额外的检索或改写 + +#### 12. `core/quality_assessor.py` - 质量评估器 + +**职责**:评估检索结果和生成回答的质量 + +#### 13. `core/reasoning_reflector.py` - 推理反思器 + +**职责**:对推理过程进行反思和优化 + +#### 14. `core/loop_guard.py` - 循环防护 + +**职责**:防止 Agent 陷入无限循环,控制最大迭代次数 + +#### 15. 缓存与检索优化模块 + +| 模块 | 职责 | +|------|------| +| `mmr.py` | MMR(Maximal Marginal Relevance)多样性排序,减少冗余结果 | +| `cache.py` | 通用缓存层,加速重复查询 | +| `semantic_cache.py` | 语义缓存,基于向量相似度的缓存匹配 | +| `adaptive_topk.py` | 自适应 TopK 选取,根据查询复杂度动态调整返回数量 | + +#### 16. LLM 工具模块 + +| 模块 | 职责 | +|------|------| +| `llm_budget.py` | LLM Token 预算管理,控制上下文与输出长度 | +| `llm_utils.py` | LLM 调用工具,封装 API 请求、重试与错误处理 | + +#### 17. 全局定义模块 + +| 模块 | 职责 | +|------|------| +| `status_codes.py` | 状态码定义(成功/失败/部分成功等) | +| `constants.py` | 全局常量(阈值、默认参数、配置键名) | + +--- + +### 三、知识库管理模块 (knowledge/) + +#### 18. `knowledge/manager.py` - 多向量库管理器 + +**职责**:多向量库的创建、管理和检索 + +**主要功能**: +- 多向量库创建与管理(public_kb + dept_xxx) +- 每个向量库独立的 BM25 索引 +- 并行检索多个向量库 +- RRF 融合结果 + +**使用方式**: +```python +from knowledge.manager import get_kb_manager + +kb_manager = get_kb_manager() + +# 创建向量库 +kb_manager.create_collection('dept_finance', display_name='财务部知识库') + +# 检索 +results = kb_manager.search_multiple(['public_kb', 'dept_finance'], query_vector) +``` + +#### 19. `knowledge/router.py` - 知识库路由器 + +**职责**:根据查询意图和用户权限智能选择目标向量库 + +**主要功能**: +- 规则匹配(关键词识别部门) +- LLM 意图分析(复杂查询) +- 权限过滤 + +#### 20. `knowledge/sync.py` - 知识库同步服务 + +**职责**:自动检测文档变更并触发增量更新 + +#### 21. 知识库新增模块 + +> v7.0.0 对 knowledge/ 进行了细粒度拆分,新增以下模块: + +| 模块 | 职责 | +|------|------| +| `base.py` | 知识库基类与公共定义(接口抽象、数据类型) | +| `collection.py` | 向量库集合管理(创建、删除、元数据维护) | +| `document.py` | 文档管理(增删改查、状态跟踪) | +| `document_versions.py` | 文档版本管理(版本创建、回滚、差异对比) | +| `search.py` | 知识库检索接口(统一检索入口、多策略融合) | +| `permission.py` | 权限控制(用户/部门/角色维度的访问控制) | +| `processing.py` | 文档处理流水线(解析 -> 分块 -> 向量化 -> 入库) | +| `chunk.py` | 切片管理(切片存储、检索、元数据) | +| `index.py` | 索引管理(向量索引构建与更新) | +| `cleanup.py` | 清理与回收(孤立切片清理、过期数据回收) | +| `lazy_enhance.py` | 延迟增强(按需优化,如懒加载索引、延迟构建 BM25) | + +--- + +### 四、数据库模块 (data/) + +#### 22. `data/db.py` - 统一数据访问层 + +**职责**:集中管理所有数据库连接 + +**主要功能**: +- 统一数据库路径配置 +- 连接池管理(上下文管理器) +- WAL 模式 + 外键约束 +- 自动事务管理 + +**数据库架构**: +| 数据库 | 主要功能 | +|--------|----------| +| `rag_core.db` | 会话管理、用户反馈、FAQ | +| `knowledge.db` | 知识库同步、文档版本、纲要缓存 | + +**使用方式**: +```python +from data.db import get_connection, init_databases + +# 初始化数据库 +init_databases() + +# 使用连接 +with get_connection("core") as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM sessions WHERE user_id = ?", (user_id,)) + rows = cursor.fetchall() +``` + +--- + +### 五、出题系统模块 (exam_pkg/) + +#### 23. `exam_pkg/generator.py` - 试题生成器 + +**职责**:基于知识库内容自动生成试题 + +**主要功能**: +- 调用 Dify 工作流生成题目 +- 题目类型控制(选择、判断、简答等) +- 难度分级生成 + +#### 24. `exam_pkg/grader.py` - 评分与批阅 + +**职责**:自动批阅试卷并生成评分报告 + +#### 25. `exam_pkg/manager.py` - 出题核心逻辑 + +**职责**:试卷生成、保存、批阅的核心业务逻辑 + +**主要功能**: +- 试卷 CRUD 操作 +- 审核流程管理 +- 自动批阅与报告生成 + +#### 26. `exam_pkg/local_db.py` - 本地题库 + +**职责**:本地题目存储与管理 + +#### 27. `exam_pkg/api.py` - 出题系统 API + +**职责**:出题系统的 Flask Blueprint + +**API 端点**: +| 端点 | 方法 | 说明 | +|------|------|------| +| `/exam/generate-by-file` | POST | 按文件生成题目(带溯源) | +| `/exam/generate` | POST | 生成试卷 | +| `/exam/list` | GET | 获取试卷列表 | +| `/exam/` | GET/PUT/DELETE | 试卷 CRUD | +| `/exam/grade-from-mysql` | POST | 基于 MySQL 数据批卷 | + +--- + +### 六、服务模块 (services/) + +#### 28. `services/session.py` - 会话管理 + +**职责**:多用户对话历史管理 + +**主要功能**: +- 会话创建与管理 +- 消息历史存储 +- 上下文压缩 +- 会话过期清理 + +**使用方式**: +```python +from services.session import SessionManager + +sm = SessionManager() +session_id = sm.create_session("user_123") +sm.add_message(session_id, "user", "出差补助标准是什么?") +history = sm.get_history(session_id) +``` + +#### 29. `services/feedback.py` - 反馈服务 + +**职责**:用户反馈收集与 FAQ 自动沉淀 + +#### 30. `services/outline.py` - 纲要生成器 + +**职责**:自动生成文档结构纲要 + +--- + +### 七、认证与安全模块 (auth/) + +#### 31. `auth/gateway.py` - 网关认证 + +**职责**:网关注入的 Header 认证与多向量库权限控制 + +**主要功能**: +- 从 Header 读取用户信息(X-User-ID、X-User-Role、X-User-Department) +- 角色映射 +- 多向量库权限控制 +- `@require_gateway_auth` 装饰器 + +**网关注入的 Header**: +| Header | 说明 | +|--------|------| +| `X-User-ID` | 用户唯一标识 | +| `X-User-Name` | 用户名 | +| `X-User-Role` | 用户角色 | +| `X-User-Department` | 部门 | + +#### 32. `auth/security.py` - 安全防护 + +**职责**:Prompt 注入防护 + +--- + +### 八、数据仓库层 (repositories/) + +#### 33. `repositories/session_repo.py` - 会话仓库(抽象接口) + +**职责**:定义会话持久化的抽象接口,支持多种后端实现 + +#### 34. `repositories/sqlite_session_repo.py` - SQLite 会话仓库 + +**职责**:基于 SQLite 的会话仓库实现,适用于开发和单机部署 + +#### 35. `repositories/stateless_session_repo.py` - 无状态会话仓库 + +**职责**:无状态会话仓库实现,会话数据由调用方管理,适用于分布式部署 + +--- + +### 九、开发与分析工具 (tools/) + +| 工具 | 职责 | +|------|------| +| `chunk_analyzer.py` | 切片质量分析(覆盖率、重叠度、语义完整性) | +| `chunk_metrics.py` | 切片指标统计(长度分布、数量汇总) | +| `chunk_report.py` | 切片报告生成(可视化分析报告) | +| `llm_evaluator.py` | LLM 评估器(基于大模型的检索质量评估) | +| `export_chunks.py` | 导出切片(导出为 JSON/CSV 格式) | +| `clean_vector_store.py` | 清理向量库(移除孤立向量、回收空间) | +| `rebuild_pdf_vectors.py` | 重建 PDF 向量(强制重新索引指定文档) | +| `upload_test_files.py` | 上传测试文件(自动化测试数据准备) | + +--- + +### 十、部署配置 (deploy/) + +| 文件 | 职责 | +|------|------| +| `Dockerfile` | 开发环境 Docker 镜像构建 | +| `Dockerfile.prod` | 生产环境 Docker 镜像构建(多阶段构建,精简体积) | +| `docker-compose.yml` | 开发环境容器编排 | +| `docker-compose.prod.yml` | 生产环境容器编排(含 Nginx、Gunicorn) | +| `gunicorn.conf.py` | Gunicorn 配置(worker 数量、超时、日志) | +| `nginx.conf` | Nginx 反向代理配置(负载均衡、静态文件、SSE 支持) | +| `wsgi.py` | WSGI 入口(Gunicorn 启动点) | + +--- + +### 十一、文件存储服务 (storage/) + +| 模块 | 职责 | +|------|------| +| `file_fetcher.py` | 文件获取(从远程/本地获取文件) | +| `file_provider.py` | 文件提供(统一文件访问接口) | + +--- + +## 数据库文件说明 + +| 文件名 | 主要功能 | 详细文档 | +|--------|----------|----------| +| `data/rag_core.db` | 会话管理、用户反馈、FAQ | [数据库设计文档.md](./数据库设计文档.md) | +| `data/knowledge.db` | 知识库同步、文档哈希、纲要缓存、版本管理 | [数据库设计文档.md](./数据库设计文档.md) | +| `knowledge/vector_store/` | 多向量库存储(ChromaDB + BM25) | [多向量库实现权限划分.md](./多向量库实现权限划分.md) | + +--- + +## 运行命令 + +```powershell +# ✨ 推荐方式 - 新入口 +python main.py # 启动 API 服务(端口 5001) +python main.py --port 8080 # 指定端口 + +# 旧入口(仍可用) +python main.py # 启动统一网关与大模型 API 服务 +python scripts/test_rag_questions.py # 自动化问答自评估测试 +python scripts/rebuild_multi_kb.py # 强制重建各个部门/集合维度的知识库 +``` + +--- + +## 相关文档 + +- [API接口文档.md](./API接口文档.md) - REST API 详细说明 +- [数据库设计文档.md](./数据库设计文档.md) - 所有数据库结构 +- [认证与权限配置指南.md](./认证与权限配置指南.md) - 网关认证说明 +- [多向量库实现权限划分.md](./多向量库实现权限划分.md) - 权限架构说明 + +--- + +## 最后更新 + +- 文档版本:v7.0.0 +- 更新时间:2026-06-04 +- 主要更新: + - 版本号从 v6.1.0 升级至 v7.0.0 + - core/ 模块:补充 Agentic 细粒度拆分模块(agentic_base/search/answer/citation/context/query/media/quality/meta)、intent_analyzer、query_decomposer、query_expansion、mmr、cache、semantic_cache、adaptive_topk、llm_budget、llm_utils、status_codes、constants + - knowledge/ 模块:补充 base、collection、document、document_versions、search、permission、processing、chunk、index、cleanup、lazy_enhance + - services/ 模块:移除 audit.py、user_info.py(仅保留 session.py、feedback.py、outline.py) + - api/ 模块:移除 graph_routes.py、question_routes.py、outline_routes.py;补充 response_utils.py + - exam_pkg/ 模块:更新为 generator.py、grader.py、manager.py、local_db.py、api.py(移除 analysis.py、question_hook.py) + - 图谱模块 (graph/) 已完全移除 + - 新增 repositories/ 模块(session_repo、sqlite_session_repo、stateless_session_repo) + - 新增 tools/ 模块(chunk_analyzer、chunk_metrics、chunk_report、llm_evaluator、export_chunks 等) + - 新增 deploy/ 部署配置(Dockerfile.prod、docker-compose.prod.yml、gunicorn.conf.py、nginx.conf、wsgi.py) + - 新增 storage/ 文件存储服务(file_fetcher、file_provider) diff --git a/docs/数据库设计文档.md b/docs/数据库设计文档.md index ad313c8..2d72683 100644 --- a/docs/数据库设计文档.md +++ b/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 | 初始版本 | diff --git a/docs/数据归属与协作方案.md b/docs/数据归属与协作方案.md index 98d2c8a..11d2734 100644 --- a/docs/数据归属与协作方案.md +++ b/docs/数据归属与协作方案.md @@ -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)│ └─────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/docs/文档审查报告.md b/docs/文档审查报告.md deleted file mode 100644 index ef42438..0000000 --- a/docs/文档审查报告.md +++ /dev/null @@ -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//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//reindex`、`/chunks/batch`、`/documents//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//chunks` 的查询参数描述,改为代码实际支持的参数 -2. **尽快修复**(影响对接体验):统一错误响应格式文档,列出两种格式及适用场景 -3. **建议修复**(改善文档质量):补充 `APP_ENV` 与 `DEV_MODE` 的区别说明、修正 `/rag` collections 必需性、删除 `/exam/generate-smart` 重复章节、移除出题接口中不必要的 Authorization header 要求说明 diff --git a/docs/服务器端测试报告.md b/docs/服务器端测试报告.md deleted file mode 100644 index 791e8b9..0000000 --- a/docs/服务器端测试报告.md +++ /dev/null @@ -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//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/` 需要传递 `?collection=xxx` 参数,否则返回错误 "请指定向量库 (collection)" - -**正确用法**: -```bash -curl -X DELETE 'http://localhost:5001/chunks/?collection=public_kb' -``` - -### 4.3 删除向量库物理文件夹 - -**验证结果**:✅ 删除向量库时会一并删除物理文件夹,无需手动清理 - -**测试验证**:创建向量库 → 检查文件夹存在 → 删除向量库 → 文件夹已删除 - -### 4.4 FAQ拒绝建议也需要请求体 - -**说明**:`POST /faq/suggestions//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/?collection=test_kb_full' -``` - -### 5.4 FAQ建议操作必须带请求体 - -```bash -# 批准 -curl -X POST 'http://localhost:5001/faq/suggestions//approve' \ - -H 'Content-Type: application/json' -d '{}' - -# 拒绝 -curl -X POST 'http://localhost:5001/faq/suggestions//reject' \ - -H 'Content-Type: application/json' -d '{}' -``` - ---- - -## 六、结论 - -### 总体评价 -服务器端 RAG API 功能正常,**52个端点全部通过测试**,通过率 **100%**。 - -### 核心功能验证 -- ✅ 文件上传自动同步向量化 -- ✅ 手动同步功能 -- ✅ 向量库CRUD操作 -- ✅ 向量库删除时物理文件夹一并删除 -- ✅ 知识库问答 -- ✅ 出题系统 -- ✅ 反馈系统 -- ✅ 图片服务(67张图片已上传) - -### 完成事项 -1. ✅ FAQ批准/拒绝建议接口已确认正确用法(需要空body) -2. ✅ 图片服务数据已上传(67张图片) -3. ✅ 残留向量库文件夹已清理 -4. ✅ curl测试手册已更新 - ---- - -## 七、测试命令参考 - -完整的测试命令请参考 `docs/curl测试手册.md`。 diff --git a/docs/架构与部署方案.md b/docs/架构与部署方案.md index 1dc6779..4fbfdfe 100644 --- a/docs/架构与部署方案.md +++ b/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 | diff --git a/docs/模型统一管理方案.md b/docs/模型统一管理方案.md deleted file mode 100644 index c59d9af..0000000 --- a/docs/模型统一管理方案.md +++ /dev/null @@ -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 服务开发组 diff --git a/docs/测试指南.md b/docs/测试指南.md index ce9be83..984d85a 100644 --- a/docs/测试指南.md +++ b/docs/测试指南.md @@ -1,565 +1,485 @@ -# 测试指南 - -> **文档类型**: 测试指南 -> **创建日期**: 2026-04-05 -> **最后更新**: 2026-04-13 -> **文档总数**: 21个测试文档 - ---- - -## 一、测试文档清单 - -### 1.1 文档目录结构 - -``` -documents/ -├── public/ # 公开文档 - 所有人可见(包括未登录用户) -│ ├── 公司简介.txt -│ ├── 产品手册.pdf -│ ├── 产品手册.txt -│ ├── 员工手册.txt -│ ├── 组织架构.xlsx -│ ├── 组织架构说明.txt -│ └── 常见问题.txt -│ -├── internal/ # 内部文档 - 登录用户可见 -│ ├── 差旅管理办法.txt -│ ├── 请假制度.docx -│ ├── 信息安全管理制度.pdf -│ ├── 项目管理制度.xlsx -│ └── 会议纪要_2024Q1.txt -│ -├── confidential/ # 机密文档 - 管理层及以上可见 -│ ├── 财务报表_2024.pdf -│ ├── 薪酬制度.docx -│ ├── 合同台账.xlsx -│ ├── 战略规划.txt -│ └── 人员名册.txt -│ -└── secret/ # 绝密文档 - 仅管理员可见 - ├── 董事会决议.pdf - ├── 并购方案.docx - ├── 股权结构.xlsx - └── 核心技术机密.txt -``` - -### 1.2 文档格式分布 - -| 格式 | 数量 | 测试目的 | -|------|------|---------| -| TXT | 10个 | 测试纯文本解析、编码识别 | -| PDF | 4个 | 测试PDF解析、表格提取、中文字体 | -| DOCX | 3个 | 测试Word解析、标题样式、表格处理 | -| XLSX | 4个 | 测试Excel解析、多工作表、单元格数据 | - -### 1.3 权限级别 - -| 目录 | 权限级别 | 可见角色 | -|------|---------|---------| -| public | public | 所有人(含未登录用户) | -| internal | internal | user, manager, admin | -| confidential | confidential | manager, admin | -| secret | secret | admin only | - ---- - -## 二、测试环境准备 - -### 2.1 环境检查 - -```bash -# 检查 Python 版本 -python --version # 需要 Python 3.8+ - -# 检查依赖安装 -pip list | grep -E "chromadb|sentence-transformers|neo4j|flask|jieba" - -# 检查模型文件 -ls models/bge-base-zh-v1.5/ -``` - -### 2.2 启动 Neo4j(用于 Graph RAG) - -```bash -# Docker 启动 Neo4j -docker run -d --name neo4j \ - -p 7474:7474 -p 7687:7687 \ - -e NEO4J_AUTH=neo4j/password123 \ - -v neo4j_data:/data \ - neo4j:latest - -# 等待 Neo4j 启动(约30秒) -# 访问 http://localhost:7474 验证 -``` - -### 2.3 配置检查 - -确保 `config.py` 配置正确: -```python -# API配置 -DASHSCOPE_API_KEY = "your-api-key" -DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" -DASHSCOPE_MODEL = "qwen3.5-plus" - -# Neo4j配置 -NEO4J_URI = "bolt://localhost:7687" -NEO4J_USER = "neo4j" -NEO4J_PASSWORD = "password123" -USE_GRAPH_RAG = True -``` - ---- - -## 三、测试执行流程 - -### 3.1 第一阶段:索引构建测试 - -#### 测试 1.1:向量索引构建 - -**测试步骤**: -```bash -# 清除旧索引 -rm -rf chroma_db/ - -# 重建向量索引 -python scripts/rebuild_multi_kb.py -``` - -**预期结果**: -- 控制台显示文档加载进度 -- 显示各格式文档解析数量 -- 显示向量构建进度 -- 生成 `chroma_db/` 目录 - -**验证方法**: -```python -import chromadb -client = chromadb.PersistentClient(path="./chroma_db") -collection = client.get_collection("knowledge_base") -print(f"向量数量: {collection.count()}") -``` - -#### 测试 1.2:BM25 索引构建 - -**测试步骤**: -```bash -# BM25索引会随向量索引一起构建 -# 检查索引文件 -ls -la bm25_index.pkl -``` - -**预期结果**: -- 生成 `bm25_index.pkl` 文件 -- 文件大小约 1-5 MB - -#### 测试 1.3:知识图谱构建 - -**测试步骤**: -```bash -# 构建知识图谱 -python graph_build.py -``` - -**预期结果**: -- 显示实体提取进度 -- 显示关系提取进度 -- 显示图谱存储进度 -- 无错误信息 - ---- - -### 3.2 第二阶段:权限控制测试 - -#### 测试 2.1:未登录用户权限 - -**测试步骤**: -```bash -# 不带 Token 访问 -curl -X POST http://localhost:5001/rag \ - -H "Content-Type: application/json" \ - -d '{"message": "公司的产品有哪些?"}' -``` - -**预期结果**: -- 只返回 public 目录下的内容 -- 不返回 internal、confidential、secret 内容 - -#### 测试 2.2:user 角色权限 - -**测试步骤**: -```bash -# 使用 mock token 登录 -curl -X POST http://localhost:5001/rag \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer mock-token-testuser" \ - -d '{"message": "差旅费标准是多少?"}' -``` - -**预期结果**: -- 返回 public + internal 目录内容 -- 不返回 confidential、secret 内容 - -#### 测试 2.3:manager 角色权限 - -**测试步骤**: -```bash -curl -X POST http://localhost:5001/rag \ - -H "Authorization: Bearer mock-token-manager" \ - -H "Content-Type: application/json" \ - -d '{"message": "2024年财务报表显示净利润是多少?"}' -``` - -**预期结果**: -- 返回 public + internal + confidential 内容 -- 正确回答财务相关问题 -- 不返回 secret 目录内容 - -#### 测试 2.4:admin 角色权限 - -**测试步骤**: -```bash -curl -X POST http://localhost:5001/rag \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"message": "董事会决议的并购方案是什么?"}' -``` - -**预期结果**: -- 返回所有目录内容 -- 正确回答涉及绝密信息的问题 - ---- - -### 3.3 第三阶段:检索质量测试 - -#### 测试 3.1:向量语义检索 - -| 测试问题 | 预期命中文档 | 预期答案关键点 | -|---------|------------|--------------| -| 公司有哪些产品? | 公司简介.txt、产品手册.pdf | 智能数据分析平台、AI知识图谱平台、RAG系统 | -| 请假需要提前几天申请? | 请假制度.docx | 1天以内直属上级、3天内部门负责人、7天以上总经理 | -| 年假有几天? | 请假制度.docx | 1年5天、5年7天、10年10天、20年15天 | - -**测试命令**: -```bash -curl -X POST http://localhost:5001/search \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer mock-token-admin" \ - -d '{"query": "公司有哪些产品?", "top_k": 5}' -``` - -#### 测试 3.2:BM25 关键词检索 - -| 测试关键词 | 预期命中文档 | -|-----------|------------| -| 差旅补助 500元 | 差旅管理办法.txt | -| 年假 15天 | 请假制度.docx | -| 薪酬 P5 35万 | 薪酬制度.docx | - -#### 测试 3.3:混合检索 + Rerank - -**测试步骤**: -```bash -curl -X POST http://localhost:5001/search \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer mock-token-admin" \ - -d '{"query": "出差住宿标准是多少?", "top_k": 10}' -``` - -**预期结果**: -- 返回结果包含 rerank_score -- 结果排序比纯向量检索更准确 - -#### 测试 3.4:知识图谱检索 - -| 测试问题 | 预期实体/关系 | -|---------|-------------| -| 技术研发中心负责什么? | 实体:技术研发中心、张明远;关系:负责 | -| 请假3天需要谁审批? | 实体:请假、部门负责人;关系:审批 | - -**测试命令**: -```bash -curl -X POST http://localhost:5001/graph/search \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer mock-token-admin" \ - -d '{"query": "技术研发中心负责什么?", "depth": 2}' -``` - ---- - -### 3.4 第四阶段:Agentic RAG 测试 - -#### 测试 4.1:简单问题直接回答 - -**测试问题**: -```bash -curl -X POST http://localhost:5001/rag \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"message": "公司的请假制度是什么?"}' -``` - -**预期结果**: -- Agent 决策为 "answer" -- 直接返回检索结果 -- 无需多轮检索 - -#### 测试 4.2:查询改写 - -**测试问题**: -```bash -curl -X POST http://localhost:5001/rag \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"message": "我想了解关于报销的事情"}' -``` - -**预期结果**: -- Agent 决策为 "rewrite" -- 查询被改写为更具体的表述 - -#### 测试 4.3:问题分解 - -**测试问题**: -```bash -curl -X POST http://localhost:5001/rag \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"message": "请假和报销的流程分别是什么?"}' -``` - -**预期结果**: -- Agent 决策为 "decompose" -- 问题被分解为多个子问题 -- 分别检索后合并回答 - -#### 测试 4.4:多源融合 - -**测试问题**: -```bash -curl -X POST http://localhost:5001/rag \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"message": "技术部的组织架构和职责分工是怎样的?"}' -``` - -**预期结果**: -- 同时触发知识库检索和图谱检索 -- 返回结果标注来源类型 - ---- - -### 3.5 第五阶段:出题系统测试 - -#### 测试 5.1:试卷生成 - -**测试步骤**: -```bash -curl -X POST http://localhost:5001/exam/generate \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"topic": "公司制度基础", "choice_count": 5, "name": "公司制度测试"}' -``` - -**预期结果**: -- 返回 exam_id -- 试卷状态为 "draft" -- 包含选择题、填空题、简答题 - -#### 测试 5.2:试卷审核 - -**测试步骤**: -```bash -curl -X POST http://localhost:5001/exam//review \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"action": "approve"}' -``` - -**预期结果**: -- 返回 success: true -- 试卷状态变为 "approved" - -#### 测试 5.3:试卷批阅 - -**测试步骤**: -```bash -curl -X POST http://localhost:5001/exam//grade \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"student_name": "测试学生", "answers": {"choice_1": "A", "choice_2": "B"}}' -``` - -**预期结果**: -- 返回批阅报告 -- 包含每题得分和总分 - ---- - -### 3.6 第六阶段:API 接口测试 - -#### 测试 6.1:认证接口 - -```bash -# 获取用户信息 -curl http://localhost:5001/auth/me \ - -H "Authorization: Bearer mock-token-admin" -``` - -#### 测试 6.2:会话管理 - -```bash -# 创建会话并对话 -curl -X POST http://localhost:5001/chat \ - -H "Authorization: Bearer mock-token-admin" \ - -H "Content-Type: application/json" \ - -d '{"message": "你好", "session_id": "test-001"}' - -# 获取会话列表 -curl http://localhost:5001/sessions \ - -H "Authorization: Bearer mock-token-admin" - -# 获取会话历史 -curl http://localhost:5001/history/test-001 \ - -H "Authorization: Bearer mock-token-admin" -``` - -#### 测试 6.3:健康检查 - -```bash -curl http://localhost:5001/health -``` - -**预期结果**: -```json -{ - "status": "ok", - "knowledge_base": "多向量库模式 (按集合提供服务)", - "bm25_index": "动态按需加载", - "mode": "Agentic RAG" -} -``` - ---- - -## 四、知识图谱测试要点 - -### 4.1 实体覆盖 - -| 实体类型 | 覆盖文档 | 示例实体 | -|---------|---------|---------| -| 部门 | 组织架构、员工手册 | 技术研发中心、人力资源部、财务部 | -| 人员 | 组织架构、人员名册 | 张志远、李明辉、王志强 | -| 制度 | 员工手册、请假制度、差旅管理办法 | 差旅管理办法、请假制度、信息安全管理制度 | -| 金额 | 差旅管理办法、财务报表、薪酬制度 | 500元/天、280万元、8-12万元 | -| 时间 | 请假制度、项目管理制度 | 3个工作日、30天、2024年1月 | -| 地点 | 公司简介、差旅管理办法 | 北京、上海、深圳 | - -### 4.2 关系类型覆盖 - -| 关系类型 | 覆盖文档 | 示例关系 | -|---------|---------|---------| -| 负责 | 组织架构 | 技术研发中心 → 负责 → 技术总监张明远 | -| 审批 | 请假制度、差旅管理办法 | 请假3天 → 审批 → 部门负责人 | -| 限额 | 差旅管理办法 | 一线城市住宿 → 限额 → 500元/晚 | -| 时效 | 请假制度 | 离职申请 → 时效 → 提前30天 | - -### 4.3 多跳推理测试点 - -| 测试问题 | 推理链 | 涉及文档 | -|---------|--------|---------| -| 请假5天需要谁审批? | 5天 > 3天 → 部门负责人 + 人力资源部 + 总经理 | 请假制度.docx | -| 差旅费超过3000元怎么处理? | >3000元 → 部门负责人 + 财务部经理审批 | 差旅管理办法.txt | -| 技术总监的薪酬范围是多少? | 技术总监 → M4级 → 50-80万元 | 薪酬制度.docx | - ---- - -## 五、测试报告模板 - -### 5.1 测试执行摘要 - -| 项目 | 内容 | -|------|------| -| 测试日期 | YYYY-MM-DD | -| 测试人员 | | -| 测试环境 | | -| 文档数量 | 21个 | -| 发现问题数量 | | - -### 5.2 测试结果统计 - -| 测试类型 | 用例数 | 通过 | 失败 | 阻塞 | -|----------|--------|------|------|------| -| 索引构建测试 | 3 | | | | -| 权限控制测试 | 4 | | | | -| 检索质量测试 | 4 | | | | -| Agentic RAG测试 | 4 | | | | -| 出题系统测试 | 3 | | | | -| API接口测试 | 3 | | | | -| **总计** | **21** | | | | - -### 5.3 问题列表 - -| 编号 | 测试用例 | 问题描述 | 严重程度 | 状态 | -|------|----------|----------|----------|------| -| BUG-001 | | | 高/中/低 | 待修复 | - ---- - -## 六、快速测试命令 - -```bash -# 一键索引重建 -python scripts/rebuild_multi_kb.py - -# 构建知识图谱 -python graph_build.py - -# 启动服务 -python main.py - -# 快速测试 -curl http://localhost:5001/health -``` - ---- - -## 七、测试文档生成 - -测试文档通过脚本 `generate_test_docs.py` 自动生成,支持以下格式: - -- **TXT**: 直接文本写入 -- **DOCX**: 使用 `python-docx` 库生成 -- **XLSX**: 使用 `openpyxl` 库生成 -- **PDF**: 使用 `reportlab` 库生成 - -运行命令: -```bash -python generate_test_docs.py -``` - -依赖安装: -```bash -pip install python-docx openpyxl reportlab -``` - ---- - -## 八、文本量统计 - -| 目录 | 文件数 | 总字符数 | 总词数(估计) | -|------|--------|---------|-------------| -| public | 7 | ~35,000 | ~15,000 | -| internal | 5 | ~25,000 | ~10,000 | -| confidential | 5 | ~20,000 | ~8,000 | -| secret | 4 | ~15,000 | ~6,000 | -| **合计** | **21** | **~95,000** | **~39,000** | - ---- - -## 九、变更记录 - -| 日期 | 版本 | 变更内容 | -|------|------|---------| -| 2026-04-13 | 2.0 | 合并测试文档清单和测试执行流程 | -| 2026-04-05 | 1.0 | 初始版本 | +# 测试指南 + +> **文档类型**: 测试指南 +> **创建日期**: 2026-04-05 +> **最后更新**: 2026-06-04 +> **文档总数**: 21个测试文档 + +--- + +## 一、测试文档清单 + +### 1.1 文档目录结构 + +``` +documents/ +├── public/ # 公开文档 - 所有人可见(包括未登录用户) +│ ├── 公司简介.txt +│ ├── 产品手册.pdf +│ ├── 产品手册.txt +│ ├── 员工手册.txt +│ ├── 组织架构.xlsx +│ ├── 组织架构说明.txt +│ └── 常见问题.txt +│ +├── internal/ # 内部文档 - 登录用户可见 +│ ├── 差旅管理办法.txt +│ ├── 请假制度.docx +│ ├── 信息安全管理制度.pdf +│ ├── 项目管理制度.xlsx +│ └── 会议纪要_2024Q1.txt +│ +├── confidential/ # 机密文档 - 管理层及以上可见 +│ ├── 财务报表_2024.pdf +│ ├── 薪酬制度.docx +│ ├── 合同台账.xlsx +│ ├── 战略规划.txt +│ └── 人员名册.txt +│ +└── secret/ # 绝密文档 - 仅管理员可见 + ├── 董事会决议.pdf + ├── 并购方案.docx + ├── 股权结构.xlsx + └── 核心技术机密.txt +``` + +### 1.2 文档格式分布 + +| 格式 | 数量 | 测试目的 | +|------|------|---------| +| TXT | 10个 | 测试纯文本解析、编码识别 | +| PDF | 4个 | 测试PDF解析、表格提取、中文字体 | +| DOCX | 3个 | 测试Word解析、标题样式、表格处理 | +| XLSX | 4个 | 测试Excel解析、多工作表、单元格数据 | + +### 1.3 权限级别 + +| 目录 | 权限级别 | 可见角色 | +|------|---------|---------| +| public | public | 所有人(含未登录用户) | +| internal | internal | user, manager, admin | +| confidential | confidential | manager, admin | +| secret | secret | admin only | + +--- + +## 二、测试环境准备 + +### 2.1 环境检查 + +```bash +# 检查 Python 版本 +python --version # 需要 Python 3.8+ + +# 检查依赖安装 +pip list | grep -E "chromadb|sentence-transformers|flask|jieba" + +# 检查模型文件 +ls models/bge-base-zh-v1.5/ +``` + +### 2.2 配置检查 + +确保 `config.py` 配置正确: +```python +# API配置 +DASHSCOPE_API_KEY = "your-api-key" +DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +DASHSCOPE_MODEL = "qwen3.6-flash" # 主 LLM(文本生成 / RAG 对话) +INTENT_MODEL = "qwen-turbo" # 意图分析模型(轻量、确定性高) +``` + +> **注意**: Graph RAG(Neo4j)功能已废弃,相关配置(NEO4J_URI、USE_GRAPH_RAG 等)已移除。 + +--- + +## 三、测试执行流程 + +### 3.1 第一阶段:索引构建测试 + +#### 测试 1.1:向量索引构建 + +**测试步骤**: +```bash +# 清除旧索引 +rm -rf chroma_db/ + +# 重建向量索引 +python scripts/rebuild_multi_kb.py +``` + +**预期结果**: +- 控制台显示文档加载进度 +- 显示各格式文档解析数量 +- 显示向量构建进度 +- 生成 `chroma_db/` 目录 + +**验证方法**: +```python +import chromadb +client = chromadb.PersistentClient(path="./chroma_db") +collection = client.get_collection("knowledge_base") +print(f"向量数量: {collection.count()}") +``` + +#### 测试 1.2:BM25 索引构建 + +**测试步骤**: +```bash +# BM25索引会随向量索引一起构建 +# 检查索引文件 +ls -la bm25_index.pkl +``` + +**预期结果**: +- 生成 `bm25_index.pkl` 文件 +- 文件大小约 1-5 MB + +--- + +### 3.2 第二阶段:权限控制测试 + +#### 测试 2.1:未登录用户权限 + +**测试步骤**: +```bash +# 不带 Token 访问 +curl -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d '{"message": "公司的产品有哪些?"}' +``` + +**预期结果**: +- 只返回 public 目录下的内容 +- 不返回 internal、confidential、secret 内容 + +#### 测试 2.2:user 角色权限 + +**测试步骤**: +```bash +# 使用 mock token 登录 +curl -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer mock-token-testuser" \ + -d '{"message": "差旅费标准是多少?"}' +``` + +**预期结果**: +- 返回 public + internal 目录内容 +- 不返回 confidential、secret 内容 + +#### 测试 2.3:manager 角色权限 + +**测试步骤**: +```bash +curl -X POST http://localhost:5001/rag \ + -H "Authorization: Bearer mock-token-manager" \ + -H "Content-Type: application/json" \ + -d '{"message": "2024年财务报表显示净利润是多少?"}' +``` + +**预期结果**: +- 返回 public + internal + confidential 内容 +- 正确回答财务相关问题 +- 不返回 secret 目录内容 + +#### 测试 2.4:admin 角色权限 + +**测试步骤**: +```bash +curl -X POST http://localhost:5001/rag \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"message": "董事会决议的并购方案是什么?"}' +``` + +**预期结果**: +- 返回所有目录内容 +- 正确回答涉及绝密信息的问题 + +--- + +### 3.3 第三阶段:检索质量测试 + +#### 测试 3.1:向量语义检索 + +| 测试问题 | 预期命中文档 | 预期答案关键点 | +|---------|------------|--------------| +| 公司有哪些产品? | 公司简介.txt、产品手册.pdf | 智能数据分析平台、AI知识图谱平台、RAG系统 | +| 请假需要提前几天申请? | 请假制度.docx | 1天以内直属上级、3天内部门负责人、7天以上总经理 | +| 年假有几天? | 请假制度.docx | 1年5天、5年7天、10年10天、20年15天 | + +**测试命令**: +```bash +curl -X POST http://localhost:5001/search \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer mock-token-admin" \ + -d '{"query": "公司有哪些产品?", "top_k": 5}' +``` + +#### 测试 3.2:BM25 关键词检索 + +| 测试关键词 | 预期命中文档 | +|-----------|------------| +| 差旅补助 500元 | 差旅管理办法.txt | +| 年假 15天 | 请假制度.docx | +| 薪酬 P5 35万 | 薪酬制度.docx | + +#### 测试 3.3:混合检索 + Rerank + +**测试步骤**: +```bash +curl -X POST http://localhost:5001/search \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer mock-token-admin" \ + -d '{"query": "出差住宿标准是多少?", "top_k": 10}' +``` + +**预期结果**: +- 返回结果包含 rerank_score +- 结果排序比纯向量检索更准确 + +--- + +### 3.4 第四阶段:Agentic RAG 测试 + +#### 测试 4.1:简单问题直接回答 + +**测试问题**: +```bash +curl -X POST http://localhost:5001/rag \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"message": "公司的请假制度是什么?"}' +``` + +**预期结果**: +- Agent 决策为 "answer" +- 直接返回检索结果 +- 无需多轮检索 + +#### 测试 4.2:查询改写 + +**测试问题**: +```bash +curl -X POST http://localhost:5001/rag \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"message": "我想了解关于报销的事情"}' +``` + +**预期结果**: +- Agent 决策为 "rewrite" +- 查询被改写为更具体的表述 + +#### 测试 4.3:问题分解 + +**测试问题**: +```bash +curl -X POST http://localhost:5001/rag \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"message": "请假和报销的流程分别是什么?"}' +``` + +**预期结果**: +- Agent 决策为 "decompose" +- 问题被分解为多个子问题 +- 分别检索后合并回答 + +#### 测试 4.4:多源融合 + +**测试问题**: +```bash +curl -X POST http://localhost:5001/rag \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"message": "技术部的组织架构和职责分工是怎样的?"}' +``` + +**预期结果**: +- 同时触发向量检索和 BM25 关键词检索 +- 返回结果经过 Rerank 重排序并标注来源 + +--- + +### 3.5 第五阶段:出题系统测试 + +#### 测试 5.1:试卷生成 + +**测试步骤**: +```bash +curl -X POST http://localhost:5001/exam/generate \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"topic": "公司制度基础", "choice_count": 5, "name": "公司制度测试"}' +``` + +**预期结果**: +- 返回 exam_id +- 试卷状态为 "draft" +- 包含选择题、填空题、简答题 + +#### 测试 5.2:试卷审核 + +**测试步骤**: +```bash +curl -X POST http://localhost:5001/exam//review \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"action": "approve"}' +``` + +**预期结果**: +- 返回 success: true +- 试卷状态变为 "approved" + +#### 测试 5.3:试卷批阅 + +**测试步骤**: +```bash +curl -X POST http://localhost:5001/exam//grade \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"student_name": "测试学生", "answers": {"choice_1": "A", "choice_2": "B"}}' +``` + +**预期结果**: +- 返回批阅报告 +- 包含每题得分和总分 + +--- + +### 3.6 第六阶段:API 接口测试 + +#### 测试 6.1:认证接口 + +```bash +# 获取用户信息 +curl http://localhost:5001/auth/me \ + -H "Authorization: Bearer mock-token-admin" +``` + +#### 测试 6.2:会话管理 + +```bash +# 创建会话并对话 +curl -X POST http://localhost:5001/chat \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"message": "你好", "session_id": "test-001"}' + +# 获取会话列表 +curl http://localhost:5001/sessions \ + -H "Authorization: Bearer mock-token-admin" + +# 获取会话历史 +curl http://localhost:5001/history/test-001 \ + -H "Authorization: Bearer mock-token-admin" +``` + +#### 测试 6.3:健康检查 + +```bash +curl http://localhost:5001/health +``` + +**预期结果**: +```json +{ + "status": "ok", + "knowledge_base": "多向量库模式 (按集合提供服务)", + "bm25_index": "动态按需加载", + "mode": "Agentic RAG" +} +``` + +--- + +## 四、测试报告模板 + +### 4.1 测试执行摘要 + +| 项目 | 内容 | +|------|------| +| 测试日期 | YYYY-MM-DD | +| 测试人员 | | +| 测试环境 | | +| 文档数量 | 21个 | +| 发现问题数量 | | + +### 4.2 测试结果统计 + +| 测试类型 | 用例数 | 通过 | 失败 | 阻塞 | +|----------|--------|------|------|------| +| 索引构建测试 | 2 | | | | +| 权限控制测试 | 4 | | | | +| 检索质量测试 | 3 | | | | +| Agentic RAG测试 | 4 | | | | +| 出题系统测试 | 3 | | | | +| API接口测试 | 3 | | | | +| **总计** | **19** | | | | + +### 4.3 问题列表 + +| 编号 | 测试用例 | 问题描述 | 严重程度 | 状态 | +|------|----------|----------|----------|------| +| BUG-001 | | | 高/中/低 | 待修复 | + +--- + +## 五、快速测试命令 + +```bash +# 一键索引重建 +python scripts/rebuild_multi_kb.py + +# 启动服务 +python main.py + +# 快速测试 +curl http://localhost:5001/health +``` + +--- + +## 六、测试文档生成 + +测试文档通过脚本 `generate_test_docs.py` 自动生成,支持以下格式: + +- **TXT**: 直接文本写入 +- **DOCX**: 使用 `python-docx` 库生成 +- **XLSX**: 使用 `openpyxl` 库生成 +- **PDF**: 使用 `reportlab` 库生成 + +运行命令: +```bash +python generate_test_docs.py +``` + +依赖安装: +```bash +pip install python-docx openpyxl reportlab +``` + +--- + +## 七、文本量统计 + +| 目录 | 文件数 | 总字符数 | 总词数(估计) | +|------|--------|---------|-------------| +| public | 7 | ~35,000 | ~15,000 | +| internal | 5 | ~25,000 | ~10,000 | +| confidential | 5 | ~20,000 | ~8,000 | +| secret | 4 | ~15,000 | ~6,000 | +| **合计** | **21** | **~95,000** | **~39,000** | + +--- + +## 八、变更记录 + +| 日期 | 版本 | 变更内容 | +|------|------|---------| +| 2026-06-04 | 3.0 | 移除 Graph RAG(Neo4j)相关内容,更新模型配置和章节编号 | +| 2026-04-13 | 2.0 | 合并测试文档清单和测试执行流程 | +| 2026-04-05 | 1.0 | 初始版本 | diff --git a/docs/版本管理实施完成报告.md b/docs/版本管理实施完成报告.md index 5628e83..0757d8b 100644 --- a/docs/版本管理实施完成报告.md +++ b/docs/版本管理实施完成报告.md @@ -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 -**代码审查**: 待进行 -**测试状态**: 待测试 -**部署状态**: 待部署 +**代码审查**: 已完成 +**测试状态**: 已实施 +**部署状态**: 已部署 diff --git a/docs/生产路径优化计划.md b/docs/生产路径优化计划.md index 611ad50..4e07e5f 100644 --- a/docs/生产路径优化计划.md +++ b/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 (引用标注) [已实施] ← 独立,已迁移至独立模块 ``` ### 回退策略 diff --git a/docs/认证与权限配置指南.md b/docs/认证与权限配置指南.md index 6d88f4d..833bc9f 100644 --- a/docs/认证与权限配置指南.md +++ b/docs/认证与权限配置指南.md @@ -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/` | 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 | 创建认证对接文档 | diff --git a/docs/风险边界问题修复注意事项.md b/docs/风险边界问题修复注意事项.md new file mode 100644 index 0000000..9a310da --- /dev/null +++ b/docs/风险边界问题修复注意事项.md @@ -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. **批量上传中不要包含同名文件** — 如果一次批量上传请求中包含两个同名文件,第一个会被第二个无意义地覆盖。请确保单次批量上传中文件名不重复。 diff --git a/knowledge/cleanup.py b/knowledge/cleanup.py index cae4dbe..8239605 100644 --- a/knowledge/cleanup.py +++ b/knowledge/cleanup.py @@ -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: """ diff --git a/knowledge/collection.py b/knowledge/collection.py index d6ae162..ae06685 100644 --- a/knowledge/collection.py +++ b/knowledge/collection.py @@ -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() diff --git a/knowledge/document.py b/knowledge/document.py index 0a1e3e9..bdeca24 100644 --- a/knowledge/document.py +++ b/knowledge/document.py @@ -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']), diff --git a/knowledge/manager.py b/knowledge/manager.py index 9a1d678..31a8e86 100644 --- a/knowledge/manager.py +++ b/knowledge/manager.py @@ -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)} # ==================== 辅助检索方法 ==================== diff --git a/knowledge/processing.py b/knowledge/processing.py index 2773b6a..dd75084 100644 --- a/knowledge/processing.py +++ b/knowledge/processing.py @@ -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) diff --git a/knowledge/search.py b/knowledge/search.py index 2d28576..4056d42 100644 --- a/knowledge/search.py +++ b/knowledge/search.py @@ -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] diff --git a/knowledge/sync.py b/knowledge/sync.py index bfb4b86..a669458 100644 --- a/knowledge/sync.py +++ b/knowledge/sync.py @@ -1,891 +1,915 @@ -""" -知识库同步服务 - 自动检测文档变更并触发增量更新 - -功能: -1. 文件变更监控 - 使用 watchdog 监控 documents 目录 -2. 哈希比对 - 识别文件具体变更类型(新增/修改/删除) -3. 增量向量化 - 仅处理变更文件 -4. 变更日志 - 记录变更历史 - -使用方式: - from knowledge.sync import KnowledgeSyncService - - # 启动同步服务 - sync_service = KnowledgeSyncService() - sync_service.start() # 启动后台监控 - - # 手动触发同步 - result = sync_service.sync_now() -""" - -import os -import sys -import json -import hashlib -import threading -import time -from datetime import datetime -from typing import Dict, List, Optional, Callable -from dataclasses import dataclass, asdict -from enum import Enum -import logging - -from data.db import get_connection, init_databases - -# 缓存支持 -try: - from core.cache import get_cache_manager - CACHE_AVAILABLE = True -except ImportError: - CACHE_AVAILABLE = False - -# 设置日志 -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# 尝试导入 watchdog -try: - from watchdog.observers import Observer - from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent, FileDeletedEvent - HAS_WATCHDOG = True -except ImportError: - HAS_WATCHDOG = False - logger.warning("watchdog 未安装,文件监控功能不可用。请运行: pip install watchdog") - - -class ChangeType(Enum): - """变更类型""" - ADDED = "added" # 新增 - MODIFIED = "modified" # 修改 - DELETED = "deleted" # 删除 - - -class SyncStatus(Enum): - """同步状态""" - IDLE = "idle" # 空闲 - RUNNING = "running" # 运行中 - COMPLETED = "completed" # 已完成 - FAILED = "failed" # 失败 - - -@dataclass -class DocumentChange: - """文档变更记录""" - document_id: str # 文档ID(相对路径) - document_name: str # 文件名 - change_type: ChangeType # 变更类型 - old_hash: Optional[str] # 旧哈希 - new_hash: Optional[str] # 新哈希 - change_time: datetime # 变更时间 - processed: bool = False # 是否已处理 - error_message: Optional[str] = None - - def to_dict(self): - return { - "document_id": self.document_id, - "document_name": self.document_name, - "change_type": self.change_type.value, - "old_hash": self.old_hash, - "new_hash": self.new_hash, - "change_time": self.change_time.isoformat(), - "processed": self.processed, - "error_message": self.error_message - } - - -@dataclass -class SyncResult: - """同步结果""" - status: SyncStatus - start_time: datetime - end_time: Optional[datetime] - documents_processed: int - documents_added: int - documents_modified: int - documents_deleted: int - errors: List[str] - - def to_dict(self): - return { - "status": self.status.value, - "start_time": self.start_time.isoformat(), - "end_time": self.end_time.isoformat() if self.end_time else None, - "documents_processed": self.documents_processed, - "documents_added": self.documents_added, - "documents_modified": self.documents_modified, - "documents_deleted": self.documents_deleted, - "errors": self.errors - } - - -class SyncDatabase: - """同步数据库管理""" - - def __init__(self): - """初始化数据库""" - init_databases() - - def get_document_hash(self, document_id: str) -> Optional[Dict]: - """获取文档的当前哈希""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT document_id, document_name, content_hash, file_size, last_modified - FROM document_hashes WHERE document_id = ? - ''', (document_id,)) - row = cursor.fetchone() - - if row: - return { - "document_id": row[0], - "document_name": row[1], - "content_hash": row[2], - "file_size": row[3], - "last_modified": row[4] - } - return None - - def set_document_hash(self, document_id: str, document_name: str, - content_hash: str, file_size: int, last_modified: datetime): - """设置文档哈希""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT OR REPLACE INTO document_hashes - (document_id, document_name, content_hash, file_size, last_modified, updated_at) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - ''', (document_id, document_name, content_hash, file_size, last_modified)) - - def delete_document_hash(self, document_id: str): - """删除文档哈希记录""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute('DELETE FROM document_hashes WHERE document_id = ?', (document_id,)) - - def get_all_document_hashes(self) -> Dict[str, Dict]: - """获取所有文档哈希""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT document_id, document_name, content_hash, file_size, last_modified - FROM document_hashes - ''') - rows = cursor.fetchall() - - return { - row[0]: { - "document_id": row[0], - "document_name": row[1], - "content_hash": row[2], - "file_size": row[3], - "last_modified": row[4] - } - for row in rows - } - - def log_change(self, change: DocumentChange) -> int: - """记录变更""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO change_logs - (document_id, document_name, change_type, old_hash, new_hash, change_time, processed, error_message) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - change.document_id, - change.document_name, - change.change_type.value, - change.old_hash, - change.new_hash, - change.change_time, - change.processed, - change.error_message - )) - return cursor.lastrowid - - def get_change_logs(self, limit: int = 100, processed: Optional[bool] = None, - days: int = 30) -> List[Dict]: - """获取变更日志""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - - sql = ''' - SELECT id, document_id, document_name, change_type, old_hash, new_hash, - change_time, processed, error_message - FROM change_logs - WHERE change_time >= datetime('now', ?) - ''' - params = [f'-{days} days'] - - if processed is not None: - sql += ' AND processed = ?' - params.append(1 if processed else 0) - - sql += ' ORDER BY change_time DESC LIMIT ?' - params.append(limit) - - cursor.execute(sql, params) - rows = cursor.fetchall() - - return [ - { - "id": row[0], - "document_id": row[1], - "document_name": row[2], - "change_type": row[3], - "old_hash": row[4], - "new_hash": row[5], - "change_time": row[6], - "processed": bool(row[7]), - "error_message": row[8] - } - for row in rows - ] - - def mark_change_processed(self, change_id: int, error_message: str = None): - """标记变更已处理""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - UPDATE change_logs - SET processed = 1, error_message = ? - WHERE id = ? - ''', (error_message, change_id)) - - def log_sync_status(self, result: SyncResult) -> int: - """记录同步状态""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO sync_status - (sync_type, status, start_time, end_time, documents_processed, - documents_added, documents_modified, documents_deleted, error_message) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - "incremental", - result.status.value, - result.start_time, - result.end_time, - result.documents_processed, - result.documents_added, - result.documents_modified, - result.documents_deleted, - "; ".join(result.errors) if result.errors else None - )) - return cursor.lastrowid - - def get_sync_history(self, limit: int = 20) -> List[Dict]: - """获取同步历史""" - with get_connection("knowledge") as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT id, sync_type, status, start_time, end_time, - documents_processed, documents_added, documents_modified, documents_deleted, error_message - FROM sync_status - ORDER BY start_time DESC - LIMIT ? - ''', (limit,)) - rows = cursor.fetchall() - - return [ - { - "id": row[0], - "sync_type": row[1], - "status": row[2], - "start_time": row[3], - "end_time": row[4], - "documents_processed": row[5], - "documents_added": row[6], - "documents_modified": row[7], - "documents_deleted": row[8], - "error_message": row[9] - } - for row in rows - ] - - -class FileChangeHandler(FileSystemEventHandler if HAS_WATCHDOG else object): - """文件变更处理器""" - - def __init__(self, sync_service: 'KnowledgeSyncService'): - if HAS_WATCHDOG: - super().__init__() - self.sync_service = sync_service - # v5 统一解析支持的所有格式 - self.supported_extensions = {'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.bmp', '.tiff'} - self._pending_changes = {} # 防抖:短时间内多次修改只记录一次 - self._debounce_seconds = 2 - - def _is_supported_file(self, file_path: str) -> bool: - """检查是否为支持的文件类型""" - ext = os.path.splitext(file_path)[1].lower() - return ext in self.supported_extensions - - def _debounce_change(self, file_path: str, change_type: ChangeType): - """防抖处理:短时间内多次修改合并为一次""" - current_time = time.time() - - if file_path in self._pending_changes: - last_time, last_type = self._pending_changes[file_path] - # 如果是修改事件且距离上次事件很近,忽略 - if current_time - last_time < self._debounce_seconds: - return - - self._pending_changes[file_path] = (current_time, change_type) - - # 延迟处理 - threading.Timer(self._debounce_seconds, self._process_change, args=[file_path, change_type]).start() - - def _process_change(self, file_path: str, change_type: ChangeType): - """处理文件变更""" - try: - # 计算相对路径 - rel_path = os.path.relpath(file_path, self.sync_service.documents_path).replace(chr(92), "/") - document_name = os.path.basename(file_path) - - logger.info(f"检测到文件变更: {rel_path} ({change_type.value})") - - # 创建变更记录 - change = DocumentChange( - document_id=rel_path, - document_name=document_name, - change_type=change_type, - old_hash=None, - new_hash=None, - change_time=datetime.now() - ) - - # 获取旧哈希 - old_doc = self.sync_service.db.get_document_hash(rel_path) - if old_doc: - change.old_hash = old_doc['content_hash'] - - # 计算新哈希(如果不是删除) - if change_type != ChangeType.DELETED and os.path.exists(file_path): - change.new_hash = self.sync_service.calculate_file_hash(file_path) - - # 记录变更 - self.sync_service.db.log_change(change) - - # 触发回调 - if self.sync_service.on_change_callback: - self.sync_service.on_change_callback(change) - - except Exception as e: - logger.error(f"处理文件变更失败: {file_path}, 错误: {e}") - - def on_created(self, event): - """文件创建事件""" - if event.is_directory: - return - if not self._is_supported_file(event.src_path): - return - self._debounce_change(event.src_path, ChangeType.ADDED) - - def on_modified(self, event): - """文件修改事件""" - if event.is_directory: - return - if not self._is_supported_file(event.src_path): - return - self._debounce_change(event.src_path, ChangeType.MODIFIED) - - def on_deleted(self, event): - """文件删除事件""" - if event.is_directory: - return - if not self._is_supported_file(event.src_path): - return - self._debounce_change(event.src_path, ChangeType.DELETED) - - def on_moved(self, event): - """文件移动事件""" - if event.is_directory: - return - # 移动视为删除旧文件 + 创建新文件 - if self._is_supported_file(event.src_path): - self._debounce_change(event.src_path, ChangeType.DELETED) - if self._is_supported_file(event.dest_path): - self._debounce_change(event.dest_path, ChangeType.ADDED) - - -class KnowledgeSyncService: - """知识库同步服务""" - - def __init__(self, documents_path: str = None): - """ - 初始化同步服务 - - Args: - documents_path: 文档目录路径,默认为 ./documents - """ - self.documents_path = documents_path or os.path.join( - os.path.dirname(os.path.abspath(__file__)), "documents" - ) - self.db = SyncDatabase() - - self._observer = None - self._running = False - self.on_change_callback: Optional[Callable] = None - self.on_sync_callback: Optional[Callable] = None - - - - @staticmethod - def calculate_file_hash(file_path: str) -> str: - """计算文件哈希""" - hasher = hashlib.md5() - try: - with open(file_path, 'rb') as f: - for chunk in iter(lambda: f.read(8192), b''): - hasher.update(chunk) - return hasher.hexdigest() - except Exception as e: - logger.error(f"计算文件哈希失败: {file_path}, 错误: {e}") - return "" - - def scan_documents(self) -> Dict[str, Dict]: - """扫描文档目录,返回所有文档信息""" - documents = {} - # v5 统一解析支持的所有格式 - supported_extensions = {'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.bmp', '.tiff'} - - for root, dirs, files in os.walk(self.documents_path): - for filename in files: - ext = os.path.splitext(filename)[1].lower() - if ext not in supported_extensions: - continue - - file_path = os.path.join(root, filename) - rel_path = os.path.relpath(file_path, self.documents_path).replace(chr(92), "/") - - try: - file_stat = os.stat(file_path) - documents[rel_path] = { - "document_id": rel_path, - "document_name": filename, - "file_path": file_path, - "file_size": file_stat.st_size, - "last_modified": datetime.fromtimestamp(file_stat.st_mtime), - "content_hash": self.calculate_file_hash(file_path) - } - except Exception as e: - logger.error(f"扫描文档失败: {rel_path}, 错误: {e}") - - return documents - - def detect_changes(self) -> List[DocumentChange]: - """检测文档变更""" - changes = [] - current_docs = self.scan_documents() - stored_docs = self.db.get_all_document_hashes() - - current_ids = set(current_docs.keys()) - stored_ids = set(stored_docs.keys()) - - # 新增的文档 - for doc_id in current_ids - stored_ids: - doc = current_docs[doc_id] - changes.append(DocumentChange( - document_id=doc_id, - document_name=doc["document_name"], - change_type=ChangeType.ADDED, - old_hash=None, - new_hash=doc["content_hash"], - change_time=datetime.now() - )) - - # 删除的文档 - for doc_id in stored_ids - current_ids: - doc = stored_docs[doc_id] - changes.append(DocumentChange( - document_id=doc_id, - document_name=doc["document_name"], - change_type=ChangeType.DELETED, - old_hash=doc["content_hash"], - new_hash=None, - change_time=datetime.now() - )) - - # 修改的文档 - for doc_id in current_ids & stored_ids: - current_doc = current_docs[doc_id] - stored_doc = stored_docs[doc_id] - - if current_doc["content_hash"] != stored_doc["content_hash"]: - changes.append(DocumentChange( - document_id=doc_id, - document_name=current_doc["document_name"], - change_type=ChangeType.MODIFIED, - old_hash=stored_doc["content_hash"], - new_hash=current_doc["content_hash"], - change_time=datetime.now() - )) - - return changes - - def process_change(self, change: DocumentChange) -> bool: - """处理单个变更""" - try: - file_path = os.path.join(self.documents_path, change.document_id) - - # 从 document_id 中解析目标向量库 - # document_id 格式: "public/filename.pdf" 或 "finance/filename.pdf" - kb_name = self._get_kb_name_from_path(change.document_id) - - # 导入知识库管理器 - from knowledge.manager import get_kb_manager - kb_manager = get_kb_manager() - - if change.change_type == ChangeType.ADDED: - # 新增文档 - 使用多向量库方法 - chunks_added = kb_manager.add_file_to_kb( - kb_name=kb_name, - filepath=file_path, - extra_metadata={ - 'status': 'active', - 'version': 'v1', - 'change_time': datetime.now().isoformat() - } - ) - # 更新哈希记录 - self.db.set_document_hash( - change.document_id, - change.document_name, - change.new_hash, - os.path.getsize(file_path) if os.path.exists(file_path) else 0, - datetime.now() - ) - - # 创建版本记录 - try: - from knowledge.document_versions import get_version_query - version_query = get_version_query() - version_query.create_version_record( - collection=kb_name, - document_id=change.document_name, - version="v1", - status="active", - change_summary="新增文档", - created_by="sync_service", - chunk_count=chunks_added - ) - except Exception as e: - logger.warning(f"创建版本记录失败: {e}") - - logger.info(f"已添加文档到 {kb_name}: {change.document_id}, 片段数: {chunks_added}") - - elif change.change_type == ChangeType.MODIFIED: - # 修改文档:使用版本管理策略 - # 1. 获取当前版本号 - old_version = self._get_current_version(kb_name, change.document_name) - - # 2. 标记旧版本为 superseded(如果存在) - if old_version: - try: - kb_manager.mark_document_as_superseded( - kb_name, - change.document_name, - reason="文档更新" - ) - logger.info(f"标记旧版本为 superseded: {change.document_name} {old_version}") - except Exception as e: - logger.warning(f"标记旧版本失败: {e}") - - # 3. 生成新版本号 - new_version = self._generate_version_id(kb_name, change.document_name) - - # 4. 添加新版本 - chunks_added = kb_manager.add_file_to_kb( - kb_name=kb_name, - filepath=file_path, - extra_metadata={ - 'status': 'active', - 'version': new_version, - 'previous_version': old_version or '', - 'change_time': datetime.now().isoformat() - } - ) - - # 5. 更新哈希记录 - self.db.set_document_hash( - change.document_id, - change.document_name, - change.new_hash, - os.path.getsize(file_path) if os.path.exists(file_path) else 0, - datetime.now() - ) - - # 6. 记录版本变更 - if old_version: - self._record_version_change( - kb_name, - change.document_name, - old_version, - new_version, - "文档更新" - ) - - logger.info(f"已更新文档: {change.document_id}, 版本: {old_version} → {new_version}, 添加 {chunks_added} 片段") - - elif change.change_type == ChangeType.DELETED: - # 删除文档 - deleted = kb_manager.delete_document(kb_name, change.document_name) - # 删除哈希记录 - self.db.delete_document_hash(change.document_id) - logger.info(f"已删除文档: {change.document_id}, 删除 {deleted} 片段") - - # ==================== 缓存失效 ==================== - # 文档变更后递增知识库版本号,使旧缓存自动失效 - if CACHE_AVAILABLE: - try: - cache = get_cache_manager() - cache.increment_kb_version(kb_name) - logger.debug(f"已递增知识库版本号: {kb_name}") - except Exception as e: - logger.warning(f"递增缓存版本号失败: {e}") - - return True - - except Exception as e: - logger.error(f"处理变更失败: {change.document_id}, 错误: {e}") - import traceback - traceback.print_exc() - return False - - def _get_kb_name_from_path(self, document_id: str) -> str: - """ - 从文档ID中解析目标向量库名称 - - Args: - document_id: 文档ID,格式如 "public_kb/filename.pdf" 或 "dept_hr/filename.pdf" - - Returns: - 向量库名称(目录名 = 向量库名) - """ - # 统一路径分隔符(兼容 Windows 和 Linux) - normalized = document_id.replace('\\', '/') - # 获取第一级目录名(即向量库名) - parts = normalized.split('/') - if len(parts) > 1: - return parts[0] # 目录名即向量库名 - else: - return 'public_kb' # 默认公开库 - - def _get_current_version(self, kb_name: str, filename: str) -> str: - """ - 获取文档当前版本号 - - Args: - kb_name: 知识库名称 - filename: 文件名 - - Returns: - 当前版本号,如 "v1", "v2",不存在则返回 None - """ - try: - from knowledge.document_versions import get_version_query - version_query = get_version_query() - active_version = version_query.get_active_version(kb_name, filename) - return active_version.version if active_version else None - except Exception as e: - logger.warning(f"获取当前版本失败: {e}") - return None - - def _generate_version_id(self, kb_name: str, filename: str) -> str: - """ - 生成新版本号 - - Args: - kb_name: 知识库名称 - filename: 文件名 - - Returns: - 新版本号,如 "v1", "v2", "v3" - """ - current_version = self._get_current_version(kb_name, filename) - if not current_version: - return "v1" - - # 从 "v1" 提取数字并递增 - try: - version_num = int(current_version.replace('v', '')) - return f"v{version_num + 1}" - except (ValueError, AttributeError): - return "v1" - - def _record_version_change( - self, - kb_name: str, - filename: str, - old_version: str, - new_version: str, - reason: str - ): - """ - 记录版本变更到数据库 - - Args: - kb_name: 知识库名称 - filename: 文件名 - old_version: 旧版本号 - new_version: 新版本号 - reason: 变更原因 - """ - try: - from knowledge.document_versions import get_version_query - version_query = get_version_query() - version_query.log_version_change( - collection=kb_name, - document_id=filename, - change_type="update", - old_version=old_version, - new_version=new_version, - old_status="active", - new_status="active", - reason=reason, - changed_by="sync_service" - ) - except Exception as e: - logger.warning(f"记录版本变更失败: {e}") - - def sync_now(self) -> SyncResult: - """立即执行同步""" - logger.info("开始同步...") - - result = SyncResult( - status=SyncStatus.RUNNING, - start_time=datetime.now(), - end_time=None, - documents_processed=0, - documents_added=0, - documents_modified=0, - documents_deleted=0, - errors=[] - ) - - try: - # 检测变更 - changes = self.detect_changes() - - # 处理变更 - for change in changes: - success = self.process_change(change) - result.documents_processed += 1 - - if success: - if change.change_type == ChangeType.ADDED: - result.documents_added += 1 - elif change.change_type == ChangeType.MODIFIED: - result.documents_modified += 1 - elif change.change_type == ChangeType.DELETED: - result.documents_deleted += 1 - else: - result.errors.append(f"处理失败: {change.document_id}") - - # 记录变更 - self.db.log_change(change) - - result.status = SyncStatus.COMPLETED - - except Exception as e: - result.status = SyncStatus.FAILED - result.errors.append(str(e)) - logger.error(f"同步失败: {e}") - - result.end_time = datetime.now() - - # 记录同步状态 - self.db.log_sync_status(result) - - # 触发回调 - if self.on_sync_callback: - self.on_sync_callback(result) - - logger.info(f"同步完成: 处理 {result.documents_processed} 个文档, " - f"新增 {result.documents_added}, " - f"修改 {result.documents_modified}, " - f"删除 {result.documents_deleted}") - - return result - - def start(self): - """启动文件监控""" - if not HAS_WATCHDOG: - logger.error("watchdog 未安装,无法启动文件监控") - return False - - if self._running: - logger.warning("文件监控已在运行") - return True - - # 首次同步 - logger.info("执行首次同步...") - self.sync_now() - - # 启动监控 - event_handler = FileChangeHandler(self) - self._observer = Observer() - self._observer.schedule(event_handler, self.documents_path, recursive=True) - self._observer.start() - - self._running = True - logger.info(f"文件监控已启动,监控目录: {self.documents_path}") - return True - - def stop(self): - """停止文件监控""" - if self._observer: - self._observer.stop() - self._observer.join() - self._observer = None - - self._running = False - logger.info("文件监控已停止") - - def is_running(self) -> bool: - """检查监控是否在运行""" - return self._running - - -# 便捷函数 -def create_sync_service(documents_path: str = None) -> KnowledgeSyncService: - """创建同步服务实例""" - return KnowledgeSyncService(documents_path) - - -# 测试代码 -if __name__ == "__main__": - print("=" * 60) - print("知识库同步服务测试") - print("=" * 60) - - # 创建服务 - sync_service = KnowledgeSyncService() - - # 测试扫描文档 - print("\n[1] 扫描文档...") - docs = sync_service.scan_documents() - print(f"找到 {len(docs)} 个文档") - for doc_id, doc in list(docs.items())[:5]: - print(f" - {doc['document_name']}: {doc['content_hash'][:8]}...") - - # 测试变更检测 - print("\n[2] 检测变更...") - changes = sync_service.detect_changes() - print(f"检测到 {len(changes)} 个变更") - for change in changes[:5]: - print(f" - {change.document_name}: {change.change_type.value}") - - # 测试同步 - print("\n[3] 执行同步...") - result = sync_service.sync_now() - print(f"同步状态: {result.status.value}") - print(f"处理文档: {result.documents_processed}") - print(f"新增: {result.documents_added}, 修改: {result.documents_modified}, 删除: {result.documents_deleted}") - - print("\n" + "=" * 60) - print("测试完成") +""" +知识库同步服务 - 自动检测文档变更并触发增量更新 + +功能: +1. 文件变更监控 - 使用 watchdog 监控 documents 目录 +2. 哈希比对 - 识别文件具体变更类型(新增/修改/删除) +3. 增量向量化 - 仅处理变更文件 +4. 变更日志 - 记录变更历史 + +使用方式: + from knowledge.sync import KnowledgeSyncService + + # 启动同步服务 + sync_service = KnowledgeSyncService() + sync_service.start() # 启动后台监控 + + # 手动触发同步 + result = sync_service.sync_now() +""" + +import os +import sys +import json +import hashlib +import threading +import time +from datetime import datetime +from typing import Dict, List, Optional, Callable +from dataclasses import dataclass, asdict +from enum import Enum +import logging + +from data.db import get_connection, init_databases + +# 缓存支持 +try: + from core.cache import get_cache_manager + CACHE_AVAILABLE = True +except ImportError: + CACHE_AVAILABLE = False + +# 设置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# 尝试导入 watchdog +try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent, FileDeletedEvent + HAS_WATCHDOG = True +except ImportError: + HAS_WATCHDOG = False + logger.warning("watchdog 未安装,文件监控功能不可用。请运行: pip install watchdog") + + +class ChangeType(Enum): + """变更类型""" + ADDED = "added" # 新增 + MODIFIED = "modified" # 修改 + DELETED = "deleted" # 删除 + + +class SyncStatus(Enum): + """同步状态""" + IDLE = "idle" # 空闲 + RUNNING = "running" # 运行中 + COMPLETED = "completed" # 已完成 + FAILED = "failed" # 失败 + + +@dataclass +class DocumentChange: + """文档变更记录""" + document_id: str # 文档ID(相对路径) + document_name: str # 文件名 + change_type: ChangeType # 变更类型 + old_hash: Optional[str] # 旧哈希 + new_hash: Optional[str] # 新哈希 + change_time: datetime # 变更时间 + processed: bool = False # 是否已处理 + error_message: Optional[str] = None + + def to_dict(self): + return { + "document_id": self.document_id, + "document_name": self.document_name, + "change_type": self.change_type.value, + "old_hash": self.old_hash, + "new_hash": self.new_hash, + "change_time": self.change_time.isoformat(), + "processed": self.processed, + "error_message": self.error_message + } + + +@dataclass +class SyncResult: + """同步结果""" + status: SyncStatus + start_time: datetime + end_time: Optional[datetime] + documents_processed: int + documents_added: int + documents_modified: int + documents_deleted: int + errors: List[str] + + def to_dict(self): + return { + "status": self.status.value, + "start_time": self.start_time.isoformat(), + "end_time": self.end_time.isoformat() if self.end_time else None, + "documents_processed": self.documents_processed, + "documents_added": self.documents_added, + "documents_modified": self.documents_modified, + "documents_deleted": self.documents_deleted, + "errors": self.errors + } + + +class SyncDatabase: + """同步数据库管理""" + + def __init__(self): + """初始化数据库""" + init_databases() + + def get_document_hash(self, document_id: str) -> Optional[Dict]: + """获取文档的当前哈希""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT document_id, document_name, content_hash, file_size, last_modified + FROM document_hashes WHERE document_id = ? + ''', (document_id,)) + row = cursor.fetchone() + + if row: + return { + "document_id": row[0], + "document_name": row[1], + "content_hash": row[2], + "file_size": row[3], + "last_modified": row[4] + } + return None + + def set_document_hash(self, document_id: str, document_name: str, + content_hash: str, file_size: int, last_modified: datetime): + """设置文档哈希""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO document_hashes + (document_id, document_name, content_hash, file_size, last_modified, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ''', (document_id, document_name, content_hash, file_size, last_modified)) + + def delete_document_hash(self, document_id: str): + """删除文档哈希记录""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM document_hashes WHERE document_id = ?', (document_id,)) + + def get_all_document_hashes(self) -> Dict[str, Dict]: + """获取所有文档哈希""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT document_id, document_name, content_hash, file_size, last_modified + FROM document_hashes + ''') + rows = cursor.fetchall() + + return { + row[0]: { + "document_id": row[0], + "document_name": row[1], + "content_hash": row[2], + "file_size": row[3], + "last_modified": row[4] + } + for row in rows + } + + def log_change(self, change: DocumentChange) -> int: + """记录变更""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO change_logs + (document_id, document_name, change_type, old_hash, new_hash, change_time, processed, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + change.document_id, + change.document_name, + change.change_type.value, + change.old_hash, + change.new_hash, + change.change_time, + change.processed, + change.error_message + )) + return cursor.lastrowid + + def get_change_logs(self, limit: int = 100, processed: Optional[bool] = None, + days: int = 30) -> List[Dict]: + """获取变更日志""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + + sql = ''' + SELECT id, document_id, document_name, change_type, old_hash, new_hash, + change_time, processed, error_message + FROM change_logs + WHERE change_time >= datetime('now', ?) + ''' + params = [f'-{days} days'] + + if processed is not None: + sql += ' AND processed = ?' + params.append(1 if processed else 0) + + sql += ' ORDER BY change_time DESC LIMIT ?' + params.append(limit) + + cursor.execute(sql, params) + rows = cursor.fetchall() + + return [ + { + "id": row[0], + "document_id": row[1], + "document_name": row[2], + "change_type": row[3], + "old_hash": row[4], + "new_hash": row[5], + "change_time": row[6], + "processed": bool(row[7]), + "error_message": row[8] + } + for row in rows + ] + + def mark_change_processed(self, change_id: int, error_message: str = None): + """标记变更已处理""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE change_logs + SET processed = 1, error_message = ? + WHERE id = ? + ''', (error_message, change_id)) + + def log_sync_status(self, result: SyncResult) -> int: + """记录同步状态""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO sync_status + (sync_type, status, start_time, end_time, documents_processed, + documents_added, documents_modified, documents_deleted, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + "incremental", + result.status.value, + result.start_time, + result.end_time, + result.documents_processed, + result.documents_added, + result.documents_modified, + result.documents_deleted, + "; ".join(result.errors) if result.errors else None + )) + return cursor.lastrowid + + def get_sync_history(self, limit: int = 20) -> List[Dict]: + """获取同步历史""" + with get_connection("knowledge") as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, sync_type, status, start_time, end_time, + documents_processed, documents_added, documents_modified, documents_deleted, error_message + FROM sync_status + ORDER BY start_time DESC + LIMIT ? + ''', (limit,)) + rows = cursor.fetchall() + + return [ + { + "id": row[0], + "sync_type": row[1], + "status": row[2], + "start_time": row[3], + "end_time": row[4], + "documents_processed": row[5], + "documents_added": row[6], + "documents_modified": row[7], + "documents_deleted": row[8], + "error_message": row[9] + } + for row in rows + ] + + +class FileChangeHandler(FileSystemEventHandler if HAS_WATCHDOG else object): + """文件变更处理器""" + + def __init__(self, sync_service: 'KnowledgeSyncService'): + if HAS_WATCHDOG: + super().__init__() + self.sync_service = sync_service + # v5 统一解析支持的所有格式 + self.supported_extensions = {'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.bmp', '.tiff'} + self._pending_changes = {} # 防抖:短时间内多次修改只记录一次 + self._debounce_seconds = 2 + + def _is_supported_file(self, file_path: str) -> bool: + """检查是否为支持的文件类型""" + ext = os.path.splitext(file_path)[1].lower() + return ext in self.supported_extensions + + def _debounce_change(self, file_path: str, change_type: ChangeType): + """防抖处理:短时间内多次修改合并为一次""" + current_time = time.time() + + if file_path in self._pending_changes: + last_time, last_type = self._pending_changes[file_path] + # 如果是修改事件且距离上次事件很近,忽略 + if current_time - last_time < self._debounce_seconds: + return + + self._pending_changes[file_path] = (current_time, change_type) + + # 延迟处理 + threading.Timer(self._debounce_seconds, self._process_change, args=[file_path, change_type]).start() + + def _process_change(self, file_path: str, change_type: ChangeType): + """处理文件变更""" + try: + # 计算相对路径 + rel_path = os.path.relpath(file_path, self.sync_service.documents_path).replace(chr(92), "/") + document_name = os.path.basename(file_path) + + logger.info(f"检测到文件变更: {rel_path} ({change_type.value})") + + # 创建变更记录 + change = DocumentChange( + document_id=rel_path, + document_name=document_name, + change_type=change_type, + old_hash=None, + new_hash=None, + change_time=datetime.now() + ) + + # 获取旧哈希 + old_doc = self.sync_service.db.get_document_hash(rel_path) + if old_doc: + change.old_hash = old_doc['content_hash'] + + # 计算新哈希(如果不是删除) + if change_type != ChangeType.DELETED and os.path.exists(file_path): + change.new_hash = self.sync_service.calculate_file_hash(file_path) + + # 记录变更 + self.sync_service.db.log_change(change) + + # 触发回调 + if self.sync_service.on_change_callback: + self.sync_service.on_change_callback(change) + + except Exception as e: + logger.error(f"处理文件变更失败: {file_path}, 错误: {e}") + + def on_created(self, event): + """文件创建事件""" + if event.is_directory: + return + if not self._is_supported_file(event.src_path): + return + self._debounce_change(event.src_path, ChangeType.ADDED) + + def on_modified(self, event): + """文件修改事件""" + if event.is_directory: + return + if not self._is_supported_file(event.src_path): + return + self._debounce_change(event.src_path, ChangeType.MODIFIED) + + def on_deleted(self, event): + """文件删除事件""" + if event.is_directory: + return + if not self._is_supported_file(event.src_path): + return + self._debounce_change(event.src_path, ChangeType.DELETED) + + def on_moved(self, event): + """文件移动事件""" + if event.is_directory: + return + # 移动视为删除旧文件 + 创建新文件 + if self._is_supported_file(event.src_path): + self._debounce_change(event.src_path, ChangeType.DELETED) + if self._is_supported_file(event.dest_path): + self._debounce_change(event.dest_path, ChangeType.ADDED) + + +class KnowledgeSyncService: + """知识库同步服务""" + + def __init__(self, documents_path: str = None): + """ + 初始化同步服务 + + Args: + documents_path: 文档目录路径,默认为 ./documents + """ + self.documents_path = documents_path or os.path.join( + os.path.dirname(os.path.abspath(__file__)), "documents" + ) + self.db = SyncDatabase() + + self._observer = None + self._running = False + self.on_change_callback: Optional[Callable] = None + self.on_sync_callback: Optional[Callable] = None + + + + @staticmethod + def calculate_file_hash(file_path: str) -> str: + """计算文件哈希""" + hasher = hashlib.md5() + try: + with open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b''): + hasher.update(chunk) + return hasher.hexdigest() + except Exception as e: + logger.error(f"计算文件哈希失败: {file_path}, 错误: {e}") + return "" + + def scan_documents(self) -> Dict[str, Dict]: + """扫描文档目录,返回所有文档信息""" + documents = {} + # v5 统一解析支持的所有格式 + supported_extensions = {'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.bmp', '.tiff'} + + for root, dirs, files in os.walk(self.documents_path): + for filename in files: + ext = os.path.splitext(filename)[1].lower() + if ext not in supported_extensions: + continue + + file_path = os.path.join(root, filename) + rel_path = os.path.relpath(file_path, self.documents_path).replace(chr(92), "/") + + try: + file_stat = os.stat(file_path) + documents[rel_path] = { + "document_id": rel_path, + "document_name": filename, + "file_path": file_path, + "file_size": file_stat.st_size, + "last_modified": datetime.fromtimestamp(file_stat.st_mtime), + "content_hash": self.calculate_file_hash(file_path) + } + except Exception as e: + logger.error(f"扫描文档失败: {rel_path}, 错误: {e}") + + return documents + + def detect_changes(self) -> List[DocumentChange]: + """检测文档变更""" + changes = [] + current_docs = self.scan_documents() + stored_docs = self.db.get_all_document_hashes() + + current_ids = set(current_docs.keys()) + stored_ids = set(stored_docs.keys()) + + # 新增的文档 + for doc_id in current_ids - stored_ids: + doc = current_docs[doc_id] + changes.append(DocumentChange( + document_id=doc_id, + document_name=doc["document_name"], + change_type=ChangeType.ADDED, + old_hash=None, + new_hash=doc["content_hash"], + change_time=datetime.now() + )) + + # 删除的文档 + for doc_id in stored_ids - current_ids: + doc = stored_docs[doc_id] + changes.append(DocumentChange( + document_id=doc_id, + document_name=doc["document_name"], + change_type=ChangeType.DELETED, + old_hash=doc["content_hash"], + new_hash=None, + change_time=datetime.now() + )) + + # 修改的文档 + for doc_id in current_ids & stored_ids: + current_doc = current_docs[doc_id] + stored_doc = stored_docs[doc_id] + + if current_doc["content_hash"] != stored_doc["content_hash"]: + changes.append(DocumentChange( + document_id=doc_id, + document_name=current_doc["document_name"], + change_type=ChangeType.MODIFIED, + old_hash=stored_doc["content_hash"], + new_hash=current_doc["content_hash"], + change_time=datetime.now() + )) + + return changes + + def process_change(self, change: DocumentChange) -> bool: + """处理单个变更""" + try: + file_path = os.path.join(self.documents_path, change.document_id) + + # 从 document_id 中解析目标向量库 + # document_id 格式: "public/filename.pdf" 或 "finance/filename.pdf" + kb_name = self._get_kb_name_from_path(change.document_id) + + # 导入知识库管理器 + from knowledge.manager import get_kb_manager + kb_manager = get_kb_manager() + + if change.change_type == ChangeType.ADDED: + # 新增文档 - 使用多向量库方法 + new_version = self._generate_version_id(kb_name, change.document_name) + chunks_added = kb_manager.add_file_to_kb( + kb_name=kb_name, + filepath=file_path, + extra_metadata={ + 'status': 'active', + 'version': new_version, + 'change_time': datetime.now().isoformat() + } + ) + # 更新哈希记录 + self.db.set_document_hash( + change.document_id, + change.document_name, + change.new_hash, + os.path.getsize(file_path) if os.path.exists(file_path) else 0, + datetime.now() + ) + + # 创建版本记录 + try: + from knowledge.document_versions import get_version_query + version_query = get_version_query() + version_query.create_version_record( + collection=kb_name, + document_id=change.document_name, + version=new_version, + status="active", + change_summary="新增文档", + created_by="sync_service", + chunk_count=chunks_added + ) + except Exception as e: + logger.warning(f"创建版本记录失败: {e}") + + logger.info(f"已添加文档到 {kb_name}: {change.document_id}, 片段数: {chunks_added}") + + elif change.change_type == ChangeType.MODIFIED: + # 修改文档:版本管理策略 + # 执行顺序:SQLite 标记旧版本 → ChromaDB 替换切片 → SQLite 创建新版本 + + # 1. 获取当前版本号 & 生成新版本号 + old_version = self._get_current_version(kb_name, change.document_name) + new_version = self._generate_version_id(kb_name, change.document_name) + + # 2. 在 SQLite 中标记旧版本为 superseded(ChromaDB 切片由 Phase 3 去重自动清理) + if old_version: + try: + kb_manager.mark_document_as_superseded( + kb_name, + change.document_name, + new_version=new_version, + reason="文档更新" + ) + logger.info( + f"标记旧版本为 superseded: " + f"{change.document_name} {old_version} -> {new_version}" + ) + except Exception as e: + logger.warning(f"标记旧版本失败: {e}") + + # 3. 添加新版本(Phase 3 去重会自动清理同名旧切片) + chunks_added = kb_manager.add_file_to_kb( + kb_name=kb_name, + filepath=file_path, + extra_metadata={ + 'status': 'active', + 'version': new_version, + 'previous_version': old_version or '', + 'change_time': datetime.now().isoformat() + } + ) + + # 4. 更新哈希记录 + self.db.set_document_hash( + change.document_id, + change.document_name, + change.new_hash, + os.path.getsize(file_path) if os.path.exists(file_path) else 0, + datetime.now() + ) + + # 5. 在 SQLite 中创建新版本记录 + try: + from knowledge.document_versions import get_version_query + version_query = get_version_query() + version_query.create_version_record( + collection=kb_name, + document_id=change.document_name, + version=new_version, + status="active", + change_summary=f"从 {old_version or '(无)'} 更新", + supersedes=old_version, + created_by="sync_service", + chunk_count=chunks_added + ) + except Exception as e: + logger.warning(f"创建版本记录失败: {e}") + + logger.info( + f"已更新文档: {change.document_id}, " + f"版本: {old_version or '(无)'} -> {new_version}, " + f"添加 {chunks_added} 片段" + ) + + elif change.change_type == ChangeType.DELETED: + # 删除文档 + deleted = kb_manager.delete_document(kb_name, change.document_name) + # 删除哈希记录 + self.db.delete_document_hash(change.document_id) + logger.info(f"已删除文档: {change.document_id}, 删除 {deleted} 片段") + + # ==================== 缓存失效 ==================== + # 文档变更后递增知识库版本号,使旧缓存自动失效 + if CACHE_AVAILABLE: + try: + cache = get_cache_manager() + cache.increment_kb_version(kb_name) + logger.debug(f"已递增知识库版本号: {kb_name}") + except Exception as e: + logger.warning(f"递增缓存版本号失败: {e}") + + return True + + except Exception as e: + logger.error(f"处理变更失败: {change.document_id}, 错误: {e}") + import traceback + traceback.print_exc() + return False + + def _get_kb_name_from_path(self, document_id: str) -> str: + """ + 从文档ID中解析目标向量库名称 + + Args: + document_id: 文档ID,格式如 "public_kb/filename.pdf" 或 "dept_hr/filename.pdf" + + Returns: + 向量库名称(目录名 = 向量库名) + """ + # 统一路径分隔符(兼容 Windows 和 Linux) + normalized = document_id.replace('\\', '/') + # 获取第一级目录名(即向量库名) + parts = normalized.split('/') + if len(parts) > 1: + return parts[0] # 目录名即向量库名 + else: + return 'public_kb' # 默认公开库 + + def _get_current_version(self, kb_name: str, filename: str) -> str: + """ + 获取文档当前版本号 + + Args: + kb_name: 知识库名称 + filename: 文件名 + + Returns: + 当前版本号,如 "v1", "v2",不存在则返回 None + """ + try: + from knowledge.document_versions import get_version_query + version_query = get_version_query() + active_version = version_query.get_active_version(kb_name, filename) + return active_version.version if active_version else None + except Exception as e: + logger.warning(f"获取当前版本失败: {e}") + return None + + def _generate_version_id(self, kb_name: str, filename: str) -> str: + """ + 生成新版本号 + + 基于所有版本记录(不限状态)中的最高版本号递增, + 避免已替代版本被覆盖后版本号回退到 v1。 + + Args: + kb_name: 知识库名称 + filename: 文件名 + + Returns: + 新版本号,如 "v1", "v2", "v3" + """ + try: + from knowledge.document_versions import get_version_query + version_query = get_version_query() + return version_query.get_next_version(kb_name, filename) + except Exception as e: + logger.warning(f"生成版本号失败: {e}") + # 回退:基于当前 active 版本递增 + current_version = self._get_current_version(kb_name, filename) + if not current_version: + return "v1" + try: + version_num = int(current_version.replace('v', '')) + return f"v{version_num + 1}" + except (ValueError, AttributeError): + return "v1" + + def _record_version_change( + self, + kb_name: str, + filename: str, + old_version: str, + new_version: str, + reason: str + ): + """ + 记录版本变更到数据库 + + Args: + kb_name: 知识库名称 + filename: 文件名 + old_version: 旧版本号 + new_version: 新版本号 + reason: 变更原因 + """ + try: + from knowledge.document_versions import get_version_query + version_query = get_version_query() + version_query.log_version_change( + collection=kb_name, + document_id=filename, + change_type="update", + old_version=old_version, + new_version=new_version, + old_status="active", + new_status="active", + reason=reason, + changed_by="sync_service" + ) + except Exception as e: + logger.warning(f"记录版本变更失败: {e}") + + def sync_now(self) -> SyncResult: + """立即执行同步""" + logger.info("开始同步...") + + result = SyncResult( + status=SyncStatus.RUNNING, + start_time=datetime.now(), + end_time=None, + documents_processed=0, + documents_added=0, + documents_modified=0, + documents_deleted=0, + errors=[] + ) + + try: + # 检测变更 + changes = self.detect_changes() + + # 处理变更 + for change in changes: + success = self.process_change(change) + result.documents_processed += 1 + + if success: + if change.change_type == ChangeType.ADDED: + result.documents_added += 1 + elif change.change_type == ChangeType.MODIFIED: + result.documents_modified += 1 + elif change.change_type == ChangeType.DELETED: + result.documents_deleted += 1 + else: + result.errors.append(f"处理失败: {change.document_id}") + + # 记录变更 + self.db.log_change(change) + + result.status = SyncStatus.COMPLETED + + except Exception as e: + result.status = SyncStatus.FAILED + result.errors.append(str(e)) + logger.error(f"同步失败: {e}") + + result.end_time = datetime.now() + + # 记录同步状态 + self.db.log_sync_status(result) + + # 触发回调 + if self.on_sync_callback: + self.on_sync_callback(result) + + logger.info(f"同步完成: 处理 {result.documents_processed} 个文档, " + f"新增 {result.documents_added}, " + f"修改 {result.documents_modified}, " + f"删除 {result.documents_deleted}") + + return result + + def start(self): + """启动文件监控""" + if not HAS_WATCHDOG: + logger.error("watchdog 未安装,无法启动文件监控") + return False + + if self._running: + logger.warning("文件监控已在运行") + return True + + # 首次同步 + logger.info("执行首次同步...") + self.sync_now() + + # 启动监控 + event_handler = FileChangeHandler(self) + self._observer = Observer() + self._observer.schedule(event_handler, self.documents_path, recursive=True) + self._observer.start() + + self._running = True + logger.info(f"文件监控已启动,监控目录: {self.documents_path}") + return True + + def stop(self): + """停止文件监控""" + if self._observer: + self._observer.stop() + self._observer.join() + self._observer = None + + self._running = False + logger.info("文件监控已停止") + + def is_running(self) -> bool: + """检查监控是否在运行""" + return self._running + + +# 便捷函数 +def create_sync_service(documents_path: str = None) -> KnowledgeSyncService: + """创建同步服务实例""" + return KnowledgeSyncService(documents_path) + + +# 测试代码 +if __name__ == "__main__": + print("=" * 60) + print("知识库同步服务测试") + print("=" * 60) + + # 创建服务 + sync_service = KnowledgeSyncService() + + # 测试扫描文档 + print("\n[1] 扫描文档...") + docs = sync_service.scan_documents() + print(f"找到 {len(docs)} 个文档") + for doc_id, doc in list(docs.items())[:5]: + print(f" - {doc['document_name']}: {doc['content_hash'][:8]}...") + + # 测试变更检测 + print("\n[2] 检测变更...") + changes = sync_service.detect_changes() + print(f"检测到 {len(changes)} 个变更") + for change in changes[:5]: + print(f" - {change.document_name}: {change.change_type.value}") + + # 测试同步 + print("\n[3] 执行同步...") + result = sync_service.sync_now() + print(f"同步状态: {result.status.value}") + print(f"处理文档: {result.documents_processed}") + print(f"新增: {result.documents_added}, 修改: {result.documents_modified}, 删除: {result.documents_deleted}") + + print("\n" + "=" * 60) + print("测试完成") diff --git a/test_files/parse_rag.py b/test_files/parse_rag.py new file mode 100644 index 0000000..f66d48b --- /dev/null +++ b/test_files/parse_rag.py @@ -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]}...') diff --git a/test_files/rag_output.txt b/test_files/rag_output.txt new file mode 100644 index 0000000..a331c65 --- /dev/null +++ b/test_files/rag_output.txt @@ -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}} + diff --git a/test_files/rag_test2.json b/test_files/rag_test2.json new file mode 100644 index 0000000..f3a622b --- /dev/null +++ b/test_files/rag_test2.json @@ -0,0 +1 @@ +{"message":"迟到处罚有什么规定","collections":["dept_a_kb","dept_b_kb"],"chat_history":[]} diff --git a/test_files/rule_a.txt b/test_files/rule_a.txt new file mode 100644 index 0000000..68037a7 --- /dev/null +++ b/test_files/rule_a.txt @@ -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前完成签到打卡。 +远程工作日需提交日报至直属主管。 \ No newline at end of file diff --git a/test_files/rule_a_v2.txt b/test_files/rule_a_v2.txt new file mode 100644 index 0000000..68037a7 --- /dev/null +++ b/test_files/rule_a_v2.txt @@ -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前完成签到打卡。 +远程工作日需提交日报至直属主管。 \ No newline at end of file diff --git a/test_files/规章制度.txt b/test_files/规章制度.txt new file mode 100644 index 0000000..f0607be --- /dev/null +++ b/test_files/规章制度.txt @@ -0,0 +1,16 @@ +部门A考勤管理制度 + +第一条 工作时间 +部门A实行标准工时制,每日工作8小时,上午9:00至下午18:00。 + +第二条 迟到处罚 +部门A员工迟到超过15分钟,扣除当日绩效奖金的20%。 + +第三条 加班规定 +部门A加班需提前申请主管审批,加班费按1.5倍时薪计算。 + +第四条 请假流程 +部门A员工请假需填写OA系统申请,3天以内主管审批,3天以上经理审批。 + +第五条 特殊条款 +部门A因业务特殊性,每月允许2次弹性工作制。 \ No newline at end of file diff --git a/test_files/规章制度_deptA.txt b/test_files/规章制度_deptA.txt new file mode 100644 index 0000000..f0607be --- /dev/null +++ b/test_files/规章制度_deptA.txt @@ -0,0 +1,16 @@ +部门A考勤管理制度 + +第一条 工作时间 +部门A实行标准工时制,每日工作8小时,上午9:00至下午18:00。 + +第二条 迟到处罚 +部门A员工迟到超过15分钟,扣除当日绩效奖金的20%。 + +第三条 加班规定 +部门A加班需提前申请主管审批,加班费按1.5倍时薪计算。 + +第四条 请假流程 +部门A员工请假需填写OA系统申请,3天以内主管审批,3天以上经理审批。 + +第五条 特殊条款 +部门A因业务特殊性,每月允许2次弹性工作制。 \ No newline at end of file diff --git a/test_files/规章制度_deptB.txt b/test_files/规章制度_deptB.txt new file mode 100644 index 0000000..60488cf --- /dev/null +++ b/test_files/规章制度_deptB.txt @@ -0,0 +1,16 @@ +部门B考勤管理制度 + +第一条 工作时间 +部门B实行弹性工时制,核心工作时间为10:00-16:00,其余时间自由安排。 + +第二条 迟到处罚 +部门B不设迟到处罚,但月度累计迟到超过5次需提交书面说明。 + +第三条 加班规定 +部门B鼓励高效工作不提倡加班,如需加班可调休补偿。 + +第四条 请假流程 +部门B员工请假通过钉钉申请,5天以内直属主管审批即可。 + +第五条 特殊条款 +部门B因研发性质,每周五下午为技术分享日,不计入考勤。 \ No newline at end of file diff --git a/tests/e2e_risk_test.py b/tests/e2e_risk_test.py new file mode 100644 index 0000000..94321b9 --- /dev/null +++ b/tests/e2e_risk_test.py @@ -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) diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..c4fbd11 --- /dev/null +++ b/tests/test_edge_cases.py @@ -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("全部通过!") diff --git a/tests/test_upload_dedup.py b/tests/test_upload_dedup.py new file mode 100644 index 0000000..f04322c --- /dev/null +++ b/tests/test_upload_dedup.py @@ -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) diff --git a/tests/test_version_management.py b/tests/test_version_management.py new file mode 100644 index 0000000..f4a8859 --- /dev/null +++ b/tests/test_version_management.py @@ -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)