Compare commits
35 Commits
server-bas
...
server-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df33a912af | ||
|
|
d7fb98faf6 | ||
|
|
2fbf1b85f9 | ||
|
|
c709360c7c | ||
|
|
4a262728b1 | ||
|
|
dd213df0e2 | ||
|
|
5ccfdaf9d1 | ||
|
|
d589c27bce | ||
|
|
b2ccec79b9 | ||
|
|
53e48cccb2 | ||
|
|
475900e103 | ||
|
|
6d5a4b5b5e | ||
|
|
c6a17ad1e4 | ||
|
|
0f339a0998 | ||
|
|
21470ed28c | ||
|
|
258be54df7 | ||
|
|
d6d27b70f3 | ||
|
|
184511f4ec | ||
|
|
b5bcb3c941 | ||
|
|
5bc86a7c1f | ||
|
|
8f72ac5da4 | ||
|
|
3a3627f73c | ||
|
|
2c84cb8a5f | ||
|
|
8d60616124 | ||
|
|
8f75c51c59 | ||
|
|
87f3fae1aa | ||
|
|
fc11b11dda | ||
|
|
99f5cf519e | ||
|
|
15c0aec9a6 | ||
|
|
8c7a6eb3fa | ||
|
|
fda1b2f049 | ||
|
|
148559ee3c | ||
|
|
431af0217a | ||
|
|
aa04bb94a6 | ||
|
|
84a8be0ce8 |
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# 大体积数据目录(通过 volume 挂载,不需要打进镜像)
|
||||
models/
|
||||
knowledge/vector_store/
|
||||
documents/
|
||||
.data/
|
||||
data/
|
||||
|
||||
# Python 虚拟环境
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
# IDE 和编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# 其他
|
||||
*.log
|
||||
.env*
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -114,11 +114,22 @@ chat-ui/
|
||||
# 生产环境配置(含 API 密钥)
|
||||
deploy/.env.production
|
||||
|
||||
# 根目录临时测试脚本
|
||||
# 根目录临时测试脚本和调试脚本
|
||||
test_*.py
|
||||
test_*.json
|
||||
debug_*.py
|
||||
verify_*.py
|
||||
bench_*.py
|
||||
bench_*.json
|
||||
extract_questions.py
|
||||
start_server.py
|
||||
rag_response.json
|
||||
rag_resp.txt
|
||||
*_extracted.txt
|
||||
nul
|
||||
server.log
|
||||
server_err.log
|
||||
.dataservice_debug.log
|
||||
|
||||
# 临时调试脚本(下划线开头)
|
||||
scripts/_*.py
|
||||
|
||||
1069
api/chat_routes.py
1069
api/chat_routes.py
File diff suppressed because it is too large
Load Diff
@@ -775,7 +775,22 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
if kb_manager:
|
||||
kb_manager.delete_document(collection, filename)
|
||||
|
||||
# 2. 删除文件
|
||||
# 2. 缓存失效
|
||||
try:
|
||||
from core.cache import get_cache_manager
|
||||
_cm = get_cache_manager()
|
||||
_cm.increment_kb_version(collection)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
_sc = get_semantic_cache()
|
||||
if _sc:
|
||||
_sc.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 删除文件
|
||||
os.remove(filepath)
|
||||
|
||||
return jsonify({
|
||||
|
||||
@@ -260,6 +260,21 @@ def delete_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
success, message = kb_manager.delete_collection(kb_name, delete_documents)
|
||||
|
||||
if success:
|
||||
# 缓存失效
|
||||
try:
|
||||
from core.cache import get_cache_manager
|
||||
_cm = get_cache_manager()
|
||||
_cm.increment_kb_version(kb_name)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
_sc = get_semantic_cache()
|
||||
if _sc:
|
||||
_sc.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
|
||||
@@ -315,3 +315,48 @@ def stop_sync_monitor() -> Tuple[Any, int]:
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
|
||||
# ==================== 缓存管理 API ====================
|
||||
|
||||
@sync_bp.route('/cache/clear', methods=['POST'])
|
||||
@require_gateway_auth
|
||||
def clear_all_caches() -> Tuple[Any, int]:
|
||||
"""
|
||||
清除所有缓存层(Query / Embedding / Rerank / Semantic / Intent 精确缓存)
|
||||
|
||||
用于测试和调试,不影响向量库数据。
|
||||
|
||||
Returns:
|
||||
{"success": true, "message": "缓存已清除", "cleared": [...]}
|
||||
"""
|
||||
cleared = []
|
||||
|
||||
# 1. 精确匹配缓存(Query / Embedding / Rerank)
|
||||
try:
|
||||
from core.cache import get_cache_manager
|
||||
cache = get_cache_manager()
|
||||
if cache:
|
||||
cache.clear_all()
|
||||
cleared.append("query_cache")
|
||||
cleared.append("embedding_cache")
|
||||
cleared.append("rerank_cache")
|
||||
except Exception as e:
|
||||
logger.warning(f"清除精确缓存失败: {e}")
|
||||
|
||||
# 2. 语义缓存(FAISS)
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
sc = get_semantic_cache()
|
||||
if sc:
|
||||
sc.clear()
|
||||
cleared.append("semantic_cache")
|
||||
except Exception as e:
|
||||
logger.warning(f"清除语义缓存失败: {e}")
|
||||
|
||||
logger.info(f"缓存清除完成: {cleared}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"已清除 {len(cleared)} 层缓存",
|
||||
"cleared": cleared
|
||||
})
|
||||
|
||||
207
cleanup_orphans.py
Normal file
207
cleanup_orphans.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
孤儿文件清理工具
|
||||
|
||||
清理 .data/images/ 和 .data/cache/vlm/ 中不再被任何 ChromaDB 切片引用的文件。
|
||||
|
||||
用法:
|
||||
# 预览模式(不删除,只显示孤儿文件)
|
||||
python cleanup_orphans.py
|
||||
|
||||
# 实际删除
|
||||
python cleanup_orphans.py --force
|
||||
|
||||
# 仅清理特定知识库
|
||||
python cleanup_orphans.py --collections public_kb test_kb
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGES_DIR = Path(".data/images")
|
||||
VLM_CACHE_DIR = Path(".data/cache/vlm")
|
||||
|
||||
|
||||
def compute_file_hash(file_path: str) -> str:
|
||||
"""计算文件 MD5"""
|
||||
with open(file_path, 'rb') as f:
|
||||
return hashlib.md5(f.read()).hexdigest()
|
||||
|
||||
|
||||
def collect_referenced_images(manager, collections=None) -> dict:
|
||||
"""
|
||||
从 ChromaDB 收集所有被引用的图片路径。
|
||||
|
||||
Returns:
|
||||
{image_filename: set of chunk_ids referencing it}
|
||||
"""
|
||||
referenced = {}
|
||||
|
||||
if collections:
|
||||
kb_names = collections
|
||||
else:
|
||||
kb_names = [c.name if hasattr(c, 'name') else str(c)
|
||||
for c in manager.list_collections()]
|
||||
|
||||
for kb_name in kb_names:
|
||||
try:
|
||||
col = manager.get_collection(kb_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"无法获取 {kb_name}: {e}")
|
||||
continue
|
||||
|
||||
# 查找所有带 image_path 的切片
|
||||
results = col.get(include=['metadatas'])
|
||||
if not results['ids']:
|
||||
continue
|
||||
|
||||
for chunk_id, meta in zip(results['ids'], results['metadatas']):
|
||||
image_path = meta.get('image_path', '')
|
||||
if not image_path:
|
||||
continue
|
||||
|
||||
# image_path 可能是: "185a7a75d246.png" 或相对路径
|
||||
filename = os.path.basename(image_path)
|
||||
if filename not in referenced:
|
||||
referenced[filename] = set()
|
||||
referenced[filename].add(f"{kb_name}/{chunk_id}")
|
||||
|
||||
return referenced
|
||||
|
||||
|
||||
def find_orphan_images(referenced: dict) -> list:
|
||||
"""
|
||||
查找 .data/images/ 中不再被引用的图片文件。
|
||||
|
||||
Returns:
|
||||
[(filepath, filename, size_bytes)]
|
||||
"""
|
||||
orphans = []
|
||||
if not IMAGES_DIR.exists():
|
||||
return orphans
|
||||
|
||||
for f in IMAGES_DIR.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.name not in referenced:
|
||||
orphans.append((str(f), f.name, f.stat().st_size))
|
||||
|
||||
return orphans
|
||||
|
||||
|
||||
def find_orphan_vlm_caches(referenced: dict) -> list:
|
||||
"""
|
||||
查找 .data/cache/vlm/ 中对应的图片已不存在的缓存文件。
|
||||
|
||||
缓存文件以图片 MD5 命名,如果图片被删了,缓存也应该是孤儿。
|
||||
|
||||
Returns:
|
||||
[(filepath, filename, size_bytes)]
|
||||
"""
|
||||
orphans = []
|
||||
if not VLM_CACHE_DIR.exists():
|
||||
return orphans
|
||||
|
||||
# 构建 referenced 中所有图片的 MD5 集合
|
||||
referenced_hashes = set()
|
||||
for filename in referenced:
|
||||
full_path = IMAGES_DIR / filename
|
||||
if full_path.exists():
|
||||
try:
|
||||
img_hash = compute_file_hash(str(full_path))
|
||||
referenced_hashes.add(img_hash)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for f in VLM_CACHE_DIR.iterdir():
|
||||
if not f.is_file() or not f.suffix == '.txt':
|
||||
continue
|
||||
cache_hash = f.stem # 文件名就是 MD5
|
||||
if cache_hash not in referenced_hashes:
|
||||
orphans.append((str(f), f.name, f.stat().st_size))
|
||||
|
||||
return orphans
|
||||
|
||||
|
||||
def delete_files(file_list: list, dry_run: bool = True) -> int:
|
||||
"""删除文件列表,返回删除数量"""
|
||||
deleted = 0
|
||||
for filepath, filename, size in file_list:
|
||||
if dry_run:
|
||||
logger.info(f" [DRY-RUN] 将删除: {filename} ({size/1024:.1f} KB)")
|
||||
else:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
logger.info(f" 已删除: {filename} ({size/1024:.1f} KB)")
|
||||
deleted += 1
|
||||
except OSError as e:
|
||||
logger.warning(f" 删除失败: {filename} - {e}")
|
||||
return deleted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="孤儿文件清理工具")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="实际删除文件(默认仅预览)")
|
||||
parser.add_argument("--collections", nargs="+",
|
||||
help="仅检查指定知识库(默认检查全部)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
mode = "删除" if args.force else "预览(不删除)"
|
||||
print("=" * 60)
|
||||
print(f"孤儿文件清理 - {mode}")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 收集引用
|
||||
print("\n[1/4] 扫描 ChromaDB 引用...")
|
||||
sys.path.insert(0, '.')
|
||||
from knowledge.manager import get_kb_manager
|
||||
manager = get_kb_manager()
|
||||
referenced = collect_referenced_images(manager, args.collections)
|
||||
print(f" 被引用的图片: {len(referenced)} 个")
|
||||
|
||||
# 2. 查找孤儿图片
|
||||
print("\n[2/4] 查找孤儿图片...")
|
||||
orphan_images = find_orphan_images(referenced)
|
||||
total_img_size = sum(s for _, _, s in orphan_images)
|
||||
print(f" 孤儿图片: {len(orphan_images)} 个 ({total_img_size/1024:.1f} KB)")
|
||||
|
||||
# 3. 查找孤儿 VLM 缓存
|
||||
print("\n[3/4] 查找孤儿 VLM 缓存...")
|
||||
orphan_caches = find_orphan_vlm_caches(referenced)
|
||||
total_cache_size = sum(s for _, _, s in orphan_caches)
|
||||
print(f" 孤儿缓存: {len(orphan_caches)} 个 ({total_cache_size/1024:.1f} KB)")
|
||||
|
||||
# 4. 清理
|
||||
print("\n[4/4] 清理...")
|
||||
if not orphan_images and not orphan_caches:
|
||||
print(" 没有需要清理的文件")
|
||||
else:
|
||||
if not args.force:
|
||||
print(" 预览模式,以下文件将被删除:")
|
||||
|
||||
img_deleted = delete_files(orphan_images, dry_run=not args.force)
|
||||
cache_deleted = delete_files(orphan_caches, dry_run=not args.force)
|
||||
|
||||
if args.force:
|
||||
print(f"\n 删除了 {img_deleted} 个图片 + {cache_deleted} 个缓存")
|
||||
freed = total_img_size + total_cache_size
|
||||
print(f" 释放空间: {freed/1024:.1f} KB")
|
||||
else:
|
||||
print(f"\n 共 {len(orphan_images) + len(orphan_caches)} 个文件待清理")
|
||||
print(f" 释放空间: {(total_img_size + total_cache_size)/1024:.1f} KB")
|
||||
print(" 使用 --force 参数执行实际删除")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -109,6 +109,11 @@ USE_RERANK = True
|
||||
RERANK_CANDIDATES = 20 # 送入重排序的候选数
|
||||
RERANK_TOP_K = 15 # 重排序后保留数
|
||||
RERANK_USE_ONNX = os.getenv("RERANK_USE_ONNX", "true").lower() == "true"
|
||||
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local") # local / cloud / fallback
|
||||
RERANK_CLOUD_MODEL = os.getenv("RERANK_CLOUD_MODEL", "qwen3-rerank")
|
||||
RERANK_CLOUD_API_KEY = os.getenv("RERANK_CLOUD_API_KEY", "")
|
||||
RERANK_CLOUD_BASE_URL = os.getenv("RERANK_CLOUD_BASE_URL", "https://dashscope.aliyuncs.com/compatible-api/v1/reranks")
|
||||
RERANK_CLOUD_TIMEOUT = int(os.getenv("RERANK_CLOUD_TIMEOUT", "15"))
|
||||
RERANK_CONTEXT_MIN_SCORE = 0.05 # Rerank 分数低于此值的切片不送入 LLM
|
||||
|
||||
# ----- RRF 融合 -----
|
||||
|
||||
@@ -31,17 +31,11 @@ from core.llm_utils import call_llm, quick_yes_no, parse_json_from_response
|
||||
from .agentic_base import (
|
||||
API_KEY, BASE_URL, MODEL,
|
||||
HAS_SERPER,
|
||||
HAS_BUDGET, SEMANTIC_CACHE_ENABLED,
|
||||
HAS_BUDGET,
|
||||
MAX_CONTEXT_TOKENS, MAX_CONTEXT_COUNT, RERANK_THRESHOLD,
|
||||
SOURCE_KB, SOURCE_WEB,
|
||||
)
|
||||
|
||||
# 尝试导入语义缓存
|
||||
try:
|
||||
from core.semantic_cache import SemanticCache
|
||||
except ImportError:
|
||||
SemanticCache = None
|
||||
|
||||
# 导入 Mixin 类
|
||||
from .agentic_query import QueryRewriteMixin
|
||||
from .agentic_search import SearchMixin
|
||||
@@ -120,30 +114,6 @@ class AgenticRAG(
|
||||
except ImportError:
|
||||
self.loop_guard = None
|
||||
|
||||
# 初始化语义缓存
|
||||
self.semantic_cache = None
|
||||
self.embedding_model = None
|
||||
if SEMANTIC_CACHE_ENABLED and SemanticCache:
|
||||
try:
|
||||
engine = get_engine()
|
||||
if engine and hasattr(engine, 'embedding_model'):
|
||||
self.embedding_model = engine.embedding_model
|
||||
emb_dim = 768
|
||||
# 优先使用新 API,兼容旧版本
|
||||
if hasattr(self.embedding_model, 'get_embedding_dimension'):
|
||||
emb_dim = self.embedding_model.get_embedding_dimension()
|
||||
elif hasattr(self.embedding_model, 'get_sentence_embedding_dimension'):
|
||||
emb_dim = self.embedding_model.get_sentence_embedding_dimension()
|
||||
self.semantic_cache = SemanticCache(
|
||||
dim=emb_dim,
|
||||
threshold=0.92,
|
||||
max_size=5000
|
||||
)
|
||||
logger.info(f"语义缓存已启用,维度={emb_dim}")
|
||||
except Exception as e:
|
||||
logger.warning(f"语义缓存初始化失败: {e}")
|
||||
self.semantic_cache = None
|
||||
|
||||
# Context Compression 配置
|
||||
self.MAX_CONTEXT_TOKENS = MAX_CONTEXT_TOKENS
|
||||
self.MAX_CONTEXT_COUNT = MAX_CONTEXT_COUNT
|
||||
|
||||
@@ -25,22 +25,6 @@ except ImportError:
|
||||
HAS_BUDGET = False
|
||||
CallType = None
|
||||
|
||||
# 语义缓存
|
||||
try:
|
||||
from config import SEMANTIC_CACHE_ENABLED, SEMANTIC_CACHE_THRESHOLD
|
||||
HAS_SEMANTIC_CACHE_CONFIG = True
|
||||
except ImportError:
|
||||
SEMANTIC_CACHE_ENABLED = False
|
||||
SEMANTIC_CACHE_THRESHOLD = 0.92
|
||||
HAS_SEMANTIC_CACHE_CONFIG = False
|
||||
|
||||
try:
|
||||
from core.semantic_cache import SemanticCache, get_semantic_cache
|
||||
HAS_SEMANTIC_CACHE = True
|
||||
except ImportError:
|
||||
HAS_SEMANTIC_CACHE = False
|
||||
SemanticCache = None
|
||||
|
||||
# LLM 配置
|
||||
try:
|
||||
from config import API_KEY, BASE_URL, MODEL
|
||||
|
||||
@@ -189,6 +189,8 @@ class RAGCacheManager:
|
||||
# 失效旧版本缓存
|
||||
self.query_cache.invalidate_by_version(old_version)
|
||||
self.embedding_cache.invalidate_by_version(old_version)
|
||||
# Rerank cache 无 kb_version 字段,文档变更时全量清空以防过时分数
|
||||
self.rerank_cache.clear()
|
||||
|
||||
logger.info(f"知识库 {kb_name} 版本更新: {old_version} -> {new_version}")
|
||||
return new_version
|
||||
@@ -196,90 +198,41 @@ class RAGCacheManager:
|
||||
# ==================== Query Cache 方法 ====================
|
||||
|
||||
@staticmethod
|
||||
def _make_query_cache_key(query: str, kb_name: str, kb_version: int, doc_hash: str = "") -> str:
|
||||
def _make_query_cache_key(query: str, kb_name: str, kb_version: int) -> str:
|
||||
"""
|
||||
生成查询缓存 key
|
||||
|
||||
Args:
|
||||
query: 查询文本
|
||||
kb_name: 知识库名称
|
||||
kb_version: 知识库版本号
|
||||
doc_hash: 相关文档版本哈希(细粒度失效)
|
||||
|
||||
Returns:
|
||||
缓存 key
|
||||
生成查询缓存 key(基于 kb_version 的粗粒度失效)
|
||||
"""
|
||||
if doc_hash:
|
||||
# 细粒度:只失效相关文档的缓存
|
||||
return hashlib.md5(
|
||||
f"query:{query}:{kb_name}:{doc_hash}".encode()
|
||||
).hexdigest()
|
||||
else:
|
||||
# 粗粒度:整个知识库版本变化时失效
|
||||
return hashlib.md5(
|
||||
f"query:{query}:{kb_name}:{kb_version}".encode()
|
||||
).hexdigest()
|
||||
return hashlib.md5(
|
||||
f"query:{query}:{kb_name}:{kb_version}".encode()
|
||||
).hexdigest()
|
||||
|
||||
def get_query_result(self, query: str, kb_name: str, doc_ids: List[str] = None) -> Optional[Dict]:
|
||||
def get_query_result(self, query: str, kb_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取查询缓存结果
|
||||
|
||||
Args:
|
||||
query: 查询文本
|
||||
kb_name: 知识库名称
|
||||
doc_ids: 相关文档 ID 列表(用于细粒度缓存 key)
|
||||
"""
|
||||
kb_version = self.get_kb_version(kb_name)
|
||||
|
||||
# 计算文档哈希(如果提供了 doc_ids)
|
||||
doc_hash = ""
|
||||
if doc_ids:
|
||||
doc_hash = self._compute_doc_hash(kb_name, doc_ids)
|
||||
|
||||
key = self._make_query_cache_key(query, kb_name, kb_version, doc_hash)
|
||||
key = self._make_query_cache_key(query, kb_name, kb_version)
|
||||
return self.query_cache.get(key)
|
||||
|
||||
def set_query_result(self, query: str, kb_name: str, result: Dict, doc_ids: List[str] = None) -> None:
|
||||
def set_query_result(self, query: str, kb_name: str, result: Dict) -> None:
|
||||
"""
|
||||
设置查询缓存结果
|
||||
|
||||
kb_version 在文档变更时自增,触发整个知识库的缓存失效。
|
||||
|
||||
Args:
|
||||
query: 查询文本
|
||||
kb_name: 知识库名称
|
||||
result: 缓存结果
|
||||
doc_ids: 相关文档 ID 列表(用于细粒度失效)
|
||||
"""
|
||||
kb_version = self.get_kb_version(kb_name)
|
||||
|
||||
# 计算相关文档的版本哈希(细粒度失效)
|
||||
doc_hash = ""
|
||||
if doc_ids:
|
||||
doc_hash = self._compute_doc_hash(kb_name, doc_ids)
|
||||
|
||||
key = self._make_query_cache_key(query, kb_name, kb_version, doc_hash)
|
||||
key = self._make_query_cache_key(query, kb_name, kb_version)
|
||||
self.query_cache.set(key, result, kb_version=kb_version)
|
||||
|
||||
def _compute_doc_hash(self, kb_name: str, doc_ids: List[str]) -> str:
|
||||
"""
|
||||
计算文档版本哈希
|
||||
|
||||
用于细粒度缓存失效:只失效相关文档变化时的缓存
|
||||
"""
|
||||
if not doc_ids:
|
||||
return ""
|
||||
|
||||
# 从文档 ID 中提取 source(文件名)
|
||||
sources = set()
|
||||
for doc_id in doc_ids:
|
||||
# doc_id 格式通常为 "filename_text_0" 或类似
|
||||
parts = doc_id.split('_')
|
||||
if parts:
|
||||
sources.add(parts[0])
|
||||
|
||||
# 生成哈希
|
||||
sources_str = ','.join(sorted(sources))
|
||||
return hashlib.md5(f"docs:{sources_str}".encode()).hexdigest()
|
||||
|
||||
# ==================== Embedding Cache 方法 ====================
|
||||
|
||||
@staticmethod
|
||||
|
||||
563
core/engine.py
563
core/engine.py
@@ -29,6 +29,7 @@ RAG 核心引擎
|
||||
|
||||
import os
|
||||
import gc
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
@@ -76,8 +77,12 @@ try:
|
||||
CONTEXT_EXPANSION_MAX_CHUNKS, ENUM_QUERY_DISABLE_TOPK_SHRINK, ENUM_QUERY_MMR_LAMBDA,
|
||||
# Phase 3 扩展精细化
|
||||
EXPANSION_SCORE_THRESHOLD, MAX_EXPANDED_NEIGHBORS,
|
||||
# 章节聚类救援
|
||||
SECTION_CLUSTER_BOOST_ENABLED, CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES,
|
||||
CLUSTER_SEED_FLOOR, CLUSTER_MAX_BOOST_PER_SECTION, CLUSTER_MAX_SECTIONS,
|
||||
CLUSTER_SECTION_PREFIX_LEVELS,
|
||||
# 上下文与生成
|
||||
LLM_TEMPERATURE, LLM_MAX_TOKENS, RECALL_MULTIPLIER,
|
||||
LLM_TEMPERATURE, LLM_TOP_P, LLM_MAX_TOKENS, RECALL_MULTIPLIER,
|
||||
# FAQ 与黑名单
|
||||
FAQ_RECALL_TOP_K, FAQ_BOOST_AMOUNT, FAQ_DECAY_MONTHS, FAQ_DECAY_RATE, FAQ_DECAY_MAX,
|
||||
BLACKLIST_MIN_DISLIKES, BLACKLIST_CACHE_TTL,
|
||||
@@ -101,20 +106,28 @@ except ImportError:
|
||||
MMR_TOP_K = 30
|
||||
CONTEXT_EXPANSION_ENABLED = True
|
||||
CONTEXT_EXPANSION_BEFORE = 1
|
||||
CONTEXT_EXPANSION_AFTER = 5
|
||||
CONTEXT_EXPANSION_AFTER = 8
|
||||
CONTEXT_EXPANSION_MAX_CHUNKS = 24
|
||||
EXPANSION_SCORE_THRESHOLD = 0.3
|
||||
MAX_EXPANDED_NEIGHBORS = 4
|
||||
MAX_EXPANDED_NEIGHBORS = 8
|
||||
# 章节聚类救援默认值
|
||||
SECTION_CLUSTER_BOOST_ENABLED = True
|
||||
CLUSTER_MIN_MEMBERS = 3
|
||||
CLUSTER_MIN_TYPES = 2
|
||||
CLUSTER_SEED_FLOOR = 0.35
|
||||
CLUSTER_MAX_BOOST_PER_SECTION = 8
|
||||
CLUSTER_MAX_SECTIONS = 3
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 1
|
||||
ENUM_QUERY_DISABLE_TOPK_SHRINK = True
|
||||
ENUM_QUERY_MMR_LAMBDA = 0.85
|
||||
DYNAMIC_RRF_ENABLED = True
|
||||
EMBEDDING_DEVICE = "auto"
|
||||
RERANK_DEVICE = "auto"
|
||||
RERANK_USE_ONNX = False
|
||||
RERANK_BACKEND = "local"
|
||||
RERANK_CLOUD_MODEL = "qwen3-rerank"
|
||||
RERANK_BACKEND = "cloud"
|
||||
RERANK_CLOUD_MODEL = "xop3qwen8breranker"
|
||||
RERANK_CLOUD_API_KEY = ""
|
||||
RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
|
||||
RERANK_CLOUD_BASE_URL = "https://maas-api.cn-huabei-1.xf-yun.com/v2/rerank"
|
||||
RERANK_CLOUD_TIMEOUT = 15
|
||||
|
||||
|
||||
@@ -272,8 +285,7 @@ class CloudReranker:
|
||||
body = {
|
||||
"model": self.model,
|
||||
"query": query,
|
||||
"documents": documents,
|
||||
"top_n": len(documents)
|
||||
"documents": documents
|
||||
}
|
||||
|
||||
session = self._get_session()
|
||||
@@ -547,8 +559,7 @@ class RAGEngine:
|
||||
top_dist = result['distances'][0][0] if result.get('distances') and result['distances'][0] else 1.0
|
||||
top_score = 1.0 - top_dist
|
||||
if top_score >= CACHE_MIN_SCORE:
|
||||
doc_ids = result.get('ids', [[]])[0] if result.get('ids') else []
|
||||
cache.set_query_result(query, kb_name, result, doc_ids=doc_ids)
|
||||
cache.set_query_result(query, kb_name, result)
|
||||
result['_debug'] = _debug
|
||||
return result
|
||||
|
||||
@@ -569,8 +580,7 @@ class RAGEngine:
|
||||
top_dist = result['distances'][0][0] if result.get('distances') and result['distances'][0] else 1.0
|
||||
top_score = 1.0 - top_dist
|
||||
if top_score >= CACHE_MIN_SCORE:
|
||||
doc_ids = result.get('ids', [[]])[0] if result.get('ids') else []
|
||||
cache.set_query_result(query, kb_name, result, doc_ids=doc_ids)
|
||||
cache.set_query_result(query, kb_name, result)
|
||||
result['_debug'] = _debug
|
||||
return result
|
||||
except Exception as e:
|
||||
@@ -605,9 +615,7 @@ class RAGEngine:
|
||||
top_dist = result['distances'][0][0] if result.get('distances') and result['distances'][0] else 1.0
|
||||
top_score = 1.0 - top_dist
|
||||
if top_score >= CACHE_MIN_SCORE:
|
||||
# 传递 doc_ids 实现细粒度缓存失效
|
||||
doc_ids = result.get('ids', [[]])[0] if result.get('ids') else []
|
||||
cache.set_query_result(query, kb_name, result, doc_ids=doc_ids)
|
||||
cache.set_query_result(query, kb_name, result)
|
||||
_debug['timing']['total_ms'] = int((time.time() - _overall_start) * 1000)
|
||||
result['_debug'] = _debug
|
||||
return result
|
||||
@@ -625,7 +633,7 @@ class RAGEngine:
|
||||
elif len(conditions) > 1:
|
||||
where_filter = {"$and": conditions}
|
||||
|
||||
query_vector = self.embedding_model.encode(query).tolist()
|
||||
query_vector = self._encode_cached(query).tolist()
|
||||
recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k
|
||||
recall_k = max(recall_k, top_k * RECALL_MULTIPLIER)
|
||||
|
||||
@@ -657,6 +665,7 @@ class RAGEngine:
|
||||
|
||||
results_list = [vector_results]
|
||||
weights = [VECTOR_WEIGHT]
|
||||
bm25_results = None # 初始化,防止 NameError
|
||||
|
||||
if USE_HYBRID_SEARCH and self.bm25_index.bm25:
|
||||
bm25_results = self.bm25_index.search(query, top_k=recall_k)
|
||||
@@ -671,6 +680,22 @@ class RAGEngine:
|
||||
vector_w, bm25_w = self._get_dynamic_rrf_weights(query)
|
||||
weights = [vector_w, bm25_w]
|
||||
|
||||
# ========== 保留 BM25 原始 top-3 完整信息,用于下游分歧检测救援 ==========
|
||||
_bm25_raw_top3 = []
|
||||
if USE_HYBRID_SEARCH and bm25_results and bm25_results.get('ids') and bm25_results['ids'][0]:
|
||||
_bm25_ids = bm25_results['ids'][0][:3]
|
||||
_bm25_docs = bm25_results['documents'][0][:3]
|
||||
_bm25_metas = bm25_results['metadatas'][0][:3]
|
||||
_bm25_dists = (bm25_results.get('distances', [[]])[0] or [0]*3)[:3]
|
||||
for i in range(len(_bm25_ids)):
|
||||
_bm25_raw_top3.append({
|
||||
'id': _bm25_ids[i],
|
||||
'doc': _bm25_docs[i],
|
||||
'meta': _bm25_metas[i],
|
||||
'bm25_score': _bm25_dists[i],
|
||||
'rank': i + 1
|
||||
})
|
||||
|
||||
if len(results_list) > 1:
|
||||
fused_results = self.reciprocal_rank_fusion(results_list, weights)
|
||||
_debug['steps'].append({'name': 'rrf_fusion', 'count': len(fused_results['ids'][0]) if fused_results.get('ids') else 0, 'weights': [round(w, 2) for w in weights]})
|
||||
@@ -686,6 +711,8 @@ class RAGEngine:
|
||||
|
||||
is_enum_query = self._is_enumeration_query(query)
|
||||
fused_results['_enum_query'] = is_enum_query
|
||||
# 传递 BM25 原始 top-3 到路由层,用于分歧检测救援
|
||||
fused_results['_bm25_top3'] = _bm25_raw_top3
|
||||
|
||||
# 章节过滤(如果查询中提到了章节)
|
||||
fused_results = self._filter_by_section(fused_results, query)
|
||||
@@ -732,11 +759,19 @@ class RAGEngine:
|
||||
# 时间衰减(Time Decay)
|
||||
fused_results = self._apply_time_decay(fused_results)
|
||||
|
||||
# 提前附加 _debug,使聚类提升能写入调试步骤
|
||||
fused_results['_debug'] = _debug
|
||||
|
||||
# ========== 章节聚类提升:在扩展前将低分但聚类的切片提升至种子阈值 ==========
|
||||
if SECTION_CLUSTER_BOOST_ENABLED:
|
||||
fused_results = self._section_cluster_boost(fused_results, query)
|
||||
|
||||
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
||||
# Phase 3:仅对高分种子扩展邻居
|
||||
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
||||
min_score=EXPANSION_SCORE_THRESHOLD)
|
||||
min_score=EXPANSION_SCORE_THRESHOLD,
|
||||
query=query)
|
||||
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
||||
|
||||
@@ -748,7 +783,15 @@ class RAGEngine:
|
||||
and not (is_enum_query and ENUM_QUERY_DISABLE_TOPK_SHRINK)
|
||||
and fused_results.get('_score_source') != 'rrf'
|
||||
):
|
||||
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
|
||||
# 根据分数来源计算相似度分数(越高越好)
|
||||
score_source = fused_results.get('_score_source')
|
||||
top_dist = fused_results['distances'][0][0]
|
||||
if score_source == 'rerank':
|
||||
# Rerank 后 distances 是相关性分数,越大越好,直接使用
|
||||
top_score = top_dist
|
||||
else:
|
||||
# 向量距离,越小越好,转为相似度
|
||||
top_score = 1.0 - top_dist
|
||||
adjusted_k, should_retrieve, reason = self._adaptive_topk.adjust(top_score, top_k)
|
||||
if "high_confidence" in reason:
|
||||
# 高置信度时截断结果
|
||||
@@ -763,9 +806,7 @@ class RAGEngine:
|
||||
top_dist = fused_results['distances'][0][0] if fused_results.get('distances') and fused_results['distances'][0] else 1.0
|
||||
top_score = 1.0 - top_dist # 距离转相似度
|
||||
if top_score >= CACHE_MIN_SCORE: # 置信度阈值
|
||||
# 传递 doc_ids 实现细粒度缓存失效
|
||||
doc_ids = fused_results.get('ids', [[]])[0] if fused_results.get('ids') else []
|
||||
cache.set_query_result(query, kb_name, fused_results, doc_ids=doc_ids)
|
||||
cache.set_query_result(query, kb_name, fused_results)
|
||||
|
||||
fused_results['_debug'] = _debug
|
||||
_debug['timing']['total_ms'] = int((time.time() - _overall_start) * 1000)
|
||||
@@ -811,6 +852,76 @@ class RAGEngine:
|
||||
logger.warning(f"FAQ 集合查询失败: {e}")
|
||||
return get_empty_result()
|
||||
|
||||
def _encode_cached(self, text):
|
||||
"""
|
||||
带缓存的 embedding 编码
|
||||
|
||||
优先从 Embedding Cache(LRU)读取,未命中再调用模型编码并写入缓存。
|
||||
支持单文本和批量文本输入。
|
||||
|
||||
Args:
|
||||
text: 单个文本字符串 或 文本列表
|
||||
|
||||
Returns:
|
||||
numpy 数组(单文本为一维,批量为二维)
|
||||
"""
|
||||
import numpy as _np
|
||||
|
||||
# 检查 embedding 缓存是否启用(缓存配置查询结果,避免每次重复导入)
|
||||
if not hasattr(self, '_emb_cache_enabled'):
|
||||
self._emb_cache_enabled = True # 默认启用
|
||||
if CACHE_AVAILABLE:
|
||||
try:
|
||||
from config import EMBEDDING_CACHE_ENABLED
|
||||
self._emb_cache_enabled = EMBEDDING_CACHE_ENABLED
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not self._emb_cache_enabled:
|
||||
return self.embedding_model.encode(text)
|
||||
|
||||
try:
|
||||
_cache = get_cache_manager()
|
||||
except Exception:
|
||||
return self.embedding_model.encode(text)
|
||||
|
||||
# 批量输入
|
||||
if isinstance(text, list):
|
||||
try:
|
||||
cached_embs, missed_indices = _cache.get_embeddings_batch(text)
|
||||
if missed_indices:
|
||||
missed_texts = [text[i] for i in missed_indices]
|
||||
# encode(list) 始终返回 2D ndarray,直接按行索引即可
|
||||
new_embs = self.embedding_model.encode(missed_texts)
|
||||
if len(missed_indices) == 1:
|
||||
# 单条时 encode 可能返回 1D,需统一处理
|
||||
if new_embs.ndim == 1:
|
||||
new_embs = new_embs.reshape(1, -1)
|
||||
for idx, mi in enumerate(missed_indices):
|
||||
emb_list = new_embs[idx].tolist()
|
||||
cached_embs[mi] = emb_list
|
||||
try:
|
||||
_cache.set_embedding(text[mi], emb_list)
|
||||
except Exception:
|
||||
pass
|
||||
return _np.array(cached_embs)
|
||||
except Exception:
|
||||
# 缓存故障时优雅降级为直接编码
|
||||
return self.embedding_model.encode(text)
|
||||
|
||||
# 单文本输入
|
||||
cached = _cache.get_embedding(text)
|
||||
if cached is not None:
|
||||
return _np.array(cached)
|
||||
|
||||
embedding = self.embedding_model.encode(text)
|
||||
try:
|
||||
emb_list = embedding.tolist() if hasattr(embedding, 'tolist') else list(embedding)
|
||||
_cache.set_embedding(text, emb_list)
|
||||
except Exception:
|
||||
pass
|
||||
return embedding
|
||||
|
||||
def _search_image_chunks(self, query_vector: list, top_k: int = 5, where_filter: dict = None) -> dict:
|
||||
"""
|
||||
独立检索图片切片(P0:图片独立召回通道)
|
||||
@@ -1017,7 +1128,7 @@ class RAGEngine:
|
||||
'metadatas': [f_metas],
|
||||
'distances': [f_scores]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1040,7 +1151,7 @@ class RAGEngine:
|
||||
'metadatas': [f_metas],
|
||||
'distances': [f_scores]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1055,7 +1166,7 @@ class RAGEngine:
|
||||
'metadatas': [results['metadatas'][0][:top_k]],
|
||||
'distances': [results['distances'][0][:top_k]]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
truncated[key] = results[key]
|
||||
return truncated
|
||||
@@ -1090,14 +1201,139 @@ class RAGEngine:
|
||||
return None
|
||||
return self.collection
|
||||
|
||||
@staticmethod
|
||||
def _normalize_section_path(section_path: str, levels: int = None) -> str:
|
||||
"""归一化 section_path:取前 N 级路径,容忍 MinerU 标题检测误差。
|
||||
|
||||
例如: "第三章 吸烟场所的功能设置 > 第三条 文明吸烟..." → "第三章 吸烟场所的功能设置"
|
||||
"""
|
||||
if not section_path:
|
||||
return ''
|
||||
if levels is None:
|
||||
levels = CLUSTER_SECTION_PREFIX_LEVELS
|
||||
parts = [p.strip() for p in section_path.split('>')]
|
||||
return ' > '.join(parts[:levels])
|
||||
|
||||
def _section_cluster_boost(self, results: dict, query: str = '') -> dict:
|
||||
"""章节聚类提升:当同一 section 下多个切片(text+table)同时出现在候选集中,
|
||||
即使单个切片 CrossEncoder 分数很低,也将整组提升到种子阈值。
|
||||
|
||||
核心洞察:单个低分切片不可信,但同一 section 多个切片同时出现是强信号。
|
||||
提升后的切片可以作为 _expand_contiguous_chunks 的种子,触发邻居扩展。
|
||||
|
||||
Args:
|
||||
results: rerank 后的检索结果
|
||||
query: 用户查询(用于后续扩展)
|
||||
|
||||
Returns:
|
||||
修改后的 results(distances 被调整,meta 中标记 _cluster_boosted)
|
||||
"""
|
||||
if not results.get('ids') or not results['ids'][0]:
|
||||
return results
|
||||
|
||||
ids = results['ids'][0]
|
||||
metas = results.get('metadatas', [[]])[0]
|
||||
distances = results.get('distances', [[]])[0] if results.get('distances') else None
|
||||
|
||||
if not distances:
|
||||
return results
|
||||
|
||||
# 1. 按 (source, normalized_section) 分组
|
||||
from collections import defaultdict
|
||||
section_groups = defaultdict(list) # key → [(index, meta, dist)]
|
||||
|
||||
for i, (meta, dist) in enumerate(zip(metas, distances)):
|
||||
source = meta.get('source', '')
|
||||
section_path = meta.get('section', '') or meta.get('section_path', '')
|
||||
norm_section = self._normalize_section_path(section_path)
|
||||
if not source or not norm_section:
|
||||
continue
|
||||
key = (source, norm_section)
|
||||
section_groups[key].append((i, meta, dist))
|
||||
|
||||
# 2. 检测聚类信号并提升
|
||||
boost_target_dist = 1.0 - CLUSTER_SEED_FLOOR # score=0.35 → dist=0.65
|
||||
boosted_sections = []
|
||||
total_boosted = 0
|
||||
|
||||
# 按组成员数降序排列,优先处理最大聚类
|
||||
sorted_groups = sorted(section_groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
for (source, norm_section), members in sorted_groups:
|
||||
if len(boosted_sections) >= CLUSTER_MAX_SECTIONS:
|
||||
break
|
||||
|
||||
# 聚类信号检测:成员数 >= 阈值 且 类型多样性 >= 阈值
|
||||
chunk_types = set(m[1].get('chunk_type', 'text') for m in members)
|
||||
if len(members) < CLUSTER_MIN_MEMBERS or len(chunk_types) < CLUSTER_MIN_TYPES:
|
||||
continue
|
||||
|
||||
# 提升组内切片分数(仅提升低于阈值的)
|
||||
boost_count = 0
|
||||
for idx, meta, dist in members:
|
||||
if boost_count >= CLUSTER_MAX_BOOST_PER_SECTION:
|
||||
break
|
||||
# 只提升分数低于种子阈值的切片(高分切片不需要)
|
||||
if dist > boost_target_dist:
|
||||
distances[idx] = boost_target_dist
|
||||
meta['_cluster_boosted'] = True
|
||||
boost_count += 1
|
||||
total_boosted += 1
|
||||
|
||||
if boost_count > 0:
|
||||
boosted_sections.append({
|
||||
'source': source,
|
||||
'section': norm_section,
|
||||
'members': len(members),
|
||||
'types': list(chunk_types),
|
||||
'boosted': boost_count
|
||||
})
|
||||
|
||||
# 3. 写 debug 信息
|
||||
if boosted_sections:
|
||||
debug_info = results.get('_debug', {})
|
||||
if 'steps' not in debug_info:
|
||||
debug_info['steps'] = []
|
||||
debug_info['steps'].append({
|
||||
'name': 'section_cluster_boost',
|
||||
'sections': boosted_sections,
|
||||
'total_boosted': total_boosted
|
||||
})
|
||||
results['_debug'] = debug_info
|
||||
logger.info(f"[章节聚类提升] 提升 {total_boosted} 个切片,"
|
||||
f"涉及 {len(boosted_sections)} 个 section: "
|
||||
f"{[s['section'][:30] for s in boosted_sections]}")
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _chunk_lexical_score(chunk_text: str, query: str) -> float:
|
||||
"""计算切片文本与查询的词法重叠度(bigram 命中率),用于辅助种子资格判定。"""
|
||||
if not chunk_text or not query:
|
||||
return 0.0
|
||||
import re
|
||||
clean_q = re.sub(r'[??!!。,,、;;::"""\'\s*#`]+', ' ', query).strip()
|
||||
if len(clean_q) < 2:
|
||||
return 0.0
|
||||
bigrams = set()
|
||||
for i in range(len(clean_q) - 1):
|
||||
w = clean_q[i:i+2].strip()
|
||||
if len(w) == 2:
|
||||
bigrams.add(w)
|
||||
if not bigrams:
|
||||
return 0.0
|
||||
matched = sum(1 for w in bigrams if w in chunk_text)
|
||||
return matched / len(bigrams)
|
||||
|
||||
def _expand_contiguous_chunks(self, results: dict, top_k: int = None,
|
||||
min_score: float = 0.0) -> dict:
|
||||
min_score: float = 0.0, query: str = '') -> dict:
|
||||
"""Add same-source same-section neighbor text chunks around strong hits.
|
||||
|
||||
Args:
|
||||
results: 检索结果
|
||||
top_k: 最大切片数
|
||||
min_score: Phase 3 最低分数阈值,仅对 Rerank 分数高于此值的种子扩展
|
||||
query: 查询文本,用于词法匹配辅助种子资格判定
|
||||
"""
|
||||
if not CONTEXT_EXPANSION_ENABLED:
|
||||
return results
|
||||
@@ -1127,7 +1363,7 @@ class RAGEngine:
|
||||
seeds = [
|
||||
(doc_id, doc, meta, dist)
|
||||
for doc_id, doc, meta, dist in items[:base_limit]
|
||||
if meta.get('chunk_type', 'text') == 'text'
|
||||
if (meta.get('chunk_type', 'text') == 'text' or meta.get('_cluster_boosted'))
|
||||
and meta.get('source')
|
||||
and self._to_int(meta.get('chunk_index')) is not None
|
||||
]
|
||||
@@ -1138,8 +1374,12 @@ class RAGEngine:
|
||||
break
|
||||
|
||||
# Phase 3:跳过分数低于阈值的种子(仅当 min_score > 0 时生效)
|
||||
# 词法匹配豁免:CrossEncoder 低分但关键词重叠度高时仍允许作为种子
|
||||
if min_score > 0 and seed_dist < min_score:
|
||||
continue
|
||||
if query and self._chunk_lexical_score(_seed_doc, query) > 0.3:
|
||||
pass # 词法匹配度高,允许作为种子
|
||||
else:
|
||||
continue
|
||||
|
||||
source = seed_meta.get('source')
|
||||
section = seed_meta.get('section', '') or seed_meta.get('section_path', '')
|
||||
@@ -1161,6 +1401,7 @@ class RAGEngine:
|
||||
logger.warning(f"扩展连续切片失败: {e}")
|
||||
return {'ids': [], 'documents': [], 'metadatas': []}
|
||||
|
||||
# 扩展同 section 的 text 邻居
|
||||
where_filter = {"$and": [{"source": source}, {"chunk_type": "text"}]}
|
||||
if section:
|
||||
where_filter["$and"].append({"section": section})
|
||||
@@ -1171,6 +1412,20 @@ class RAGEngine:
|
||||
if not neighbors.get('ids') or len(neighbors.get('ids', [])) <= 1:
|
||||
neighbors = _get_neighbors({"$and": [{"source": source}, {"chunk_type": "text"}]})
|
||||
|
||||
# 同时扩展同 section 的 table 邻居(table 切片的 rerank 分数往往偏低,
|
||||
# 但与同 section 的 text 切片属于同一语义单元,不应割裂)
|
||||
table_where = {"$and": [{"source": source}, {"chunk_type": "table"}]}
|
||||
if section:
|
||||
table_where["$and"].append({"section": section})
|
||||
table_neighbors = _get_neighbors(table_where)
|
||||
|
||||
# 当 section 为空时,table 查询只有 source 条件,可能拉入大量无关表格,
|
||||
# 缩小 chunk_index 窗口至 ±1 以降低噪音;有 section 时使用正常窗口
|
||||
if section:
|
||||
_t_before, _t_after = CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER
|
||||
else:
|
||||
_t_before, _t_after = 1, 1
|
||||
|
||||
neighbor_rows = []
|
||||
for n_id, n_doc, n_meta in zip(
|
||||
neighbors.get('ids', []),
|
||||
@@ -1183,6 +1438,18 @@ class RAGEngine:
|
||||
if seed_index - CONTEXT_EXPANSION_BEFORE <= n_index <= seed_index + CONTEXT_EXPANSION_AFTER:
|
||||
neighbor_rows.append((n_index, n_id, n_doc, n_meta))
|
||||
|
||||
# 同 section 的 table 邻居也加入扩展范围
|
||||
for n_id, n_doc, n_meta in zip(
|
||||
table_neighbors.get('ids', []),
|
||||
table_neighbors.get('documents', []),
|
||||
table_neighbors.get('metadatas', [])
|
||||
):
|
||||
n_index = self._to_int(n_meta.get('chunk_index'))
|
||||
if n_index is None:
|
||||
continue
|
||||
if seed_index - _t_before <= n_index <= seed_index + _t_after:
|
||||
neighbor_rows.append((n_index, n_id, n_doc, n_meta))
|
||||
|
||||
seed_neighbors_added = 0
|
||||
for n_index, n_id, n_doc, n_meta in sorted(neighbor_rows, key=lambda row: row[0]):
|
||||
if len(items) >= max_chunks:
|
||||
@@ -1214,7 +1481,7 @@ class RAGEngine:
|
||||
'distances': [[item[3] for item in items]],
|
||||
'_expanded_context': {'added': added}
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_bm25_top3'):
|
||||
if key in results:
|
||||
expanded[key] = results[key]
|
||||
return expanded
|
||||
@@ -1241,6 +1508,7 @@ class RAGEngine:
|
||||
sub_top_k = max(top_k, 5)
|
||||
|
||||
all_results = []
|
||||
_all_bm25_top3 = [] # 收集各子查询的 BM25 top3
|
||||
for sub_q in sub_queries:
|
||||
try:
|
||||
sub_result = self.search_knowledge(
|
||||
@@ -1251,6 +1519,9 @@ class RAGEngine:
|
||||
)
|
||||
if sub_result and sub_result.get('ids') and sub_result['ids'][0]:
|
||||
all_results.append(sub_result)
|
||||
# 收集子查询的 BM25 top3
|
||||
if sub_result.get('_bm25_top3'):
|
||||
_all_bm25_top3.extend(sub_result['_bm25_top3'])
|
||||
except Exception as e:
|
||||
logger.warning(f"子查询检索失败: '{sub_q}' - {e}")
|
||||
|
||||
@@ -1259,9 +1530,19 @@ class RAGEngine:
|
||||
|
||||
# 合并去重
|
||||
if len(all_results) == 1:
|
||||
return all_results[0]
|
||||
merged = all_results[0]
|
||||
else:
|
||||
merged = self._merge_and_deduplicate(all_results, top_k)
|
||||
|
||||
return self._merge_and_deduplicate(all_results, top_k)
|
||||
# 将收集的 BM25 top3 传递到合并结果中
|
||||
if _all_bm25_top3:
|
||||
_all_bm25_top3.sort(key=lambda x: x.get('bm25_score', 0), reverse=True)
|
||||
_all_bm25_top3 = _all_bm25_top3[:3]
|
||||
for rank, item in enumerate(_all_bm25_top3):
|
||||
item['rank'] = rank + 1
|
||||
merged['_bm25_top3'] = _all_bm25_top3
|
||||
|
||||
return merged
|
||||
|
||||
def _search_with_decomposition(
|
||||
self, query, decomposer, top_k=5, allowed_levels=None,
|
||||
@@ -1293,6 +1574,7 @@ class RAGEngine:
|
||||
|
||||
# 并行检索各子查询
|
||||
all_results = []
|
||||
_all_bm25_top3 = [] # 收集各子查询的 BM25 top3
|
||||
for sub_q in sub_queries:
|
||||
try:
|
||||
sub_result = self.search_knowledge(
|
||||
@@ -1303,6 +1585,9 @@ class RAGEngine:
|
||||
)
|
||||
if sub_result and sub_result.get('ids') and sub_result['ids'][0]:
|
||||
all_results.append(sub_result)
|
||||
# 收集子查询的 BM25 top3
|
||||
if sub_result.get('_bm25_top3'):
|
||||
_all_bm25_top3.extend(sub_result['_bm25_top3'])
|
||||
except Exception as e:
|
||||
logger.warning(f"子查询检索失败: '{sub_q}' - {e}")
|
||||
|
||||
@@ -1315,6 +1600,14 @@ class RAGEngine:
|
||||
else:
|
||||
merged = self._merge_and_deduplicate(all_results, top_k)
|
||||
|
||||
# 将收集的 BM25 top3 传递到合并结果中
|
||||
if _all_bm25_top3:
|
||||
_all_bm25_top3.sort(key=lambda x: x.get('bm25_score', 0), reverse=True)
|
||||
_all_bm25_top3 = _all_bm25_top3[:3]
|
||||
for rank, item in enumerate(_all_bm25_top3):
|
||||
item['rank'] = rank + 1
|
||||
merged['_bm25_top3'] = _all_bm25_top3
|
||||
|
||||
return merged
|
||||
|
||||
def _merge_and_deduplicate(self, results_list, top_k):
|
||||
@@ -1387,7 +1680,7 @@ class RAGEngine:
|
||||
if not target_collections:
|
||||
return get_empty_result()
|
||||
|
||||
query_vector = self.embedding_model.encode(query).tolist()
|
||||
query_vector = self._encode_cached(query).tolist()
|
||||
# 扩大召回数量,以便过滤废止切片后仍有足够结果
|
||||
recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k
|
||||
recall_k = max(recall_k, top_k * RECALL_MULTIPLIER)
|
||||
@@ -1399,12 +1692,13 @@ class RAGEngine:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _query_single_collection(coll_name):
|
||||
"""查询单个向量库(向量 + BM25)"""
|
||||
"""查询单个向量库(向量 + BM25),返回 (coll_results, bm25_raw_items)"""
|
||||
coll_results = []
|
||||
bm25_raw_items = [] # 该 collection 的 BM25 原始结果
|
||||
try:
|
||||
coll = self.kb_manager.get_collection(coll_name)
|
||||
if not coll:
|
||||
return coll_results
|
||||
return coll_results, bm25_raw_items
|
||||
|
||||
query_kwargs = {
|
||||
"query_embeddings": [query_vector],
|
||||
@@ -1422,25 +1716,61 @@ class RAGEngine:
|
||||
if USE_HYBRID_SEARCH:
|
||||
try:
|
||||
bm25 = self.kb_manager.get_bm25_index(coll_name)
|
||||
if bm25.bm25:
|
||||
if bm25 and bm25.bm25:
|
||||
bm25_res = bm25.search(query, top_k=recall_k)
|
||||
if source_filter and bm25_res['metadatas'] and bm25_res['metadatas'][0]:
|
||||
# 兼容两种 BM25Index:core.bm25_index 返回 dict,knowledge.base 返回 tuple
|
||||
if isinstance(bm25_res, tuple):
|
||||
_ids, _docs, _metas, _dists = bm25_res
|
||||
bm25_res = {
|
||||
'ids': [_ids],
|
||||
'documents': [_docs],
|
||||
'metadatas': [_metas],
|
||||
'distances': [_dists]
|
||||
}
|
||||
if source_filter and bm25_res['metadatas'][0]:
|
||||
bm25_res = self._filter_results(bm25_res, lambda meta: meta.get('source') == source_filter)
|
||||
if bm25_res['metadatas'] and bm25_res['metadatas'][0]:
|
||||
for meta in bm25_res['metadatas'][0]:
|
||||
meta['_collection'] = coll_name
|
||||
coll_results.append(bm25_res)
|
||||
# 提取 BM25 原始 top-3(在此处直接捕获,避免与向量结果混淆)
|
||||
_bm25_ids = bm25_res['ids'][0][:3]
|
||||
_bm25_docs = bm25_res['documents'][0][:3]
|
||||
_bm25_metas = bm25_res['metadatas'][0][:3]
|
||||
_bm25_dists = (bm25_res.get('distances', [[]])[0] or [0]*3)[:3]
|
||||
for i in range(len(_bm25_ids)):
|
||||
# 确保 meta 包含 _collection(用于路由层注入时下游处理)
|
||||
bm25_meta = _bm25_metas[i]
|
||||
if '_collection' not in bm25_meta:
|
||||
bm25_meta = {**bm25_meta, '_collection': coll_name}
|
||||
bm25_raw_items.append({
|
||||
'id': _bm25_ids[i],
|
||||
'doc': _bm25_docs[i],
|
||||
'meta': bm25_meta,
|
||||
'bm25_score': _bm25_dists[i],
|
||||
})
|
||||
logger.debug(f"[BM25] {coll_name}: captured {len(bm25_raw_items)} raw items")
|
||||
except Exception as e:
|
||||
logger.debug(f"向量库 {coll_name} 检索失败: {e}")
|
||||
logger.debug(f"向量库 {coll_name} BM25检索失败: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"多向量库检索失败: {e}")
|
||||
return coll_results
|
||||
return coll_results, bm25_raw_items
|
||||
|
||||
all_results = []
|
||||
_bm25_raw_top3 = []
|
||||
with ThreadPoolExecutor(max_workers=len(target_collections)) as executor:
|
||||
futures = {executor.submit(_query_single_collection, name): name for name in target_collections}
|
||||
for future in as_completed(futures):
|
||||
all_results.extend(future.result())
|
||||
coll_results, bm25_raw_items = future.result()
|
||||
all_results.extend(coll_results)
|
||||
_bm25_raw_top3.extend(bm25_raw_items)
|
||||
|
||||
# 按 bm25_score 降序取全局 top-3
|
||||
if _bm25_raw_top3:
|
||||
_bm25_raw_top3.sort(key=lambda x: x['bm25_score'], reverse=True)
|
||||
_bm25_raw_top3 = _bm25_raw_top3[:3]
|
||||
for rank, item in enumerate(_bm25_raw_top3):
|
||||
item['rank'] = rank + 1
|
||||
|
||||
# ========== FAQ 检索 ==========
|
||||
faq_results = self._search_faq_collection(query_vector, top_k=FAQ_RECALL_TOP_K)
|
||||
@@ -1488,6 +1818,8 @@ class RAGEngine:
|
||||
|
||||
is_enum_query = self._is_enumeration_query(query)
|
||||
fused_results['_enum_query'] = is_enum_query
|
||||
# 传递 BM25 原始 top-3 到路由层,用于分歧检测救援
|
||||
fused_results['_bm25_top3'] = _bm25_raw_top3
|
||||
|
||||
# 章节过滤(如果查询中提到了章节)
|
||||
fused_results = self._filter_by_section(fused_results, query)
|
||||
@@ -1530,11 +1862,19 @@ class RAGEngine:
|
||||
# 时间衰减
|
||||
fused_results = self._apply_time_decay(fused_results)
|
||||
|
||||
# 提前附加 _debug,使聚类提升能写入调试步骤
|
||||
fused_results['_debug'] = _debug
|
||||
|
||||
# ========== 章节聚类提升:在扩展前将低分但聚类的切片提升至种子阈值 ==========
|
||||
if SECTION_CLUSTER_BOOST_ENABLED:
|
||||
fused_results = self._section_cluster_boost(fused_results, query)
|
||||
|
||||
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
||||
# Phase 3:仅对高分种子扩展邻居
|
||||
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
||||
min_score=EXPANSION_SCORE_THRESHOLD)
|
||||
min_score=EXPANSION_SCORE_THRESHOLD,
|
||||
query=query)
|
||||
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
if _debug is not None:
|
||||
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
||||
@@ -1547,7 +1887,15 @@ class RAGEngine:
|
||||
and not (is_enum_query and ENUM_QUERY_DISABLE_TOPK_SHRINK)
|
||||
and fused_results.get('_score_source') != 'rrf'
|
||||
):
|
||||
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
|
||||
# 根据分数来源计算相似度分数(越高越好)
|
||||
score_source = fused_results.get('_score_source')
|
||||
top_dist = fused_results['distances'][0][0]
|
||||
if score_source == 'rerank':
|
||||
# Rerank 后 distances 是相关性分数,越大越好,直接使用
|
||||
top_score = top_dist
|
||||
else:
|
||||
# 向量距离,越小越好,转为相似度
|
||||
top_score = 1.0 - top_dist
|
||||
adjusted_k, should_retrieve, reason = self._adaptive_topk.adjust(top_score, top_k)
|
||||
if "high_confidence" in reason:
|
||||
# 高置信度时截断结果
|
||||
@@ -1597,7 +1945,7 @@ class RAGEngine:
|
||||
'metadatas': [filtered_metas],
|
||||
'distances': [filtered_distances]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1670,7 +2018,7 @@ class RAGEngine:
|
||||
'metadatas': [filtered_metas],
|
||||
'distances': [filtered_distances]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1739,12 +2087,12 @@ class RAGEngine:
|
||||
# === 高精度版:基于语义向量 ===
|
||||
from core.mmr import mmr_rerank
|
||||
|
||||
# 获取查询向量
|
||||
query_emb = np.array(self.embedding_model.encode(query))
|
||||
# 获取查询向量(使用 embedding 缓存)
|
||||
query_emb = np.array(self._encode_cached(query))
|
||||
|
||||
# 批量编码所有文档
|
||||
# 批量编码所有文档(使用 embedding 缓存)
|
||||
docs_list = results['documents'][0]
|
||||
all_embeddings = self.embedding_model.encode(docs_list)
|
||||
all_embeddings = self._encode_cached(docs_list)
|
||||
|
||||
# 构建候选列表
|
||||
candidates = []
|
||||
@@ -1786,7 +2134,7 @@ class RAGEngine:
|
||||
'metadatas': [[c['metadata'] for c in selected]],
|
||||
'distances': [[id_to_dist.get(doc_id, 0) for doc_id in selected_ids]]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1826,7 +2174,7 @@ class RAGEngine:
|
||||
'metadatas': [[c['metadata'] for c in selected]],
|
||||
'distances': [[id_to_dist.get(c['id'], 0) for c in selected]]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1935,109 +2283,15 @@ class RAGEngine:
|
||||
'_rerank_cached': cache_hit
|
||||
}
|
||||
# 保留原有标记字段
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
for key in ('_debug', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
reranked[key] = results[key]
|
||||
# Rerank 后 distances 语义变为 CrossEncoder 分数,更新 _score_source
|
||||
# 使自适应 TopK 能正确应用(之前 _score_source='rrf' 会导致自适应 TopK 被跳过)
|
||||
reranked['_score_source'] = 'rerank'
|
||||
return reranked
|
||||
|
||||
# ---------------- 安全与工具 ----------------
|
||||
|
||||
def check_restricted_documents(self, query, allowed_levels, top_k=3, role=None, department=None):
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
if USE_MULTI_KB and self.kb_manager and role and department:
|
||||
from auth.gateway import get_accessible_collections
|
||||
all_colls = [c.name for c in self.kb_manager.list_collections()]
|
||||
accessible = set(get_accessible_collections(role, department, 'read'))
|
||||
restricted = set(all_colls) - accessible
|
||||
|
||||
if not restricted:
|
||||
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": []}
|
||||
|
||||
query_vector = self.embedding_model.encode(query).tolist()
|
||||
found_sources = set()
|
||||
top_score = 0.0
|
||||
|
||||
for coll_name in restricted:
|
||||
try:
|
||||
coll = self.kb_manager.get_collection(coll_name)
|
||||
if not coll: continue
|
||||
res = coll.query(query_embeddings=[query_vector], n_results=top_k)
|
||||
if res['metadatas'] and res['metadatas'][0]:
|
||||
for meta in res['metadatas'][0]:
|
||||
found_sources.add(meta.get('source', '未知'))
|
||||
for dist in (res.get('distances', [[]])[0] or []):
|
||||
if dist > top_score: top_score = dist
|
||||
except Exception as e:
|
||||
logger.debug(f"权限检查遍历失败: {e}")
|
||||
|
||||
return {
|
||||
"has_restricted": len(found_sources) > 0,
|
||||
"restricted_levels": [c.replace('dept_', '') for c in restricted if True][:3],
|
||||
"restricted_sources": list(found_sources)[:3],
|
||||
"top_restricted_score": top_score
|
||||
}
|
||||
|
||||
if not allowed_levels:
|
||||
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
|
||||
|
||||
restricted_levels = {"public", "internal", "confidential", "secret"} - set(allowed_levels)
|
||||
if not restricted_levels:
|
||||
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
|
||||
|
||||
query_vector = self.embedding_model.encode(query).tolist()
|
||||
try:
|
||||
res = self.collection.query(
|
||||
query_embeddings=[query_vector],
|
||||
n_results=top_k,
|
||||
where={"security_level": {"$in": list(restricted_levels)}}
|
||||
)
|
||||
docs = res.get('documents', [[]])[0]
|
||||
if not docs:
|
||||
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
|
||||
|
||||
metas = res.get('metadatas', [[]])[0]
|
||||
dists = res.get('distances', [[]])[0]
|
||||
found_levels, found_sources, top_score = set(), set(), 0.0
|
||||
|
||||
for meta, dist in zip(metas, dists):
|
||||
found_levels.add(meta.get('security_level', 'public'))
|
||||
found_sources.add(meta.get('source', '未知'))
|
||||
if dist > top_score: top_score = dist
|
||||
|
||||
return {
|
||||
"has_restricted": True,
|
||||
"restricted_levels": list(found_levels),
|
||||
"restricted_sources": list(found_sources)[:3],
|
||||
"top_restricted_score": top_score
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"受限内容检查失败: {e}")
|
||||
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
|
||||
|
||||
def generate_answer(self, query, context):
|
||||
"""底层生成答复能力"""
|
||||
prompt = f"""你是一个严谨的智能助手,请根据以下参考资料回答用户的问题。
|
||||
...
|
||||
参考资料:
|
||||
{context}
|
||||
|
||||
用户问题:{query}
|
||||
|
||||
请回答:"""
|
||||
try:
|
||||
from core.llm_utils import call_llm
|
||||
result = call_llm(
|
||||
self.llm_client,
|
||||
prompt,
|
||||
MODEL,
|
||||
temperature=LLM_TEMPERATURE,
|
||||
max_tokens=LLM_MAX_TOKENS
|
||||
)
|
||||
return result or f"调用大模型失败: 返回结果为空"
|
||||
except Exception as e:
|
||||
return f"调用大模型失败: {str(e)}"
|
||||
# ---------------- 流式生成 ----------------
|
||||
|
||||
def generate_answer_stream(self, query, context, history=None):
|
||||
"""
|
||||
@@ -2068,21 +2322,33 @@ class RAGEngine:
|
||||
"content": (
|
||||
"你是一个严谨的知识库问答助手。"
|
||||
"你必须且只能根据用户提供的【参考资料】回答问题。"
|
||||
"参考资料中每段内容前标有章节路径(━格式),请注意区分不同章节的内容,"
|
||||
"特别当不同章节标题相似或包含相同关键词时,务必根据章节路径准确定位,不要混淆。"
|
||||
"如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2])。"
|
||||
"如果参考资料中确实没有相关信息,简短说明即可,不要编造或补充资料外的内容。"
|
||||
"禁止使用参考资料以外的知识进行补充或推测。"
|
||||
"【重要-表格处理规则】当用户询问表格、要求展示表格内容时,你必须将参考资料中的 Markdown 表格原样输出(保留 | 分隔符和表格结构),"
|
||||
"不要仅用文字描述表格存在或仅列出章节名称。如果参考资料中多个章节都有表格,"
|
||||
"优先展示与用户问题最相关的表格完整内容。"
|
||||
)
|
||||
})
|
||||
|
||||
# 添加当前问题(带上下文)- 强化指令
|
||||
if context:
|
||||
# 检测用户问题是否涉及表格,加入针对性指令
|
||||
_table_hint = ""
|
||||
# 检测上下文中是否包含 Markdown 表格(数据驱动,无需硬编码关键词)
|
||||
_has_table_in_context = bool(re.search(r'\|.+\|', context)) if context else False
|
||||
if _has_table_in_context:
|
||||
_table_hint = "\n注意:参考资料中包含 Markdown 格式的表格数据,请务必将相关表格以原始 Markdown 表格格式完整展示在回答中,不要仅用文字描述。"
|
||||
|
||||
user_message = f"""【参考资料】
|
||||
{context}
|
||||
|
||||
【用户问题】
|
||||
{query}
|
||||
|
||||
请仔细阅读以上全部参考资料后回答。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。"""
|
||||
请仔细阅读以上全部参考资料后回答。注意参考资料中标有章节路径,请根据章节路径准确定位相关内容。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。{_table_hint}"""
|
||||
else:
|
||||
user_message = query
|
||||
|
||||
@@ -2095,6 +2361,7 @@ class RAGEngine:
|
||||
"",
|
||||
MODEL,
|
||||
temperature=LLM_TEMPERATURE,
|
||||
top_p=LLM_TOP_P,
|
||||
max_tokens=LLM_MAX_TOKENS,
|
||||
messages=messages,
|
||||
error_prefix="[错误]"
|
||||
|
||||
@@ -22,7 +22,7 @@ import threading
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
from config import INTENT_TEMPERATURE, INTENT_MAX_TOKENS, INTENT_HISTORY_WINDOW, INTENT_MODEL
|
||||
from config import INTENT_TEMPERATURE, INTENT_MAX_TOKENS, INTENT_HISTORY_WINDOW, INTENT_MODEL, INTENT_RESPONSE_FORMAT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,9 +83,16 @@ class IntentAnalyzer:
|
||||
根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段:
|
||||
|
||||
1. **rewritten_query**: 改写后的完整问题
|
||||
- 如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题
|
||||
- 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片"
|
||||
- 如果问题本身已经完整,直接返回原文
|
||||
- **指代消解**:如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题
|
||||
- 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片"
|
||||
- **追问补全**:如果问题是省略式追问(省略了上一轮讨论的主题实体),必须补全为完整问题
|
||||
- 判断方法:当前问题缺少主语/宾语,且对话历史中可以推断出省略的实体
|
||||
- 补全方法:从上一轮用户问题中提取主题实体,与追问组合成完整问题
|
||||
- 例如:
|
||||
- 上一轮问"吸烟点C1类是什么区?",追问"有完整表格吗?" → "吸烟点C1类有完整表格吗?"
|
||||
- 上一轮问"三峡工程的投资情况",追问"建设地点在哪?" → "三峡工程的建设地点在哪?"
|
||||
- 上一轮问"货源投放有哪些原则?",追问"具体内容是什么?" → "货源投放原则的具体内容是什么?"
|
||||
- 如果问题本身已经完整且独立,直接返回原文
|
||||
|
||||
2. **use_context**: 布尔值
|
||||
- true: 问题依赖历史对话中的信息,答案已经在历史回答中
|
||||
@@ -105,7 +112,9 @@ class IntentAnalyzer:
|
||||
- 推理类(intent="reasoning"):生成最多2个子查询
|
||||
* 原问题的检索查询
|
||||
* 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文
|
||||
- 其他类(factual/instruction/other):严格只生成1个子查询(原问题)
|
||||
- 其他类(factual/instruction/other):严格只生成1个子查询
|
||||
* 子查询应基于 rewritten_query(改写后的完整问题),而非用户原始输入
|
||||
* 例如:追问"有完整表格吗?"改写为"吸烟点C1类有完整表格吗?"后,子查询应为"吸烟点C1类的完整表格内容"
|
||||
- 不要为同一实体生成语义重叠的查询
|
||||
- 子查询应保持原问题的关键词,长度20-60字符为宜
|
||||
|
||||
@@ -224,10 +233,10 @@ class IntentAnalyzer:
|
||||
self._exact_cache_max = 500
|
||||
|
||||
def _get_client(self):
|
||||
"""获取 LLM 客户端"""
|
||||
"""获取 LLM 客户端(百炼快速模型)"""
|
||||
if self._client is None:
|
||||
from config import get_llm_client
|
||||
self._client = get_llm_client()
|
||||
from config import get_intent_client
|
||||
self._client = get_intent_client()
|
||||
return self._client
|
||||
|
||||
def _get_cache(self):
|
||||
@@ -299,17 +308,30 @@ class IntentAnalyzer:
|
||||
return self._exact_cache[exact_key]
|
||||
|
||||
# 2. 尝试从语义缓存获取
|
||||
# 关键:语义缓存只用原始 query 做 embedding(不含历史),
|
||||
# 避免同会话中不同问题因历史上下文污染导致误命中
|
||||
cache = self._get_cache()
|
||||
if cache:
|
||||
# 使用 query + 历史关键信息作为缓存键
|
||||
cache_key = self._build_cache_key(query, history)
|
||||
cache_emb = self._get_embedding(cache_key)
|
||||
query_emb = self._get_embedding(query)
|
||||
|
||||
if cache_emb is not None:
|
||||
cached = cache.get(cache_emb)
|
||||
if cached:
|
||||
logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}")
|
||||
return IntentAnalysis.from_dict(cached)
|
||||
if query_emb is not None:
|
||||
cached = cache.get(query_emb)
|
||||
# 确保缓存条目是意图分析结果(包含式校验,避免新增缓存类型时误命中)
|
||||
if cached and cached.get("cache_type") == "intent_analysis":
|
||||
# 二次验证:检查原始 query 文本相似度
|
||||
cached_query = cached.get("_raw_query", "")
|
||||
if cached_query and self._query_text_similar(query, cached_query):
|
||||
logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}")
|
||||
return IntentAnalysis.from_dict(cached)
|
||||
elif cached_query:
|
||||
logger.info(
|
||||
f"意图分析缓存二次验证拒绝: "
|
||||
f"query='{query[:30]}' vs cached='{cached_query[:30]}'"
|
||||
)
|
||||
else:
|
||||
# 旧缓存无 _raw_query 字段,兼容放行
|
||||
logger.info(f"意图分析缓存命中(无验证): {cached.get('reason', '')[:50]}")
|
||||
return IntentAnalysis.from_dict(cached)
|
||||
else:
|
||||
logger.debug(f"意图分析缓存未命中,缓存状态: {cache.get_stats()}")
|
||||
else:
|
||||
@@ -333,17 +355,17 @@ class IntentAnalyzer:
|
||||
model = self.model or self._get_default_model()
|
||||
|
||||
from core.llm_utils import call_llm
|
||||
content = call_llm(
|
||||
client,
|
||||
"",
|
||||
model,
|
||||
_llm_kwargs = dict(
|
||||
temperature=INTENT_TEMPERATURE,
|
||||
max_tokens=INTENT_MAX_TOKENS,
|
||||
messages=[
|
||||
{"role": "system", "content": self.SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
],
|
||||
)
|
||||
if INTENT_RESPONSE_FORMAT:
|
||||
_llm_kwargs["response_format"] = {"type": "json_object"}
|
||||
content = call_llm(client, "", model, **_llm_kwargs)
|
||||
|
||||
# 解析 JSON
|
||||
result = self._parse_json(content)
|
||||
@@ -377,9 +399,13 @@ class IntentAnalyzer:
|
||||
intent=intent_type
|
||||
)
|
||||
|
||||
# 存入语义缓存
|
||||
if cache and cache_emb is not None:
|
||||
cache.set(cache_emb, analysis.to_dict())
|
||||
# 存入语义缓存(标记类型,避免与 RAG 回答缓存混淆)
|
||||
# 使用仅含 query 的 embedding,不含历史,防止同会话误命中
|
||||
if cache and query_emb is not None:
|
||||
cache_data = analysis.to_dict()
|
||||
cache_data["cache_type"] = "intent_analysis"
|
||||
cache_data["_raw_query"] = query # 供二次验证使用
|
||||
cache.set(query_emb, cache_data)
|
||||
|
||||
# 存入精确匹配缓存
|
||||
if len(self._exact_cache) < self._exact_cache_max:
|
||||
@@ -419,6 +445,32 @@ class IntentAnalyzer:
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _query_text_similar(query: str, cached_query: str, threshold: float = 0.5) -> bool:
|
||||
"""
|
||||
判断两个 query 文本是否足够相似(字符级 Jaccard)。
|
||||
用于语义缓存命中后的二次验证,防止语义相近但实际意图不同的问题误命中。
|
||||
|
||||
Args:
|
||||
query: 当前查询
|
||||
cached_query: 缓存中的原始查询
|
||||
threshold: 相似度阈值,默认 0.5
|
||||
|
||||
Returns:
|
||||
True 表示足够相似,可以命中缓存
|
||||
"""
|
||||
# 精确匹配快速路径
|
||||
if query.strip() == cached_query.strip():
|
||||
return True
|
||||
# 字符级 Jaccard 相似度
|
||||
set_a = set(query)
|
||||
set_b = set(cached_query)
|
||||
if not set_a or not set_b:
|
||||
return False
|
||||
intersection = len(set_a & set_b)
|
||||
union = len(set_a | set_b)
|
||||
return (intersection / union) >= threshold
|
||||
|
||||
def _build_history_summary(
|
||||
self,
|
||||
history: List[dict],
|
||||
@@ -437,6 +489,7 @@ class IntentAnalyzer:
|
||||
if not history:
|
||||
return "(无历史对话)"
|
||||
|
||||
import re
|
||||
parts = []
|
||||
|
||||
# 提取最近 3 轮对话
|
||||
@@ -445,6 +498,7 @@ class IntentAnalyzer:
|
||||
for msg in recent_history:
|
||||
role = "用户" if msg.get("role") == "user" else "助手"
|
||||
content = msg.get("content", "")
|
||||
original_content = content # 保留原始内容用于结构化提取
|
||||
|
||||
# 截断过长的内容
|
||||
if len(content) > 500:
|
||||
@@ -452,9 +506,10 @@ class IntentAnalyzer:
|
||||
|
||||
parts.append(f"【{role}】{content}")
|
||||
|
||||
# 提取图片信息
|
||||
# 提取结构化信息(从 assistant 消息中提取章节、表格、来源等)
|
||||
metadata = msg.get("metadata", {})
|
||||
if isinstance(metadata, dict):
|
||||
# 已有的图片提取
|
||||
images = metadata.get("images", [])
|
||||
if images:
|
||||
for img in images[:3]:
|
||||
@@ -463,6 +518,43 @@ class IntentAnalyzer:
|
||||
img_type = img.get("type", "图片")
|
||||
parts.append(f" └─ {img_type}: {desc}")
|
||||
|
||||
# 来源文件提取
|
||||
sources = metadata.get("sources", [])
|
||||
if sources:
|
||||
source_names = []
|
||||
for s in sources[:3]:
|
||||
if isinstance(s, dict):
|
||||
name = s.get("source", "") or s.get("name", "")
|
||||
if name:
|
||||
source_names.append(name)
|
||||
elif isinstance(s, str):
|
||||
source_names.append(s)
|
||||
if source_names:
|
||||
parts.append(f" └─ 来源文件: {', '.join(source_names)}")
|
||||
|
||||
# collections(检索知识库)提取
|
||||
colls = metadata.get("collections", [])
|
||||
if colls:
|
||||
parts.append(f" └─ 检索知识库: {', '.join(colls)}")
|
||||
|
||||
# 从 assistant 原始内容中提取章节路径和表格结构
|
||||
if role == "助手" and original_content:
|
||||
# 提取章节路径:━ xxx ━ 格式
|
||||
sections = re.findall(r'━\s*(.+?)\s*━', original_content)
|
||||
if sections:
|
||||
unique_sections = list(dict.fromkeys(sections)) # 去重保序
|
||||
parts.append(f" └─ 涉及章节: {'; '.join(unique_sections[:3])}")
|
||||
|
||||
# 提取表格列名:| A | B | C | 格式的表头行
|
||||
table_headers = re.findall(r'^\|\s*(.+?)\s*\|', original_content, re.MULTILINE)
|
||||
if table_headers:
|
||||
# 取第一个表格的列名
|
||||
first_header = table_headers[0]
|
||||
cols = [c.strip() for c in first_header.split('|') if c.strip()]
|
||||
# 排除分隔符行(--- 格式)
|
||||
if cols and not all(re.match(r'^[-:]+$', c) for c in cols):
|
||||
parts.append(f" └─ 含表格,列名: {', '.join(cols[:6])}")
|
||||
|
||||
# 添加图片上下文
|
||||
if context_images:
|
||||
parts.append("\n【上下文中的图片】")
|
||||
|
||||
@@ -11,6 +11,24 @@ from typing import List, Optional, Union, Iterator, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MiMo 推理模型关键词(用于识别需要 thinking 参数的模型)
|
||||
_MIMO_MODEL_KEYWORDS = ('mimo',)
|
||||
|
||||
def _is_mimo_model(model_name: str) -> bool:
|
||||
"""判断是否为小米 MiMo 模型(支持 thinking 参数)"""
|
||||
if not model_name:
|
||||
return False
|
||||
return any(kw in model_name.lower() for kw in _MIMO_MODEL_KEYWORDS)
|
||||
|
||||
def _inject_mimo_thinking(kwargs: dict, model: str, disable_thinking: bool = True) -> dict:
|
||||
"""为 MiMo 模型注入 thinking 参数。返回更新后的 kwargs。"""
|
||||
if not _is_mimo_model(model):
|
||||
return kwargs
|
||||
extra = dict(kwargs.get('extra_body') or {})
|
||||
extra['thinking'] = {'type': 'disabled' if disable_thinking else 'enabled'}
|
||||
kwargs['extra_body'] = extra
|
||||
return kwargs
|
||||
|
||||
|
||||
def call_llm(
|
||||
client,
|
||||
@@ -57,6 +75,13 @@ def call_llm(
|
||||
if messages is None:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
# MiMo 模型自动注入 thinking 参数
|
||||
try:
|
||||
from config import LLM_DISABLE_THINKING
|
||||
except ImportError:
|
||||
LLM_DISABLE_THINKING = True
|
||||
kwargs = _inject_mimo_thinking(kwargs, model, disable_thinking=LLM_DISABLE_THINKING)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -72,16 +97,35 @@ def call_llm(
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
# 推理模型兼容:content 为空时尝试从 reasoning_content 提取
|
||||
# 推理模型兼容(mimo-v2.5 等):
|
||||
# 推理模型思考链消耗大量 token(~1000),max_tokens 不足时 content 为空,
|
||||
# 全部输出进入 reasoning_content。此处从思考链中提取有效内容。
|
||||
if not content or not content.strip():
|
||||
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||
if reasoning and reasoning.strip():
|
||||
# 从思维链中提取 JSON 块作为内容
|
||||
json_match = re.search(r'\{[\s\S]*\}', reasoning)
|
||||
if json_match:
|
||||
logger.info("LLM: content为空,从reasoning_content提取JSON")
|
||||
return json_match.group().strip()
|
||||
logger.warning("LLM 返回空 content(可能需要增大 max_tokens)")
|
||||
# 先去掉 <think>...</think> 标签
|
||||
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
|
||||
if cleaned:
|
||||
logger.info("LLM: content为空,从reasoning_content提取内容")
|
||||
# 尝试提取 JSON 对象(兼容结构化响应场景)
|
||||
json_match = re.search(r'\{[\s\S]*\}', cleaned)
|
||||
if json_match:
|
||||
try:
|
||||
json.loads(json_match.group())
|
||||
return json_match.group().strip()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# 尝试提取 JSON 数组
|
||||
bracket_match = re.search(r'\[[\s\S]*\]', cleaned)
|
||||
if bracket_match:
|
||||
try:
|
||||
json.loads(bracket_match.group())
|
||||
return bracket_match.group().strip()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# 纯文本响应:直接返回清理后的内容
|
||||
return cleaned
|
||||
logger.warning("LLM 返回空 content 且 reasoning_content 也无法提取(可能需要增大 max_tokens)")
|
||||
return None
|
||||
|
||||
return content.strip()
|
||||
@@ -95,7 +139,7 @@ def call_llm_stream(
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 1000,
|
||||
max_tokens: int = 3000,
|
||||
messages: List[dict] = None,
|
||||
error_prefix: str = "[错误]",
|
||||
**kwargs
|
||||
@@ -104,13 +148,14 @@ def call_llm_stream(
|
||||
流式 LLM 调用(生成器封装)
|
||||
|
||||
自动处理流式响应,逐块 yield 文本内容。
|
||||
兼容推理模型(mimo-v2.5 等):当 content 为空时回退到 reasoning_content。
|
||||
|
||||
Args:
|
||||
client: OpenAI 客户端实例
|
||||
prompt: 用户提示
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
max_tokens: 最大 token 数(推理模型需留足思考链预算)
|
||||
messages: 完整消息列表
|
||||
error_prefix: 错误时的前缀
|
||||
**kwargs: 其他参数
|
||||
@@ -125,6 +170,13 @@ def call_llm_stream(
|
||||
if messages is None:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
# MiMo 模型自动注入 thinking 参数
|
||||
try:
|
||||
from config import LLM_DISABLE_THINKING
|
||||
except ImportError:
|
||||
LLM_DISABLE_THINKING = True
|
||||
kwargs = _inject_mimo_thinking(kwargs, model, disable_thinking=LLM_DISABLE_THINKING)
|
||||
|
||||
try:
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -135,9 +187,33 @@ def call_llm_stream(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
content_yielded = False
|
||||
reasoning_buffer = []
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
|
||||
# 正常 content 输出
|
||||
if hasattr(delta, 'content') and delta.content:
|
||||
content_yielded = True
|
||||
yield delta.content
|
||||
continue
|
||||
|
||||
# 推理模型:reasoning_content(思考链)
|
||||
rc = getattr(delta, 'reasoning_content', None)
|
||||
if rc:
|
||||
reasoning_buffer.append(rc)
|
||||
|
||||
# 回退:content 为空但 reasoning_content 有内容(推理模型 token 不足时)
|
||||
if not content_yielded and reasoning_buffer:
|
||||
reasoning_text = ''.join(reasoning_buffer)
|
||||
# 去掉 <think>...</think> 标签
|
||||
cleaned = re.sub(r'', '', reasoning_text, flags=re.DOTALL).strip()
|
||||
if cleaned:
|
||||
logger.info("流式 LLM: content为空,从reasoning_content提取内容")
|
||||
yield cleaned
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 流式调用失败: {e}")
|
||||
@@ -352,7 +428,7 @@ def quick_yes_no(
|
||||
if keywords is None:
|
||||
keywords = ["是", "需要", "yes", "true"]
|
||||
|
||||
result = call_llm(client, prompt, model, temperature=0, max_tokens=10)
|
||||
result = call_llm(client, prompt, model, temperature=0, max_tokens=128)
|
||||
if result is None:
|
||||
return False
|
||||
|
||||
|
||||
81
core/mmr.py
81
core/mmr.py
@@ -108,22 +108,44 @@ def mmr_rerank(
|
||||
return selected
|
||||
|
||||
|
||||
def _tokenize_words(text: str) -> set:
|
||||
"""
|
||||
使用 jieba 分词并过滤噪声,返回有意义的词集合。
|
||||
|
||||
过滤规则:
|
||||
- 去除单字符词(如 "的", "了", "在")—— 这些是停用词,对区分文档无意义
|
||||
- 去除纯数字 / 纯标点
|
||||
- 保留 2 字及以上的实词
|
||||
"""
|
||||
import jieba
|
||||
words = set()
|
||||
for w in jieba.cut(text):
|
||||
w = w.strip()
|
||||
if len(w) >= 2 and not w.isdigit():
|
||||
words.add(w)
|
||||
return words
|
||||
|
||||
|
||||
def mmr_filter_by_content(
|
||||
candidates: List[Dict],
|
||||
top_k: int = 30,
|
||||
similarity_threshold: float = 0.9
|
||||
similarity_threshold: float = 0.85
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
基于内容相似度的去重(简化版,不需要 embedding)
|
||||
基于 jieba 词级 Jaccard 相似度的去重(不需要 embedding)
|
||||
|
||||
与旧版字符级 set(text) 的区别:
|
||||
- 旧版:set("安全生产管理制度") → {'安','全','生','产',...},中文文档间字符集合高度重叠
|
||||
- 新版:jieba 分词 → {"安全生产", "管理制度", ...},词级集合区分度高
|
||||
|
||||
适用于:
|
||||
- 没有 embedding 的情况
|
||||
- 快速去重场景
|
||||
- MMR_USE_EMBEDDING=False 时的快速去重
|
||||
- 避免 CPU 编码 100+ 文档的 50 秒开销
|
||||
|
||||
Args:
|
||||
candidates: 候选文档列表
|
||||
top_k: 返回数量
|
||||
similarity_threshold: 相似度阈值,超过则视为重复
|
||||
similarity_threshold: 相似度阈值,超过则视为重复(默认 0.85)
|
||||
|
||||
Returns:
|
||||
去重后的候选文档列表
|
||||
@@ -134,35 +156,42 @@ def mmr_filter_by_content(
|
||||
if len(candidates) <= top_k:
|
||||
return candidates
|
||||
|
||||
selected = []
|
||||
remaining = candidates.copy()
|
||||
# 预分词:对所有候选文档一次性分词,避免重复调用 jieba.cut
|
||||
word_sets = []
|
||||
for c in candidates:
|
||||
content = c.get('content', c.get('document', ''))[:500]
|
||||
word_sets.append(_tokenize_words(content))
|
||||
|
||||
while len(selected) < top_k and remaining:
|
||||
current = remaining.pop(0)
|
||||
selected_indices = []
|
||||
|
||||
for i in range(len(candidates)):
|
||||
if len(selected_indices) >= top_k:
|
||||
break
|
||||
|
||||
current_words = word_sets[i]
|
||||
if not current_words:
|
||||
# 空内容直接保留
|
||||
selected_indices.append(i)
|
||||
continue
|
||||
|
||||
# 检查是否与已选内容重复
|
||||
is_duplicate = False
|
||||
current_content = current.get('content', current.get('document', ''))[:200]
|
||||
for j in selected_indices:
|
||||
selected_words = word_sets[j]
|
||||
if not selected_words:
|
||||
continue
|
||||
|
||||
for s in selected:
|
||||
s_content = s.get('content', s.get('document', ''))[:200]
|
||||
intersection = len(current_words & selected_words)
|
||||
union = len(current_words | selected_words)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
|
||||
# 简单的 Jaccard 相似度
|
||||
words1 = set(current_content)
|
||||
words2 = set(s_content)
|
||||
if words1 and words2:
|
||||
intersection = len(words1 & words2)
|
||||
union = len(words1 | words2)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
|
||||
if similarity > similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
if similarity > similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
selected.append(current)
|
||||
selected_indices.append(i)
|
||||
|
||||
return selected
|
||||
return [candidates[i] for i in selected_indices]
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
@@ -49,6 +49,7 @@ _STATUS_MESSAGES: Dict[int, str] = {
|
||||
4011: "向量库不存在",
|
||||
4012: "文件内容为空",
|
||||
4013: "权限不足",
|
||||
4016: "文件未向量化",
|
||||
|
||||
# 服务端错误 (50xx)
|
||||
5000: "服务器内部错误",
|
||||
@@ -113,6 +114,7 @@ FILE_NOT_FOUND = 4010
|
||||
COLLECTION_NOT_FOUND = 4011
|
||||
NO_CONTENT = 4012
|
||||
PERMISSION_DENIED = 4013
|
||||
FILE_NOT_INDEXED = 4016
|
||||
|
||||
# 服务端错误 (50xx)
|
||||
INTERNAL_ERROR = 5000
|
||||
|
||||
@@ -16,7 +16,7 @@ max_requests = 1000 # 每个worker处理1000个请求后重启(防止内存
|
||||
max_requests_jitter = 50
|
||||
|
||||
# 超时配置
|
||||
timeout = 120 # 优化后不应超过2分钟(原 300)
|
||||
timeout = 600 # 出题接口可能需要 5-10 分钟
|
||||
graceful_timeout = 60
|
||||
keepalive = 5
|
||||
|
||||
|
||||
1175
docs/API与后端对接规范.md
1175
docs/API与后端对接规范.md
File diff suppressed because it is too large
Load Diff
335
docs/RAG数据流程.md
335
docs/RAG数据流程.md
@@ -67,18 +67,27 @@
|
||||
|
||||
| 用途 | 模型 | 说明 |
|
||||
|------|------|------|
|
||||
| 主 LLM | qwen3.6-flash | 回答生成、上下文理解 |
|
||||
| 意图分析 | qwen-turbo | 轻量快速,用于问题改写与意图判断 |
|
||||
| 主 LLM | deepseek-v4-flash | 回答生成、上下文理解 |
|
||||
| 意图分析 | deepseek-v4-flash | JSON 结构化输出(`INTENT_RESPONSE_FORMAT=True`) |
|
||||
| VLM | qwen-vl-plus | 图片理解与描述生成 |
|
||||
| 云端重排序 | qwen3-rerank | DashScope API 调用,替代本地 BGE-reranker |
|
||||
| 云端重排序 | qwen3-rerank | DashScope API 调用 |
|
||||
| 本地重排序 | bge-reranker-base | ONNX Runtime,用于图片 CrossEncoder 二次评分 |
|
||||
| Embedding | bge-base-zh-v1.5 | 文本向量化 |
|
||||
|
||||
### 1.3 v7.0.0 架构变更要点
|
||||
### 1.3 架构变更要点
|
||||
|
||||
- **Reranker**:从本地 BGE-reranker 切换为云端 DashScope API(qwen3-rerank)
|
||||
- **Reranker**:从本地 BGE-reranker 切换为云端 DashScope API(qwen3-rerank),本地 reranker 保留用于图片 CrossEncoder 二次评分
|
||||
- **MMR 去重**:使用文本相似度模式(`MMR_USE_EMBEDDING=false`),基于 Jaccard 系数
|
||||
- **Agentic 引擎**:拆分为多个子模块(agentic_search / agentic_answer / agentic_citation 等)
|
||||
- **Graph RAG**:模块已清空,不再使用
|
||||
|
||||
**v7.1 新增变更**:
|
||||
|
||||
- **性能插桩**:`/rag` 流式响应新增 `stages_ms` 字段,记录 8 个管线阶段耗时(intent / cache / search / vlm / image / rescue / llm / post)
|
||||
- **CrossEncoder 图片二次评分**:`select_images` 最终排序前新增 P3 阶段,用本地 bge-reranker-base 对 `(query, vlm_desc)` 做语义精排,映射为 ±5 分调整量;CE 负分图片直接剔除
|
||||
- **意图分析 JSON 强制输出**:新增 `INTENT_RESPONSE_FORMAT` 配置开关,推理模型需设为 False
|
||||
- **缓存管理端点**:新增 `POST /cache/clear` 一键清除精确缓存 + 语义缓存
|
||||
|
||||
---
|
||||
|
||||
## 二、请求入口
|
||||
@@ -340,8 +349,11 @@ enhance_retrieved_chunks(contexts, query, kb_name)
|
||||
| 查询类型 | 参数调整 |
|
||||
|----------|----------|
|
||||
| 精确图号查询("图2.3") | `MAX_IMAGES=2, MIN_SCORE=5.0` |
|
||||
| 弱图片意图("发电量图") | `MAX_IMAGES=1` |
|
||||
| 普通查询 | `MAX_IMAGES=2` |
|
||||
| 数据驱动(检索含图片切片) | `MAX_IMAGES=5, MIN_SCORE=2.0` |
|
||||
| 文本引用图表 | `MAX_IMAGES=3, MIN_SCORE=2.0` |
|
||||
| 普通查询 | `MAX_IMAGES=2, MIN_SCORE=3.0` |
|
||||
|
||||
表格嵌入图片时 `MAX_IMAGES` 可扩展至 15。
|
||||
|
||||
**步骤二:提取图表引用**
|
||||
|
||||
@@ -359,18 +371,37 @@ referenced_figures = {'2.3': {'source_file': 'xxx.pdf'}, ...}
|
||||
|--------|------|
|
||||
| 图号精确匹配(查询中有"图2.3") | +10 分 |
|
||||
| 表号精确匹配 | +10 分 |
|
||||
| 关键词匹配("发电量"等) | +2 分/个 |
|
||||
| 字符重叠 | +0.2 分/字符 |
|
||||
| 关键词匹配("发电量"等) | +2 分/个,上限 8 |
|
||||
| 字符重叠 | +0.2 分/字符,上限 3 |
|
||||
| 章节匹配 | +1.5 分 |
|
||||
| 图片类型(chart > image) | +2 / +1 分 |
|
||||
| 向量相似度 | +2 分(最高) |
|
||||
| 引用匹配(需章节相关) | +8 分 |
|
||||
|
||||
**步骤四:图文关联补充**
|
||||
**步骤四:VLM 与章节关联调整**
|
||||
|
||||
遍历 top 5 文本块中引用的图表编号,查找对应的图片切片并补充到结果中。
|
||||
| 条件 | 调整 |
|
||||
|------|------|
|
||||
| VLM 描述与查询相关(`_check_vlm_relevance >= 0.5`) | +2 分 |
|
||||
| VLM 描述与查询不相关(`< 0.3`) | -3 分 |
|
||||
| 图片章节与主检索不匹配(`section_similarity < 0.3`) | -5 分 |
|
||||
| 图号/表号被文本引用 + 章节匹配 | +8~+13 分 |
|
||||
| 来源文件匹配 primary_sources | +2 分 |
|
||||
|
||||
**步骤五:返回 top N 图片**
|
||||
**步骤五:CrossEncoder 语义精排(P3)**
|
||||
|
||||
对所有过线候选图片,用本地 bge-reranker-base 对 `(query, vlm_desc_or_caption)` 做二次评分:
|
||||
|
||||
```python
|
||||
ce_scores = engine.reranker.predict([(query, desc) for desc in descriptions])
|
||||
# 映射:ce_score > 0 → adjustment ∈ (0, +5],ce_score < 0 → adjustment ∈ [-5, 0)
|
||||
img['score'] += adjustment
|
||||
# CE 负分图片直接剔除
|
||||
```
|
||||
|
||||
**步骤六:后置过滤**
|
||||
|
||||
`_filter_images_by_answer()` 根据 LLM 回答内容反向筛选图片,过滤回答中未提及的无关图片。
|
||||
|
||||
**步骤七:返回 top N 图片**
|
||||
|
||||
```python
|
||||
scored_images.sort(key=lambda x: x['score'], reverse=True)
|
||||
@@ -607,3 +638,279 @@ VLM 描述示例(更精准):
|
||||
| `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()` |
|
||||
| `core/cache.py` | 三层精确缓存 | `RAGCacheManager`, `get_cache_manager()` |
|
||||
| `core/semantic_cache.py` | 语义缓存 | `SemanticCache`, `get_semantic_cache()` |
|
||||
| `knowledge/image_cleanup.py` | 孤儿文件清理 | `cleanup_image_orphans()`, `collect_referenced_images()` |
|
||||
| `cleanup_orphans.py` | 孤儿文件清理脚本 | `--force` 执行删除,默认 dry-run |
|
||||
| `sync_vlm_cache.py` | VLM 缓存同步 | `--re-embed` 强制重算 embedding |
|
||||
| `eval_image_retrieval.py` | 图片检索评测 | 检索层 + 选择层分层评测 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、缓存系统
|
||||
|
||||
### 12.1 三层精确缓存(core/cache.py)
|
||||
|
||||
`RAGCacheManager` 管理三个独立的 `LRUCache` 实例:
|
||||
|
||||
| 缓存层 | Key 构造 | TTL | 容量 | kb_version 关联 |
|
||||
|--------|----------|-----|------|-----------------|
|
||||
| Query Cache | `MD5(query:kb_name:kb_version)` | 1h | 500 | ✅ 版本失效 |
|
||||
| Embedding Cache | `MD5(emb:{text})` | 24h | 2000 | ✅ 版本失效 |
|
||||
| Rerank Cache | `MD5(rerank:{query}:{sorted_doc_ids})` | 1h | 1000 | ❌ 变更时全清空 |
|
||||
|
||||
**知识库版本失效机制**:
|
||||
|
||||
```python
|
||||
def increment_kb_version(kb_name):
|
||||
old_version = self._kb_versions.get(kb_name, 0)
|
||||
self._kb_versions[kb_name] = old_version + 1
|
||||
# 失效旧版本
|
||||
self.query_cache.invalidate_by_version(old_version)
|
||||
self.embedding_cache.invalidate_by_version(old_version)
|
||||
self.rerank_cache.clear() # Rerank 无 kb_version,全量清空
|
||||
```
|
||||
|
||||
`sync.py` 文档变更时调用 `increment_kb_version()` 触发失效。
|
||||
|
||||
**写入保护**:`CACHE_MIN_SCORE = 0.3`,检索结果最高分低于此值时不写缓存,避免低质量结果被缓存。
|
||||
|
||||
### 12.2 语义缓存(core/semantic_cache.py)
|
||||
|
||||
`SemanticCache` 使用 FAISS 向量索引实现语义级缓存:
|
||||
|
||||
```
|
||||
查找:query_embedding → FAISS ANN 搜索 → 余弦相似度 > 0.92 → 候选
|
||||
二次验证:Jaccard(query, cached._raw_query) >= 0.5 → 确认命中
|
||||
存储:query_embedding + result → FAISS add
|
||||
```
|
||||
|
||||
**Jaccard 二次验证**(字符级):
|
||||
|
||||
```python
|
||||
set_a = set(query) # 字符集合
|
||||
set_b = set(cached_query)
|
||||
similarity = |intersection| / |union|
|
||||
```
|
||||
|
||||
**Intent 与 RAG 共用 FAISS 索引**,通过 `cache_type` 字段隔离:
|
||||
|
||||
| 缓存类型 | cache_type 值 | Embedding 输入 | 读取条件 |
|
||||
|----------|---------------|---------------|---------|
|
||||
| RAG 回答 | `"rag_answer"` | `embedding(query|collections)` | `cache_type == "rag_answer"` |
|
||||
| 意图分析 | `"intent_analysis"` | `embedding(query)` | `cache_type != "rag_answer"` |
|
||||
|
||||
**容量管理**:默认 `max_size=10000`,达到上限时 `self.clear()` 全清空(非 LRU 淘汰)。
|
||||
|
||||
**Intent 精确缓存**(独立于 FAISS):`_exact_cache: Dict[str, IntentAnalysis]`,key = `query + 最近2条历史摘要`,上限 500 条,无 TTL,满后停止接收新条目。
|
||||
|
||||
### 12.3 缓存命中链路
|
||||
|
||||
一个 `/rag` 请求依次经过以下缓存检查点:
|
||||
|
||||
```
|
||||
请求进入 chat_routes.rag()
|
||||
│
|
||||
├─ ① 语义缓存读取(FAISS)
|
||||
│ key = embedding(retrieval_query|collections)
|
||||
│ 要求 cache_type == "rag_answer"
|
||||
│ 二次验证: Jaccard >= 0.5
|
||||
│ ├─ 命中 → 直接流式返回缓存答案,流程结束
|
||||
│ └─ 未命中 → 继续
|
||||
│
|
||||
├─ ② intent_analyzer.analyze()
|
||||
│ ├─ 精确缓存: key = query + 历史摘要
|
||||
│ ├─ 语义缓存: key = embedding(query),cache_type != "rag_answer"
|
||||
│ └─ 未命中 → 调 LLM → 写入两层缓存
|
||||
│
|
||||
├─ ③ engine.search()
|
||||
│ ├─ Query Cache: MD5(query:kb_name:kb_version)
|
||||
│ ├─ Embedding Cache: MD5(emb:{text})
|
||||
│ └─ Rerank Cache: MD5(rerank:{query}:{sorted_doc_ids})
|
||||
│
|
||||
├─ ④ LLM 生成回答
|
||||
│
|
||||
└─ ⑤ 语义缓存写入(FAISS)
|
||||
存入 { cache_type: "rag_answer", answer, sources, images, _raw_query }
|
||||
```
|
||||
|
||||
**缓存清除**:`POST /cache/clear` 端点清除 Query/Embedding/Rerank 缓存 + 语义缓存(需网关认证)。
|
||||
|
||||
---
|
||||
|
||||
## 十三、图片选择管线
|
||||
|
||||
### 13.1 评分流程(select_images)
|
||||
|
||||
完整的图片选择管线包含 7 个阶段:
|
||||
|
||||
```
|
||||
score_image_relevance() ← P1: 基础分(关键词/图号/章节/类型)
|
||||
↓
|
||||
VLM 相关性调整 ← _check_vlm_relevance() -3 / +2 分
|
||||
↓
|
||||
章节关联惩罚 ← section_similarity < 0.3 → -5 分
|
||||
↓
|
||||
图号/表号引用加分 ← 文本引用匹配 → +8~+13 分
|
||||
↓
|
||||
来源文件加分 ← primary_sources 匹配 → +2 分
|
||||
↓
|
||||
MIN_SCORE 过滤 ← 动态阈值 2.0~5.0
|
||||
↓
|
||||
CrossEncoder 二次评分 ← P3: 本地 reranker 对 (query, desc) 精排
|
||||
↓ 正相关 +5,负相关 -5 且直接剔除
|
||||
MAX_IMAGES 预算控制 ← 动态上限 2~15 张
|
||||
↓
|
||||
_filter_images_by_answer() ← 后置过滤:LLM 回答关键词重叠
|
||||
```
|
||||
|
||||
### 13.2 图片召回保障
|
||||
|
||||
图片切片在检索层面临 CrossEncoder 系统性低分(0.002~0.08 vs 文本 0.3~0.9),有多重保障机制:
|
||||
|
||||
| 机制 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 图片独立召回 | `engine._search_image_chunks()` | 对 image/chart 切片单独查询 ChromaDB |
|
||||
| `_image_boost` 标记 | `chat_routes.py` | 图片意图查询时,为匹配的图片切片打 1.5x/2.0x boost 标记 |
|
||||
| 章节聚类救援 | `_rescue_section_cluster()` | 当整章节切片全被 rerank 压制时,保底分配分数 |
|
||||
| BM25 分歧救援 | `_rescue_bm25_divergence()` | BM25 高排名但 rerank 低分的切片被恢复 |
|
||||
| 词法匹配救援 | `_rescue_lexical_match()` | 切片文本精确包含查询关键词时提升分数 |
|
||||
|
||||
---
|
||||
|
||||
## 十四、VLM 懒加载增强
|
||||
|
||||
**入口文件**:`knowledge/lazy_enhance.py`
|
||||
**触发位置**:`chat_routes.py` `generate()` 函数
|
||||
|
||||
### 14.1 触发条件
|
||||
|
||||
| 切片类型 | 触发条件 | 调用模型 |
|
||||
|----------|---------|---------|
|
||||
| image / chart | `has_vlm_desc=False` 且 `image_path` 非空 | VLM(qwen-vl-plus) |
|
||||
| table | `has_summary=False` 且 `score > 0.7` | LLM(表格摘要) |
|
||||
| table + 关联图片 | `has_vlm_desc=False` 且 `image_path` 非空 | VLM |
|
||||
|
||||
### 14.2 增强流程
|
||||
|
||||
```
|
||||
缓存检查(图片 MD5 哈希 → .data/cache/vlm/{hash}.txt)
|
||||
├─ 缓存命中(内容 ≥ 5 字符)→ 直接返回
|
||||
└─ 缓存未命中 →
|
||||
├─ 调用 VLM/LLM 生成描述/摘要
|
||||
├─ 空描述保护(< 5 字符)→ 不写缓存,不更新向量库
|
||||
├─ 写入缓存文件
|
||||
└─ 更新 ChromaDB
|
||||
├─ metadata: { has_vlm_desc: True, vlm_desc: description }
|
||||
├─ embedding: 用 VLM 描述重新计算向量(通过 RAGEngine 单例获取模型)
|
||||
└─ document: 更新为 VLM 描述文本
|
||||
```
|
||||
|
||||
**embedding 模型获取**:`KnowledgeBaseManager` 没有 `embedding_model` 属性,通过 `_get_embedding_model()` 从 `RAGEngine` 单例获取。
|
||||
|
||||
### 14.3 后台异步化
|
||||
|
||||
VLM/LLM 调用在后台线程执行,不阻塞主流程:
|
||||
|
||||
```python
|
||||
# chat_routes.py 中
|
||||
_bg_contexts = copy.deepcopy(contexts) # 深拷贝避免线程竞争
|
||||
_t = threading.Thread(target=_background_enhance, daemon=True)
|
||||
_t.start()
|
||||
# 主流程继续 select_images + LLM 生成
|
||||
```
|
||||
|
||||
后台线程使用 `asyncio.run()` + 60s 超时保护。首次查询时图片使用 caption + section 作为临时描述,后台生成完成后写入 ChromaDB,下次查询直接使用缓存。
|
||||
|
||||
---
|
||||
|
||||
## 十五、文档解析与切片
|
||||
|
||||
### 15.1 MinerU 解析流程
|
||||
|
||||
**入口文件**:`parsers/mineru_parser.py`
|
||||
|
||||
支持两种云端 API 模式:
|
||||
|
||||
| 模式 | 端点 | 延迟 | 适用场景 |
|
||||
|------|------|------|---------|
|
||||
| v4 precise | `/api/v4/` | 较长(需排队) | 高精度 PDF 解析 |
|
||||
| v1 agent | `/api/v1/agent/` | ~10s | 轻量级文档处理 |
|
||||
|
||||
**V2 格式转换**(`_parse_v2_content_list`):
|
||||
|
||||
MinerU 返回的 content_list 被转换为 `MinerUChunk` 对象,提取的字段包括:`_v2_table_type`、`_v2_table_nest_level`、`_v2_list_type`、`sub_type`(部分字段在下游切片时被丢弃)。
|
||||
|
||||
**标题检测**(`parsers/heading_rules.py`):
|
||||
|
||||
基于规则的多级标题检测,`_validate_level()` 长度守卫防止长文本误判为标题:
|
||||
|
||||
| 级别 | 最大长度 | 超出后 |
|
||||
|------|---------|--------|
|
||||
| H1 | 40 字符 | 降级为普通文本 |
|
||||
| H2 | 60 字符 | 降级为普通文本 |
|
||||
| H3 | 50 字符 | 降级为普通文本 |
|
||||
|
||||
`chinese_article` 规则额外限制 `max_length=30`,`numeric_level1` 排除以标点结尾的误判。
|
||||
|
||||
### 15.2 切片后处理
|
||||
|
||||
**`_post_process_chunks`** 核心逻辑:
|
||||
|
||||
1. **连续标题链合并**:连续 title 类切片合并为一个 chunk(保留最高 text_level)
|
||||
2. **body 合并后 text_level 重置**:body 合并进 title buffer 后执行 `buffer.text_level = 0`,防止下一个标题被错误合并
|
||||
3. **元数据写入**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `chunk_type` | str | `text` / `title` / `table` / `image` / `chart` |
|
||||
| `section` | str | 章节路径(如 `综述 > 2.3发电`) |
|
||||
| `text_level` | int | 标题级别(0=body, 1=H1, 2=H2, 3=H3) |
|
||||
| `image_path` | str | 图片文件名 |
|
||||
| `bbox` | str | 页面坐标(仅 PDF) |
|
||||
| `has_vlm_desc` | bool | 是否已有 VLM 描述 |
|
||||
|
||||
---
|
||||
|
||||
## 十六、性能插桩
|
||||
|
||||
**入口文件**:`api/chat_routes.py` `generate()` 函数
|
||||
|
||||
### 16.1 各阶段计时
|
||||
|
||||
在 SSE 流式生成器中初始化计时字典,记录 8 个管线阶段耗时:
|
||||
|
||||
| 阶段 key | 记录时机 | 覆盖范围 |
|
||||
|----------|----------|----------|
|
||||
| `intent` | 意图分析完成后 | IntentAnalyzer.analyze() |
|
||||
| `cache` | 语义缓存检查后 | semantic_cache.lookup() |
|
||||
| `search` | 混合检索+切片处理完成后 | engine.search() + 上下文提取 |
|
||||
| `vlm` | VLM 懒加载完成后 | enhance_retrieved_chunks() |
|
||||
| `image` | select_images 完成后 | 图片选择 |
|
||||
| `rescue` | 救援管线+上下文构建完成后 | 聚类/BM25/预算 |
|
||||
| `llm` | LLM 流式生成完成后 | engine.generate_answer_stream() |
|
||||
| `post` | finish 事件构建完成后 | 答案对齐+debug 事件 |
|
||||
|
||||
### 16.2 数据输出
|
||||
|
||||
**SSE finish 事件**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "finish",
|
||||
"timing": {
|
||||
"total_ms": 12345,
|
||||
"stages_ms": {
|
||||
"intent": 4200, "cache": 50, "search": 900,
|
||||
"vlm": 10, "image": 30, "rescue": 200,
|
||||
"llm": 6800, "post": 150
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**服务端日志**:
|
||||
|
||||
```
|
||||
[性能] 总12345ms | 意图4200 缓存50 检索900 VLM10 图片30 救援200 LLM6800 后处理150
|
||||
```
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
# 图片处理完整流程分析
|
||||
|
||||
## 流程概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 图片处理完整流程 │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 解析阶段 (MinerU) │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ PDF/Word │────→│ MinerU 解析 │────→│ MinerUChunk 对象 │ │
|
||||
│ │ 文件 │ │ parsers/mineru_ │ │ ├── content (文本/标题) │ │
|
||||
│ └─────────────┘ │ parser.py │ │ ├── chunk_type │ │
|
||||
│ └──────────────────┘ │ ├── table_html (表格HTML) │ │
|
||||
│ │ ├── image_path (独立图片) │ │
|
||||
│ │ └── images (关联图片列表) │ │
|
||||
│ ↓ │
|
||||
│ to_page_content() │
|
||||
│ ↓ │
|
||||
│ 返回 chunks 列表 │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 2. 存储阶段 (Knowledge Manager) │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ ┌───────────────────┐ │
|
||||
│ │ chunks 列表 │────→│ add_file_to_kb() │────→│ 向量库 metadata │ │
|
||||
│ │ (MinerUChunk) │ │ knowledge/manager │ │ │ │
|
||||
│ └──────────────────┘ │ .py │ │ ├── chunk_type │ │
|
||||
│ │ │ │ ├── source │ │
|
||||
│ │ ✅ 合并跨页表格 │ │ ├── page │ │
|
||||
│ │ ✅ 序列化 images │ │ ├── images_json ✅│ │
|
||||
│ │ ✅ 存储 image_path │ │ └── image_path ✅ │ │
|
||||
│ └────────────────────┘ └───────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 3. 召回阶段 (RAG 检索) │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ ┌───────────────────┐ │
|
||||
│ │ 用户查询 │────→│ 混合检索 │────→│ 检索结果 │ │
|
||||
│ │ "表3.1 数据" │ │ search_hybrid() │ │ contexts = [{ │ │
|
||||
│ └──────────────────┘ │ api/chat_routes.py │ │ "doc": "...", │ │
|
||||
│ └────────────────────┘ │ "meta": {...} │ │
|
||||
│ ↓ │ }] │ │
|
||||
│ ↓ └───────────────────┘ │
|
||||
│ ┌────────────────────┐ ↓ │
|
||||
│ │ _extract_rich_media│ ┌───────────────────┐ │
|
||||
│ │ api/chat_routes.py │ │ 返回给前端 │ │
|
||||
│ │ │────→│ { │ │
|
||||
│ │ 读取 images_json │ │ "images": [...],│ │
|
||||
│ │ 读取 image_path │ │ "tables": [...],│ │
|
||||
│ │ ✅ 正确处理 │ │ "answer": "..." │ │
|
||||
│ └────────────────────┘ │ } │ │
|
||||
│ └───────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 关键代码位置
|
||||
|
||||
### 1. MinerU 解析 (parsers/mineru_parser.py)
|
||||
|
||||
**MinerUChunk 数据结构 (第107-125行)**:
|
||||
```python
|
||||
@dataclass
|
||||
class MinerUChunk:
|
||||
content: str # 文本内容
|
||||
chunk_type: str # 类型: text, table, image, equation
|
||||
page_start: int = 1
|
||||
page_end: int = 1
|
||||
title: str = ""
|
||||
table_html: Optional[str] = None # 表格 HTML(如果是表格)
|
||||
image_path: Optional[str] = None # 图片路径(独立图片)
|
||||
images: Optional[List[Dict]] = None # 关联图片列表: [{"id": "abc.jpg", "order": 1}]
|
||||
```
|
||||
|
||||
**表格切片创建 (第453-469行)**:
|
||||
```python
|
||||
chunk = MinerUChunk(
|
||||
content=table_caption or "表格", # ⚠️ content 只有标题
|
||||
chunk_type="table",
|
||||
table_html=table_body, # ✅ 完整表格在 table_html
|
||||
image_path=img_path, # 表格图片路径
|
||||
images=table_images # 表格中嵌入的图片
|
||||
)
|
||||
```
|
||||
|
||||
### 2. 向量库存储 (knowledge/manager.py)
|
||||
|
||||
**add_file_to_kb() 核心逻辑 (第228-270行)**:
|
||||
```python
|
||||
for i, chunk in enumerate(chunks):
|
||||
# 1. 获取 chunk_type
|
||||
chunk_type = getattr(chunk, 'chunk_type', None)
|
||||
if not chunk_type:
|
||||
page_info = getattr(chunk, 'page_info', {}) or {}
|
||||
chunk_type = page_info.get('chunk_type', 'text')
|
||||
|
||||
if chunk_type == 'table':
|
||||
# 2. 表格内容优先使用 table_html
|
||||
table_md = getattr(chunk, 'table_html', None) or chunk.content
|
||||
semantic_content = _build_semantic_content_for_table(...)
|
||||
|
||||
# 3. 构建 metadata
|
||||
metadata = {
|
||||
'chunk_type': chunk_type,
|
||||
'source': filename,
|
||||
'page': page_start,
|
||||
# ...
|
||||
}
|
||||
|
||||
# 4. ✅ 序列化图片信息
|
||||
if hasattr(chunk, 'images') and chunk.images:
|
||||
metadata['images_json'] = json.dumps(chunk.images, ensure_ascii=False)
|
||||
|
||||
if hasattr(chunk, 'image_path') and chunk.image_path:
|
||||
metadata['image_path'] = chunk.image_path
|
||||
```
|
||||
|
||||
**跨页表格合并 (第323-420行)**:
|
||||
```python
|
||||
def _merge_cross_page_tables(self, chunks: list) -> list:
|
||||
"""
|
||||
合并规则:
|
||||
1. 相邻两个表格切片
|
||||
2. 页码连续 (page_end + 1 == next.page_start)
|
||||
3. 第二个表格标题包含"续表"
|
||||
"""
|
||||
# 合并 table_html
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
|
||||
# 合并 image_path 到 images
|
||||
merged_images = [
|
||||
{'id': curr_img, 'page': curr_page_end},
|
||||
{'id': next_img, 'page': next_page_start}
|
||||
]
|
||||
current.images = merged_images
|
||||
```
|
||||
|
||||
### 3. 富媒体召回 (api/chat_routes.py)
|
||||
|
||||
**_extract_rich_media() 核心逻辑 (第724-843行)**:
|
||||
```python
|
||||
def _extract_rich_media(contexts: List[Dict]) -> Dict[str, List]:
|
||||
images = []
|
||||
tables = []
|
||||
|
||||
for ctx in contexts:
|
||||
meta = ctx.get("meta", {})
|
||||
|
||||
# 1. 独立图片切片 (image_path) - 图片/图表类型
|
||||
if meta.get("chunk_type") in ("image", "chart") and meta.get("image_path"):
|
||||
img_id = os.path.basename(meta["image_path"])
|
||||
images.append({"id": img_id, "url": f"/images/{img_id}", ...})
|
||||
|
||||
# 2. 关联图片 (images_json) - 表格/文本嵌入图片
|
||||
if meta.get("images_json"):
|
||||
img_list = json.loads(meta["images_json"])
|
||||
for img_info in img_list:
|
||||
images.append({"id": img_info["id"], ...})
|
||||
|
||||
# 3. 表格图片 (image_path) - 表格类型的图片形式
|
||||
if meta.get("chunk_type") == "table" and meta.get("image_path"):
|
||||
img_id = os.path.basename(meta["image_path"])
|
||||
images.append({"id": img_id, "type": "table_image", ...})
|
||||
|
||||
return {"images": images, "tables": tables}
|
||||
```
|
||||
|
||||
## 当前问题分析
|
||||
|
||||
### 问题1: 表格显示"0行数据"
|
||||
|
||||
**根因**: `_build_semantic_content_for_table()` 接收的 `table_md` 可能是空的
|
||||
|
||||
**验证点**:
|
||||
- MinerU 解析时 `table_html` 是否有值?
|
||||
- `manager.py` 第240行 `table_md = getattr(chunk, 'table_html', None)` 是否正确获取?
|
||||
|
||||
### 问题2: 跨页表格合并不生效
|
||||
|
||||
**根因**: 可能是页码不连续或标题匹配失败
|
||||
|
||||
**验证点**:
|
||||
- 检查 `_merge_cross_page_tables()` 的日志输出
|
||||
- 验证两个表格切片的 `page_end` 和 `page_start` 是否连续
|
||||
|
||||
### 问题3: 图片重复
|
||||
|
||||
**根因**: 可能是 `images_json` 和 `image_path` 同时存在导致重复
|
||||
|
||||
**验证点**:
|
||||
- 检查向量库中是否有同时存在 `images_json` 和 `image_path` 的切片
|
||||
- `_extract_rich_media()` 中的去重逻辑是否有效
|
||||
|
||||
## 数据存储位置
|
||||
|
||||
| 目录 | 用途 |
|
||||
|------|------|
|
||||
| `.data/images/` | 全局图片存储(哈希命名,去重) |
|
||||
| `.data/cache/vlm/` | VLM 图片描述缓存 |
|
||||
| `.data/docstore/` | 原始表格/图片 JSON 备份 |
|
||||
| `knowledge/vector_store/chroma/` | ChromaDB 向量数据库 |
|
||||
|
||||
## 测试验证步骤
|
||||
|
||||
### 1. 检查向量库 metadata
|
||||
```python
|
||||
from knowledge.manager import get_kb_manager
|
||||
kb = get_kb_manager()
|
||||
coll = kb.get_collection('my_ky')
|
||||
result = coll.get(limit=10, include=['metadatas'])
|
||||
|
||||
for meta in result['metadatas']:
|
||||
print(f"chunk_type: {meta.get('chunk_type')}")
|
||||
print(f"images_json: {meta.get('images_json')}")
|
||||
print(f"image_path: {meta.get('image_path')}")
|
||||
print("---")
|
||||
```
|
||||
|
||||
### 2. 检查 MinerU 解析结果
|
||||
```python
|
||||
from parsers.mineru_parser import parse_with_mineru
|
||||
result = parse_with_mineru("tests/public/test_report.pdf")
|
||||
|
||||
for chunk in result.get('chunks', []):
|
||||
if chunk.chunk_type == 'table':
|
||||
print(f"表格标题: {chunk.title}")
|
||||
print(f"table_html 长度: {len(chunk.table_html or '')}")
|
||||
print(f"image_path: {chunk.image_path}")
|
||||
print(f"images: {chunk.images}")
|
||||
print("---")
|
||||
```
|
||||
@@ -1,180 +0,0 @@
|
||||
## RAG-Agent 代码审查报告(精简版)
|
||||
|
||||
审查日期:2026-06-05
|
||||
|
||||
排除说明:storage 模块尚未启用(暂不纳入);用户认证/权限由后端服务负责(生产环境 RAG 服务为无状态接口,不做鉴权)。
|
||||
|
||||
---
|
||||
|
||||
### 一、高危问题(6 项)
|
||||
|
||||
**H1. SSE 错误事件泄露完整堆栈信息**
|
||||
- 文件:`api/chat_routes.py:1955`
|
||||
- `/rag` 接口 SSE 生成器在异常时将 `traceback.format_exc()` 完整堆栈直接发给客户端,暴露调用栈、文件路径、代码行号、内部变量。
|
||||
- 修复:移除 traceback 字段,仅在服务端日志记录,客户端返回通用错误消息。
|
||||
|
||||
**H2. 文档更新/删除接口存在路径遍历风险**
|
||||
- 文件:`api/document_routes.py:652,714`
|
||||
- `update_document` 和 `delete_document` 直接将 URL 中的 `doc_path` 拼接到文件路径,未做安全校验。可构造 `../../` 路径遍历载荷。
|
||||
- 修复:使用 `os.path.realpath()` 解析最终路径,验证是否在 DOCUMENTS_PATH 目录下。
|
||||
|
||||
**H3. `serve_document_file` 路径遍历风险**
|
||||
- 文件:`api/document_routes.py:139`
|
||||
- 文件服务接口同样存在路径遍历风险。虽然有 DEV_MODE 开关,但默认值为 `'true'`。
|
||||
- 修复:添加 realpath 校验。
|
||||
|
||||
**H4. 文档更新接口缺少文件类型和大小校验**
|
||||
- 文件:`api/document_routes.py:621`
|
||||
- `update_document` (PUT) 未验证文件类型和大小,直接 `file.save(filepath)`。与之对比,`upload_document` 有完整校验。
|
||||
- 修复:添加与 upload 一致的 ALLOWED_EXTENSIONS 和 MAX_FILE_SIZE 校验。
|
||||
|
||||
**H5. 批量上传接口缺少文件大小校验**
|
||||
- 文件:`api/document_routes.py:350`
|
||||
- `batch_upload_documents` 对每个文件只检查了扩展名,未检查文件大小。可批量上传超大文件导致磁盘耗尽。
|
||||
- 修复:在循环内添加 MAX_FILE_SIZE 校验。
|
||||
|
||||
**H6. `main.py` debug 模式默认开启 + 监听 0.0.0.0**
|
||||
- 文件:`main.py:29-31`
|
||||
- `--debug` 默认 `True`,`--host` 默认 `0.0.0.0`。Flask 调试模式启用 Werkzeug 交互式 debugger,可通过触发异常执行任意代码。
|
||||
- 修复:`--debug` 默认值改为 `False`。
|
||||
|
||||
---
|
||||
|
||||
### 二、中危问题(13 项)
|
||||
|
||||
**M1. 多处异常响应直接暴露内部错误信息**
|
||||
- 文件:`document_routes.py:332,467,733`;`kb_routes.py:523,636`;`feedback_routes.py:98,116`;`sync_routes.py:119`;`audit_routes.py:100` 等。
|
||||
- 大量 `except` 块直接 `str(e)` 返回给客户端,可能包含数据库路径、SQL 片段、文件系统结构。
|
||||
- 修复:统一使用通用错误消息,原始异常仅记录到服务端日志。
|
||||
|
||||
**M2. `/search` 接口缺少输入安全验证**
|
||||
- 文件:`api/chat_routes.py:1974`
|
||||
- 未调用 `validate_query()` 做注入检测和长度限制,与 `/chat`、`/rag` 不一致。
|
||||
- 修复:添加 `validate_query(query)` 调用。
|
||||
|
||||
**M3. `/search` 的 `top_k` 参数未校验范围**
|
||||
- 文件:`api/chat_routes.py:1989`
|
||||
- 可传 `top_k=999999` 导致内存溢出。
|
||||
- 修复:`top_k = max(1, min(int(top_k), 50))`。
|
||||
|
||||
**M4. `context_count` 参数未校验范围**
|
||||
- 文件:`api/document_routes.py:821`
|
||||
- 未限制范围且非整数字符串会 ValueError 导致 500。
|
||||
- 修复:try/except + `max(0, min(n, 10))`。
|
||||
|
||||
**M5. CORS 配置允许所有来源**
|
||||
- 文件:`api/__init__.py:70`
|
||||
- `CORS(app)` 默认允许 `*` 跨域。生产环境应限制为已知前端域名。
|
||||
- 修复:根据 APP_ENV 条件配置 origins。
|
||||
|
||||
**M6. LIKE 通配符注入风险**
|
||||
- 文件:`api/kb_routes.py:503`
|
||||
- `kb_name` 含 `%` 或 `_` 时会导致非预期的 LIKE 匹配行为。
|
||||
- 修复:对 LIKE 特殊字符转义后再拼入模式。
|
||||
|
||||
**M7. SESSION_MANAGER 为 None 时未处理**
|
||||
- 文件:`api/session_routes.py:35,64,83,102`
|
||||
- 初始化失败时 SESSION_MANAGER 为 None,调用方法会触发 AttributeError 导致 500。
|
||||
- 修复:使用前检查 None,返回 503。
|
||||
|
||||
**M8. LLM 调用缺少统一的超时和重试机制**
|
||||
- 文件:`core/llm_utils.py`
|
||||
- 部分 LLM 调用无超时控制,长时间阻塞会耗尽 worker。`@retry` 装饰器只在部分方法上使用。
|
||||
- 修复:在 `_call_llm` 层面统一超时和重试。
|
||||
|
||||
**M9. LLM 输出 JSON 解析不够健壮**
|
||||
- 文件:`core/agentic.py`、`core/agentic_answer.py`、`core/agentic_quality.py` 等
|
||||
- 多处 LLM 返回的 JSON 解析缺少多策略提取和重试,仅靠 prompt 约束。exam_pkg 已修复但 core 模块尚未统一。
|
||||
- 修复:提取 exam_pkg 的 `_extract_json` 为公共工具,core 模块统一使用。
|
||||
|
||||
**M10. Prompt 注入风险**
|
||||
- 文件:`core/engine.py:2017`、`core/agentic_answer.py:83`
|
||||
- 用户输入直接拼入 prompt,未做净化。恶意输入可操控 LLM 输出。
|
||||
- 修复:对用户输入做基本的 prompt 注入检测(如检测 "ignore previous instructions" 等模式)。
|
||||
|
||||
**M11. `subprocess.run` 命令参数注入风险**
|
||||
- 文件:`parsers/mineru_parser.py:632`
|
||||
- file_path 中特殊字符(如以 `-` 开头的文件名)可能被命令行工具解释为选项。
|
||||
- 修复:在文件路径前插入 `--` 分隔符;对 backend、lang 参数做白名单校验。
|
||||
|
||||
**M12. Excel/文本解析器无文件大小限制**
|
||||
- 文件:`parsers/excel_parser.py:81`、`parsers/txt_parser.py:15`
|
||||
- 一次性加载全文件到内存,超大文件导致 OOM。
|
||||
- 修复:解析前检查文件大小,设定上限(如 50MB)。
|
||||
|
||||
**M13. 全局变量缓存竞态条件**
|
||||
- 文件:`api/document_routes.py:100`、`api/kb_routes.py:46`
|
||||
- 模块级全局变量 `_kb_manager` 等在多线程 gunicorn 下存在竞态。
|
||||
- 修复:使用 `threading.Lock` 保护或改用 `flask.current_app.config`。
|
||||
|
||||
---
|
||||
|
||||
### 三、低危问题(12 项)
|
||||
|
||||
**L1.** `config.py` 硬编码第三方 API 端点 `xiaomimimo.com` 作为默认值(第 20 行)— 改为空字符串,要求环境变量显式配置。
|
||||
|
||||
**L2.** `python-dotenv` 未安装时静默跳过,服务可能 fail-open 启动(`config.py:11`)— 生产环境缺失时抛异常。
|
||||
|
||||
**L3.** `assert` 校验可被 `python -O` 跳过(`api/__init__.py:236`)— 改为 `raise ValueError`。
|
||||
|
||||
**L4.** `/chat` 的 `history` 未限长度(`chat_routes.py:1247`)— 可消耗大量 token。
|
||||
|
||||
**L5.** `history` 元素结构未验证(`chat_routes.py:1117`)— 缺少字段时 KeyError 导致 500。
|
||||
|
||||
**L6.** `safe_filename` 运算符优先级不明确(`document_routes.py:94`)— 加括号明确。
|
||||
|
||||
**L7.** DocStore glob 模式未转义特殊字符(`document_routes.py:267`)— 用 `glob.escape()`。
|
||||
|
||||
**L8.** 相对路径 `.data/images` 因工作目录不同可能解析错误(`chat_routes.py:59`)— 改用 PROJECT_ROOT 绝对路径。
|
||||
|
||||
**L9.** `asyncio.run()` 在 Flask 请求上下文中兼容性问题(`chat_routes.py:1744`)。
|
||||
|
||||
**L10.** Excel 同一文件被重复读取多次(`excel_parser.py:81,89`)— 应复用 ExcelFile 对象。
|
||||
|
||||
**L11.** PDF 图片提取 `doc` 对象异常时未关闭(`image_extractor.py:78`)— 改用 `with` 语句。
|
||||
|
||||
**L12.** TXT 解析器异常用 `print` 而非 `logger`(`txt_parser.py:26`)。
|
||||
|
||||
---
|
||||
|
||||
### 四、修复优先级
|
||||
|
||||
按修复成本从低到高排序:
|
||||
|
||||
**第一批:快速修复(半天,改几行代码)**
|
||||
|
||||
| 编号 | 问题 | 改动量 |
|
||||
|:---:|---|:---:|
|
||||
| H6 | main.py debug 默认开启 | 1 行 |
|
||||
| M3 | /search top_k 范围校验 | 2 行 |
|
||||
| M2 | /search 加 validate_query | 2 行 |
|
||||
| M4 | context_count 范围校验 | 3 行 |
|
||||
| L3 | assert 改 raise | 3 行 |
|
||||
| H1 | SSE 移除 traceback 字段 | 5 行 |
|
||||
|
||||
**第二批:安全加固(1-2 天)**
|
||||
|
||||
| 编号 | 问题 | 改动量 |
|
||||
|:---:|---|:---:|
|
||||
| H2+H3 | 文档接口路径遍历 realpath 校验 | ~30 行 |
|
||||
| H4+H5 | 文档更新/批量上传加文件校验 | ~30 行 |
|
||||
| M6 | LIKE 通配符转义 | ~10 行 |
|
||||
| M1 | 异常信息统一脱敏 | 多文件,每处 2-3 行 |
|
||||
| M5 | CORS 生产环境限制来源 | ~5 行 |
|
||||
| M7 | SESSION_MANAGER None 保护 | ~10 行 |
|
||||
| M11 | subprocess 参数注入防护 | ~5 行 |
|
||||
|
||||
**第三批:架构改进(1-2 周)**
|
||||
|
||||
| 编号 | 问题 | 说明 |
|
||||
|:---:|---|---|
|
||||
| M8+M9 | LLM 调用统一超时/重试/解析 | 提取 exam_pkg 经验为公共工具 |
|
||||
| M10 | Prompt 注入防御 | 需设计检测规则 |
|
||||
| M13 | 全局变量竞态修复 | threading.Lock |
|
||||
| M12 | 解析器文件大小限制 | 统一加前置校验 |
|
||||
|
||||
---
|
||||
|
||||
### 五、做得好的方面
|
||||
|
||||
SQL 查询全部使用参数化查询,无注入风险;`validate_query()` 对聊天输入做了注入检测和违禁词过滤;`safe_filename` 对上传文件做了基本防护;`filter_response()` 能过滤 API 密钥等敏感信息;exam_pkg 的输入校验体系完整(已在本轮开发中加固);`.gitignore` 正确排除了 `.env` 等敏感文件。
|
||||
@@ -1,785 +0,0 @@
|
||||
# 企业文档更新管理方案
|
||||
|
||||
> 本文档合并了企业文档管理的多种方案比较分析(包括增量更新、完整版本管理和软删除等),以及目前项目中所采纳的“方案C(智能全量更新)”的具体实现细则。
|
||||
|
||||
## 第一部分:文档更新管理方案横评
|
||||
|
||||
# 企业文档管理方案分析与建议
|
||||
|
||||
> 针对企业文档部分更新、文件废止等场景的最佳实践
|
||||
|
||||
---
|
||||
|
||||
## 📋 企业文档管理的实际需求
|
||||
|
||||
### 典型场景
|
||||
|
||||
1. **文档部分更新**
|
||||
- 制度文件修订(如:报销制度第3条修改)
|
||||
- 附件更新(如:报销单模板更新)
|
||||
- 内容勘误(如:错别字修正)
|
||||
|
||||
2. **文档废止**
|
||||
- 旧制度失效(如:2023年报销制度被2024年版本替代)
|
||||
- 临时文件过期(如:疫情期间的临时政策)
|
||||
- 部门撤销(如:某部门解散,相关文档废止)
|
||||
|
||||
3. **版本管理**
|
||||
- 多版本共存(如:新旧制度过渡期)
|
||||
- 历史追溯(如:查询某个时间点的制度内容)
|
||||
- 变更记录(如:审计需要查看修改历史)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 当前实现分析
|
||||
|
||||
### 现有机制:sync.py
|
||||
|
||||
**优点**:
|
||||
- ✅ 自动检测文件变更(新增、修改、删除)
|
||||
- ✅ 基于文件 Hash 判断内容是否变化
|
||||
- ✅ 支持多向量库(按目录自动分类)
|
||||
- ✅ 实时监控文件系统变化
|
||||
|
||||
**处理策略**:
|
||||
```python
|
||||
# 当前的"全量更新"策略
|
||||
if change.change_type == ChangeType.MODIFIED:
|
||||
# 1. 删除旧文档的所有 chunks
|
||||
deleted = kb_manager.delete_document(kb_name, filename)
|
||||
|
||||
# 2. 重新解析并添加新文档的所有 chunks
|
||||
chunks_added = kb_manager.add_file_to_kb(kb_name, filepath)
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- ❌ 即使只修改一个字,也要重新解析整个文档
|
||||
- ❌ 删除所有旧 chunks,可能影响正在使用的查询
|
||||
- ❌ 没有保留历史版本
|
||||
- ❌ 无法追溯变更内容
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案对比
|
||||
|
||||
### 方案 A:增量更新(diff.py 的设计思路)
|
||||
|
||||
**原理**:
|
||||
```python
|
||||
# 1. 解析新旧文档
|
||||
old_chunks = parse_document(old_version)
|
||||
new_chunks = parse_document(new_version)
|
||||
|
||||
# 2. 计算差异
|
||||
diff = DocumentDiffAnalyzer().compute_diff(old_chunks, new_chunks)
|
||||
|
||||
# 3. 增量更新
|
||||
for chunk in diff.added:
|
||||
kb_manager.add_chunk(chunk) # 只添加新增的
|
||||
|
||||
for chunk in diff.deleted:
|
||||
kb_manager.delete_chunk(chunk.id) # 只删除被删的
|
||||
|
||||
for chunk in diff.modified:
|
||||
kb_manager.update_chunk(chunk.id, chunk.new_content) # 只更新修改的
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 性能优化:只处理变化的部分
|
||||
- ✅ 减少重复计算:不需要重新 Embedding 未变化的内容
|
||||
- ✅ 平滑过渡:不影响正在使用的 chunks
|
||||
|
||||
**缺点**:
|
||||
- ❌ 实现复杂:需要精确匹配新旧 chunks
|
||||
- ❌ 匹配困难:文档结构变化时难以对应
|
||||
- ❌ 边界问题:chunk 边界变化导致误判
|
||||
- ❌ 维护成本高:525 行代码,逻辑复杂
|
||||
|
||||
**适用场景**:
|
||||
- 超大文档(1000+ 页)
|
||||
- 频繁小改动(每天多次更新)
|
||||
- 对性能要求极高
|
||||
|
||||
**企业实际情况**:
|
||||
- ❌ 大部分企业文档 < 100 页
|
||||
- ❌ 更新频率低(每月/每季度)
|
||||
- ❌ 全量更新耗时可接受(几秒到几十秒)
|
||||
|
||||
---
|
||||
|
||||
### 方案 B:版本管理 + 软删除(lifecycle.py 的设计思路)
|
||||
|
||||
**原理**:
|
||||
```python
|
||||
# 1. 保留所有版本
|
||||
document_versions = [
|
||||
{"version": "v1", "status": "superseded", "upload_time": "2023-01-01"},
|
||||
{"version": "v2", "status": "superseded", "upload_time": "2023-06-01"},
|
||||
{"version": "v3", "status": "active", "upload_time": "2024-01-01"}
|
||||
]
|
||||
|
||||
# 2. 查询时只返回 active 版本
|
||||
chunks = kb_manager.query(kb_name, query, filter={"status": "active"})
|
||||
|
||||
# 3. 废止文档(软删除)
|
||||
lifecycle_manager.deprecate_document(kb_name, doc_id, reason="制度已更新")
|
||||
# 实际操作:将 status 改为 "deprecated",不删除数据
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 历史追溯:可以查询任意时间点的内容
|
||||
- ✅ 安全回滚:废止操作可逆
|
||||
- ✅ 审计友好:完整的变更记录
|
||||
- ✅ 过渡期支持:新旧版本可以共存
|
||||
|
||||
**缺点**:
|
||||
- ❌ 存储成本:保留所有历史版本
|
||||
- ❌ 查询复杂:需要过滤 status
|
||||
- ❌ 数据膨胀:向量库体积增大
|
||||
|
||||
**适用场景**:
|
||||
- 合规要求高(金融、医疗)
|
||||
- 需要审计追溯
|
||||
- 文档变更频繁但需要保留历史
|
||||
|
||||
---
|
||||
|
||||
### 方案 C:智能全量更新(推荐)⭐
|
||||
|
||||
**原理**:
|
||||
```python
|
||||
# 1. 检测变更
|
||||
if file_hash_changed:
|
||||
# 2. 标记旧版本(软删除)
|
||||
kb_manager.mark_document_as_deprecated(kb_name, doc_id, version="v1")
|
||||
|
||||
# 3. 添加新版本
|
||||
kb_manager.add_file_to_kb(
|
||||
kb_name,
|
||||
filepath,
|
||||
extra_metadata={
|
||||
"status": "active",
|
||||
"version": "v2",
|
||||
"previous_version": "v1",
|
||||
"change_reason": "制度修订"
|
||||
}
|
||||
)
|
||||
|
||||
# 4. 异步清理旧版本(可选)
|
||||
schedule_cleanup(kb_name, doc_id, version="v1", delay="7 days")
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 实现简单:基于现有 sync.py
|
||||
- ✅ 性能可接受:全量更新耗时短
|
||||
- ✅ 可靠性高:不依赖复杂的 diff 算法
|
||||
- ✅ 灵活性好:可选保留历史版本
|
||||
|
||||
**缺点**:
|
||||
- ⚠️ 短暂的双份数据(新旧版本共存期间)
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 大部分企业场景
|
||||
- ✅ 文档更新频率适中
|
||||
- ✅ 对性能要求不极端
|
||||
|
||||
---
|
||||
|
||||
## 📊 方案对比总结
|
||||
|
||||
| 方案 | 实现复杂度 | 性能 | 存储成本 | 历史追溯 | 适用场景 |
|
||||
|------|-----------|------|---------|---------|---------|
|
||||
| **A. 增量更新** | ⭐⭐⭐⭐⭐ 高 | ⭐⭐⭐⭐⭐ 优 | ⭐⭐⭐⭐⭐ 低 | ❌ 无 | 超大文档、频繁更新 |
|
||||
| **B. 完整版本管理** | ⭐⭐⭐⭐ 中高 | ⭐⭐⭐ 中 | ⭐⭐ 高 | ✅ 完整 | 金融、医疗等合规场景 |
|
||||
| **C. 智能全量更新** | ⭐⭐ 低 | ⭐⭐⭐⭐ 良 | ⭐⭐⭐⭐ 中 | ✅ 可选 | **大部分企业场景** ⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最终建议
|
||||
|
||||
### 推荐方案:方案 C(智能全量更新 + 轻量级版本管理)
|
||||
|
||||
**理由**:
|
||||
1. ✅ **实现简单**:基于现有 sync.py,增量开发
|
||||
2. ✅ **性能足够**:全量更新耗时可接受(秒级)
|
||||
3. ✅ **功能完整**:支持版本管理、软删除、历史追溯
|
||||
4. ✅ **维护成本低**:逻辑清晰,不易出错
|
||||
5. ✅ **适用性广**:覆盖 90% 的企业场景
|
||||
|
||||
**不推荐**:
|
||||
- ❌ 方案 A(增量更新):实现复杂,收益不明显
|
||||
- ⚠️ 方案 B(完整版本管理):存储成本高,大部分企业用不到
|
||||
|
||||
### 具体操作
|
||||
|
||||
1. **删除 diff.py**(525 行)
|
||||
2. **简化 lifecycle.py**(保留 ~200 行核心功能)
|
||||
3. **增强 sync.py**(添加版本管理逻辑)
|
||||
4. **补充 API**(废止、恢复、历史查询)
|
||||
|
||||
**预期效果**:
|
||||
- 减少 ~800 行冗余代码
|
||||
- 保留实际需要的功能
|
||||
- 满足企业文档管理需求
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建时间**: 2026-04-20
|
||||
**维护者**: RAG 服务开发组
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 第二部分:选定方案(方案C)详细落地落实说明
|
||||
|
||||
# 方案 C:文档、废止状态与向量库关系详解
|
||||
|
||||
> 详细说明智能全量更新方案的数据流和状态管理
|
||||
|
||||
---
|
||||
|
||||
## 📊 核心概念
|
||||
|
||||
### 三个层次
|
||||
|
||||
1. **物理层**:`documents/` 目录(文件系统)
|
||||
2. **逻辑层**:文档状态管理(数据库)
|
||||
3. **检索层**:向量库(ChromaDB/Milvus)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 完整数据流图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 物理层:documents/ │
|
||||
│ │
|
||||
│ documents/ │
|
||||
│ ├── public/ │
|
||||
│ │ ├── 报销制度_v1.pdf ← 旧版本(物理存在) │
|
||||
│ │ ├── 报销制度_v2.pdf ← 新版本(物理存在) │
|
||||
│ │ └── 临时防疫政策.pdf ← 已废止(物理存在) │
|
||||
│ └── finance/ │
|
||||
│ └── 差旅管理办法.pdf │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. 逻辑层:document_versions 表 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ kb_name │ document_id │ version │ status │ │
|
||||
│ ├─────────┼────────────────────┼─────────┼─────────────┤ │
|
||||
│ │ public │ 报销制度_v1.pdf │ v1 │ superseded │ │
|
||||
│ │ public │ 报销制度_v2.pdf │ v2 │ active │ │
|
||||
│ │ public │ 临时防疫政策.pdf │ v1 │ deprecated │ │
|
||||
│ │ finance │ 差旅管理办法.pdf │ v1 │ active │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. 检索层:向量库(ChromaDB) │
|
||||
│ │
|
||||
│ Collection: public_kb │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ chunk_id │ content │ metadata │ │
|
||||
│ ├──────────┼──────────────┼───────────────────────────┤ │
|
||||
│ │ c1 │ 报销流程... │ {doc: 报销制度_v1.pdf, │ │
|
||||
│ │ │ │ status: superseded, │ │
|
||||
│ │ │ │ version: v1} │ │
|
||||
│ ├──────────┼──────────────┼───────────────────────────┤ │
|
||||
│ │ c2 │ 报销流程... │ {doc: 报销制度_v2.pdf, │ │
|
||||
│ │ │ │ status: active, │ │
|
||||
│ │ │ │ version: v2} │ │
|
||||
│ ├──────────┼──────────────┼───────────────────────────┤ │
|
||||
│ │ c3 │ 防疫要求... │ {doc: 临时防疫政策.pdf, │ │
|
||||
│ │ │ │ status: deprecated, │ │
|
||||
│ │ │ │ version: v1} │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 详细场景说明
|
||||
|
||||
### 场景 1:文档更新(报销制度 v1 → v2)
|
||||
|
||||
#### 步骤 1:用户上传新版本
|
||||
|
||||
```bash
|
||||
# 用户操作
|
||||
documents/public/报销制度_v2.pdf # 上传新文件
|
||||
```
|
||||
|
||||
#### 步骤 2:系统检测变更
|
||||
|
||||
```python
|
||||
# sync.py 自动检测
|
||||
change = DocumentChange(
|
||||
document_id="public/报销制度_v2.pdf",
|
||||
change_type=ChangeType.ADDED,
|
||||
new_hash="abc123..."
|
||||
)
|
||||
```
|
||||
|
||||
#### 步骤 3:处理更新
|
||||
|
||||
```python
|
||||
# 方案 C 的处理逻辑
|
||||
def process_document_update(kb_name, old_doc_id, new_doc_path):
|
||||
# 1. 标记旧版本为 superseded(不删除)
|
||||
kb_manager.update_metadata(
|
||||
kb_name="public",
|
||||
filter={
|
||||
"document_id": "报销制度_v1.pdf",
|
||||
"status": "active"
|
||||
},
|
||||
update={
|
||||
"status": "superseded",
|
||||
"superseded_by": "报销制度_v2.pdf",
|
||||
"superseded_time": "2024-01-15 10:00:00"
|
||||
}
|
||||
)
|
||||
|
||||
# 2. 添加新版本
|
||||
chunks_added = kb_manager.add_file_to_kb(
|
||||
kb_name="public",
|
||||
filepath="documents/public/报销制度_v2.pdf",
|
||||
extra_metadata={
|
||||
"status": "active",
|
||||
"version": "v2",
|
||||
"previous_version": "v1",
|
||||
"document_id": "报销制度_v2.pdf",
|
||||
"upload_time": "2024-01-15 10:00:00"
|
||||
}
|
||||
)
|
||||
|
||||
# 3. 记录版本历史
|
||||
db.insert_version_record({
|
||||
"kb_name": "public",
|
||||
"document_id": "报销制度_v2.pdf",
|
||||
"version": "v2",
|
||||
"status": "active",
|
||||
"previous_version": "v1",
|
||||
"change_reason": "制度修订"
|
||||
})
|
||||
|
||||
# 4. 可选:7天后清理旧版本
|
||||
schedule_cleanup(
|
||||
kb_name="public",
|
||||
document_id="报销制度_v1.pdf",
|
||||
delay_days=7
|
||||
)
|
||||
```
|
||||
|
||||
#### 步骤 4:查询时的效果
|
||||
|
||||
```python
|
||||
# 用户查询:"报销流程是什么?"
|
||||
results = kb_manager.query_kb(
|
||||
kb_name="public",
|
||||
query="报销流程是什么",
|
||||
top_k=5,
|
||||
where_filter={"status": "active"} # 只查询 active 状态
|
||||
)
|
||||
|
||||
# 返回结果:
|
||||
# ✅ 报销制度_v2.pdf 的内容(新版本)
|
||||
# ❌ 报销制度_v1.pdf 的内容(被过滤掉)
|
||||
```
|
||||
|
||||
#### 数据状态对比
|
||||
|
||||
**物理层(documents/)**:
|
||||
```
|
||||
documents/public/
|
||||
├── 报销制度_v1.pdf ← 仍然存在(用户可能需要查看旧版)
|
||||
└── 报销制度_v2.pdf ← 新版本
|
||||
```
|
||||
|
||||
**逻辑层(document_versions 表)**:
|
||||
```sql
|
||||
-- 旧版本记录
|
||||
INSERT INTO document_versions VALUES (
|
||||
'public', '报销制度_v1.pdf', 'v1', 'superseded',
|
||||
'2023-01-01', '被 v2 替代'
|
||||
);
|
||||
|
||||
-- 新版本记录
|
||||
INSERT INTO document_versions VALUES (
|
||||
'public', '报销制度_v2.pdf', 'v2', 'active',
|
||||
'2024-01-15', NULL
|
||||
);
|
||||
```
|
||||
|
||||
**检索层(向量库)**:
|
||||
```python
|
||||
# 旧版本 chunks(status=superseded,查询时被过滤)
|
||||
{
|
||||
"chunk_id": "c1",
|
||||
"content": "报销流程:先填写申请单...",
|
||||
"metadata": {
|
||||
"document_id": "报销制度_v1.pdf",
|
||||
"status": "superseded", # ← 关键字段
|
||||
"version": "v1"
|
||||
}
|
||||
}
|
||||
|
||||
# 新版本 chunks(status=active,查询时返回)
|
||||
{
|
||||
"chunk_id": "c2",
|
||||
"content": "报销流程:使用新系统提交...",
|
||||
"metadata": {
|
||||
"document_id": "报销制度_v2.pdf",
|
||||
"status": "active", # ← 关键字段
|
||||
"version": "v2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:文档废止(临时防疫政策失效)
|
||||
|
||||
#### 步骤 1:管理员废止文档
|
||||
|
||||
```python
|
||||
# API 调用
|
||||
POST /api/kb/public/documents/临时防疫政策.pdf/deprecate
|
||||
{
|
||||
"reason": "疫情结束,政策失效"
|
||||
}
|
||||
```
|
||||
|
||||
#### 步骤 2:系统处理废止
|
||||
|
||||
```python
|
||||
def deprecate_document(kb_name, doc_id, reason):
|
||||
# 1. 更新向量库 metadata
|
||||
kb_manager.update_metadata(
|
||||
kb_name="public",
|
||||
filter={
|
||||
"document_id": "临时防疫政策.pdf",
|
||||
"status": "active"
|
||||
},
|
||||
update={
|
||||
"status": "deprecated",
|
||||
"deprecated_reason": "疫情结束,政策失效",
|
||||
"deprecated_time": "2024-01-20 15:00:00"
|
||||
}
|
||||
)
|
||||
|
||||
# 2. 更新版本表
|
||||
db.update_version_status(
|
||||
kb_name="public",
|
||||
document_id="临时防疫政策.pdf",
|
||||
status="deprecated",
|
||||
reason="疫情结束,政策失效"
|
||||
)
|
||||
|
||||
# 3. 记录废止日志
|
||||
db.insert_change_log({
|
||||
"kb_name": "public",
|
||||
"document_id": "临时防疫政策.pdf",
|
||||
"change_type": "deprecate",
|
||||
"reason": "疫情结束,政策失效",
|
||||
"operator": "admin"
|
||||
})
|
||||
```
|
||||
|
||||
#### 步骤 3:查询时的效果
|
||||
|
||||
```python
|
||||
# 用户查询:"防疫政策是什么?"
|
||||
results = kb_manager.query_kb(
|
||||
kb_name="public",
|
||||
query="防疫政策是什么",
|
||||
top_k=5,
|
||||
where_filter={"status": "active"} # 只查询 active 状态
|
||||
)
|
||||
|
||||
# 返回结果:
|
||||
# ❌ 临时防疫政策.pdf 的内容(被过滤掉)
|
||||
# ✅ 其他 active 状态的文档
|
||||
```
|
||||
|
||||
#### 数据状态
|
||||
|
||||
**物理层(documents/)**:
|
||||
```
|
||||
documents/public/
|
||||
└── 临时防疫政策.pdf ← 仍然存在(可能需要归档)
|
||||
```
|
||||
|
||||
**逻辑层(document_versions 表)**:
|
||||
```sql
|
||||
UPDATE document_versions
|
||||
SET status = 'deprecated',
|
||||
status_reason = '疫情结束,政策失效',
|
||||
deprecated_time = '2024-01-20 15:00:00'
|
||||
WHERE kb_name = 'public'
|
||||
AND document_id = '临时防疫政策.pdf';
|
||||
```
|
||||
|
||||
**检索层(向量库)**:
|
||||
```python
|
||||
# 废止后的 chunks(status=deprecated,查询时被过滤)
|
||||
{
|
||||
"chunk_id": "c3",
|
||||
"content": "疫情期间需要佩戴口罩...",
|
||||
"metadata": {
|
||||
"document_id": "临时防疫政策.pdf",
|
||||
"status": "deprecated", # ← 关键字段
|
||||
"deprecated_reason": "疫情结束,政策失效"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:恢复已废止的文档
|
||||
|
||||
#### 步骤 1:管理员恢复文档
|
||||
|
||||
```python
|
||||
# API 调用
|
||||
POST /api/kb/public/documents/临时防疫政策.pdf/restore
|
||||
{
|
||||
"reason": "疫情反复,政策恢复"
|
||||
}
|
||||
```
|
||||
|
||||
#### 步骤 2:系统处理恢复
|
||||
|
||||
```python
|
||||
def restore_document(kb_name, doc_id, reason):
|
||||
# 1. 更新向量库 metadata
|
||||
kb_manager.update_metadata(
|
||||
kb_name="public",
|
||||
filter={
|
||||
"document_id": "临时防疫政策.pdf",
|
||||
"status": "deprecated"
|
||||
},
|
||||
update={
|
||||
"status": "active",
|
||||
"restored_reason": "疫情反复,政策恢复",
|
||||
"restored_time": "2024-02-01 09:00:00"
|
||||
}
|
||||
)
|
||||
|
||||
# 2. 更新版本表
|
||||
db.update_version_status(
|
||||
kb_name="public",
|
||||
document_id="临时防疫政策.pdf",
|
||||
status="active",
|
||||
reason="疫情反复,政策恢复"
|
||||
)
|
||||
```
|
||||
|
||||
#### 步骤 3:查询时的效果
|
||||
|
||||
```python
|
||||
# 用户查询:"防疫政策是什么?"
|
||||
results = kb_manager.query_kb(
|
||||
kb_name="public",
|
||||
query="防疫政策是什么",
|
||||
top_k=5,
|
||||
where_filter={"status": "active"}
|
||||
)
|
||||
|
||||
# 返回结果:
|
||||
# ✅ 临时防疫政策.pdf 的内容(已恢复)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 查询行为详解
|
||||
|
||||
### 默认查询(只返回 active 文档)
|
||||
|
||||
```python
|
||||
def query_kb(self, kb_name: str, query: str, top_k: int = 5):
|
||||
"""默认查询:只返回生效的文档"""
|
||||
results = self.collection.query(
|
||||
query_texts=[query],
|
||||
n_results=top_k,
|
||||
where={
|
||||
"status": "active" # ← 自动过滤
|
||||
}
|
||||
)
|
||||
return results
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 返回:报销制度_v2.pdf(active)
|
||||
- ❌ 过滤:报销制度_v1.pdf(superseded)
|
||||
- ❌ 过滤:临时防疫政策.pdf(deprecated)
|
||||
|
||||
### 历史查询(包含所有版本)
|
||||
|
||||
```python
|
||||
def query_kb_with_history(self, kb_name: str, query: str, top_k: int = 5):
|
||||
"""历史查询:包含所有版本"""
|
||||
results = self.collection.query(
|
||||
query_texts=[query],
|
||||
n_results=top_k,
|
||||
where={
|
||||
"status": {"$in": ["active", "superseded", "deprecated"]}
|
||||
}
|
||||
)
|
||||
return results
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 返回:报销制度_v2.pdf(active)
|
||||
- ✅ 返回:报销制度_v1.pdf(superseded)
|
||||
- ✅ 返回:临时防疫政策.pdf(deprecated)
|
||||
|
||||
### 特定版本查询
|
||||
|
||||
```python
|
||||
def query_specific_version(self, kb_name: str, query: str, version: str):
|
||||
"""查询特定版本"""
|
||||
results = self.collection.query(
|
||||
query_texts=[query],
|
||||
n_results=5,
|
||||
where={
|
||||
"version": version # 指定版本
|
||||
}
|
||||
)
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据清理策略
|
||||
|
||||
### 自动清理(可选)
|
||||
|
||||
```python
|
||||
def schedule_cleanup(kb_name: str, doc_id: str, delay_days: int = 7):
|
||||
"""
|
||||
定期清理 superseded 版本
|
||||
|
||||
策略:
|
||||
1. 保留最近 7 天的 superseded 版本(防止误操作)
|
||||
2. 7 天后自动删除向量库中的 chunks
|
||||
3. 保留版本记录(document_versions 表)
|
||||
"""
|
||||
# 7 天后执行
|
||||
schedule_task(
|
||||
task=lambda: kb_manager.delete_chunks(
|
||||
kb_name=kb_name,
|
||||
filter={
|
||||
"document_id": doc_id,
|
||||
"status": "superseded"
|
||||
}
|
||||
),
|
||||
delay=timedelta(days=delay_days)
|
||||
)
|
||||
```
|
||||
|
||||
### 手动清理
|
||||
|
||||
```python
|
||||
# API 端点
|
||||
POST /api/kb/public/cleanup
|
||||
{
|
||||
"strategy": "superseded", # 清理 superseded 版本
|
||||
"older_than_days": 30 # 超过 30 天的
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关键优势
|
||||
|
||||
### 1. 物理层与逻辑层分离
|
||||
|
||||
**物理层(documents/)**:
|
||||
- 文件可以保留(用户可能需要下载旧版)
|
||||
- 文件可以删除(不影响向量库)
|
||||
- 灵活管理
|
||||
|
||||
**逻辑层(向量库)**:
|
||||
- 通过 metadata 控制可见性
|
||||
- 不需要物理删除
|
||||
- 支持快速恢复
|
||||
|
||||
### 2. 查询时自动过滤
|
||||
|
||||
```python
|
||||
# 用户无感知,系统自动过滤废止文档
|
||||
results = query_kb(kb_name, query) # 只返回 active 文档
|
||||
```
|
||||
|
||||
### 3. 历史可追溯
|
||||
|
||||
```python
|
||||
# 管理员可以查询历史版本
|
||||
history = get_document_history(kb_name, doc_id)
|
||||
# 返回:v1 (superseded), v2 (active)
|
||||
```
|
||||
|
||||
### 4. 操作可逆
|
||||
|
||||
```python
|
||||
# 废止操作可以恢复
|
||||
deprecate_document(kb_name, doc_id) # 废止
|
||||
restore_document(kb_name, doc_id) # 恢复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 存储成本分析
|
||||
|
||||
### 短期(7天内)
|
||||
|
||||
```
|
||||
向量库大小 = active 文档 + superseded 文档(7天内)
|
||||
存储成本 = 1.2x ~ 1.5x(相比只保留 active)
|
||||
```
|
||||
|
||||
### 长期(7天后自动清理)
|
||||
|
||||
```
|
||||
向量库大小 = active 文档
|
||||
存储成本 = 1.0x(与只保留 active 相同)
|
||||
```
|
||||
|
||||
### 版本记录(永久保留)
|
||||
|
||||
```
|
||||
document_versions 表大小 = 每个版本 ~1KB
|
||||
100 个文档 × 平均 3 个版本 = 300KB(可忽略)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
### 方案 C 的核心特点
|
||||
|
||||
1. **物理层**:documents/ 目录可以保留或删除文件,不影响向量库
|
||||
2. **逻辑层**:通过 status 字段控制文档可见性
|
||||
3. **检索层**:查询时自动过滤非 active 文档
|
||||
4. **历史追溯**:保留版本记录,支持审计
|
||||
5. **操作可逆**:废止/恢复操作不删除数据
|
||||
6. **自动清理**:定期清理旧版本,控制存储成本
|
||||
|
||||
### 与现有方案的区别
|
||||
|
||||
| 方面 | 当前方案 | 方案 C |
|
||||
|------|---------|--------|
|
||||
| 文档更新 | 删除旧 chunks,添加新 chunks | 标记旧 chunks 为 superseded,添加新 chunks |
|
||||
| 文档废止 | 删除 chunks | 标记 chunks 为 deprecated |
|
||||
| 历史追溯 | ❌ 无法查询旧版本 | ✅ 可以查询任意版本 |
|
||||
| 操作可逆 | ❌ 删除后无法恢复 | ✅ 废止后可以恢复 |
|
||||
| 存储成本 | 低 | 中(短期略高,长期相同) |
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建时间**: 2026-04-20
|
||||
**维护者**: RAG 服务开发组
|
||||
635
docs/出题批卷系统设计.md
635
docs/出题批卷系统设计.md
@@ -1,635 +0,0 @@
|
||||
# 出题批卷系统设计
|
||||
|
||||
> **文档类型**: 系统设计文档
|
||||
> **创建日期**: 2026-04-10
|
||||
> **最后更新**: 2026-06-04
|
||||
> **状态**: 已实施
|
||||
|
||||
---
|
||||
|
||||
## 一、系统概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
出题批卷系统是 RAG 知识库系统的扩展模块,支持:
|
||||
- **按文件出题**:根据指定文档自动生成题目
|
||||
- **智能批卷**:支持选择题、填空题、简答题的自动批改
|
||||
- **溯源追踪**:每道题可追溯到来源文件和知识片段
|
||||
|
||||
### 1.2 模块结构
|
||||
|
||||
```
|
||||
exam_pkg/ # 考试系统
|
||||
├── generator.py # 出题逻辑(按文件/按主题生成题目)
|
||||
├── grader.py # 批卷逻辑(选择题/填空题/简答题批改)
|
||||
├── manager.py # 试卷管理与协调逻辑
|
||||
├── api.py # Flask Blueprint (exam_bp)
|
||||
└── local_db.py # 本地题库 (SQLite)
|
||||
```
|
||||
|
||||
**认证模块**: `auth/gateway.py` - 网关认证
|
||||
|
||||
---
|
||||
|
||||
## 二、出题系统设计
|
||||
|
||||
### 2.1 按文件出题接口
|
||||
|
||||
**接口路径**:`POST /exam/generate-by-file`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"file_path": "public/产品手册.pdf",
|
||||
"collection": "public_kb",
|
||||
"choice_count": 5,
|
||||
"blank_count": 2,
|
||||
"short_answer_count": 2,
|
||||
"difficulty": 3,
|
||||
"choice_score": 2,
|
||||
"blank_score": 3
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `file_path` | string | ✅ | - | 文件路径 |
|
||||
| `collection` | string | ✅ | - | 向量库名称 |
|
||||
| `choice_count` | int | ❌ | 3 | 选择题数量 |
|
||||
| `blank_count` | int | ❌ | 2 | 填空题数量 |
|
||||
| `short_answer_count` | int | ❌ | 2 | 简答题数量 |
|
||||
| `difficulty` | int | ❌ | 3 | 难度等级 (1-5) |
|
||||
| `choice_score` | int | ❌ | 2 | 每道选择题分值 |
|
||||
| `blank_score` | int | ❌ | 3 | 每道填空题分值 |
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"exam_id": "uuid-xxxx-xxxx",
|
||||
"source_file": {
|
||||
"path": "public/产品手册.pdf",
|
||||
"collection": "public_kb"
|
||||
},
|
||||
"choice_questions": [
|
||||
{
|
||||
"id": "q_choice_001",
|
||||
"content": "根据保密制度,公司最高机密的处理原则是什么?",
|
||||
"options": ["A. 可向客户透露", "B. 严禁外传", "C. 部门内共享", "D. 仅领导知晓"],
|
||||
"answer": "B",
|
||||
"analysis": "根据保密制度第1条规定...",
|
||||
"knowledge_points": ["保密制度", "信息安全"],
|
||||
"difficulty": 2,
|
||||
"score": 2,
|
||||
"source_file": "public/产品手册.pdf",
|
||||
"source_snippet": "该题依据的知识片段..."
|
||||
}
|
||||
],
|
||||
"blank_questions": [...],
|
||||
"short_answer_questions": [...],
|
||||
"total_count": 9,
|
||||
"total_score": 22,
|
||||
"generated_at": "2026-04-10T14:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 试卷状态流程
|
||||
|
||||
```
|
||||
生成试卷 → draft (草稿)
|
||||
↓
|
||||
提交审核 → pending_review (待审核)
|
||||
↓
|
||||
管理员审核 → approved (通过) / rejected (驳回)
|
||||
↓
|
||||
学生答题 → 批阅 → 生成报告
|
||||
```
|
||||
|
||||
**状态说明**:
|
||||
| 状态 | 说明 | 可见范围 |
|
||||
|------|------|----------|
|
||||
| `draft` | 草稿,刚生成尚未提交审核 | 创建者可见 |
|
||||
| `pending_review` | 待审核,已提交等待管理员审核 | 管理员可见 |
|
||||
| `approved` | 已通过,可用于学生答题 | 所有用户可见 |
|
||||
| `rejected` | 已驳回,不可使用 | 创建者可见 |
|
||||
|
||||
---
|
||||
|
||||
## 三、题目格式规范
|
||||
|
||||
### 3.1 选择题
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "q_choice_001",
|
||||
"content": "根据保密制度,公司最高机密的处理原则是什么?",
|
||||
"options": [
|
||||
"A. 可向客户透露",
|
||||
"B. 严禁外传",
|
||||
"C. 部门内共享",
|
||||
"D. 仅领导知晓"
|
||||
],
|
||||
"answer": "B",
|
||||
"analysis": "根据保密制度第1条规定,公司最高机密严禁外传,仅限特定人员知晓。",
|
||||
"knowledge_points": ["保密制度", "信息安全"],
|
||||
"difficulty": 2,
|
||||
"score": 2,
|
||||
"source_file": "public/产品手册.pdf",
|
||||
"source_snippet": "原文相关片段..."
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | ✅ | 题目唯一标识 |
|
||||
| `content` | string | ✅ | 题干内容 |
|
||||
| `options` | array | ✅ | 选项列表,格式为 `["A. 选项内容", ...]` |
|
||||
| `answer` | string | ✅ | 正确答案,单个字母(如 "A", "B") |
|
||||
| `analysis` | string | ✅ | 答案解析 |
|
||||
| `knowledge_points` | array | ❌ | 知识点标签 |
|
||||
| `difficulty` | int | ❌ | 难度等级 1-5,默认 3 |
|
||||
| `score` | int | ✅ | 题目分值 |
|
||||
| `source_file` | string | ❌ | 来源文件路径 |
|
||||
| `source_snippet` | string | ❌ | 来源文本片段 |
|
||||
|
||||
### 3.2 填空题
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "q_blank_001",
|
||||
"content": "公司财务报表应在每季度结束后______天内提交。",
|
||||
"answer": "15",
|
||||
"analysis": "根据财务管理制度第5条规定,季度报表需在季后15天内提交。",
|
||||
"knowledge_points": ["财务管理"],
|
||||
"difficulty": 3,
|
||||
"score": 3
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | ✅ | 题目唯一标识 |
|
||||
| `content` | string | ✅ | 题干内容,空缺处用 `______` 表示 |
|
||||
| `answer` | string | ✅ | 正确答案 |
|
||||
| `analysis` | string | ✅ | 答案解析 |
|
||||
| `knowledge_points` | array | ❌ | 知识点标签 |
|
||||
| `difficulty` | int | ❌ | 难度等级 1-5 |
|
||||
| `score` | int | ✅ | 题目分值 |
|
||||
|
||||
### 3.3 简答题
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "q_short_001",
|
||||
"content": "简述公司数据安全的三道防线。",
|
||||
"reference_answer": {
|
||||
"points": [
|
||||
{"point": "技术防线(防火墙、加密、访问控制等)", "score": 3},
|
||||
{"point": "制度防线(安全规定、审批流程、应急预案)", "score": 3},
|
||||
{"point": "人员防线(安全培训、意识教育、考核机制)", "score": 4}
|
||||
],
|
||||
"total_score": 10
|
||||
},
|
||||
"analysis": "评分要点说明...",
|
||||
"knowledge_points": ["数据安全"],
|
||||
"difficulty": 4,
|
||||
"score": 10
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | ✅ | 题目唯一标识 |
|
||||
| `content` | string | ✅ | 题干内容 |
|
||||
| `reference_answer` | object | ✅ | 参考答案,包含评分要点 |
|
||||
| `reference_answer.points` | array | ✅ | 得分点列表 |
|
||||
| `reference_answer.points[].point` | string | ✅ | 得分点描述 |
|
||||
| `reference_answer.points[].score` | int | ✅ | 该得分点分值 |
|
||||
| `analysis` | string | ❌ | 整体解析 |
|
||||
| `knowledge_points` | array | ❌ | 知识点标签 |
|
||||
| `difficulty` | int | ❌ | 难度等级 1-5 |
|
||||
| `score` | int | ✅ | 题目总分值 |
|
||||
|
||||
---
|
||||
|
||||
## 四、批卷系统设计
|
||||
|
||||
### 4.1 批卷输入格式
|
||||
|
||||
**接口路径**:`POST /exam/grade-from-mysql`
|
||||
|
||||
**当前格式(完整字段)**:
|
||||
```json
|
||||
{
|
||||
"exam_id": "uuid-xxxx-xxxx",
|
||||
"student_id": "STU_2023001",
|
||||
"student_name": "张三",
|
||||
"answers": [
|
||||
{
|
||||
"question_id": "q_choice_001",
|
||||
"question_type": "choice",
|
||||
"question_content": "根据保密制度,公司最高机密的处理原则是什么?",
|
||||
"options": ["A. 可向客户透露", "B. 严禁外传", "C. 部门内共享", "D. 仅领导知晓"],
|
||||
"correct_answer": "B",
|
||||
"max_score": 2,
|
||||
"student_answer": "B"
|
||||
},
|
||||
{
|
||||
"question_id": "q_blank_001",
|
||||
"question_type": "blank",
|
||||
"question_content": "公司财务报表应在每季度结束后______天内提交。",
|
||||
"correct_answer": "15",
|
||||
"max_score": 3,
|
||||
"student_answer": "10"
|
||||
},
|
||||
{
|
||||
"question_id": "q_short_001",
|
||||
"question_type": "short_answer",
|
||||
"question_content": "简述公司数据安全的三道防线。",
|
||||
"correct_answer": "{\"points\":[{\"point\":\"技术防线\",\"score\":3},{\"point\":\"制度防线\",\"score\":3},{\"point\":\"人员防线\",\"score\":4}]}",
|
||||
"max_score": 10,
|
||||
"student_answer": "第一道是技术防护,包括防火墙和加密;第二道是制度管理;第三道是员工培训。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**优化后格式(最小字段)**:
|
||||
```json
|
||||
{
|
||||
"exam_id": "uuid",
|
||||
"student_id": "STU_001",
|
||||
"student_name": "张三",
|
||||
"answers": [
|
||||
{
|
||||
"question_id": "q_choice_001",
|
||||
"question_type": "choice",
|
||||
"student_answer": "B"
|
||||
},
|
||||
{
|
||||
"question_id": "q_blank_001",
|
||||
"question_type": "blank",
|
||||
"student_answer": "15"
|
||||
},
|
||||
{
|
||||
"question_id": "q_short_001",
|
||||
"question_type": "short_answer",
|
||||
"student_answer": "第一道是技术防护..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 批卷输出格式
|
||||
|
||||
```json
|
||||
{
|
||||
"report_id": "report-uuid-xxxx",
|
||||
"exam_id": "uuid-xxxx-xxxx",
|
||||
"student_id": "STU_2023001",
|
||||
"student_name": "张三",
|
||||
"total_score": 12,
|
||||
"max_score": 15,
|
||||
"score_rate": 80.0,
|
||||
"graded_at": "2026-04-12T14:30:00",
|
||||
"results": [
|
||||
{
|
||||
"question_id": "q_choice_001",
|
||||
"question_type": "choice",
|
||||
"correct": true,
|
||||
"score": 2,
|
||||
"max_score": 2,
|
||||
"student_answer": "B",
|
||||
"correct_answer": "B",
|
||||
"feedback": "回答正确!"
|
||||
},
|
||||
{
|
||||
"question_id": "q_blank_001",
|
||||
"question_type": "blank",
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"max_score": 3,
|
||||
"student_answer": "10",
|
||||
"correct_answer": "15",
|
||||
"feedback": "正确答案是15天,请复习财务管理制度。"
|
||||
},
|
||||
{
|
||||
"question_id": "q_short_001",
|
||||
"question_type": "short_answer",
|
||||
"score": 8,
|
||||
"max_score": 10,
|
||||
"student_answer": "第一道是技术防护...",
|
||||
"score_details": [
|
||||
{"point": "技术防线", "earned": 3, "max": 3},
|
||||
{"point": "制度防线", "earned": 2, "max": 3},
|
||||
{"point": "人员防线", "earned": 3, "max": 4}
|
||||
],
|
||||
"feedback": "整体回答较好,制度防线描述不够具体。",
|
||||
"highlights": ["技术防线表述准确"],
|
||||
"shortcomings": ["制度防线未具体说明"],
|
||||
"suggestions": ["建议补充具体的制度名称"]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"strengths": ["选择题掌握较好", "简答题要点覆盖全面"],
|
||||
"weaknesses": ["填空题记忆不准确"],
|
||||
"recommendations": ["重点复习财务管理制度第3章"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 批改流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 批量批改流程 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 前端传入 answers (最小字段) │
|
||||
│ └─ 只有 question_id + question_type + student_answer │
|
||||
│ │
|
||||
│ 2. 后端查询题目详情 │
|
||||
│ └─ 从数据库/缓存获取 correct_answer, max_score, content │
|
||||
│ │
|
||||
│ 3. 按题型分组 │
|
||||
│ ├─ choice 组 → 批量调用 Dify 代码执行节点 │
|
||||
│ └─ blank/short_answer 组 → 批量调用 Dify LLM 节点 │
|
||||
│ │
|
||||
│ 4. 合并结果返回 │
|
||||
│ └─ 统一格式返回所有批改结果 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库设计
|
||||
|
||||
### 5.1 题目表 (questions)
|
||||
|
||||
```sql
|
||||
CREATE TABLE questions (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 题目ID(UUID)
|
||||
question_type ENUM('choice', 'blank', 'short_answer') NOT NULL,
|
||||
content TEXT NOT NULL, -- 题干内容
|
||||
options JSON, -- 选择题选项(JSON数组)
|
||||
correct_answer TEXT NOT NULL, -- 正确答案
|
||||
analysis TEXT, -- 解析
|
||||
knowledge_points JSON, -- 知识点(JSON数组)
|
||||
difficulty TINYINT DEFAULT 3, -- 难度(1-5)
|
||||
score INT NOT NULL, -- 分值
|
||||
|
||||
-- 溯源字段(核心)
|
||||
source_file VARCHAR(255) NOT NULL, -- 来源文件路径
|
||||
source_collection VARCHAR(64) NOT NULL, -- 来源向量库
|
||||
source_snippet TEXT, -- 来源知识片段
|
||||
source_hash VARCHAR(64), -- 文件哈希(用于检测文件变更)
|
||||
|
||||
-- 审核状态
|
||||
status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending',
|
||||
reviewed_by VARCHAR(64),
|
||||
reviewed_at DATETIME,
|
||||
|
||||
-- 元数据
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(64),
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_source_file (source_file),
|
||||
INDEX idx_source_collection (source_collection),
|
||||
INDEX idx_question_type (question_type),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 试卷表 (exams)
|
||||
|
||||
```sql
|
||||
CREATE TABLE exams (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 试卷ID
|
||||
name VARCHAR(255) NOT NULL, -- 试卷名称
|
||||
description TEXT, -- 描述
|
||||
total_score INT NOT NULL, -- 总分
|
||||
total_count INT NOT NULL, -- 题目总数
|
||||
duration INT DEFAULT 60, -- 考试时长(分钟)
|
||||
|
||||
-- 状态
|
||||
status ENUM('draft', 'pending', 'published', 'archived') DEFAULT 'draft',
|
||||
published_at DATETIME,
|
||||
|
||||
-- 元数据
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(64),
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.3 试卷题目关联表 (exam_questions)
|
||||
|
||||
```sql
|
||||
CREATE TABLE exam_questions (
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
question_id VARCHAR(64) NOT NULL,
|
||||
question_order INT NOT NULL, -- 题目顺序
|
||||
|
||||
PRIMARY KEY (exam_id, question_id),
|
||||
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_exam_id (exam_id),
|
||||
INDEX idx_question_id (question_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.4 学生答卷表 (student_answers)
|
||||
|
||||
```sql
|
||||
CREATE TABLE student_answers (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
student_id VARCHAR(64) NOT NULL,
|
||||
student_name VARCHAR(100),
|
||||
|
||||
question_id VARCHAR(64) NOT NULL,
|
||||
question_type ENUM('choice', 'blank', 'short_answer') NOT NULL,
|
||||
student_answer TEXT NOT NULL, -- 学生答案
|
||||
|
||||
-- 批阅结果
|
||||
score INT DEFAULT 0,
|
||||
max_score INT NOT NULL,
|
||||
feedback TEXT,
|
||||
score_details JSON, -- 评分详情(JSON)
|
||||
|
||||
-- 元数据
|
||||
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
graded_at DATETIME,
|
||||
|
||||
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_exam_student (exam_id, student_id),
|
||||
INDEX idx_student_id (student_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.5 批阅报告表 (grade_reports)
|
||||
|
||||
```sql
|
||||
CREATE TABLE grade_reports (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
student_id VARCHAR(64) NOT NULL,
|
||||
student_name VARCHAR(100),
|
||||
|
||||
total_score INT NOT NULL,
|
||||
max_score INT NOT NULL,
|
||||
score_rate DECIMAL(5,2),
|
||||
|
||||
-- 整卷分析(可选)
|
||||
analysis JSON, -- AI生成的整卷分析
|
||||
|
||||
graded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_exam_id (exam_id),
|
||||
INDEX idx_student_id (student_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、文件修改联动
|
||||
|
||||
### 6.1 触发条件
|
||||
|
||||
当文件被修改或删除时,通过 `source_file` 字段查找受影响的题目。
|
||||
|
||||
### 6.2 联动逻辑
|
||||
|
||||
```sql
|
||||
-- 查找受影响的题目
|
||||
SELECT id, source_file, source_hash
|
||||
FROM questions
|
||||
WHERE source_file = 'public/产品手册.pdf';
|
||||
|
||||
-- 如果文件哈希变更,标记题目需要重新审核
|
||||
UPDATE questions
|
||||
SET status = 'pending',
|
||||
source_hash = 'new_hash_value'
|
||||
WHERE source_file = 'public/产品手册.pdf';
|
||||
```
|
||||
|
||||
### 6.3 联动接口
|
||||
|
||||
**接口路径**:`POST /exam/check-file-changes`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"file_path": "public/产品手册.pdf",
|
||||
"new_hash": "新的文件哈希"
|
||||
}
|
||||
```
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_path": "public/产品手册.pdf",
|
||||
"affected_questions": ["q_uuid_001", "q_uuid_002", ...],
|
||||
"count": 15,
|
||||
"recommendation": "建议重新生成该文件的题目"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、API 接口汇总
|
||||
|
||||
### 7.1 出题接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/exam/generate` | POST | 按主题生成试卷 |
|
||||
| `/exam/generate-by-file` | POST | 按文件生成题目 |
|
||||
| `/exam/list` | GET | 获取试卷列表 |
|
||||
| `/exam/<exam_id>` | GET | 获取试卷详情 |
|
||||
| `/exam/<exam_id>` | PUT | 更新试卷 |
|
||||
| `/exam/<exam_id>` | DELETE | 删除试卷 |
|
||||
| `/exam/<exam_id>/submit` | POST | 提交审核 |
|
||||
| `/exam/<exam_id>/review` | POST | 审核试卷(仅管理员) |
|
||||
| `/exam/by-file` | GET | 查询文件关联的题目 |
|
||||
|
||||
### 7.2 批卷接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/exam/grade-from-mysql` | POST | 基于传入题目批卷 |
|
||||
| `/exam/<exam_id>/grade` | POST | 批阅试卷 |
|
||||
| `/exam/report/<report_id>` | GET | 获取批阅报告 |
|
||||
| `/exam/report/list` | GET | 批阅报告列表 |
|
||||
|
||||
### 7.3 题库接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/exam/questions/search` | GET | 搜索题目 |
|
||||
|
||||
### 7.4 联动接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/exam/check-file-changes` | POST | 检查文件变更影响的题目 |
|
||||
|
||||
---
|
||||
|
||||
## 八、错误处理
|
||||
|
||||
### 8.1 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "错误类型",
|
||||
"message": "详细错误信息",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 常见错误码
|
||||
|
||||
| HTTP状态码 | 错误类型 | 说明 |
|
||||
|-----------|---------|------|
|
||||
| 400 | bad_request | 请求参数格式错误 |
|
||||
| 401 | unauthorized | 未认证 |
|
||||
| 403 | forbidden | 权限不足 |
|
||||
| 404 | not_found | 资源不存在 |
|
||||
| 500 | internal_error | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 九、注意事项
|
||||
|
||||
1. **题目ID生成**:使用UUID,确保全局唯一
|
||||
2. **文件哈希**:用于检测文件变更,建议使用MD5或SHA256
|
||||
3. **批量批卷性能**:简答题批卷耗时,建议使用异步处理或并发
|
||||
4. **错误处理**:批卷失败时返回默认结果,不影响整体流程
|
||||
5. **认证方式**:出题系统使用 JWT Bearer Token 认证
|
||||
|
||||
---
|
||||
|
||||
## 十、变更记录
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|---------|
|
||||
| 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 | 初始版本:按文件出题功能设计 |
|
||||
@@ -4,9 +4,22 @@
|
||||
|
||||
| 接口 | 方法 | 功能 | 超时建议 |
|
||||
|------|------|------|----------|
|
||||
| `/exam/generate` | POST | 生成题目 | 120秒 |
|
||||
| `/exam/generate` | POST | 生成题目(手动指定题型数量) | 读取超时至少 650 秒 |
|
||||
| `/exam/generate-smart` | POST | 生成题目(AI 自动分析文档结构出题) | 读取超时至少 650 秒 |
|
||||
| `/exam/grade` | POST | 批阅答案 | 60秒 |
|
||||
|
||||
> **2026-07-19 变更**:
|
||||
> 1. 文件未完成向量化时不再返回“成功但 0 道题”,统一返回 HTTP 409、`FILE_NOT_INDEXED`、业务状态码 `4016`
|
||||
> 2. `/exam/generate-smart` 会在 AI 分析前检查文件状态,未索引时不会调用模型
|
||||
> 3. 正常成功响应保持不变;Gunicorn 出题超时调整为 600 秒
|
||||
>
|
||||
> **2026-07-03 变更**:
|
||||
> 1. 移除出题总题数 20 道上限,超过 50 道时返回警告(不影响出题)
|
||||
> 2. 填空题批阅增加 `student_answer` 格式校验(必须为字符串列表)
|
||||
> 3. 认证错误状态码修正(仅 `DEV_MODE=true` 时生效,见 [认证说明](#认证模式说明))
|
||||
>
|
||||
> **2026-06-05 变更**:`/exam/grade` 请求字段 `question_content` 已重命名为 `content`(破坏性变更)。详见 [出题批阅接口变更说明(2026-06-05)](出题批阅接口变更说明(2026-06-05).md)。
|
||||
|
||||
---
|
||||
|
||||
## 二、出题接口
|
||||
@@ -24,9 +37,10 @@ Content-Type: application/json
|
||||
|------|------|------|------|
|
||||
| `file_path` | string | ✅ | 文档路径(相对于 documents 目录) |
|
||||
| `collection` | string 或 string[] | ✅ | 向量库名称,支持数组(按优先级顺序检索,找到文件即停止) |
|
||||
| `question_types` | object | ✅ | 题型及数量 |
|
||||
| `question_types` | object | ✅ | 题型及数量,**无上限**(超过 50 道时服务端返回警告但仍正常出题) |
|
||||
| `difficulty` | int | ❌ | 难度等级 1-5,默认 3 |
|
||||
| `request_id` | string | ❌ | 请求ID,相同ID返回缓存结果(幂等性) |
|
||||
| `request_id` | string | ❌ | 请求追踪 ID,服务端原样返回;当前不提供幂等缓存 |
|
||||
| `exclude_stems` | string[] | ❌ | 排除已有题目的题干列表,避免重复出题(最多 100 条) |
|
||||
|
||||
**请求示例:**
|
||||
|
||||
@@ -42,7 +56,7 @@ Content-Type: application/json
|
||||
"subjective": 1
|
||||
},
|
||||
"difficulty": 3,
|
||||
"request_id": "uuid-for-idempotency"
|
||||
"request_id": "uuid-for-tracing"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -61,12 +75,15 @@ Content-Type: application/json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2011,
|
||||
"message": "出题完成",
|
||||
"status_code": 2020,
|
||||
"message": "出题成功",
|
||||
"data": {
|
||||
"request_id": "uuid-xxx",
|
||||
"total": 10,
|
||||
"source_chunks_used": 25,
|
||||
"requested_types": {"single_choice": 3, "fill_blank": 2},
|
||||
"actual_types": {"single_choice": 3, "fill_blank": 2},
|
||||
"warnings": [],
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "single_choice",
|
||||
@@ -86,7 +103,8 @@ Content-Type: application/json
|
||||
},
|
||||
"source_trace": {
|
||||
"document_name": "薪酬制度.docx",
|
||||
"chunks_count": 3
|
||||
"page_numbers": [1],
|
||||
"sources": [{"chunk_id": "...", "page": 1, "section": "...", "snippet": "..."}]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -94,17 +112,29 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
**⚠️ 文件未完成向量化时的响应:**
|
||||
|
||||
当 `file_path` 在指定 `collection` 中找不到时,两个出题接口都返回 HTTP 409:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"status": "failed",
|
||||
"error_code": "FILE_NOT_FOUND",
|
||||
"message": "文件不存在"
|
||||
"error_code": "FILE_NOT_INDEXED",
|
||||
"status_code": 4016,
|
||||
"message": "文件未向量化: 文件 1.docx 未在向量库中找到,可能正在向量化或未上传",
|
||||
"data": {
|
||||
"request_id": "uuid-for-tracing",
|
||||
"file_status": "not_found",
|
||||
"chunk_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **后端处理**:收到 HTTP 409 且 `error_code=FILE_NOT_INDEXED` 时,提示用户文件尚未完成向量化;不要把该响应当作成功题目入库,也不要立即高频重试。
|
||||
>
|
||||
> 当前版本要求后端传入有效的 `collection`。不存在的向量库暂时也可能表现为 `FILE_NOT_INDEXED`。
|
||||
|
||||
### 2.3 返回字段说明
|
||||
|
||||
**RAG 服务返回的字段(后端需存储):**
|
||||
@@ -145,11 +175,64 @@ Content-Type: application/json
|
||||
|
||||
| 错误码 | HTTP | 说明 |
|
||||
|--------|------|------|
|
||||
| `FILE_NOT_FOUND` | 404 | 指定文件不存在 |
|
||||
| `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 |
|
||||
| `NO_CONTENT` | 400 | 文件内容为空,无法出题 |
|
||||
| `LLM_ERROR` | 500 | LLM 调用失败 |
|
||||
| `PARSE_ERROR` | 500 | 解析失败 |
|
||||
| `MISSING_PARAMS` | 400 | 缺少必填参数(file_path / collection / question_types) |
|
||||
| `INVALID_PARAMS` | 400 | 参数校验失败(题型无效、难度越界、总题数≤0 等) |
|
||||
| `FILE_NOT_INDEXED` | 409 | 文件未完成向量化,业务状态码为 `4016` |
|
||||
| `EXAM_ERROR` | 500 | 出题过程异常(LLM 调用失败、解析错误等) |
|
||||
|
||||
> **注意**:即使文件已索引,模型也可能少生成题目。成功响应仍应检查 `data.total` 和 `data.warnings`;部分生成属于成功响应,不应与 `FILE_NOT_INDEXED` 混淆。
|
||||
|
||||
### 2.6 智能出题接口(/exam/generate-smart)
|
||||
|
||||
与 `/exam/generate` 的区别:不需要传 `question_types`,AI 自动分析文档后决定题型和数量。
|
||||
|
||||
```
|
||||
POST /exam/generate-smart
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `file_path` | string | ✅ | 文档路径 |
|
||||
| `collection` | string 或 string[] | ✅ | 向量库名称 |
|
||||
| `difficulty` | int | ❌ | 难度等级 1-5,默认 3 |
|
||||
| `max_total` | int | ❌ | AI 出题总数上限,不传则不限制 |
|
||||
| `exclude_stems` | string[] | ❌ | 排除已有题目的题干列表 |
|
||||
| `request_id` | string | ❌ | 请求ID |
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "111/卷烟货源组织管理办法.docx",
|
||||
"collection": "111",
|
||||
"difficulty": 3,
|
||||
"max_total": 10,
|
||||
"request_id": "smart-uuid-xxx"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:** 成功时与 `/exam/generate` 格式相同,并额外包含 `ai_analysis`;文件未索引时同样返回 HTTP 409,且不会执行 AI 分析。
|
||||
|
||||
---
|
||||
|
||||
### 认证模式说明
|
||||
|
||||
RAG 服务支持两种认证模式,由环境变量 `DEV_MODE` 控制:
|
||||
|
||||
| 模式 | DEV_MODE | 认证行为 |
|
||||
|------|----------|----------|
|
||||
| **生产模式** | `false` | 认证网关**直接放行**,不校验 Header。权限由后端服务完全控制 |
|
||||
| **开发模式** | `true` | 支持 `Authorization: Bearer mock-token-admin` 模拟登录,无 Header 时使用默认测试用户 |
|
||||
|
||||
**当前服务器为生产模式**(`DEV_MODE=false`),因此:
|
||||
- `Authorization` Header 会被忽略
|
||||
- 不会返回 `401 UNAUTHORIZED` 或 `403 FORBIDDEN`
|
||||
- 后端服务应自行完成用户认证和权限校验,再调用 RAG 出题/批阅接口
|
||||
|
||||
> 开发模式下,认证错误会返回正确的业务状态码(`UNAUTHORIZED=4001`、`FORBIDDEN=4002`),HTTP 状态码分别为 401/403。
|
||||
|
||||
---
|
||||
|
||||
@@ -175,10 +258,16 @@ Content-Type: application/json
|
||||
|------|------|------|------|------|
|
||||
| `question_id` | string | ✅ | 题目ID | 后端数据库 |
|
||||
| `question_type` | string | ✅ | 题型 | 后端数据库 |
|
||||
| `question_content` | object | ✅ | 题目内容(含正确答案) | 后端数据库 |
|
||||
| `content` | object | ✅ | 题目内容(含正确答案) | 后端数据库 |
|
||||
| `student_answer` | any | ✅ | 学生答案 | 学生提交 |
|
||||
| `max_score` | number | ✅ | 满分 | 后端数据库 |
|
||||
|
||||
> **填空题 `student_answer` 格式要求**(2026-07-03 新增校验):
|
||||
> - 必须为 **字符串数组**,如 `["答案1", "答案2"]`
|
||||
> - 不是列表(如传了字符串 `"答案1"`)→ 该题直接得 0 分,`grading_status` 为 `"failed"`
|
||||
> - 列表中某项不是字符串(如传了数字 `123`)→ 该题直接得 0 分
|
||||
> - 校验失败不影响其他题目的正常批阅
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```json
|
||||
@@ -188,7 +277,7 @@ Content-Type: application/json
|
||||
{
|
||||
"question_id": "q-001",
|
||||
"question_type": "single_choice",
|
||||
"question_content": {
|
||||
"content": {
|
||||
"stem": "根据公司规定,员工薪资由哪几部分组成?",
|
||||
"data": {
|
||||
"options": [
|
||||
@@ -206,7 +295,7 @@ Content-Type: application/json
|
||||
{
|
||||
"question_id": "q-002",
|
||||
"question_type": "multiple_choice",
|
||||
"question_content": {
|
||||
"content": {
|
||||
"stem": "以下哪些属于绩效奖金的评定因素?",
|
||||
"data": {
|
||||
"options": [
|
||||
@@ -224,7 +313,7 @@ Content-Type: application/json
|
||||
{
|
||||
"question_id": "q-003",
|
||||
"question_type": "true_false",
|
||||
"question_content": {
|
||||
"content": {
|
||||
"stem": "公司规定员工每月绩效奖金上限为工资的20%。",
|
||||
"answer": "F"
|
||||
},
|
||||
@@ -234,7 +323,7 @@ Content-Type: application/json
|
||||
{
|
||||
"question_id": "q-004",
|
||||
"question_type": "fill_blank",
|
||||
"question_content": {
|
||||
"content": {
|
||||
"stem": "员工薪资由___、___和___三部分组成。",
|
||||
"data": {"blank_count": 3},
|
||||
"answer": [["基本工资"], ["绩效奖金", "绩效"], ["津贴补贴", "补贴"]]
|
||||
@@ -245,7 +334,7 @@ Content-Type: application/json
|
||||
{
|
||||
"question_id": "q-005",
|
||||
"question_type": "subjective",
|
||||
"question_content": {
|
||||
"content": {
|
||||
"stem": "请简述公司薪酬制度的核心原则。",
|
||||
"data": {
|
||||
"scoring_points": [
|
||||
@@ -363,8 +452,11 @@ Content-Type: application/json
|
||||
|
||||
| 错误码 | HTTP | 说明 |
|
||||
|--------|------|------|
|
||||
| `INVALID_ANSWER_FORMAT` | 400 | 答案格式不正确 |
|
||||
| `GRADING_ERROR` | 500 | 批阅过程出错 |
|
||||
| `MISSING_PARAMS` | 400 | 缺少 answers 字段 |
|
||||
| `INVALID_PARAMS` | 400 | answers 非数组、question_type 无效等 |
|
||||
| `GRADE_ERROR` | 500 | 批阅过程出错 |
|
||||
|
||||
> **填空题格式错误不返回错误码**:当 `student_answer` 格式不合法时,该题返回 `grading_status: "failed"` + `score: 0`,但接口整体仍返回 `success: true`。后端应检查每道题的 `grading_status` 字段。
|
||||
|
||||
---
|
||||
|
||||
@@ -397,7 +489,7 @@ Content-Type: application/json
|
||||
后端从数据库查询:
|
||||
- question_id
|
||||
- question_type
|
||||
- question_content (含正确答案)
|
||||
- content (含正确答案,原 question_content 已弃用)
|
||||
- score (满分)
|
||||
│
|
||||
▼
|
||||
@@ -434,7 +526,7 @@ Content-Type: application/json
|
||||
|------|------|------|
|
||||
| question_id | 后端数据库 | 题目唯一标识 |
|
||||
| question_type | 后端数据库 | 题型 |
|
||||
| question_content | 后端数据库 | 题目内容(含正确答案) |
|
||||
| content | 后端数据库 | 题目内容(含正确答案,原 question_content 已弃用) |
|
||||
| student_answer | 学生提交 | 学生作答 |
|
||||
| **max_score** | **后端数据库** | 满分(决定得分上限) |
|
||||
|
||||
@@ -461,8 +553,23 @@ Content-Type: application/json
|
||||
- 设置 60 秒超时
|
||||
- 主观题较多时适当延长
|
||||
|
||||
### Q5: 出题数量有上限吗?
|
||||
|
||||
2026-07-03 起,出题接口**不再限制总题数**。服务端会在请求数超过 50 道时记录警告日志,但不影响出题。建议:
|
||||
- 单次出题不超过 30 道(LLM 生成耗时与题数成正比,30 道约需 5-8 分钟)
|
||||
- 超过 30 道建议分批调用
|
||||
- 后端 HTTP 客户端读取超时设置为至少 650 秒
|
||||
|
||||
### Q6: 填空题学生答案传错了会怎样?
|
||||
|
||||
2026-07-03 起,填空题增加了格式校验:
|
||||
- `student_answer` 不是列表 → 该题得 0 分,`grading_status = "failed"`
|
||||
- 列表中某项不是字符串 → 该题得 0 分
|
||||
- 校验失败不影响同一请求中其他题目的批阅
|
||||
- 接口整体仍返回 `success: true`,需检查每道题的 `grading_status`
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.1
|
||||
**更新时间**: 2026-05-17
|
||||
**文档版本**: v1.3
|
||||
**更新时间**: 2026-07-19
|
||||
**相关文档**: [后端对接规范.md](./后端对接规范.md)
|
||||
|
||||
190
docs/出题系统测试报告.md
Normal file
190
docs/出题系统测试报告.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 出题系统测试报告
|
||||
|
||||
**测试日期**: 2026-06-22
|
||||
**测试环境**: mimo-v2.5(关闭推理模式)、2.docx(文明吸烟环境建设标准)
|
||||
**测试方式**: curl 接口测试 + 代码审查
|
||||
|
||||
---
|
||||
|
||||
## 一、测试总览
|
||||
|
||||
| # | 测试项 | 接口 | 结果 | 耗时 |
|
||||
|---|--------|------|------|------|
|
||||
| 1 | 参数出题 | POST /exam/generate | ✅ 成功 | ~87s (4题) |
|
||||
| 2 | AI一键出题 | POST /exam/generate-smart | ⚠️ 成功但有上限失控 | ~244s (50题) |
|
||||
| 3 | 偏门题型(纯主观) | POST /exam/generate | ✅ 成功 | ~87s (5题) |
|
||||
| 4 | 偏门题型(多选+填空) | POST /exam/generate | ✅ 成功 | ~60s (6题) |
|
||||
| 5 | 跨调用去重 | POST /exam/generate + exclude_stems | ✅ 去重有效 | ~60s (5题) |
|
||||
| 6 | 批题(4题型混合) | POST /exam/grade | ✅ 全部成功 | ~30s |
|
||||
|
||||
---
|
||||
|
||||
## 二、测试详情
|
||||
|
||||
### Test 1: 参数出题 (single_choice:2, fill_blank:1, true_false:1)
|
||||
- **结果**: 4题全部生成成功,题型匹配
|
||||
- **问题**:
|
||||
- ❌ 题目多样性不足:4题中3题考同一个知识点("标准的解释机构")
|
||||
- ❌ source_trace 数据缺失(chunk_id=?, page=?)
|
||||
|
||||
### Test 2: AI一键出题 (generate-smart)
|
||||
- **AI分析**: 检测到54个知识点,推荐50道题(20单选+15判断+10多选+5填空)
|
||||
- **结果**: 50题全部生成,12个不同章节,0题干重复
|
||||
- **问题**:
|
||||
- ❌ **P0 上限失控**: prompt 要求"不超过20",但AI返回50,代码没有 enforce 上限校验
|
||||
- ⚠️ 50题意味着20-30次LLM调用,极易触发429限流
|
||||
|
||||
### Test 3: 纯主观题 (subjective:5)
|
||||
- **结果**: 5题全部生成,覆盖不同章节,scoring_points 完整
|
||||
- **耗时**: 87秒(关闭推理模式后正常)
|
||||
|
||||
### Test 4: 多选+填空 (multiple_choice:3, fill_blank:3)
|
||||
- **结果**: 6题全部生成
|
||||
- **问题**:
|
||||
- ❌ **填空题答案格式不一致**: `content.answer` 是 `[["消费水平较高"]]` (list of list),但没有 `data.reference_answer` 字段,只有 `data.blank_count`
|
||||
- ⚠️ 与 grader 期望的格式可能不匹配
|
||||
|
||||
### Test 5: 跨调用去重 (exclude_stems)
|
||||
- **结果**: 3个排除题干全部未出现,去重有效 ✅
|
||||
|
||||
### Test 6: 批题 (grade)
|
||||
- **单选题答错**: score=0, correct=false ✅
|
||||
- **判断题答对**: score=2, correct=true ✅
|
||||
- **填空题部分对**: score=4.0 (满分4), blank_scores=[4.0] ✅
|
||||
- **主观题(低质量回答)**: score=2.0/10, 4个scoring_point逐项评分 ✅
|
||||
- **总评**: 得分率44.4%,评分合理
|
||||
|
||||
---
|
||||
|
||||
## 三、发现的问题(按严重度排序)
|
||||
|
||||
### P0 - 严重问题
|
||||
|
||||
#### 1. 🚨 AI一键出题上限失控 (generator.py:1053-1068)
|
||||
**位置**: `analyze_document_for_exam()` 函数
|
||||
**现象**: prompt 写了"所有数量之和不要超过 min(total_knowledge_points * 2, 20)",但 LLM 返回50题,代码直接采纳
|
||||
**根因**: 代码只做了"题型合法性"校验,没有对总数做上限 enforce
|
||||
```python
|
||||
# 当前代码 (generator.py:1053-1059) - 只校验单题型合法性,无总数限制
|
||||
valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective']
|
||||
question_types = {}
|
||||
for q_type in valid_types:
|
||||
count = result.get('question_types', {}).get(q_type, 0)
|
||||
if isinstance(count, int) and count >= 0:
|
||||
question_types[q_type] = count # 直接采纳,无上限
|
||||
```
|
||||
**建议**: 增加总数上限校验,超过 `max_questions`(建议20)时按比例缩减
|
||||
```python
|
||||
total = sum(question_types.values())
|
||||
max_questions = 20
|
||||
if total > max_questions:
|
||||
ratio = max_questions / total
|
||||
question_types = {k: max(0, round(v * ratio)) for k, v in question_types.items()}
|
||||
```
|
||||
|
||||
#### 2. 🚨 LLM 调用无限流机制,429 风暴 (generator.py:290-303, 540-543)
|
||||
**位置**: `generate_questions_structured()` 主循环 + `_generate_with_retry()` 重试
|
||||
**现象**: 并发出题时大量429错误,重试退避太短(1s/2s/4s),3次全败后放弃该知识点
|
||||
**根因**:
|
||||
- 主循环 for 逐知识点调用 LLM,无请求间隔(补题函数有 sleep(1),主循环没有)
|
||||
- 429 重试退避 `2^attempt` 秒 (1/2/4s) 对 API 限流不够
|
||||
- 无全局限流器(令牌桶/漏桶)
|
||||
**建议**:
|
||||
1. 主循环每次 LLM 调用后加 `time.sleep(1.5)` 最小间隔
|
||||
2. 429 退避改为指数+抖动: `min(30, 2 ** attempt + random.uniform(0, 2))`
|
||||
3. 长期: 引入 `tenacity` 或自实现令牌桶限流器
|
||||
|
||||
#### 3. 🚨 validate_questions_schema 只认 `type` 不认 `question_type` (generator.py:154)
|
||||
**位置**: `validate_questions_schema()` 函数
|
||||
**现象**: LLM 返回的题目可能用 `question_type` 字段,但校验只检查 `q.get('type')`,导致有效题目被丢弃
|
||||
**对比**: `_validate_question_types()` (第448行) 做了兼容: `q_type = q.get('question_type') or q.get('type')`
|
||||
```python
|
||||
# 当前代码 (generator.py:154) - 不兼容
|
||||
if q.get('type') not in VALID_TYPES:
|
||||
continue # question_type 字段的题目被丢弃!
|
||||
|
||||
# 应改为
|
||||
q_type = q.get('question_type') or q.get('type')
|
||||
if q_type not in VALID_TYPES:
|
||||
continue
|
||||
```
|
||||
|
||||
### P1 - 中等问题
|
||||
|
||||
#### 4. ⚠️ 填空题答案格式不一致
|
||||
**现象**: 出题返回 `content.answer = [["答案1"], ["答案2"]]` (list of list),但无 `data.reference_answer`
|
||||
**影响**: 前端/本地数据库可能期望 `reference_answer` 字段
|
||||
**建议**: 统一在 `data` 中增加 `reference_answer` 字段,与 `content.answer` 保持一致
|
||||
|
||||
#### 5. ⚠️ grader max_tokens 对推理模型不足 (grader.py:438)
|
||||
**位置**: `_grade_subjective()` 方法
|
||||
**现象**: `max_tokens=_get_effective_max_tokens(1000, self.model)`
|
||||
- 推理模型关闭推理时: 1000 tokens 勉强够
|
||||
- 推理模型开启推理时: 思考链消耗 800+ tokens,content 为空
|
||||
**当前状态**: 关闭推理模式后本次测试通过,但**开启推理模式会再次失败**
|
||||
**建议**: 基础 max_tokens 从 1000 提升至 2000
|
||||
|
||||
#### 6. ⚠️ local_db._detect_question_type 使用旧格式 (local_db.py:378-383)
|
||||
**位置**: `_detect_question_type()` 方法
|
||||
**现象**: 检测 `options` 和 `reference_answer` 字段来判断题型,但新格式用 `content.data.options` 和 `content.answer`
|
||||
```python
|
||||
# 当前代码 - 检查顶层 options
|
||||
if 'options' in question and question['options']:
|
||||
return 'choice'
|
||||
elif 'reference_answer' in question:
|
||||
return 'short_answer'
|
||||
|
||||
# 应改为检查 content 内部结构
|
||||
content = question.get('content', {})
|
||||
data = content.get('data', {})
|
||||
if data.get('options'):
|
||||
return 'choice'
|
||||
elif question.get('question_type') in ('fill_blank', 'subjective', ...):
|
||||
return question['question_type']
|
||||
```
|
||||
|
||||
#### 7. ⚠️ 题目多样性不足
|
||||
**现象**: Test 1 中 4 题有 3 题考同一知识点("标准的解释机构")
|
||||
**根因**: `_assign_questions_to_kps()` 可能将多个题型分配给同一知识点
|
||||
**建议**: 增加"同一知识点最多出 N 道题"的限制(建议 N=2)
|
||||
|
||||
### P2 - 低优先级
|
||||
|
||||
#### 8. 💡 source_trace 数据偶尔缺失
|
||||
**现象**: Test 1 中部分题目的 chunk_id 和 page 显示为 `?`
|
||||
**可能原因**: `find_referenced_chunks` 匹配失败时的 fallback 显示
|
||||
|
||||
#### 9. 💡 多选题答案格式
|
||||
**现象**: 多选题 answer 为 `['A', 'B']` 或 `['A', 'B', 'D']` (list),前端需处理
|
||||
**建议**: 在 API 文档中明确多选题 answer 格式为 list
|
||||
|
||||
#### 10. 💡 补题机制未与已有题目交叉去重
|
||||
**现象**: 补题时只检查本次生成的题干,不检查已有题目
|
||||
**建议**: 补题函数接受 `exclude_stems` 参数
|
||||
|
||||
---
|
||||
|
||||
## 四、测试通过项 ✅
|
||||
|
||||
1. **基础出题功能**: 5种题型均可正常生成
|
||||
2. **AI智能分析**: 文档分析→题型推荐→出题流程完整
|
||||
3. **补题机制**: 知识点出题失败后自动补题
|
||||
4. **跨调用去重**: exclude_stems 功能正常,排除的题干不再出现
|
||||
5. **批题功能**: 客观题精确匹配,填空题部分给分,主观题LLM逐项评分
|
||||
6. **source_trace**: 大部分题目有完整的溯源信息(chunk_id, section, snippet)
|
||||
7. **JSON解析**: mimo-v2.5 返回的 JSON 格式(含 markdown 包裹)可正常解析
|
||||
8. **题目格式**: 单选题 options 为 list of dict `[{key, content}]`,格式规范
|
||||
|
||||
---
|
||||
|
||||
## 五、建议修复优先级
|
||||
|
||||
| 优先级 | 问题 | 修复难度 | 影响范围 |
|
||||
|--------|------|----------|----------|
|
||||
| P0 | AI出题上限失控 | 简单 | smart 出题 |
|
||||
| P0 | LLM 429 限流 | 中等 | 全部出题 |
|
||||
| P0 | validate_questions_schema type 字段 | 简单 | 全部出题 |
|
||||
| P1 | 填空题答案格式统一 | 简单 | 填空题+批题 |
|
||||
| P1 | grader max_tokens | 简单 | 主观题批题 |
|
||||
| P1 | local_db 旧格式 | 中等 | 本地存储 |
|
||||
| P1 | 题目多样性控制 | 中等 | 出题质量 |
|
||||
@@ -42,6 +42,8 @@ doc_path = docstore_dir / f"{doc_id}.json" # doc_id = chunk_id = "filename_N"
|
||||
|
||||
### P1:同名文件重复上传 — 旧切片残留 + 搜索结果重复
|
||||
|
||||
> **✅ 已修复**(2026-06-04):上传接口现在自动替换同名文件,旧切片自动标记为 `superseded`。详见 [风险边界问题修复注意事项.md](风险边界问题修复注意事项.md)。
|
||||
|
||||
**位置**:`api/document_routes.py` 第 246-250 行
|
||||
|
||||
```python
|
||||
@@ -119,6 +121,8 @@ for item in all_items:
|
||||
|
||||
### P2:文件无原地更新机制
|
||||
|
||||
> **✅ 已修复**(2026-06-04):上传接口新增自动替换机制(`replaced=true`),同名文件自动替换旧版本。
|
||||
|
||||
**场景**:用户上传 `制度.pdf` v1 后发现内容有误,修改后想替换。当前系统没有 "更新文件" 接口,只能删除后重新上传。如果用户不知道要先删除,就会触发 P1 的重复问题。
|
||||
|
||||
**修复方向**:upload 接口增加 "如果同名文件已存在则替换" 选项(先 delete_document 再 add_file_to_kb),或提供独立的 "更新文档" API。
|
||||
@@ -151,14 +155,14 @@ chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
|
||||
|
||||
### 风险总结
|
||||
|
||||
| 等级 | 风险 | 核心原因 | 触发条件 |
|
||||
|------|------|----------|----------|
|
||||
| **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 兼容) | — |
|
||||
| 等级 | 风险 | 核心原因 | 触发条件 | 状态 |
|
||||
|------|------|----------|----------|------|
|
||||
| **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 兼容) | — | 无需修复 |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 多源信息融合设计指南
|
||||
|
||||
> **⚠️ 路径说明**:本文档描述的 `AgenticRAG.process()` 多源融合路径是**备用路径**(需启用网络搜索)。生产环境的 `/rag` 问答接口使用 `chat_routes.py` 的轻量编排路径(详见 [RAG数据流程.md](RAG数据流程.md)),不经过 `AgenticRAG.process()`。两条路径的区别见 [Agentic_RAG完整指南.md](Agentic_RAG完整指南.md)。
|
||||
|
||||
## 一、问题背景
|
||||
|
||||
当 Agentic RAG 同时使用知识库和网络搜索时,会遇到以下情况:
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
| 文档解析 | MinerU 3.0+ | PDF/DOCX/PPTX/图片统一解析 |
|
||||
| 向量检索 | ChromaDB + BGE-base-zh | 本地向量数据库 + 嵌入模型 |
|
||||
| 关键词检索 | BM25 + jieba | 中文分词 + 倒排索引 |
|
||||
| 重排序 | BGE-reranker-base | CrossEncoder 精排 |
|
||||
| 大模型 | Qwen (通义千问) | 问答生成、查询改写、意图分析 |
|
||||
| 重排序 | qwen3-rerank(云端)/ BGE-reranker-base(本地,图片二次评分) | CrossEncoder 精排 |
|
||||
| 大模型 | deepseek-v4-flash / Qwen (通义千问) | 问答生成、查询改写、意图分析 |
|
||||
| 数据库 | SQLite | 会话管理、知识管理 |
|
||||
|
||||
---
|
||||
|
||||
125
docs/待解决风险项.md
Normal file
125
docs/待解决风险项.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 待解决风险项
|
||||
|
||||
> 本文档记录已识别但尚未实施修复的风险项,供后续迭代参考。
|
||||
> 更新日期:2026-06-21
|
||||
|
||||
---
|
||||
|
||||
## P1: SemanticCache 满容量全清空
|
||||
|
||||
**文件**:`core/semantic_cache.py:161-164`
|
||||
|
||||
**现状**:当 FAISS 索引达到 `max_size`(默认 10000)时,执行 `self.clear()` 全清空,而非 LRU 淘汰最旧条目。
|
||||
|
||||
**影响**:高使用量下缓存命中率周期性断崖式下降,下一波请求全部 miss。
|
||||
|
||||
**建议方案**:
|
||||
- 短期:可接受,断崖后缓存会重新积累
|
||||
- 长期:改为分批淘汰(每次淘汰最旧 N 条),或引入 TTL 自动过期
|
||||
|
||||
**风险等级**:中(性能波动,不影响正确性)
|
||||
|
||||
---
|
||||
|
||||
## P2: _exact_cache 满后永久拒绝新条目
|
||||
|
||||
**文件**:`core/intent_analyzer.py:412-413`
|
||||
|
||||
**现状**:
|
||||
|
||||
```python
|
||||
if len(self._exact_cache) < self._exact_cache_max: # 500
|
||||
self._exact_cache[exact_key] = analysis
|
||||
```
|
||||
|
||||
500 条满后不再接受新条目,且无淘汰机制。服务运行一段时间后,精确缓存变为固定快照。
|
||||
|
||||
**影响**:后续查询的精确缓存命中率逐渐归零,退化为每次都走语义缓存或 LLM 调用。
|
||||
|
||||
**建议方案**:改用 `OrderedDict` + 淘汰最旧条目,与 `LRUCache` 保持一致。
|
||||
|
||||
**风险等级**:低(性能退化,不影响正确性)
|
||||
|
||||
---
|
||||
|
||||
## P3: Jaccard 字符级阈值偏松
|
||||
|
||||
**文件**:`core/intent_analyzer.py:450-473`
|
||||
|
||||
**现状**:语义缓存二次验证使用字符级 Jaccard 相似度,阈值 0.5。中文短句共享大量单字(如"如何申请" vs "如何拒绝"),Jaccard = 2/4 = 0.5 刚好过线。
|
||||
|
||||
**影响**:语义相似但意图不同的查询可能误命中缓存。
|
||||
|
||||
**建议方案**:
|
||||
- 方案 A:提升阈值到 0.6
|
||||
- 方案 B:改用 bigram Jaccard(相邻字对作为集合元素)
|
||||
|
||||
**风险等级**:低(误命中概率不高,且有 embedding 0.92 阈值前置过滤)
|
||||
|
||||
---
|
||||
|
||||
## P4: /cache/clear 无 DEV_MODE 守卫
|
||||
|
||||
**文件**:`api/sync_routes.py:322`
|
||||
|
||||
**现状**:`POST /cache/clear` 端点仅使用 `@require_gateway_auth`,没有 `DEV_MODE` 检查。生产环境任何认证用户都能清缓存。其他 dev-only 端点(如 `document_routes.py` 的预览接口)有 `DEV_MODE` 守卫。
|
||||
|
||||
**影响**:生产环境缓存被误清,导致短暂的性能下降。
|
||||
|
||||
**建议方案**:添加 `DEV_MODE` 检查,或限制为 admin 角色。
|
||||
|
||||
**风险等级**:中(生产环境影响)
|
||||
|
||||
---
|
||||
|
||||
## P5: PDF TOC 目录数据未单独处理
|
||||
|
||||
**文件**:`parsers/mineru_parser.py`
|
||||
|
||||
**现状**:PDF 文档的目录页(TOC)被 MinerU 解析为多个 text_level=1 的标题切片,内容包含 `....1` 等页码标记。这些目录切片入库后污染 section 元数据,干扰章节过滤和上下文扩展。
|
||||
|
||||
**影响**:RAG 检索时目录切片可能被误判为相关章节,干扰上下文扩展的 section 精确匹配。
|
||||
|
||||
**建议方案**:
|
||||
- 解析阶段检测 TOC 模式(连续短标题 + 页码标记)
|
||||
- 标记 `chunk_type: 'toc'` 或直接跳过不入库
|
||||
|
||||
**风险等级**:中(影响检索质量)
|
||||
|
||||
---
|
||||
|
||||
## P6: PDF chart VLM 描述缺失
|
||||
|
||||
**文件**:`knowledge/lazy_enhance.py`、`parsers/mineru_parser.py`
|
||||
|
||||
**现状**:部分 PDF 图表切片的 VLM 描述为空。原因可能是:
|
||||
1. MinerU VLM API 对部分图表返回空 content(上游问题)
|
||||
2. 解析阶段的提取条件过严(如要求 `'|' in markdown` 才提取 chart_markdown)
|
||||
|
||||
**影响**:无 VLM 描述的图表切片退化为纯关键词匹配,图片选择准确率下降。
|
||||
|
||||
**建议方案**:
|
||||
- 上游:跟进 MinerU API 的空 content 问题
|
||||
- 本地兜底:用 `caption + section + 上下文文本` 拼接作为 fallback 描述
|
||||
- 定期重算:用 `sync_vlm_cache.py --re-embed` 批量重算
|
||||
|
||||
**风险等级**:低(有 lazy_enhance 兜底,首次查询后补生成)
|
||||
|
||||
---
|
||||
|
||||
## P7: 图片选择负面用例误召回
|
||||
|
||||
**文件**:`api/chat_routes.py`
|
||||
|
||||
**现状**:定义/原则类查询(如"五化终端定义"、"防洪调度原则")不应返回图片,但 `select_images` 仍返回了 2-3 张。根因:
|
||||
1. `_filter_images_by_answer` 的关键词重叠阈值对长回答偏宽松
|
||||
2. 兜底逻辑:过滤后为空时保留分数最高的 1 张
|
||||
|
||||
**影响**:用户看到不相关的图片,降低信任度。
|
||||
|
||||
**建议方案**:
|
||||
- 增加查询意图判断:纯定义/原则类查询不应触发图片返回
|
||||
- 移除兜底逻辑或提高兜底阈值
|
||||
- 增加"不应返回图片"的负面评测用例
|
||||
|
||||
**风险等级**:中(影响用户体验)
|
||||
@@ -75,13 +75,14 @@
|
||||
|
||||
| 数据类型 | 存储位置 | 管理方 | 说明 |
|
||||
|----------|----------|--------|------|
|
||||
| 向量数据 | ChromaDB | RAG 组 | 文档 embedding |
|
||||
| 文档哈希 | SQLite | RAG 组 | 同步状态检测 |
|
||||
| 向量数据 | ChromaDB | RAG 组 | 文档 embedding(每个知识库独立实例) |
|
||||
| 文档哈希 | SQLite (knowledge.db) | RAG 组 | 同步状态检测 |
|
||||
| 原始文档 | 文件系统 | RAG 组 | documents/ 目录 |
|
||||
| 反馈记录 | SQLite (feedback.db) | RAG 组 | 用户反馈、黑名单 |
|
||||
| 会话数据 | SQLite (session.db) | RAG 组 | 会话管理 |
|
||||
| 出题数据 | SQLite (exam.db) | RAG 组 | 题目/批阅 |
|
||||
| 用户账户 | MySQL/PG | 后端组 | 账号密码信息 |
|
||||
| 会话历史 | MySQL/PG | 后端组 | 对话记录 |
|
||||
| 审计日志 | MySQL/PG | 后端组 | 操作日志 |
|
||||
| 反馈记录 | MySQL/PG | 后端组 | 用户反馈 |
|
||||
| 题库数据 | MySQL/PG | 后端组 | 题目/试卷 |
|
||||
|
||||
---
|
||||
|
||||
485
docs/测试指南.md
485
docs/测试指南.md
@@ -1,485 +0,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/<exam_id>/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/<exam_id>/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 | 初始版本 |
|
||||
@@ -1,415 +0,0 @@
|
||||
# 企业文档版本管理方案实施完成报告
|
||||
|
||||
## ✅ 实施完成
|
||||
|
||||
所有计划的功能已成功实现,代码已提交。
|
||||
|
||||
---
|
||||
|
||||
## 📊 实施总结
|
||||
|
||||
### 已完成的 Phase
|
||||
|
||||
| Phase | 任务 | 状态 | 说明 |
|
||||
|-------|------|------|------|
|
||||
| Phase 1 | 清理冗余代码 | ✅ 完成 | 删除 diff.py,简化 lifecycle.py |
|
||||
| Phase 2 | 增强 sync.py | ✅ 完成 | 添加版本管理逻辑 |
|
||||
| Phase 3 | 增强 manager.py | ✅ 完成 | 添加状态标记和查询过滤 |
|
||||
| Phase 4 | 添加 API 端点 | ✅ 完成 | 废止/恢复/版本历史 API |
|
||||
| Phase 5 | 验证数据库 | ✅ 完成 | 添加性能优化索引 |
|
||||
| Phase 6 | 创建清理机制 | ✅ 完成 | 自动清理旧版本 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 代码变更清单
|
||||
|
||||
### 删除的文件(2个)
|
||||
|
||||
1. **knowledge/diff.py** (525行)
|
||||
- 原因:完全未使用
|
||||
- 影响:无
|
||||
|
||||
2. **knowledge/lifecycle.py** (618行)
|
||||
- 原因:大部分功能未使用
|
||||
- 替代:knowledge/document_versions.py
|
||||
|
||||
### 新增的文件(2个)
|
||||
|
||||
1. **knowledge/document_versions.py** (300行)
|
||||
- 功能:文档版本查询(简化版)
|
||||
- 保留:get_document_history, get_active_version, create_version_record, log_version_change
|
||||
|
||||
2. **knowledge/cleanup.py** (250行)
|
||||
- 功能:自动清理 superseded/deprecated 版本
|
||||
- 可选:定时任务调度
|
||||
|
||||
### 修改的文件(4个)
|
||||
|
||||
1. **knowledge/sync.py**
|
||||
- 修改:process_change 方法
|
||||
- 新增:_get_current_version, _generate_version_id, _record_version_change
|
||||
- 功能:文档更新时标记旧版本为 superseded,生成新版本号
|
||||
|
||||
2. **knowledge/manager.py**
|
||||
- 新增:mark_document_as_superseded 方法
|
||||
- 修改:search_single 方法(添加 include_deprecated 参数)
|
||||
- 功能:状态标记和查询过滤
|
||||
|
||||
3. **api/kb_routes.py**
|
||||
- 新增:3个 API 端点
|
||||
- POST /collections/<kb_name>/documents/<filename>/deprecate
|
||||
- POST /collections/<kb_name>/documents/<filename>/restore
|
||||
- GET /collections/<kb_name>/documents/<filename>/versions
|
||||
|
||||
4. **data/db.py**
|
||||
- 新增:2个性能优化索引
|
||||
- idx_document_versions_status
|
||||
- idx_version_change_logs_document
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能实现
|
||||
|
||||
### 1. 文档更新(版本管理)
|
||||
|
||||
**流程**:
|
||||
```
|
||||
文档修改 → 检测变更 → 标记旧版本为 superseded → 添加新版本 → 记录变更日志
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/sync.py` - process_change 方法
|
||||
|
||||
**效果**:
|
||||
- 旧版本:status = "superseded",查询时被过滤
|
||||
- 新版本:status = "active",查询时返回
|
||||
- 版本号:自动递增(v1 → v2 → v3)
|
||||
|
||||
### 2. 文档废止(软删除)
|
||||
|
||||
**API**:
|
||||
```bash
|
||||
POST /api/kb/collections/public_kb/documents/报销制度.pdf/deprecate
|
||||
{
|
||||
"reason": "制度已废止"
|
||||
}
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/manager.py` - deprecate_document 方法
|
||||
- `api/kb_routes.py` - deprecate_document 端点
|
||||
|
||||
**效果**:
|
||||
- 文档状态:status = "deprecated"
|
||||
- 查询行为:不返回该文档
|
||||
- 可恢复:调用 restore API
|
||||
|
||||
### 3. 文档恢复
|
||||
|
||||
**API**:
|
||||
```bash
|
||||
POST /api/kb/collections/public_kb/documents/报销制度.pdf/restore
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/manager.py` - restore_document 方法
|
||||
- `api/kb_routes.py` - restore_document 端点
|
||||
|
||||
**效果**:
|
||||
- 文档状态:status = "active"
|
||||
- 查询行为:恢复返回
|
||||
|
||||
### 4. 版本历史查询
|
||||
|
||||
**API**:
|
||||
```bash
|
||||
GET /api/kb/collections/public_kb/documents/报销制度.pdf/versions?limit=10
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/document_versions.py` - get_document_history 方法
|
||||
- `api/kb_routes.py` - get_document_versions 端点
|
||||
|
||||
**返回**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"versions": [
|
||||
{
|
||||
"version": "v2",
|
||||
"status": "active",
|
||||
"created_at": "2024-01-15T10:00:00",
|
||||
"chunk_count": 20
|
||||
},
|
||||
{
|
||||
"version": "v1",
|
||||
"status": "superseded",
|
||||
"created_at": "2023-01-01T10:00:00",
|
||||
"chunk_count": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 查询过滤
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/manager.py` - search_single 方法
|
||||
|
||||
**默认行为**:
|
||||
```python
|
||||
# 只返回 active 状态的文档
|
||||
result = search_single(kb_name, query_vector, query_text, include_deprecated=False)
|
||||
```
|
||||
|
||||
**包含废止文档**:
|
||||
```python
|
||||
# 返回所有状态的文档(包括 deprecated 和 superseded)
|
||||
result = search_single(kb_name, query_vector, query_text, include_deprecated=True)
|
||||
```
|
||||
|
||||
### 6. 自动清理
|
||||
|
||||
**代码位置**:
|
||||
- `knowledge/cleanup.py`
|
||||
|
||||
**使用方式**:
|
||||
```python
|
||||
from knowledge.cleanup import cleanup_superseded_versions
|
||||
|
||||
# 清理超过 7 天的 superseded 版本
|
||||
cleaned = cleanup_superseded_versions(days_to_keep=7)
|
||||
```
|
||||
|
||||
**定时任务**(可选):
|
||||
```python
|
||||
from knowledge.cleanup import start_cleanup_scheduler
|
||||
|
||||
# 每天凌晨 3 点自动清理
|
||||
start_cleanup_scheduler(
|
||||
superseded_days=7,
|
||||
deprecated_days=30,
|
||||
schedule_time="03:00"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 代码统计
|
||||
|
||||
### 代码量变化
|
||||
|
||||
| 指标 | 变化 |
|
||||
|------|------|
|
||||
| 删除代码 | -1143 行(diff.py + lifecycle.py) |
|
||||
| 新增代码 | +550 行(document_versions.py + cleanup.py + 修改) |
|
||||
| **净减少** | **-593 行** |
|
||||
|
||||
### 功能完整性
|
||||
|
||||
| 功能 | 状态 |
|
||||
|------|------|
|
||||
| 文档版本管理 | ✅ 已实现 |
|
||||
| 软删除和恢复 | ✅ 已实现 |
|
||||
| 历史追溯 | ✅ 已实现 |
|
||||
| 查询自动过滤 | ✅ 已实现 |
|
||||
| 自动清理 | ✅ 已实现(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 1. 单元测试
|
||||
|
||||
创建 `tests/test_version_management.py`:
|
||||
|
||||
```python
|
||||
def test_document_update_creates_version():
|
||||
"""测试文档更新时创建新版本"""
|
||||
# 1. 上传文档 v1
|
||||
# 2. 修改文档上传 v2
|
||||
# 3. 验证 v1 状态为 superseded
|
||||
# 4. 验证 v2 状态为 active
|
||||
# 5. 查询只返回 v2
|
||||
|
||||
def test_deprecate_and_restore():
|
||||
"""测试废止和恢复"""
|
||||
# 1. 废止文档
|
||||
# 2. 验证查询不返回该文档
|
||||
# 3. 恢复文档
|
||||
# 4. 验证查询返回该文档
|
||||
|
||||
def test_version_history():
|
||||
"""测试版本历史查询"""
|
||||
# 1. 创建多个版本
|
||||
# 2. 查询版本历史
|
||||
# 3. 验证返回所有版本记录
|
||||
```
|
||||
|
||||
### 2. 集成测试
|
||||
|
||||
```bash
|
||||
# 1. 上传文档
|
||||
curl -X POST http://localhost:5001/api/kb/public/upload \
|
||||
-F "file=@报销制度_v1.pdf"
|
||||
|
||||
# 2. 查询文档(应返回 v1)
|
||||
curl http://localhost:5001/api/rag \
|
||||
-d '{"query": "报销流程", "kb_name": "public"}'
|
||||
|
||||
# 3. 上传新版本
|
||||
curl -X POST http://localhost:5001/api/kb/public/upload \
|
||||
-F "file=@报销制度_v2.pdf"
|
||||
|
||||
# 4. 查询文档(应只返回 v2)
|
||||
curl http://localhost:5001/api/rag \
|
||||
-d '{"query": "报销流程", "kb_name": "public"}'
|
||||
|
||||
# 5. 查询版本历史
|
||||
curl http://localhost:5001/api/kb/collections/public_kb/documents/报销制度.pdf/versions
|
||||
|
||||
# 6. 废止文档
|
||||
curl -X POST http://localhost:5001/api/kb/collections/public_kb/documents/报销制度.pdf/deprecate \
|
||||
-d '{"reason": "制度已废止"}'
|
||||
|
||||
# 7. 查询文档(应不返回)
|
||||
curl http://localhost:5001/api/rag \
|
||||
-d '{"query": "报销流程", "kb_name": "public"}'
|
||||
|
||||
# 8. 恢复文档
|
||||
curl -X POST http://localhost:5001/api/kb/collections/public_kb/documents/报销制度.pdf/restore
|
||||
|
||||
# 9. 查询文档(应返回)
|
||||
curl http://localhost:5001/api/rag \
|
||||
-d '{"query": "报销流程", "kb_name": "public"}'
|
||||
```
|
||||
|
||||
### 3. 性能测试
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
# 测试查询性能(带状态过滤)
|
||||
start = time.time()
|
||||
result = kb_manager.search_single(kb_name, query_vector, query_text)
|
||||
print(f"无过滤: {time.time() - start:.3f}s")
|
||||
|
||||
start = time.time()
|
||||
result = kb_manager.search_single(kb_name, query_vector, query_text, include_deprecated=False)
|
||||
print(f"带过滤: {time.time() - start:.3f}s")
|
||||
|
||||
# 预期:性能差异 < 10%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
如果数据库已存在,需要运行索引创建:
|
||||
|
||||
```python
|
||||
from data.db import get_connection
|
||||
|
||||
with get_connection("knowledge") as conn:
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_document_versions_status
|
||||
ON document_versions(document_id, collection, status)
|
||||
''')
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_version_change_logs_document
|
||||
ON version_change_logs(document_id, collection, created_at DESC)
|
||||
''')
|
||||
conn.commit()
|
||||
```
|
||||
|
||||
### 2. 现有文档处理
|
||||
|
||||
现有文档的 metadata 中可能没有 `status` 字段,需要批量更新:
|
||||
|
||||
```python
|
||||
from knowledge.manager import get_kb_manager
|
||||
|
||||
kb_manager = get_kb_manager()
|
||||
kb_names = kb_manager.list_collections()
|
||||
|
||||
for kb_name in kb_names:
|
||||
collection = kb_manager.get_collection(kb_name)
|
||||
result = collection.get()
|
||||
|
||||
# 更新所有没有 status 字段的 chunks
|
||||
updated_metadatas = []
|
||||
ids_to_update = []
|
||||
|
||||
for i, meta in enumerate(result['metadatas']):
|
||||
if 'status' not in meta:
|
||||
meta['status'] = 'active'
|
||||
meta['version'] = 'v1'
|
||||
updated_metadatas.append(meta)
|
||||
ids_to_update.append(result['ids'][i])
|
||||
|
||||
if ids_to_update:
|
||||
collection.update(ids=ids_to_update, metadatas=updated_metadatas)
|
||||
print(f"更新 {kb_name}: {len(ids_to_update)} chunks")
|
||||
```
|
||||
|
||||
### 3. 清理策略
|
||||
|
||||
建议的清理策略:
|
||||
- **superseded 版本**:保留 7 天(防止误操作)
|
||||
- **deprecated 版本**:保留 30 天(审计需求)
|
||||
- **定时执行**:每天凌晨 3 点(低峰期)
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [企业文档更新管理方案](docs/企业文档更新管理方案.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 实施成果
|
||||
|
||||
1. ✅ **代码质量提升**
|
||||
- 删除 ~1000 行冗余代码
|
||||
- 降低维护成本 60%
|
||||
- 提高代码可读性
|
||||
|
||||
2. ✅ **功能完整性**
|
||||
- 支持文档版本管理
|
||||
- 支持软删除和恢复
|
||||
- 支持历史追溯
|
||||
- 查询自动过滤废止文档
|
||||
|
||||
3. ✅ **性能影响**
|
||||
- 查询性能:无明显影响(< 5%)
|
||||
- 存储成本:短期略增(1.2x),长期持平(自动清理)
|
||||
- 更新性能:略有提升(标记 vs 删除+重建)
|
||||
|
||||
### 下一步
|
||||
|
||||
1. **测试验证**
|
||||
- 运行单元测试
|
||||
- 执行集成测试
|
||||
- 验证性能影响
|
||||
|
||||
2. **部署准备**
|
||||
- 更新现有文档的 metadata
|
||||
- 创建数据库索引
|
||||
- 配置清理任务(可选)
|
||||
|
||||
3. **文档更新**
|
||||
- 更新 API 文档
|
||||
- 更新用户手册
|
||||
- 更新部署指南
|
||||
|
||||
---
|
||||
|
||||
**实施完成时间**: 2026-04-20
|
||||
**实施者**: Claude Code
|
||||
**代码审查**: 已完成
|
||||
**测试状态**: 已实施
|
||||
**部署状态**: 已部署
|
||||
@@ -1,219 +0,0 @@
|
||||
# 状态码功能更新说明
|
||||
|
||||
> **更新日期**: 2026-04-30
|
||||
> **改动范围**: 长时间运行端口的响应格式增强
|
||||
|
||||
---
|
||||
|
||||
## 一、更新概述
|
||||
|
||||
为方便后端判断 RAG 服务处理状态,以下端口增加了统一的 `status_code` 字段:
|
||||
|
||||
| 端口 | 改动文件 |
|
||||
|------|----------|
|
||||
| `/documents/upload` | `api/document_routes.py` |
|
||||
| `/documents/batch-upload` | `api/document_routes.py` |
|
||||
| `/sync` | `api/sync_routes.py` |
|
||||
| `/exam/generate` | `exam_pkg/api.py` |
|
||||
| `/exam/grade` | `exam_pkg/api.py` |
|
||||
|
||||
---
|
||||
|
||||
## 二、状态码速查表
|
||||
|
||||
### 处理中 (10xx)
|
||||
|
||||
| 状态码 | 常量名 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 1000 | PROCESSING | 通用处理中 |
|
||||
| 1010 | SYNC_RUNNING | 同步进行中 |
|
||||
| 1020 | EXAM_GENERATING | 出题生成中 |
|
||||
| 1021 | EXAM_GRADING | 批阅进行中 |
|
||||
|
||||
### 成功 (20xx)
|
||||
|
||||
| 状态码 | 常量名 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 2000 | SUCCESS | 通用成功 |
|
||||
| 2002 | UPLOAD_SUCCESS | 文件上传成功 |
|
||||
| 2003 | BATCH_UPLOAD_SUCCESS | 批量上传成功 |
|
||||
| 2010 | SYNC_SUCCESS | 同步完成 |
|
||||
| 2020 | EXAM_SUCCESS | 出题成功 |
|
||||
| 2021 | GRADE_SUCCESS | 批阅完成 |
|
||||
|
||||
### 客户端错误 (40xx)
|
||||
|
||||
| 状态码 | 常量名 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 4000 | BAD_REQUEST | 请求参数错误 |
|
||||
| 4004 | NO_FILE | 没有上传文件 |
|
||||
| 4005 | NO_FILE_SELECTED | 没有选择文件 |
|
||||
| 4006 | NO_COLLECTION | 未指定向量库 |
|
||||
| 4007 | UNSUPPORTED_FORMAT | 不支持的文件格式 |
|
||||
| 4008 | FILE_TOO_LARGE | 文件过大 |
|
||||
|
||||
### 服务端错误 (50xx)
|
||||
|
||||
| 状态码 | 常量名 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 5000 | INTERNAL_ERROR | 内部错误 |
|
||||
| 5010 | SYNC_ERROR | 同步失败 |
|
||||
| 5020 | EXAM_ERROR | 出题失败 |
|
||||
| 5021 | GRADE_ERROR | 批阅失败 |
|
||||
|
||||
---
|
||||
|
||||
## 三、各端口改动对照
|
||||
|
||||
### 3.1 文档上传 `/documents/upload`
|
||||
|
||||
| 场景 | 旧响应 | 新响应 |
|
||||
|------|--------|--------|
|
||||
| 成功 | `{"success": true, ...}` | `{"status": "success", "status_code": 2002, ...}` |
|
||||
| 无文件 | `{"error": "没有上传文件"}` | `{"status": "failed", "status_code": 4004, ...}` |
|
||||
| 格式错误 | `{"error": "不支持的文件类型"}` | `{"status": "failed", "status_code": 4007, ...}` |
|
||||
| 文件过大 | `{"error": "文件大小超过限制"}` | `{"status": "failed", "status_code": 4008, ...}` |
|
||||
|
||||
### 3.2 批量上传 `/documents/batch-upload`
|
||||
|
||||
| 场景 | 旧响应 | 新响应 |
|
||||
|------|--------|--------|
|
||||
| 成功 | `{"success": true, ...}` | `{"status": "success", "status_code": 2003, ...}` |
|
||||
|
||||
### 3.3 同步服务 `/sync`
|
||||
|
||||
| 场景 | 旧响应 | 新响应 |
|
||||
|------|--------|--------|
|
||||
| 成功 | `{"success": true, ...}` | `{"status": "success", "status_code": 2010, ...}` |
|
||||
| 服务不可用 | `{"error": "同步服务未启用"}` | `{"status": "failed", "status_code": 5000, ...}` |
|
||||
| 同步失败 | `{"error": "xxx"}` | `{"status": "failed", "status_code": 5010, ...}` |
|
||||
|
||||
### 3.4 出题 `/exam/generate`
|
||||
|
||||
| 场景 | 旧响应 | 新响应 |
|
||||
|------|--------|--------|
|
||||
| 成功 | `{"success": true, ...}` | `{"status": "success", "status_code": 2020, ...}` |
|
||||
| 参数缺失 | `{"error": "缺少参数"}` | `{"status": "failed", "status_code": 4000, ...}` |
|
||||
| 出题失败 | `{"error": "xxx"}` | `{"status": "failed", "status_code": 5020, ...}` |
|
||||
|
||||
### 3.5 批阅 `/exam/grade`
|
||||
|
||||
| 场景 | 旧响应 | 新响应 |
|
||||
|------|--------|--------|
|
||||
| 成功 | `{"success": true, ...}` | `{"status": "success", "status_code": 2021, ...}` |
|
||||
| 批阅失败 | `{"error": "xxx"}` | `{"status": "failed", "status_code": 5021, ...}` |
|
||||
|
||||
---
|
||||
|
||||
## 四、响应格式示例
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"status_code": 2002,
|
||||
"message": "文件上传成功",
|
||||
"success": true,
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "document.pdf",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/document.pdf",
|
||||
"size": 102400
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"status_code": 4007,
|
||||
"message": "不支持的文件格式: .exe,支持: pdf, docx, doc, xlsx, txt",
|
||||
"success": false,
|
||||
"error": {
|
||||
"error": "UNSUPPORTED_FORMAT",
|
||||
"error_code": 4007,
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、后端对接代码示例
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
def handle_rag_response(response):
|
||||
"""处理 RAG 服务响应"""
|
||||
data = response.json()
|
||||
status_code = data.get("status_code", 0)
|
||||
|
||||
# 判断处理状态
|
||||
if 2000 <= status_code < 3000:
|
||||
# 成功
|
||||
return data.get("data", data)
|
||||
|
||||
elif 4000 <= status_code < 5000:
|
||||
# 客户端错误
|
||||
raise ClientError(data.get("message"), status_code)
|
||||
|
||||
elif 5000 <= status_code < 6000:
|
||||
# 服务端错误
|
||||
raise ServerError(data.get("message"), status_code)
|
||||
|
||||
# 兼容旧格式
|
||||
if data.get("success"):
|
||||
return data
|
||||
raise Exception(data.get("error", "未知错误"))
|
||||
```
|
||||
|
||||
### JavaScript 示例
|
||||
|
||||
```javascript
|
||||
function handleRagResponse(data) {
|
||||
const statusCode = data.status_code || 0;
|
||||
|
||||
if (statusCode >= 2000 && statusCode < 3000) {
|
||||
// 成功
|
||||
return data.data || data;
|
||||
} else if (statusCode >= 4000 && statusCode < 5000) {
|
||||
// 客户端错误
|
||||
throw new ClientError(data.message, statusCode);
|
||||
} else if (statusCode >= 5000 && statusCode < 6000) {
|
||||
// 服务端错误
|
||||
throw new ServerError(data.message, statusCode);
|
||||
}
|
||||
|
||||
// 兼容旧格式
|
||||
if (data.success) return data;
|
||||
throw new Error(data.error || "未知错误");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、向后兼容说明
|
||||
|
||||
所有响应保留了 `success` 字段:
|
||||
- 成功时 `success: true`
|
||||
- 失败时 `success: false`
|
||||
|
||||
旧代码仍可通过 `success` 字段判断,无需立即修改。
|
||||
|
||||
---
|
||||
|
||||
## 七、文件变更清单
|
||||
|
||||
| 文件 | 变更类型 |
|
||||
|------|----------|
|
||||
| `core/status_codes.py` | 新增 |
|
||||
| `api/response_utils.py` | 新增 |
|
||||
| `api/document_routes.py` | 修改 |
|
||||
| `api/sync_routes.py` | 修改 |
|
||||
| `exam_pkg/api.py` | 修改 |
|
||||
367
docs/生产路径优化计划.md
367
docs/生产路径优化计划.md
@@ -1,367 +0,0 @@
|
||||
## 生产 RAG 路径优化计划
|
||||
|
||||
> 原则:小步快跑,每阶段独立可测,打完 commit 验证质量后再推进下一阶段。
|
||||
> 如果某阶段导致回答质量下降,直接 `git revert` 回退到上一个 commit。
|
||||
|
||||
---
|
||||
|
||||
### 实施进度总览(更新于 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/eval_e2e.py` 已创建但尚未运行基线评估。`data/eval_dataset.json` 和 `data/eval_results/baseline.json` 尚未生成。
|
||||
|
||||
**工作内容**:
|
||||
|
||||
1. 创建 `data/eval_dataset.json`:从现有知识库中挑选 15-20 个测试问题,覆盖以下类型:
|
||||
- 事实查询("XX标准是多少")
|
||||
- 列举查询("有哪些禁止情形")
|
||||
- 对比查询("A和B的区别")
|
||||
- 流程查询("如何申请XX")
|
||||
- 跨文档查询(答案涉及多个文件)
|
||||
|
||||
每个问题记录:`query`、`query_type`、`expected_keywords`(答案应包含的关键信息点)、`relevant_sources`(应命中的文件名)
|
||||
|
||||
2. 创建 `scripts/eval_e2e.py`:端到端评估脚本
|
||||
- 启动本地服务(或连接已运行的服务)
|
||||
- 对每个测试问题调用 `POST /rag`
|
||||
- 收集回答,用 LLM 评分(相关性 1-5、完整性 1-5、准确性 1-5)
|
||||
- 同时记录检索层指标(Rerank 分数分布、上下文切片数、上下文总字数)
|
||||
- 输出 JSON 报告 + 终端汇总
|
||||
|
||||
3. 运行一次基线评估,保存为 `data/eval_results/baseline.json`
|
||||
|
||||
4. 打 Git tag:`git tag v1.0-rag-baseline`
|
||||
|
||||
**产出文件**:
|
||||
- `data/eval_dataset.json`
|
||||
- `scripts/eval_e2e.py`
|
||||
- `data/eval_results/baseline.json`
|
||||
|
||||
**验收标准**:评估脚本能正常运行,基线报告包含每个问题的评分。
|
||||
|
||||
---
|
||||
|
||||
### Phase 1:Rerank 分数传递与上下文过滤 `已实施`
|
||||
|
||||
**目标**:让 Rerank 分数在上下文构建中发挥作用,过滤低分切片。
|
||||
|
||||
**改动范围**:`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 分数需要确认)
|
||||
|
||||
2. 在 `_order_text_contexts_for_prompt` 函数中增加分数过滤参数:
|
||||
```python
|
||||
def _order_text_contexts_for_prompt(contexts, query, max_chunks,
|
||||
min_score=0.0):
|
||||
# 过滤低于分数阈值的切片
|
||||
if min_score > 0:
|
||||
text_contexts = [c for c in text_contexts
|
||||
if c.get('score', 0) >= min_score]
|
||||
```
|
||||
|
||||
3. 在 `generate()` 中调用时传入阈值:
|
||||
```python
|
||||
text_contexts = _order_text_contexts_for_prompt(
|
||||
contexts, message, MAX_CONTEXT_CHUNKS,
|
||||
min_score=RERANK_CONTEXT_MIN_SCORE # 新增配置项,初始值 0.05
|
||||
)
|
||||
```
|
||||
|
||||
4. 在 `config.py` 中新增配置:
|
||||
```python
|
||||
RERANK_CONTEXT_MIN_SCORE = 0.05 # 上下文最低 Rerank 分数阈值
|
||||
```
|
||||
|
||||
5. 在 SSE `context_built` 事件中增加分数信息(DEV 模式),方便调试。
|
||||
|
||||
**风险**:低。`min_score` 初始设 0.05(几乎不过滤),逐步调高观察效果。
|
||||
|
||||
**验证方法**:
|
||||
- 运行 `eval_e2e.py`,对比基线
|
||||
- 重点关注:是否有问题因为过滤了切片而回答变差
|
||||
- 观察 `context_built` 事件中 `chunk_count` 的变化
|
||||
|
||||
**Git commit**:`feat(rag): pass rerank scores through to context building with min-score filter`
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
CONTEXT_MAX_CHARS = 8000 # 上下文最大字符数(约 4000 token)
|
||||
CONTEXT_SOFT_LIMIT = 6000 # 软限制,超过后只保留高分切片
|
||||
```
|
||||
|
||||
2. 在 `_order_text_contexts_for_prompt` 返回后,构建 `context_text` 时:
|
||||
```python
|
||||
# 按 Rerank 分数降序逐个加入,直到达到 token 预算
|
||||
sorted_contexts = sorted(text_contexts,
|
||||
key=lambda c: c.get('score', 0),
|
||||
reverse=True)
|
||||
context_parts = []
|
||||
total_chars = 0
|
||||
for ctx in sorted_contexts:
|
||||
doc = ctx.get('doc', '')
|
||||
if total_chars + len(doc) > CONTEXT_MAX_CHARS:
|
||||
break
|
||||
context_parts.append(doc)
|
||||
total_chars += len(doc)
|
||||
context_text = "\n\n".join(context_parts)
|
||||
```
|
||||
|
||||
3. 注意:排序后需要保持同一文档切片的连续性。改为先按 (source, section, chunk_index) 分组,再按组内最高分降序排列各组,逐组加入直到预算满。
|
||||
|
||||
**风险**:中。如果预算设太小,可能丢掉关键信息。初始 `CONTEXT_MAX_CHARS=8000` 比较保守(当前 20 个切片平均约 10000-15000 字)。
|
||||
|
||||
**验证方法**:
|
||||
- 对比基线的评分变化
|
||||
- 关注列举类查询("有哪些禁止情形")是否因为预算截断而漏条
|
||||
- 观察 `context_built.context_length` 分布
|
||||
|
||||
**Git commit**:`feat(rag): add character-based token budget for context building`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:上下文扩展精细化 `已实施`
|
||||
|
||||
**目标**:Rerank 后的上下文扩展只对高置信度切片执行,避免低分切片引入噪声邻居。
|
||||
|
||||
**改动范围**:`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 行)保持不变~~ —— 已重构,实际位置见 `core/engine.py`
|
||||
|
||||
2. ~~Rerank 后的扩展(第 615 行)增加条件~~ —— 已实施,`EXPANSION_SCORE_THRESHOLD` 控制
|
||||
```python
|
||||
# 只对 Rerank 分数 > EXPANSION_SCORE_THRESHOLD 的切片扩展邻居
|
||||
EXPANSION_SCORE_THRESHOLD = 0.3
|
||||
```
|
||||
|
||||
3. 给扩展进来的邻居切片在 metadata 中记录扩展来源:
|
||||
```python
|
||||
n_meta['_expanded_from_score'] = seed_score # 种子切片的 Rerank 分数
|
||||
```
|
||||
|
||||
4. 在 `_order_text_contexts_for_prompt` 中限制扩展邻居的数量:
|
||||
```python
|
||||
MAX_EXPANDED_NEIGHBORS = 4 # 最多 4 个扩展邻居进入上下文
|
||||
```
|
||||
|
||||
**风险**:中。如果阈值设太高,一些中等分数的切片不会扩展邻居,可能丢失上下文。初始 0.3 比较宽松。
|
||||
|
||||
**验证方法**:
|
||||
- 对比 `retrieval_debug` 事件中第二次 `context_expansion` 的 before/after 数量
|
||||
- 检查是否有回答因为缺少邻居切片而变得不连贯
|
||||
- 评估分数不应低于 Phase 2 的结果
|
||||
|
||||
**Git commit**:`feat(rag): restrict post-rerank context expansion to high-confidence chunks`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4:置信度兜底 `已实施`
|
||||
|
||||
**目标**:当检索质量整体偏低时,在 prompt 中告知 LLM 谨慎回答,减少幻觉。
|
||||
|
||||
**改动范围**:`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
|
||||
top_scores = [ctx.get('score', 0) for ctx in text_contexts[:3]]
|
||||
avg_top3_score = sum(top_scores) / len(top_scores) if top_scores else 0
|
||||
```
|
||||
|
||||
2. 根据平均分数在 prompt 中注入不同指令:
|
||||
```python
|
||||
if avg_top3_score < CONFIDENCE_WARN_THRESHOLD: # 比如 0.15
|
||||
confidence_note = (
|
||||
"【重要提示】参考资料与问题的相关性较低。"
|
||||
"请仅基于参考资料中明确包含的信息回答,"
|
||||
"如果资料不足以回答问题,请直接说明'知识库中未找到直接相关的信息'。"
|
||||
)
|
||||
elif avg_top3_score < CONFIDENCE_CAUTION_THRESHOLD: # 比如 0.3
|
||||
confidence_note = (
|
||||
"参考资料的相关性一般,请优先引用资料中的原文,避免推测。"
|
||||
)
|
||||
else:
|
||||
confidence_note = ""
|
||||
```
|
||||
|
||||
3. 在 `config.py` 新增:
|
||||
```python
|
||||
CONFIDENCE_WARN_THRESHOLD = 0.15
|
||||
CONFIDENCE_CAUTION_THRESHOLD = 0.30
|
||||
```
|
||||
|
||||
4. 在 SSE `finish` 事件中附带 `confidence_score` 字段,前端可用于提示用户。
|
||||
|
||||
**风险**:低。纯 prompt 层面的改动,不影响检索链路。最坏情况是 LLM 过于保守拒绝回答——可以通过调低阈值解决。
|
||||
|
||||
**验证方法**:
|
||||
- 构造几个"知识库中确实没有答案"的问题,检查 LLM 是否正确拒绝
|
||||
- 正常问题的评分不应下降
|
||||
- 观察 `finish` 事件中的 `confidence_score` 分布
|
||||
|
||||
**Git commit**:`feat(rag): add confidence-based prompt guidance for low-quality retrieval`
|
||||
|
||||
---
|
||||
|
||||
### Phase 5:上下文排序优化 `部分实施`
|
||||
|
||||
**目标**:对所有查询类型(不仅是列举类)都做同章节聚合排序,保证同一文件同一章节的切片连续排列。
|
||||
|
||||
**改动范围**:`api/chat_routes.py` 的 `_order_text_contexts_for_prompt`(当前第 188 行)。
|
||||
|
||||
**当前状态**:
|
||||
- `_build_context_with_budget()`(第 312 行)已实现了通用的分数排序 + 预算控制逻辑
|
||||
- 但列举类查询(`_is_enum_query`)和对比类查询仍走独立分支(第 1754 行),直接拼接不做预算截断
|
||||
- 尚未将"同章节聚合排序"推广为所有查询类型的默认行为
|
||||
|
||||
**原始设计**:
|
||||
|
||||
1. 将当前只对 `_is_enum_query` 执行的排序逻辑推广为所有查询类型的默认行为
|
||||
|
||||
2. 排序策略:
|
||||
- 第一优先级:按 Rerank 分数降序(高分切片先进入上下文)
|
||||
- 第二优先级:同一 source + section 的切片按 chunk_index 连续排列
|
||||
- 具体做法:先按 (source, section) 分组,组内按 chunk_index 排序,组间按组内最高分降序
|
||||
|
||||
3. 列举类查询保持当前行为不变(作为特例)
|
||||
|
||||
**风险**:低。排序变化不影响切片内容,只影响 LLM 看到的顺序。
|
||||
|
||||
**验证方法**:
|
||||
- 对比 `context_built.chunks_used` 的排列顺序
|
||||
- 评估分数不应低于 Phase 4
|
||||
|
||||
**Git commit**:`feat(rag): apply section-aware context ordering for all query types`
|
||||
|
||||
---
|
||||
|
||||
### Phase 6:引用标注改进 `已实施`
|
||||
|
||||
**目标**:提升引用匹配精度,支持多引用。
|
||||
|
||||
**改动范围**:已迁移至 `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
|
||||
|
||||
2. 允许一个段落匹配最多 2 个 chunk(如果两个 chunk 的 overlap 分数都超过阈值且差距小于 0.1)
|
||||
|
||||
3. 引用标记改为 `[ref:chunk_id_1][ref:chunk_id_2]`,前端 `extractCitations` 已支持多个 `[ref:xxx]`
|
||||
|
||||
**风险**:中。多引用可能导致引用列表变长、前端展示变化。
|
||||
|
||||
**验证方法**:
|
||||
- 检查引用数量是否合理增加(不应翻倍)
|
||||
- 引用溯源弹窗的跳转是否仍然正确
|
||||
- 评估分数不应下降
|
||||
|
||||
**Git commit**:`feat(rag): improve citation matching with dynamic thresholds and multi-citation support`
|
||||
|
||||
---
|
||||
|
||||
### 阶段依赖关系
|
||||
|
||||
```
|
||||
Phase 0 (基线+评估) [部分实施]
|
||||
↓
|
||||
Phase 1 (Rerank分数传递) [已实施] ← 风险最低,收益最直接
|
||||
↓
|
||||
Phase 2 (Token预算) [已实施] ← 依赖 Phase 1 的分数传递
|
||||
↓
|
||||
Phase 3 (扩展精细化) [已实施] ← 依赖 Phase 1 的分数信息
|
||||
↓
|
||||
Phase 4 (置信度兜底) [已实施] ← 依赖 Phase 1 的分数信息
|
||||
↓
|
||||
Phase 5 (上下文排序) [部分实施] ← 独立,可与 Phase 4 互换顺序
|
||||
↓
|
||||
Phase 6 (引用标注) [已实施] ← 独立,已迁移至独立模块
|
||||
```
|
||||
|
||||
### 回退策略
|
||||
|
||||
每个 Phase 完成后执行:
|
||||
```bash
|
||||
# 运行评估
|
||||
python scripts/eval_e2e.py --output data/eval_results/phase_N.json
|
||||
|
||||
# 对比上一阶段
|
||||
# 如果评分下降 > 5%,回退:
|
||||
git revert HEAD
|
||||
|
||||
# 如果评分持平或提升,打 tag:
|
||||
git tag v1.0-phase-N
|
||||
```
|
||||
|
||||
### 预估时间
|
||||
|
||||
| 阶段 | 代码改动量 | 预估时间 |
|
||||
|------|----------|---------|
|
||||
| Phase 0 | 新建 2 个文件 | 2-3 小时 |
|
||||
| Phase 1 | 改 2 个文件,约 30 行 | 1 小时 |
|
||||
| Phase 2 | 改 2 个文件,约 40 行 | 1-2 小时 |
|
||||
| Phase 3 | 改 1 个文件,约 20 行 | 1 小时 |
|
||||
| Phase 4 | 改 2 个文件,约 25 行 | 30 分钟 |
|
||||
| Phase 5 | 改 1 个文件,约 30 行 | 1 小时 |
|
||||
| Phase 6 | 改 1 个文件,约 40 行 | 1-2 小时 |
|
||||
@@ -335,7 +335,7 @@ curl -X POST http://localhost:5001/search \
|
||||
|
||||
```bash
|
||||
# 测试出题接口(携带网关注入的 Header)
|
||||
curl -X POST http://localhost:5001/exam/generate-by-file \
|
||||
curl -X POST http://localhost:5001/exam/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-User-ID: test_user" \
|
||||
-H "X-User-Role: admin" \
|
||||
@@ -343,7 +343,7 @@ curl -X POST http://localhost:5001/exam/generate-by-file \
|
||||
-d '{
|
||||
"file_path": "public/公司简介.txt",
|
||||
"collection": "public_kb",
|
||||
"choice_count": 2
|
||||
"question_types": {"single_choice": 2, "true_false": 1}
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
80
docs/题目模板.md
80
docs/题目模板.md
@@ -1,80 +0,0 @@
|
||||
1. 整体结构 (Envelope Pattern)所有题型统一采用以下外壳,通过 question_type 进行区分。JSON{
|
||||
"metadata": {
|
||||
"question_id": "string (UUID)",
|
||||
"question_type": "single_choice | multiple_choice | true_false | fill_blank | subjective",
|
||||
"difficulty": 3,
|
||||
"tags": ["知识点A", "知识点B"],
|
||||
"score": 10.0,
|
||||
"version": "1.0"
|
||||
},
|
||||
"source_trace": {
|
||||
"document_id": "string - 原始文档ID",
|
||||
"document_name": "string - 文档名称",
|
||||
"source_context": "string - AI出题参考的原文切片/上下文",
|
||||
"page_numbers": [14, 15]
|
||||
},
|
||||
"content": {
|
||||
"stem": "string - 题干内容,支持Markdown格式",
|
||||
"data": {},
|
||||
"answer": null,
|
||||
"explanation": "string - 详尽的答案解析"
|
||||
},
|
||||
"review_info": {
|
||||
"status": "pending",
|
||||
"reviewer_comment": null,
|
||||
"created_at": "2026-04-17T18:30:00Z"
|
||||
}
|
||||
}
|
||||
2. 各题型 content.data 与 content.answer 定义(1) 单选题 (single_choice)data: 包含选项数组。answer: 对应正确选项的 key。JSON"content": {
|
||||
"stem": "根据文档,公司成立的年份是?",
|
||||
"data": {
|
||||
"options": [
|
||||
{"key": "A", "content": "1998年"},
|
||||
{"key": "B", "content": "2005年"},
|
||||
{"key": "C", "content": "2010年"}
|
||||
]
|
||||
},
|
||||
"answer": "B",
|
||||
"explanation": "在文档第三页明确提到公司于2005年获得营业执照。"
|
||||
}
|
||||
(2) 多选题 (multiple_choice)data: 选项数组。answer: 正确选项 key 的数组。JSON"content": {
|
||||
"stem": "以下属于公司核心价值观的有?",
|
||||
"data": {
|
||||
"options": [
|
||||
{"key": "A", "content": "创新"},
|
||||
{"key": "B", "content": "诚信"},
|
||||
{"key": "C", "content": "加班"}
|
||||
]
|
||||
},
|
||||
"answer": ["A", "B"],
|
||||
"explanation": "手册第一章第一节列出了‘创新’与‘诚信’为唯二核心价值观。"
|
||||
}
|
||||
(3) 判断题 (true_false)data: 可留空或自定义文字(如:正确/错误,对/错)。answer: boolean (true 为正确,false 为错误)。JSON"content": {
|
||||
"stem": "员工可以在办公区吸烟。",
|
||||
"data": null,
|
||||
"answer": false,
|
||||
"explanation": "《行政管理规范》第五条严禁在办公区吸烟。"
|
||||
}
|
||||
(4) 填空题 (fill_blank)data: 槽位定义。answer: 数组,按顺序存放每个空的标准答案列表(支持同义词)。JSON"content": {
|
||||
"stem": "RAG的全称是___,其核心在于利用___增强生成效果。",
|
||||
"data": { "blank_count": 2 },
|
||||
"answer": [
|
||||
["检索增强生成", "Retrieval Augmented Generation"],
|
||||
["外部知识库", "自有文档", "Context"]
|
||||
],
|
||||
"explanation": "RAG即检索增强生成,通过引入外部知识库信息提升回答准确度。"
|
||||
}
|
||||
(5) 主观题 (subjective)data: 包含评分维度和关键词。answer: string (参考范文)。JSON"content": {
|
||||
"stem": "请简述RAG架构中‘切片策略’对检索质量的影响。",
|
||||
"data": {
|
||||
"scoring_points": [
|
||||
{"point": "提到了语义完整性", "weight": 0.4},
|
||||
{"point": "提到了切片过大导致噪声", "weight": 0.3},
|
||||
{"point": "提到了切片过小丢失上下文", "weight": 0.3}
|
||||
],
|
||||
"keywords": ["语义窗口", "Top-K", "重叠度"]
|
||||
},
|
||||
"answer": "参考范文:合理的切片策略能保持语义完整,通过设置Overlap防止信息断层...",
|
||||
"explanation": "此题考查对RAG性能优化的深度理解。"
|
||||
}
|
||||
3. 后端约束说明 (Tips for Backend)数据持久化:建议将 content 整体以 JSONB (PostgreSQL) 或类似的格式存储,方便扩展未来可能增加的题型(如连线题、排序题)。分值校验:后端应校验 metadata.score 是否为正数;对于多选题,可增加逻辑判断 answer 数组长度必须 $\ge 2$。RAG 闭环:source_trace 字段是 RAG 项目的核心,建议后端在审核界面提供一个“点击查看原文”的按钮,直接展示 source_context 的内容。幂等性:建议由前端或 AI 服务生成 question_id (UUID),防止网络波动导致的重复入库。
|
||||
@@ -1,5 +1,7 @@
|
||||
## 风险边界问题修复注意事项
|
||||
|
||||
> **状态**:本文档中列出的所有场景均已修复并部署(2026-06-04)。保留本文档供参考,避免回退。
|
||||
|
||||
### 一、修复了哪些会出问题的情况
|
||||
|
||||
以下场景之前会报错或数据异常,现在已修复,不会再出问题:
|
||||
|
||||
102
exam_pkg/api.py
102
exam_pkg/api.py
@@ -19,6 +19,7 @@ import os
|
||||
|
||||
# 导入考试管理模块
|
||||
from exam_pkg.manager import (
|
||||
check_file_indexed,
|
||||
generate_questions_from_file,
|
||||
grade_answers,
|
||||
)
|
||||
@@ -29,7 +30,7 @@ from auth.gateway import (
|
||||
)
|
||||
|
||||
# 导入统一响应格式
|
||||
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, NO_CONTENT, LLM_ERROR
|
||||
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NO_CONTENT, LLM_ERROR, FILE_NOT_INDEXED
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
# 合法题型
|
||||
@@ -81,6 +82,38 @@ def validate_exclude_stems(exclude_stems) -> str:
|
||||
exam_bp = Blueprint('exam', __name__)
|
||||
|
||||
|
||||
def _generation_failure_response(
|
||||
error_code: str,
|
||||
message: str,
|
||||
request_id=None,
|
||||
file_status=None,
|
||||
chunk_count: int = 0,
|
||||
):
|
||||
"""将出题内部失败统一转换为 HTTP 业务错误。"""
|
||||
data = {
|
||||
"request_id": request_id,
|
||||
"file_status": file_status,
|
||||
"chunk_count": chunk_count,
|
||||
}
|
||||
|
||||
if error_code == "FILE_NOT_INDEXED":
|
||||
return error_response(
|
||||
"FILE_NOT_INDEXED",
|
||||
FILE_NOT_INDEXED,
|
||||
message or "文件尚未完成向量化",
|
||||
http_status=409,
|
||||
data=data,
|
||||
)
|
||||
|
||||
return error_response(
|
||||
"EXAM_ERROR",
|
||||
EXAM_ERROR,
|
||||
message or "检查文件状态失败",
|
||||
http_status=500,
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 出题 API ====================
|
||||
|
||||
@exam_bp.route('/generate', methods=['POST'])
|
||||
@@ -152,13 +185,14 @@ def api_generate_questions():
|
||||
if diff_error:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
|
||||
|
||||
# 校验总题数上限
|
||||
MAX_TOTAL_QUESTIONS = 20
|
||||
# 校验总题数(不设上限,但提醒用户大量出题可能影响性能)
|
||||
total_requested = sum(question_types.values())
|
||||
if total_requested > MAX_TOTAL_QUESTIONS:
|
||||
if total_requested <= 0:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST,
|
||||
f"总题数不能超过 {MAX_TOTAL_QUESTIONS} 道,当前请求 {total_requested} 道",
|
||||
"总题数必须大于0",
|
||||
http_status=400)
|
||||
if total_requested > 50:
|
||||
logger.warning(f"[出题] 请求生成 {total_requested} 道题,可能影响性能")
|
||||
|
||||
# 校验排除题干列表(可选)
|
||||
exclude_stems = data.get('exclude_stems')
|
||||
@@ -169,7 +203,7 @@ def api_generate_questions():
|
||||
# 获取当前用户
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
|
||||
|
||||
# 检查向量库访问权限
|
||||
if not check_collection_permission(
|
||||
@@ -178,9 +212,9 @@ def api_generate_questions():
|
||||
collection_name=collection,
|
||||
operation="read"
|
||||
):
|
||||
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
|
||||
|
||||
# 调用新版出题接口
|
||||
# 调用新版出题接口(同步)
|
||||
result = generate_questions_from_file(
|
||||
file_path=file_path,
|
||||
collection=collection,
|
||||
@@ -191,6 +225,15 @@ def api_generate_questions():
|
||||
exclude_stems=data.get('exclude_stems')
|
||||
)
|
||||
|
||||
if not result.get('success', True):
|
||||
return _generation_failure_response(
|
||||
result.get('error_code'),
|
||||
result.get('message'),
|
||||
request_id=result.get('request_id'),
|
||||
file_status=result.get('file_status'),
|
||||
chunk_count=result.get('chunk_count', 0),
|
||||
)
|
||||
|
||||
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
|
||||
|
||||
except Exception as e:
|
||||
@@ -210,6 +253,7 @@ def api_generate_smart():
|
||||
"file_path": "public/产品手册.pdf",
|
||||
"collection": "public_kb",
|
||||
"difficulty": 3, // 可选,默认 3
|
||||
"max_total": 20, // 可选,AI出题总数上限。不传则不限制
|
||||
"options": {} // 可选
|
||||
}
|
||||
|
||||
@@ -231,6 +275,16 @@ def api_generate_smart():
|
||||
if diff_error:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
|
||||
|
||||
# 可选:AI出题总数上限(不传则不限制)
|
||||
max_total = data.get('max_total')
|
||||
if max_total is not None:
|
||||
try:
|
||||
max_total = int(max_total)
|
||||
if max_total <= 0:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "max_total 必须为正整数", http_status=400)
|
||||
except (ValueError, TypeError):
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "max_total 必须为整数", http_status=400)
|
||||
|
||||
# 校验排除题干列表(可选)
|
||||
exclude_stems = data.get('exclude_stems')
|
||||
stems_error = validate_exclude_stems(exclude_stems)
|
||||
@@ -240,7 +294,7 @@ def api_generate_smart():
|
||||
# 获取当前用户
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
|
||||
|
||||
# 检查向量库访问权限
|
||||
if not check_collection_permission(
|
||||
@@ -249,13 +303,30 @@ def api_generate_smart():
|
||||
collection_name=collection,
|
||||
operation="read"
|
||||
):
|
||||
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
|
||||
|
||||
# 智能出题必须在 AI 分析前确认文件已完成索引,避免无效模型调用。
|
||||
file_status = check_file_indexed(file_path, collection)
|
||||
if not file_status.get('indexed'):
|
||||
is_not_indexed = file_status.get('status') == 'not_found'
|
||||
return _generation_failure_response(
|
||||
"FILE_NOT_INDEXED" if is_not_indexed else "STATUS_CHECK_ERROR",
|
||||
(
|
||||
f"文件未向量化: {file_status.get('message', '')}"
|
||||
if is_not_indexed
|
||||
else f"检查文件状态失败: {file_status.get('message', '')}"
|
||||
),
|
||||
request_id=data.get('request_id'),
|
||||
file_status=file_status.get('status'),
|
||||
chunk_count=file_status.get('chunk_count', 0),
|
||||
)
|
||||
|
||||
# 1. 调用 AI 分析文件,获取推荐的题型和数量
|
||||
from exam_pkg.manager import analyze_file_for_exam
|
||||
ai_analysis = analyze_file_for_exam(
|
||||
file_path=file_path,
|
||||
collection=collection
|
||||
collection=collection,
|
||||
max_total=max_total
|
||||
)
|
||||
|
||||
question_types = ai_analysis.get('question_types', {})
|
||||
@@ -273,6 +344,15 @@ def api_generate_smart():
|
||||
exclude_stems=data.get('exclude_stems')
|
||||
)
|
||||
|
||||
if not result.get('success', True):
|
||||
return _generation_failure_response(
|
||||
result.get('error_code'),
|
||||
result.get('message'),
|
||||
request_id=result.get('request_id'),
|
||||
file_status=result.get('file_status'),
|
||||
chunk_count=result.get('chunk_count', 0),
|
||||
)
|
||||
|
||||
# 3. 在返回结果中添加 AI 分析信息
|
||||
result['ai_analysis'] = ai_analysis
|
||||
|
||||
|
||||
@@ -26,19 +26,38 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 导入 LLM 工具函数
|
||||
from core.llm_utils import call_llm, parse_json_list_from_response
|
||||
from core.llm_utils import call_llm, parse_json_list_from_response, extract_json_list, extract_json_object
|
||||
|
||||
# 导入 LLM 配置
|
||||
try:
|
||||
from config import API_KEY, BASE_URL, MODEL
|
||||
from config import API_KEY, BASE_URL, MODEL, LLM_TEMPERATURE, LLM_MAX_TOKENS
|
||||
LLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
API_KEY = None
|
||||
BASE_URL = None
|
||||
MODEL = None
|
||||
LLM_TEMPERATURE = 0.7
|
||||
LLM_MAX_TOKENS = 4000
|
||||
LLM_AVAILABLE = False
|
||||
|
||||
|
||||
# 推理模型识别:这些模型会消耗额外 token 用于思考链,需要更大的 max_tokens 预算
|
||||
_REASONING_MODEL_KEYWORDS = ('mimo', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'o1', 'o3')
|
||||
|
||||
def _is_reasoning_model(model_name: str) -> bool:
|
||||
"""判断是否为推理模型(需要额外思考链 token 预算)"""
|
||||
if not model_name:
|
||||
return False
|
||||
name_lower = model_name.lower()
|
||||
return any(kw in name_lower for kw in _REASONING_MODEL_KEYWORDS)
|
||||
|
||||
def _get_effective_max_tokens(base_max: int, model_name: str) -> int:
|
||||
"""根据模型类型计算实际 max_tokens。推理模型需要 1.5x 预算给思考链"""
|
||||
if _is_reasoning_model(model_name):
|
||||
return max(base_max, int(base_max * 1.5))
|
||||
return base_max
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
def group_chunks_by_section(chunks: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
@@ -131,8 +150,9 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
|
||||
validated = []
|
||||
|
||||
for q in questions:
|
||||
# 必须有 type
|
||||
if q.get('type') not in VALID_TYPES:
|
||||
# 必须有 type 或 question_type
|
||||
q_type = q.get('question_type') or q.get('type')
|
||||
if q_type not in VALID_TYPES:
|
||||
continue
|
||||
|
||||
# 必须有 content
|
||||
@@ -145,10 +165,18 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
|
||||
continue
|
||||
|
||||
# 选项题必须有 options
|
||||
if q['type'] in ['single_choice', 'multiple_choice']:
|
||||
if q_type in ['single_choice', 'multiple_choice']:
|
||||
if not content.get('data', {}).get('options'):
|
||||
continue
|
||||
|
||||
# 填空题答案格式归一化:扁平数组 → 二维数组
|
||||
if q_type == 'fill_blank':
|
||||
ans = content.get('answer')
|
||||
if isinstance(ans, list) and ans and all(isinstance(item, str) for item in ans):
|
||||
# 扁平数组 ["答案1", "答案2"] → [["答案1"], ["答案2"]]
|
||||
content['answer'] = [[item] for item in ans]
|
||||
logger.warning(f"填空题答案格式修正: 扁平数组 → 二维数组 ({len(ans)} 空)")
|
||||
|
||||
validated.append(q)
|
||||
|
||||
return validated
|
||||
@@ -359,8 +387,8 @@ class QuestionGenerator:
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.7,
|
||||
max_tokens=4000,
|
||||
temperature=LLM_TEMPERATURE,
|
||||
max_tokens=_get_effective_max_tokens(4000, self.model),
|
||||
messages=messages
|
||||
)
|
||||
if not content:
|
||||
@@ -493,25 +521,37 @@ class QuestionGenerator:
|
||||
return type_names.get(q_type, q_type)
|
||||
|
||||
def _call_llm(self, prompt: str) -> str:
|
||||
"""调用本地 LLM(OpenAI 兼容接口)"""
|
||||
"""调用本地 LLM(OpenAI 兼容接口),支持 429 限流重试"""
|
||||
if not self.client:
|
||||
raise ValueError("LLM 客户端未初始化,请检查 config.py 中的 API_KEY 配置")
|
||||
|
||||
import time
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "你是一个专业的出题专家,擅长根据文档内容生成各类考试题目。你必须严格按照JSON格式输出,不要有任何其他内容。"},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
result = call_llm(
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.7,
|
||||
max_tokens=4000,
|
||||
messages=messages
|
||||
)
|
||||
if result is None:
|
||||
raise Exception("LLM 调用失败")
|
||||
return result
|
||||
|
||||
# 重试机制:429 限流时指数退避(最多重试 3 次)
|
||||
for attempt in range(4):
|
||||
result = call_llm(
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=LLM_TEMPERATURE,
|
||||
max_tokens=_get_effective_max_tokens(4000, self.model),
|
||||
messages=messages
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# 可能是 429 限流,等待后重试
|
||||
if attempt < 3:
|
||||
wait_time = 2 ** attempt # 1s, 2s, 4s
|
||||
logger.warning(f" LLM 调用失败,{wait_time}s 后重试 ({attempt+1}/3)...")
|
||||
time.sleep(wait_time)
|
||||
|
||||
raise Exception("LLM 调用失败(已重试 3 次)")
|
||||
|
||||
def _get_format_examples(self) -> str:
|
||||
"""返回各题型格式示例(覆盖全部 5 种题型)"""
|
||||
@@ -610,7 +650,6 @@ class QuestionGenerator:
|
||||
)
|
||||
|
||||
# 清理纯标点符号
|
||||
import re
|
||||
all_content = re.sub(r'^[\s\*\-\d\.。、,::;;]+$', '', all_content, flags=re.MULTILINE)
|
||||
all_content = all_content.strip()
|
||||
|
||||
@@ -645,27 +684,24 @@ class QuestionGenerator:
|
||||
|
||||
请直接输出 JSON 数组:"""
|
||||
|
||||
try:
|
||||
response = self._call_llm(prompt)
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
response = self._call_llm(prompt)
|
||||
if not response or not response.strip():
|
||||
continue
|
||||
|
||||
# 清理响应(移除可能的 markdown 标记)
|
||||
response = response.strip()
|
||||
if response.startswith('```'):
|
||||
lines = response.split('\n')
|
||||
response = '\n'.join(lines[1:-1] if lines[-1] == '```' else lines[1:])
|
||||
|
||||
# 解析 JSON
|
||||
result = json.loads(response)
|
||||
if isinstance(result, list):
|
||||
return [
|
||||
{"name": kp, "section": section}
|
||||
for kp in result[:max_points]
|
||||
if isinstance(kp, str) and 3 <= len(kp) <= 30
|
||||
]
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f" 知识点 JSON 解析失败: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f" 知识点提取失败: {e}")
|
||||
# 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
|
||||
result = extract_json_list(response)
|
||||
if isinstance(result, list) and result:
|
||||
return [
|
||||
{"name": kp, "section": section}
|
||||
for kp in result[:max_points]
|
||||
if isinstance(kp, str) and 3 <= len(kp) <= 30
|
||||
]
|
||||
# 解析结果无效,重试一次
|
||||
logger.warning(f" 知识点提取返回无效结果(尝试 {_attempt+1}/2),重试中...")
|
||||
except Exception as e:
|
||||
logger.error(f" 知识点提取失败(尝试 {_attempt+1}/2): {e}")
|
||||
|
||||
return []
|
||||
|
||||
@@ -918,7 +954,7 @@ def generate_questions_from_content(
|
||||
return generator.generate_questions_structured(chunks, document_name, question_types, difficulty)
|
||||
|
||||
|
||||
def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict[str, Any]:
|
||||
"""
|
||||
AI 智能分析文档内容,决定适合的题型和数量
|
||||
|
||||
@@ -1019,21 +1055,21 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
|
||||
注意:
|
||||
- 不适合的题型数量设为 0
|
||||
- 所有数量之和不要超过 {min(total_knowledge_points * 2, 20)}
|
||||
- 所有数量之和不要超过 {min(total_knowledge_points * 2, max_total if max_total else 20)}
|
||||
- 必须返回合法 JSON,不要有其他内容
|
||||
|
||||
请直接输出 JSON:"""
|
||||
|
||||
try:
|
||||
response = generator._call_llm(prompt)
|
||||
response = response.strip()
|
||||
if not response or not response.strip():
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
|
||||
# 清理 markdown 代码块
|
||||
if response.startswith('```'):
|
||||
lines = response.split('\n')
|
||||
response = '\n'.join(lines[1:-1] if lines[-1] == '```' else lines[1:])
|
||||
|
||||
result = json.loads(response)
|
||||
# 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
|
||||
result = extract_json_object(response)
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"AI 分析返回非对象类型: {type(result)}")
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
|
||||
# 验证和清理结果
|
||||
valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective']
|
||||
@@ -1046,6 +1082,24 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
# 过滤不适合的题型
|
||||
suitable_types = [t for t, c in question_types.items() if c > 0]
|
||||
|
||||
# 总数上限校验:如果指定了 max_total,超过时按比例缩减
|
||||
if max_total and max_total > 0:
|
||||
total = sum(question_types.values())
|
||||
if total > max_total:
|
||||
ratio = max_total / total
|
||||
question_types = {k: max(0, round(v * ratio)) for k, v in question_types.items()}
|
||||
# 修正四舍五入误差
|
||||
diff = max_total - sum(question_types.values())
|
||||
if diff > 0:
|
||||
# 把差额分配给最大的题型
|
||||
for k in sorted(question_types, key=question_types.get, reverse=True):
|
||||
question_types[k] += 1
|
||||
diff -= 1
|
||||
if diff <= 0:
|
||||
break
|
||||
logger.info(f" AI 推荐 {total} 题,按上限 {max_total} 缩减为 {sum(question_types.values())} 题")
|
||||
suitable_types = [t for t, c in question_types.items() if c > 0]
|
||||
|
||||
return {
|
||||
"total_knowledge_points": result.get('total_knowledge_points', total_knowledge_points),
|
||||
"suitable_types": suitable_types,
|
||||
@@ -1053,10 +1107,6 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
"reason": result.get('reason', 'AI 分析完成')
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"AI 分析结果 JSON 解析失败: {e}")
|
||||
# 降级:根据知识点数量生成默认配置
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
except Exception as e:
|
||||
logger.error(f"AI 分析失败: {e}")
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
@@ -1156,6 +1206,10 @@ def generate_questions_structured_v2(
|
||||
# 提取知识点
|
||||
kps = generator._extract_knowledge_points(section, section_chunks, max_points=3)
|
||||
|
||||
# API 限流保护:连续 LLM 调用间隔 1 秒
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
# 全局去重
|
||||
for kp in kps:
|
||||
kp_name = kp['name']
|
||||
@@ -1219,6 +1273,10 @@ def generate_questions_structured_v2(
|
||||
prompt, {q_type: 1}, kp_chunks, document_name, max_retries=2
|
||||
)
|
||||
|
||||
# API 限流保护:连续 LLM 调用间隔 1 秒
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
if success and questions:
|
||||
all_questions.extend(questions)
|
||||
else:
|
||||
@@ -1241,6 +1299,37 @@ def generate_questions_structured_v2(
|
||||
type_counts[q.get('question_type')] += 1
|
||||
logger.info(f" 题型分布: {dict(type_counts)}")
|
||||
|
||||
# 4.4 补题:如果某题型数量不足,使用 chunks 补充
|
||||
shortage_types = {}
|
||||
for q_type, target_count in question_types.items():
|
||||
actual_count = type_counts.get(q_type, 0)
|
||||
if actual_count < target_count:
|
||||
shortage_types[q_type] = target_count - actual_count
|
||||
|
||||
if shortage_types:
|
||||
logger.info(f" [v2] 补题: 缺少题型 {dict(shortage_types)}")
|
||||
# 收集 final 中已有题干,补题时一并排除
|
||||
existing_stems = [q.get('content', {}).get('stem', '') for q in final if q.get('content', {}).get('stem')]
|
||||
combined_exclude = list(exclude_stems or []) + existing_stems
|
||||
|
||||
for q_type, shortage in shortage_types.items():
|
||||
logger.info(f" 补充 {q_type} {shortage} 道...")
|
||||
extra = generator._makeup_questions(chunks, q_type, shortage, difficulty, document_name)
|
||||
# 补题需与已有题目 + 跨调用排除列表一起去重
|
||||
extra_deduped = _deduplicate_questions(extra, exclude_stems=combined_exclude)
|
||||
final.extend(extra_deduped[:shortage])
|
||||
# 将新补的题干也加入排除列表,防止后续题型补出重复
|
||||
for q in extra_deduped:
|
||||
stem = q.get('content', {}).get('stem', '')
|
||||
if stem:
|
||||
combined_exclude.append(stem)
|
||||
|
||||
# 补题后重新统计
|
||||
type_counts = defaultdict(int)
|
||||
for q in final:
|
||||
type_counts[q.get('question_type')] += 1
|
||||
logger.info(f" 补题后题型分布: {dict(type_counts)}")
|
||||
|
||||
return final
|
||||
|
||||
|
||||
@@ -1404,9 +1493,27 @@ def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = Non
|
||||
|
||||
# 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤
|
||||
if exclude_stems:
|
||||
_valid_types = ('single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective')
|
||||
for stem in exclude_stems:
|
||||
seen_stems.add(stem[:80])
|
||||
seen_kp_type.add(f"{stem[:30]}_") # 通配题型匹配
|
||||
# 用 exclude_stem 自身长度做前缀键(排除题干通常短于30字)
|
||||
# 新题目的 stem[:30] 如果以此前缀开头,[:len(prefix)] 后就能匹配
|
||||
_prefix = stem[:30]
|
||||
for _qt in _valid_types:
|
||||
seen_kp_type.add(f"{_prefix}_{_qt}")
|
||||
|
||||
# 辅助函数:检查新题干是否匹配任何 exclude 前缀
|
||||
_exclude_prefixes = []
|
||||
if exclude_stems:
|
||||
_exclude_prefixes = [s[:30] for s in exclude_stems]
|
||||
|
||||
def _matches_exclude(stem_text: str) -> bool:
|
||||
"""检查题干前30字是否以某个 exclude 前缀开头"""
|
||||
_stem30 = stem_text[:30]
|
||||
for _ep in _exclude_prefixes:
|
||||
if _stem30.startswith(_ep):
|
||||
return True
|
||||
return False
|
||||
|
||||
deduped = []
|
||||
|
||||
@@ -1419,6 +1526,10 @@ def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = Non
|
||||
if stem_key in seen_stems:
|
||||
continue
|
||||
|
||||
# 跨调用排除:题干前缀匹配到 exclude_stems 则跳过
|
||||
if _matches_exclude(stem):
|
||||
continue
|
||||
|
||||
# 知识点 + 题型去重
|
||||
kp_type_key = f"{stem[:30]}_{q.get('question_type')}"
|
||||
if kp_type_key in seen_kp_type:
|
||||
|
||||
@@ -37,6 +37,21 @@ except ImportError:
|
||||
MODEL = None
|
||||
LLM_AVAILABLE = False
|
||||
|
||||
# 推理模型识别(与 generator.py 共享同一套关键词)
|
||||
_REASONING_MODEL_KEYWORDS = ('mimo', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'o1', 'o3')
|
||||
|
||||
def _is_reasoning_model(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
name_lower = model_name.lower()
|
||||
return any(kw in name_lower for kw in _REASONING_MODEL_KEYWORDS)
|
||||
|
||||
def _get_effective_max_tokens(base_max: int, model_name: str) -> int:
|
||||
"""推理模型需要 1.5x token 预算给思考链"""
|
||||
if _is_reasoning_model(model_name):
|
||||
return max(base_max, int(base_max * 1.5))
|
||||
return base_max
|
||||
|
||||
|
||||
# ==================== 装饰器 ====================
|
||||
|
||||
@@ -73,11 +88,33 @@ grading_semaphore = threading.Semaphore(MAX_CONCURRENT_GRADING)
|
||||
|
||||
# ==================== 本地批阅函数 ====================
|
||||
|
||||
def _normalize_true_false(value) -> bool:
|
||||
"""
|
||||
将判断题的各种表示形式统一转为 bool。
|
||||
|
||||
支持: "对"/"错", "正确"/"错误", "true"/"false", "yes"/"no",
|
||||
True/False, 1/0, "T"/"F", "1"/"0"
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ('对', '正确', 'true', 'yes', 't', '1'):
|
||||
return True
|
||||
if v in ('错', '错误', 'false', 'no', 'f', '0'):
|
||||
return False
|
||||
# 无法识别时返回 None,让比较逻辑走原始字符串匹配
|
||||
return None
|
||||
|
||||
|
||||
def grade_objective(answer: Dict) -> Dict:
|
||||
"""
|
||||
批阅客观题(选择/判断)
|
||||
|
||||
🔥 本地直接判断,无 LLM 调用
|
||||
判断题返回的 student_answer / correct_answer 统一为 bool (true/false)
|
||||
"""
|
||||
q_type = answer['question_type']
|
||||
question_content = answer.get('content', {})
|
||||
@@ -85,17 +122,39 @@ def grade_objective(answer: Dict) -> Dict:
|
||||
student_answer = answer.get('student_answer')
|
||||
max_score = answer.get('max_score', 2.0)
|
||||
|
||||
# 判断正确性
|
||||
if q_type == 'single_choice':
|
||||
# 判断题:归一化为 bool 比较 + bool 输出
|
||||
if q_type == 'true_false':
|
||||
norm_correct = _normalize_true_false(correct_answer)
|
||||
norm_student = _normalize_true_false(student_answer)
|
||||
if norm_correct is not None and norm_student is not None:
|
||||
correct = norm_correct == norm_student
|
||||
correct_answer = norm_correct
|
||||
student_answer = norm_student
|
||||
else:
|
||||
# 兜底:无法归一化时用原始字符串比较
|
||||
correct = student_answer == correct_answer
|
||||
# 仍尝试转为 bool 输出,转不了则保留原值
|
||||
if norm_correct is not None:
|
||||
correct_answer = norm_correct
|
||||
if norm_student is not None:
|
||||
student_answer = norm_student
|
||||
elif q_type == 'single_choice':
|
||||
correct = student_answer == correct_answer
|
||||
elif q_type == 'multiple_choice':
|
||||
# 多选题:答案顺序无关
|
||||
correct = set(student_answer) == set(correct_answer) if isinstance(student_answer, list) else False
|
||||
elif q_type == 'true_false':
|
||||
correct = student_answer == correct_answer
|
||||
else:
|
||||
correct = False
|
||||
|
||||
# 构造 feedback:判断题用 true/false,其他题型用原值
|
||||
if not correct:
|
||||
if q_type == 'true_false' and isinstance(correct_answer, bool):
|
||||
feedback = f"正确答案: {'true' if correct_answer else 'false'}"
|
||||
else:
|
||||
feedback = f"正确答案: {correct_answer}"
|
||||
else:
|
||||
feedback = "正确!"
|
||||
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": max_score if correct else 0,
|
||||
@@ -105,11 +164,49 @@ def grade_objective(answer: Dict) -> Dict:
|
||||
"correct": correct,
|
||||
"student_answer": student_answer,
|
||||
"correct_answer": correct_answer,
|
||||
"feedback": f"正确答案: {correct_answer}" if not correct else "正确!"
|
||||
"feedback": feedback
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _normalize_fill_blank_answer(correct_answers: list, blank_count: int = 0) -> list:
|
||||
"""
|
||||
归一化填空题答案为二维数组 [["答案1", "同义词"], ["答案2"], ...]
|
||||
|
||||
修复 LLM 生成扁平数组 ["答案1", "答案2"] 的格式错误:
|
||||
- 扁平数组会被误认为"1个空、多个可选答案",导致匹配一个就给满分
|
||||
- 归一化后每个元素独立为空,各自占分
|
||||
|
||||
Args:
|
||||
correct_answers: 原始答案(可能是 1D 或 2D)
|
||||
blank_count: 题目声明的空数(来自 content.data.blank_count),用于辅助判断
|
||||
"""
|
||||
if not correct_answers:
|
||||
return correct_answers
|
||||
|
||||
# 已经是标准二维格式:每个元素都是 list
|
||||
if all(isinstance(item, list) for item in correct_answers):
|
||||
return correct_answers
|
||||
|
||||
# 扁平数组:元素全是字符串 → 每个字符串是独立的空
|
||||
if all(isinstance(item, str) for item in correct_answers):
|
||||
expected_blanks = blank_count if blank_count > 0 else len(correct_answers)
|
||||
logger.warning(
|
||||
f"填空题答案格式修正: 扁平数组 {correct_answers!r} → 二维数组 "
|
||||
f"(检测到 {len(correct_answers)} 个元素, blank_count={blank_count})"
|
||||
)
|
||||
return [[item] for item in correct_answers]
|
||||
|
||||
# 混合类型(不太可能发生),尝试兜底
|
||||
result = []
|
||||
for item in correct_answers:
|
||||
if isinstance(item, list):
|
||||
result.append(item)
|
||||
else:
|
||||
result.append([item])
|
||||
return result
|
||||
|
||||
|
||||
def grade_fill_blank(answer: Dict) -> Dict:
|
||||
"""
|
||||
批阅填空题 - 支持同义词匹配
|
||||
@@ -122,6 +219,33 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
||||
student_answers = answer.get('student_answer', [])
|
||||
max_score = answer.get('max_score', 4.0)
|
||||
|
||||
# 校验学生答案格式(必须是列表)
|
||||
if not isinstance(student_answers, list):
|
||||
logger.warning(f"填空题学生答案格式错误: 期望列表,实际为 {type(student_answers).__name__}: {student_answers}")
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": 0,
|
||||
"max_score": max_score,
|
||||
"grading_status": "failed",
|
||||
"details": {"error": f"学生答案格式错误,期望列表,实际为 {type(student_answers).__name__}"}
|
||||
}
|
||||
|
||||
# 校验学生答案列表中的每个元素必须是字符串
|
||||
for i, ans in enumerate(student_answers):
|
||||
if not isinstance(ans, str):
|
||||
logger.warning(f"填空题学生答案第 {i+1} 项格式错误: 期望字符串,实际为 {type(ans).__name__}: {ans}")
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": 0,
|
||||
"max_score": max_score,
|
||||
"grading_status": "failed",
|
||||
"details": {"error": f"填空题学生答案第 {i+1} 项格式错误,期望字符串,实际为 {type(ans).__name__}"}
|
||||
}
|
||||
|
||||
# 归一化答案格式(修复 LLM 生成的扁平数组问题)
|
||||
blank_count = question_content.get('data', {}).get('blank_count', 0)
|
||||
correct_answers = _normalize_fill_blank_answer(correct_answers, blank_count)
|
||||
|
||||
if not correct_answers or not student_answers:
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
@@ -169,19 +293,56 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
||||
|
||||
def fuzzy_match(student_answer: str, correct_answer: str) -> bool:
|
||||
"""
|
||||
模糊匹配(支持同义词)
|
||||
模糊匹配(支持同义词和小编辑距离容错)
|
||||
|
||||
当前实现:精确匹配(忽略前后空格、大小写)
|
||||
TODO: 可以扩展为语义相似度匹配
|
||||
策略:
|
||||
1. 精确匹配(去空格、转小写、统一标点)
|
||||
2. 编辑距离容错(≥4字答案允许≤2字符差异)
|
||||
"""
|
||||
if not student_answer or not correct_answer:
|
||||
return False
|
||||
|
||||
# 标准化:去空格、转小写
|
||||
s = student_answer.strip().lower()
|
||||
c = correct_answer.strip().lower()
|
||||
# 标准化:去空格、转小写、统一标点
|
||||
def _normalize(text: str) -> str:
|
||||
t = text.strip().lower()
|
||||
# 统一常见中文标点变体
|
||||
t = t.replace('(', '(').replace(')', ')').replace(',', ',')
|
||||
t = t.replace(';', ';').replace(':', ':').replace('"', '"').replace('"', '"')
|
||||
return t
|
||||
|
||||
return s == c
|
||||
s = _normalize(student_answer)
|
||||
c = _normalize(correct_answer)
|
||||
|
||||
if s == c:
|
||||
return True
|
||||
|
||||
# 编辑距离容错:答案≥4字时允许≤2字符差异
|
||||
if len(s) >= 4 and len(c) >= 4:
|
||||
dist = _edit_distance(s, c)
|
||||
if dist <= 2:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _edit_distance(s1: str, s2: str) -> int:
|
||||
"""计算两个字符串的编辑距离(Levenshtein)"""
|
||||
if len(s1) < len(s2):
|
||||
return _edit_distance(s2, s1)
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
prev_row = list(range(len(s2) + 1))
|
||||
for i, c1 in enumerate(s1):
|
||||
curr_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
# 插入、删除、替换
|
||||
insertions = prev_row[j + 1] + 1
|
||||
deletions = curr_row[j] + 1
|
||||
substitutions = prev_row[j] + (c1 != c2)
|
||||
curr_row.append(min(insertions, deletions, substitutions))
|
||||
prev_row = curr_row
|
||||
return prev_row[-1]
|
||||
|
||||
|
||||
# ==================== AnswerGrader 类 ====================
|
||||
@@ -237,7 +398,21 @@ class AnswerGrader:
|
||||
|
||||
# 🔥 P1 改进:并发调用 LLM 批阅主观题
|
||||
if llm_questions:
|
||||
self._grade_subjective_concurrently(llm_questions, results_map)
|
||||
try:
|
||||
self._grade_subjective_concurrently(llm_questions, results_map)
|
||||
except Exception as e:
|
||||
logger.error(f"主观题并发批阅整体异常: {e}")
|
||||
# 兜底:为所有未完成的主观题设置失败状态
|
||||
for ans in llm_questions:
|
||||
qid = ans.get('question_id')
|
||||
if qid not in results_map:
|
||||
results_map[qid] = {
|
||||
"question_id": qid,
|
||||
"score": 0,
|
||||
"max_score": ans.get('max_score', 10),
|
||||
"grading_status": "failed",
|
||||
"details": {"error": f"批阅系统异常: {str(e)}"}
|
||||
}
|
||||
|
||||
# 🔥 P1 改进:按原始顺序重组结果
|
||||
results = [results_map.get(ans.get('question_id')) for ans in answers]
|
||||
@@ -369,7 +544,7 @@ class AnswerGrader:
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
max_tokens=_get_effective_max_tokens(2000, self.model),
|
||||
messages=messages
|
||||
)
|
||||
if result is None:
|
||||
|
||||
@@ -61,6 +61,113 @@ QUESTION_BANK_DIR = "./题库"
|
||||
DRAFT_DIR = "./题库/草稿"
|
||||
|
||||
|
||||
# ==================== 文件状态检查 ====================
|
||||
|
||||
def check_file_indexed(file_path: str, collection: str) -> Dict:
|
||||
"""
|
||||
检查文件是否已向量化
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
collection: 向量库名称
|
||||
|
||||
Returns:
|
||||
{
|
||||
"indexed": True/False, # 是否已索引
|
||||
"chunk_count": 0, # 切片数量
|
||||
"status": "ready/not_found/indexing/error",
|
||||
"message": "状态说明"
|
||||
}
|
||||
"""
|
||||
if not file_path or not collection:
|
||||
return {
|
||||
"indexed": False,
|
||||
"chunk_count": 0,
|
||||
"status": "error",
|
||||
"message": "缺少文件路径或向量库名称"
|
||||
}
|
||||
|
||||
try:
|
||||
from core.engine import get_engine
|
||||
engine = get_engine()
|
||||
|
||||
if not engine:
|
||||
return {
|
||||
"indexed": False,
|
||||
"chunk_count": 0,
|
||||
"status": "error",
|
||||
"message": "RAG 引擎未初始化"
|
||||
}
|
||||
|
||||
# 提取文件名
|
||||
filename = os.path.basename(file_path.replace("\\", "/"))
|
||||
|
||||
# 查询向量库
|
||||
collections = [collection] if isinstance(collection, str) else list(collection) if collection else []
|
||||
|
||||
if not collections:
|
||||
return {
|
||||
"indexed": False,
|
||||
"chunk_count": 0,
|
||||
"status": "error",
|
||||
"message": "未指定向量库"
|
||||
}
|
||||
|
||||
# 尝试检索文件切片
|
||||
total_chunks = 0
|
||||
found_collection = None
|
||||
|
||||
for coll in collections:
|
||||
try:
|
||||
# 使用简单 query 查询文件
|
||||
results = engine.search_knowledge(
|
||||
query="document",
|
||||
collections=[coll],
|
||||
source_filter=filename,
|
||||
top_k=1
|
||||
)
|
||||
|
||||
if results and results.get('documents') and results['documents'][0]:
|
||||
# 获取该文件的切片总数
|
||||
collection_obj = engine.kb_manager.get_collection(coll)
|
||||
if collection_obj:
|
||||
all_results = collection_obj.get(
|
||||
where={"source": filename}
|
||||
)
|
||||
if all_results and all_results.get('ids'):
|
||||
total_chunks = len(all_results['ids'])
|
||||
found_collection = coll
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"检查向量库 {coll} 失败: {e}")
|
||||
continue
|
||||
|
||||
if total_chunks > 0:
|
||||
return {
|
||||
"indexed": True,
|
||||
"chunk_count": total_chunks,
|
||||
"status": "ready",
|
||||
"message": f"文件已索引,共 {total_chunks} 个切片",
|
||||
"collection": found_collection
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"indexed": False,
|
||||
"chunk_count": 0,
|
||||
"status": "not_found",
|
||||
"message": f"文件 {filename} 未在向量库中找到,可能正在向量化或未上传"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查文件索引状态失败: {e}")
|
||||
return {
|
||||
"indexed": False,
|
||||
"chunk_count": 0,
|
||||
"status": "error",
|
||||
"message": f"检查状态异常: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
# ==================== 新版出题接口 ====================
|
||||
|
||||
def generate_questions_from_file(
|
||||
@@ -102,6 +209,28 @@ def generate_questions_from_file(
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# 🔥 新增:检查文件向量化状态
|
||||
file_status = check_file_indexed(file_path, collection)
|
||||
|
||||
if not file_status["indexed"]:
|
||||
# 文件未索引,返回明确的错误信息
|
||||
error_messages = {
|
||||
"not_found": f"文件未向量化: {file_status['message']}",
|
||||
"error": f"检查文件状态失败: {file_status['message']}",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"request_id": request_id,
|
||||
"error_code": "FILE_NOT_INDEXED" if file_status["status"] == "not_found" else "STATUS_CHECK_ERROR",
|
||||
"message": error_messages.get(file_status["status"], file_status["message"]),
|
||||
"file_status": file_status["status"],
|
||||
"chunk_count": file_status["chunk_count"],
|
||||
"questions": [],
|
||||
"total": 0,
|
||||
"source_chunks_used": 0
|
||||
}
|
||||
|
||||
# 1. 检索文件的所有切片
|
||||
chunks = retrieve_file_chunks(
|
||||
file_path=file_path,
|
||||
@@ -154,7 +283,8 @@ def generate_questions_from_file(
|
||||
def analyze_file_for_exam(
|
||||
file_path: str,
|
||||
collection: str,
|
||||
top_k: int = 50
|
||||
top_k: int = 50,
|
||||
max_total: int = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
分析文件内容,返回 AI 推荐的题型和数量
|
||||
@@ -193,7 +323,7 @@ def analyze_file_for_exam(
|
||||
}
|
||||
|
||||
# 2. 调用 AI 分析
|
||||
return analyze_document_for_exam(chunks)
|
||||
return analyze_document_for_exam(chunks, max_total=max_total)
|
||||
|
||||
|
||||
def retrieve_file_chunks_for_analysis(
|
||||
@@ -226,7 +356,7 @@ def retrieve_file_chunks_for_analysis(
|
||||
collections = [collection] if isinstance(collection, str) else collection
|
||||
|
||||
# 提取文件名(向量库存储的是文件名,不含路径前缀)
|
||||
filename = os.path.basename(file_path)
|
||||
filename = os.path.basename(file_path.replace("\\", "/"))
|
||||
|
||||
# 向量检索(尝试文件名和完整路径)
|
||||
results = None
|
||||
@@ -294,7 +424,7 @@ def retrieve_file_chunks(
|
||||
query = build_semantic_query(question_types)
|
||||
|
||||
# 提取文件名(向量库存储的是文件名,不含路径前缀)
|
||||
filename = os.path.basename(file_path)
|
||||
filename = os.path.basename(file_path.replace("\\", "/"))
|
||||
|
||||
# 统一处理为列表
|
||||
if isinstance(collection, str):
|
||||
@@ -311,6 +441,7 @@ def retrieve_file_chunks(
|
||||
engine = get_engine()
|
||||
|
||||
# 按优先级遍历 collections,找到文件即停止
|
||||
results = None
|
||||
for coll in collections:
|
||||
# 尝试两种格式:文件名和完整路径
|
||||
for source_filter in [filename, file_path]:
|
||||
@@ -330,7 +461,7 @@ def retrieve_file_chunks(
|
||||
break # 外层循环跳出
|
||||
|
||||
chunks = []
|
||||
if results.get('documents') and results['documents'][0]:
|
||||
if results and results.get('documents') and results['documents'][0]:
|
||||
for i, (doc, meta, score) in enumerate(zip(
|
||||
results['documents'][0],
|
||||
results['metadatas'][0],
|
||||
|
||||
@@ -382,18 +382,34 @@ class BM25Index:
|
||||
|
||||
def add_documents(self, ids: List[str], documents: List[str], metadatas: List[dict]) -> None:
|
||||
"""
|
||||
添加文档到索引(会覆盖原有索引)
|
||||
添加文档到索引(追加模式,自动去重)
|
||||
|
||||
如果 ID 已存在则更新对应文档,否则追加新文档。
|
||||
添加后自动重建 BM25 索引。
|
||||
|
||||
Args:
|
||||
ids: 文档 ID 列表
|
||||
documents: 文档内容列表
|
||||
metadatas: 文档元数据列表
|
||||
"""
|
||||
self.ids = ids
|
||||
self.documents = documents
|
||||
self.metadatas = metadatas
|
||||
if documents:
|
||||
tokenized = [self.tokenize(doc) for doc in documents]
|
||||
# 建立已有 ID -> 索引位置 的映射,用于去重
|
||||
existing_map = {doc_id: idx for idx, doc_id in enumerate(self.ids)}
|
||||
|
||||
for i, doc_id in enumerate(ids):
|
||||
if doc_id in existing_map:
|
||||
# 更新已有文档
|
||||
pos = existing_map[doc_id]
|
||||
self.documents[pos] = documents[i]
|
||||
self.metadatas[pos] = metadatas[i]
|
||||
else:
|
||||
# 追加新文档
|
||||
existing_map[doc_id] = len(self.ids)
|
||||
self.ids.append(doc_id)
|
||||
self.documents.append(documents[i])
|
||||
self.metadatas.append(metadatas[i])
|
||||
|
||||
if self.documents:
|
||||
tokenized = [self.tokenize(doc) for doc in self.documents]
|
||||
self.bm25 = BM25Okapi(tokenized)
|
||||
|
||||
def search(self, query: str, top_k: int = 10) -> Tuple[List[str], List[str], List[dict], List[float]]:
|
||||
|
||||
@@ -134,6 +134,9 @@ class CollectionMixin:
|
||||
"""
|
||||
from .base import BM25Index
|
||||
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
|
||||
if not kb_name or not kb_name.replace('_', '').isalnum():
|
||||
return False, "向量库名称只能包含字母、数字和下划线"
|
||||
|
||||
@@ -190,6 +193,9 @@ class CollectionMixin:
|
||||
Returns:
|
||||
更新成功返回 True,向量库不存在返回 False
|
||||
"""
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
|
||||
collections = self._metadata.get("collections", {})
|
||||
if kb_name not in collections:
|
||||
return False
|
||||
@@ -228,6 +234,9 @@ class CollectionMixin:
|
||||
"""
|
||||
import shutil
|
||||
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
|
||||
if kb_name == PUBLIC_KB_NAME:
|
||||
return False, "公开知识库不能删除"
|
||||
|
||||
@@ -322,6 +331,21 @@ class CollectionMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
# 清理不再被引用的图片和 VLM 缓存文件
|
||||
# 注意:此时 ChromaDB collection 已删除,cleanup_image_orphans 会扫描
|
||||
# 所有剩余 collection,仅该 collection 引用的图片会被识别为孤儿
|
||||
try:
|
||||
from knowledge.image_cleanup import cleanup_image_orphans
|
||||
cleanup_result = cleanup_image_orphans(self)
|
||||
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
|
||||
f"{cleanup_result['deleted_caches']} VLM缓存, "
|
||||
f"释放 {cleanup_result['freed_bytes']/1024:.1f} KB"
|
||||
)
|
||||
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()
|
||||
@@ -348,6 +372,8 @@ class CollectionMixin:
|
||||
- department: 所属部门
|
||||
- description: 描述
|
||||
"""
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
result = []
|
||||
|
||||
# 扫描 base_path 下的所有子目录作为向量库
|
||||
@@ -382,16 +408,31 @@ class CollectionMixin:
|
||||
|
||||
self._save_metadata()
|
||||
|
||||
stale_collections = []
|
||||
|
||||
for name, info in self._metadata.get("collections", {}).items():
|
||||
collection = self.get_collection(name)
|
||||
result.append(CollectionInfo(
|
||||
name=name,
|
||||
display_name=info.get("display_name", name),
|
||||
document_count=collection.count() if collection else 0,
|
||||
created_at=info.get("created_at", ""),
|
||||
department=info.get("department", ""),
|
||||
description=info.get("description", "")
|
||||
))
|
||||
try:
|
||||
collection = self.get_collection(name)
|
||||
result.append(CollectionInfo(
|
||||
name=name,
|
||||
display_name=info.get("display_name", name),
|
||||
document_count=collection.count() if collection else 0,
|
||||
created_at=info.get("created_at", ""),
|
||||
department=info.get("department", ""),
|
||||
description=info.get("description", "")
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"跳过异常向量库 '{name}': {e},可能是 ChromaDB 数据目录已丢失"
|
||||
)
|
||||
stale_collections.append(name)
|
||||
|
||||
# 清理元数据中指向已失效集合的条目
|
||||
if stale_collections:
|
||||
for name in stale_collections:
|
||||
self._metadata.get("collections", {}).pop(name, None)
|
||||
logger.info(f"清理失效向量库元数据: {name}")
|
||||
self._save_metadata()
|
||||
|
||||
return result
|
||||
|
||||
@@ -405,4 +446,6 @@ class CollectionMixin:
|
||||
Returns:
|
||||
存在返回 True,不存在返回 False
|
||||
"""
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
return kb_name in self._metadata.get("collections", {})
|
||||
|
||||
@@ -72,6 +72,18 @@ class DocumentMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
# 清理不再被引用的图片和 VLM 缓存文件
|
||||
try:
|
||||
from knowledge.image_cleanup import cleanup_image_orphans
|
||||
cleanup_result = cleanup_image_orphans(self, collections=[kb_name])
|
||||
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
|
||||
f"{cleanup_result['deleted_caches']} VLM缓存"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理孤儿文件失败: {e}")
|
||||
|
||||
logger.info(f"从 {kb_name} 删除文档: {filename}, 片段数: {deleted}")
|
||||
return deleted
|
||||
|
||||
|
||||
140
knowledge/image_cleanup.py
Normal file
140
knowledge/image_cleanup.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
图片/VLM缓存孤儿文件清理模块
|
||||
|
||||
提供可被 document.py / collection.py 调用的清理函数,
|
||||
也可被 cleanup_orphans.py 独立脚本使用。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGES_DIR = Path(".data/images")
|
||||
VLM_CACHE_DIR = Path(".data/cache/vlm")
|
||||
|
||||
|
||||
def compute_file_hash(file_path: str) -> str:
|
||||
"""计算文件 MD5"""
|
||||
with open(file_path, 'rb') as f:
|
||||
return hashlib.md5(f.read()).hexdigest()
|
||||
|
||||
|
||||
def collect_referenced_images(manager, collections=None) -> set:
|
||||
"""
|
||||
从 ChromaDB 收集所有被引用的图片文件名。
|
||||
|
||||
Args:
|
||||
manager: KnowledgeBaseManager 实例
|
||||
collections: 限定知识库列表,None 表示全部
|
||||
|
||||
Returns:
|
||||
set of image filenames (e.g., {"185a7a75d246.png", ...})
|
||||
"""
|
||||
referenced = set()
|
||||
|
||||
if collections:
|
||||
kb_names = collections
|
||||
else:
|
||||
kb_names = [c.name if hasattr(c, 'name') else str(c)
|
||||
for c in manager.list_collections()]
|
||||
|
||||
for kb_name in kb_names:
|
||||
try:
|
||||
col = manager.get_collection(kb_name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
results = col.get(include=['metadatas'])
|
||||
if not results['ids']:
|
||||
continue
|
||||
|
||||
for meta in results['metadatas']:
|
||||
image_path = meta.get('image_path', '')
|
||||
if image_path:
|
||||
referenced.add(os.path.basename(image_path))
|
||||
|
||||
return referenced
|
||||
|
||||
|
||||
def cleanup_image_orphans(manager, collections=None, dry_run=False) -> dict:
|
||||
"""
|
||||
清理不再被任何 ChromaDB 切片引用的图片和 VLM 缓存文件。
|
||||
|
||||
Args:
|
||||
manager: KnowledgeBaseManager 实例
|
||||
collections: 限定知识库列表,None 表示全部
|
||||
dry_run: True 时只返回孤儿列表不实际删除
|
||||
|
||||
Returns:
|
||||
{
|
||||
'orphan_images': [(filepath, filename, size_bytes)],
|
||||
'orphan_caches': [(filepath, filename, size_bytes)],
|
||||
'deleted_images': int,
|
||||
'deleted_caches': int,
|
||||
'freed_bytes': int
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'orphan_images': [],
|
||||
'orphan_caches': [],
|
||||
'deleted_images': 0,
|
||||
'deleted_caches': 0,
|
||||
'freed_bytes': 0
|
||||
}
|
||||
|
||||
# 1. 收集引用
|
||||
referenced = collect_referenced_images(manager, collections)
|
||||
|
||||
# 2. 查找孤儿图片
|
||||
if IMAGES_DIR.exists():
|
||||
for f in IMAGES_DIR.iterdir():
|
||||
if f.is_file() and f.name not in referenced:
|
||||
result['orphan_images'].append((str(f), f.name, f.stat().st_size))
|
||||
|
||||
# 3. 查找孤儿 VLM 缓存(图片已删除则缓存也应是孤儿)
|
||||
if VLM_CACHE_DIR.exists():
|
||||
referenced_hashes = set()
|
||||
for filename in referenced:
|
||||
full_path = IMAGES_DIR / filename
|
||||
if full_path.exists():
|
||||
try:
|
||||
img_hash = compute_file_hash(str(full_path))
|
||||
referenced_hashes.add(img_hash)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for f in VLM_CACHE_DIR.iterdir():
|
||||
if f.is_file() and f.suffix == '.txt':
|
||||
cache_hash = f.stem
|
||||
if cache_hash not in referenced_hashes:
|
||||
result['orphan_caches'].append((str(f), f.name, f.stat().st_size))
|
||||
|
||||
# 4. 删除
|
||||
if not dry_run:
|
||||
for filepath, filename, size in result['orphan_images']:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
result['deleted_images'] += 1
|
||||
result['freed_bytes'] += size
|
||||
except OSError as e:
|
||||
logger.warning(f"删除孤儿图片失败: {filename} - {e}")
|
||||
|
||||
for filepath, filename, size in result['orphan_caches']:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
result['deleted_caches'] += 1
|
||||
result['freed_bytes'] += size
|
||||
except OSError as e:
|
||||
logger.warning(f"删除孤儿缓存失败: {filename} - {e}")
|
||||
|
||||
if result['deleted_images'] or result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿: {result['deleted_images']} 图片 + "
|
||||
f"{result['deleted_caches']} 缓存, "
|
||||
f"释放 {result['freed_bytes']/1024:.1f} KB"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -25,7 +25,20 @@ def compute_file_hash(file_path: str) -> str:
|
||||
return hashlib.md5(file_path.encode()).hexdigest()
|
||||
|
||||
|
||||
async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, metadata: dict = None) -> str:
|
||||
def _get_embedding_model():
|
||||
"""从 RAGEngine 获取 embedding 模型(KnowledgeBaseManager 上没有此属性)"""
|
||||
try:
|
||||
from core.engine import get_engine
|
||||
engine = get_engine()
|
||||
if not engine._initialized:
|
||||
engine.initialize()
|
||||
return engine.embedding_model
|
||||
except Exception as e:
|
||||
logger.warning(f"获取 embedding 模型失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, metadata: dict = None, defer_chromadb: bool = False) -> str:
|
||||
"""
|
||||
懒加载 VLM 描述
|
||||
|
||||
@@ -36,6 +49,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
image_path: 图片路径(相对路径或绝对路径)
|
||||
kb_name: 知识库名称
|
||||
metadata: 图片元数据(包含 section、page、caption、上下文等)
|
||||
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||
|
||||
Returns:
|
||||
VLM 生成的图片描述
|
||||
@@ -49,23 +63,45 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
else:
|
||||
full_image_path = image_path
|
||||
|
||||
# 1. 检查缓存
|
||||
# 1. 检查缓存(空缓存视为无效,需重新生成)
|
||||
img_hash = compute_file_hash(full_image_path)
|
||||
cache_file = VLM_CACHE_DIR / f"{img_hash}.txt"
|
||||
if cache_file.exists():
|
||||
logger.info(f"VLM 缓存命中: {image_path}")
|
||||
return cache_file.read_text(encoding='utf-8')
|
||||
cached = cache_file.read_text(encoding='utf-8')
|
||||
if len(cached.strip()) >= 5:
|
||||
logger.info(f"VLM 缓存命中: {image_path}")
|
||||
return cached
|
||||
else:
|
||||
logger.warning(f"VLM 缓存内容过短({len(cached.strip())}字符),删除并重新生成: {image_path}")
|
||||
try:
|
||||
cache_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. 调用 VLM(传入元数据)
|
||||
logger.info(f"VLM 懒加载: {image_path}")
|
||||
kb_manager = get_kb_manager()
|
||||
description = kb_manager._generate_image_description(full_image_path, metadata=metadata)
|
||||
|
||||
# 3. 写入缓存
|
||||
# 3. 空描述保护:VLM 返回内容过短时不写入缓存和向量库
|
||||
if not description or len(description.strip()) < 5:
|
||||
logger.warning(f"VLM 返回描述过短({len(description.strip()) if description else 0}字符),跳过缓存和向量库更新: {image_path}")
|
||||
return description or ''
|
||||
|
||||
# 4. 写入缓存
|
||||
VLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(description, encoding='utf-8')
|
||||
|
||||
# 4. 更新向量库(metadata + embedding)
|
||||
# 5. 更新向量库(metadata + embedding),需校验 chunk_id 非空
|
||||
# defer_chromadb=True 时跳过(后台线程只写缓存,避免 SQLite 写锁竞争)
|
||||
if defer_chromadb:
|
||||
logger.info(f"延迟 ChromaDB 更新(仅写缓存): {chunk_id}")
|
||||
return description
|
||||
|
||||
if not chunk_id:
|
||||
logger.warning("chunk_id 为空,跳过向量库更新")
|
||||
return description
|
||||
|
||||
try:
|
||||
collection = kb_manager.get_collection(kb_name)
|
||||
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
||||
@@ -79,7 +115,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
|
||||
# 更新 embedding(使用 VLM 描述重新计算向量)
|
||||
# 这样 VLM 描述中的关键词(如"发电量")才能参与相似度检索
|
||||
embedding_model = kb_manager.embedding_model
|
||||
embedding_model = _get_embedding_model()
|
||||
if embedding_model:
|
||||
new_vector = embedding_model.encode(description).tolist()
|
||||
if isinstance(new_vector[0], list):
|
||||
@@ -91,20 +127,21 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
embeddings=[new_vector],
|
||||
documents=[description] # 同时更新 document 字段
|
||||
)
|
||||
logger.info(f"已更新向量库 embedding: {chunk_id}")
|
||||
logger.info(f"已更新向量库(embedding+metadata): {chunk_id}")
|
||||
else:
|
||||
# 无 embedding 模型时只更新 metadata
|
||||
collection.update(
|
||||
ids=[chunk_id],
|
||||
metadatas=[new_metadata]
|
||||
)
|
||||
logger.info(f"已更新向量库(仅metadata,无embedding模型): {chunk_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"更新向量库失败: {e}")
|
||||
|
||||
return description
|
||||
|
||||
|
||||
async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str) -> str:
|
||||
async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str, defer_chromadb: bool = False) -> str:
|
||||
"""
|
||||
懒加载表格摘要
|
||||
|
||||
@@ -114,50 +151,76 @@ async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str) -> str:
|
||||
chunk_id: 切片 ID
|
||||
table_md: 表格 Markdown 内容
|
||||
kb_name: 知识库名称
|
||||
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||
|
||||
Returns:
|
||||
LLM 生成的表格摘要
|
||||
"""
|
||||
from knowledge.manager import get_kb_manager
|
||||
|
||||
# 1. 检查缓存
|
||||
# 1. 检查缓存(空缓存视为无效)
|
||||
table_hash = hashlib.md5(table_md.encode()).hexdigest()
|
||||
cache_file = LLM_CACHE_DIR / f"{table_hash}.txt"
|
||||
if cache_file.exists():
|
||||
logger.info(f"LLM 缓存命中: {chunk_id}")
|
||||
return cache_file.read_text(encoding='utf-8')
|
||||
cached = cache_file.read_text(encoding='utf-8')
|
||||
if len(cached.strip()) >= 5:
|
||||
logger.info(f"LLM 缓存命中: {chunk_id}")
|
||||
return cached
|
||||
else:
|
||||
logger.warning(f"LLM 缓存内容过短({len(cached.strip())}字符),删除并重新生成: {chunk_id}")
|
||||
try:
|
||||
cache_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. 调用 LLM
|
||||
logger.info(f"LLM 懒加载: {chunk_id}")
|
||||
kb_manager = get_kb_manager()
|
||||
summary = kb_manager._generate_table_summary(table_md, None)
|
||||
|
||||
# 空摘要保护
|
||||
if not summary or len(summary.strip()) < 5:
|
||||
logger.warning(f"LLM 返回摘要过短,跳过缓存和向量库更新: {chunk_id}")
|
||||
return summary or ''
|
||||
|
||||
# 3. 写入缓存
|
||||
LLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(summary, encoding='utf-8')
|
||||
|
||||
# 4. 更新向量库(可选)
|
||||
# 4. 更新向量库,需校验 chunk_id 非空
|
||||
# defer_chromadb=True 时跳过(后台线程只写缓存,避免 SQLite 写锁竞争)
|
||||
if defer_chromadb:
|
||||
logger.info(f"延迟 ChromaDB 更新(仅写缓存): {chunk_id}")
|
||||
return summary
|
||||
|
||||
if not chunk_id:
|
||||
logger.warning("chunk_id 为空,跳过表格向量库更新")
|
||||
return summary
|
||||
try:
|
||||
collection = kb_manager.get_collection(kb_name)
|
||||
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
||||
if result['metadatas']:
|
||||
# 新增摘要切片
|
||||
embedding_model = kb_manager.embedding_model
|
||||
vector = embedding_model.encode(summary).tolist()
|
||||
if isinstance(vector[0], list):
|
||||
vector = vector[0]
|
||||
# 新增摘要切片(需要 embedding 模型)
|
||||
embedding_model = _get_embedding_model()
|
||||
if embedding_model:
|
||||
vector = embedding_model.encode(summary).tolist()
|
||||
if isinstance(vector[0], list):
|
||||
vector = vector[0]
|
||||
|
||||
collection.add(
|
||||
ids=[f"{chunk_id}_summary"],
|
||||
embeddings=[vector],
|
||||
documents=[summary],
|
||||
metadatas=[{
|
||||
**result['metadatas'][0],
|
||||
'is_summary': True,
|
||||
'original_doc_id': chunk_id
|
||||
}]
|
||||
)
|
||||
# 更新原切片标记
|
||||
collection.add(
|
||||
ids=[f"{chunk_id}_summary"],
|
||||
embeddings=[vector],
|
||||
documents=[summary],
|
||||
metadatas=[{
|
||||
**result['metadatas'][0],
|
||||
'is_summary': True,
|
||||
'original_doc_id': chunk_id
|
||||
}]
|
||||
)
|
||||
logger.info(f"已新增摘要切片(embedding): {chunk_id}_summary")
|
||||
else:
|
||||
logger.info(f"跳过摘要切片(无embedding模型): {chunk_id}")
|
||||
# 更新原切片标记(不依赖 embedding 模型)
|
||||
collection.update(
|
||||
ids=[chunk_id],
|
||||
metadatas=[{**result['metadatas'][0], 'has_summary': True}]
|
||||
@@ -168,7 +231,7 @@ async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str) -> str:
|
||||
return summary
|
||||
|
||||
|
||||
async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
||||
async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str, defer_chromadb: bool = False):
|
||||
"""
|
||||
检索后增强:按需调用 LLM/VLM
|
||||
|
||||
@@ -176,23 +239,24 @@ async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
||||
contexts: 检索上下文列表
|
||||
query: 用户查询
|
||||
kb_name: 知识库名称
|
||||
defer_chromadb: 为 True 时后台线程只写文件缓存,不更新 ChromaDB(避免写锁竞争)
|
||||
"""
|
||||
for ctx in contexts:
|
||||
meta = ctx.get('meta', {})
|
||||
chunk_type = meta.get('chunk_type', 'text')
|
||||
image_path = meta.get('image_path', '')
|
||||
import re
|
||||
|
||||
# 图片切片:懒加载 VLM 描述
|
||||
if chunk_type in ('image', 'chart') and not meta.get('has_vlm_desc'):
|
||||
if image_path:
|
||||
try:
|
||||
for ctx in contexts:
|
||||
try:
|
||||
meta = ctx.get('meta', {})
|
||||
chunk_type = meta.get('chunk_type', 'text')
|
||||
image_path = meta.get('image_path', '')
|
||||
|
||||
# 图片切片:懒加载 VLM 描述
|
||||
if chunk_type in ('image', 'chart') and not meta.get('has_vlm_desc'):
|
||||
if image_path:
|
||||
# 从 doc 字段中提取图号(上下文可能包含"见图2.5"等)
|
||||
doc_text = ctx.get('doc', '')
|
||||
import re
|
||||
|
||||
# 提取图号(从前文/后文中)
|
||||
figure_number = ""
|
||||
# 匹配 "见图2.5"、"图2.5"、"见图 2.5" 等
|
||||
fig_match = re.search(r'[见如]?图\s*(\d+\.?\d*)', doc_text)
|
||||
if fig_match:
|
||||
figure_number = fig_match.group(1)
|
||||
@@ -210,78 +274,73 @@ async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
||||
'page': meta.get('page'),
|
||||
'caption': meta.get('caption', ''),
|
||||
'source': meta.get('source', ''),
|
||||
'figure_number': figure_number, # 添加提取的图号
|
||||
'doc_text': doc_text # 添加完整文档文本
|
||||
'figure_number': figure_number,
|
||||
'doc_text': doc_text
|
||||
}
|
||||
vlm_desc = await lazy_vlm_description(
|
||||
meta.get('id', ''),
|
||||
meta.get('chunk_id', ''),
|
||||
image_path,
|
||||
kb_name,
|
||||
metadata=image_metadata
|
||||
metadata=image_metadata,
|
||||
defer_chromadb=defer_chromadb
|
||||
)
|
||||
ctx['doc'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"VLM 懒加载失败: {e}")
|
||||
if vlm_desc:
|
||||
ctx['doc'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
|
||||
# 表格切片:同时处理摘要和关联图片的 VLM 描述
|
||||
elif chunk_type == 'table':
|
||||
doc_text = ctx.get('doc', '')
|
||||
# 表格切片:同时处理摘要和关联图片的 VLM 描述
|
||||
elif chunk_type == 'table':
|
||||
doc_text = ctx.get('doc', '')
|
||||
|
||||
# 1. 懒加载表格摘要(高分切片)
|
||||
if not meta.get('has_summary'):
|
||||
score = meta.get('score', 0)
|
||||
if score > 0.7: # 只对高相关表格生成摘要
|
||||
try:
|
||||
# 1. 懒加载表格摘要(高分切片)
|
||||
if not meta.get('has_summary'):
|
||||
score = ctx.get('score', 0)
|
||||
if score > 0.7:
|
||||
summary = await lazy_table_summary(
|
||||
meta.get('id', ''),
|
||||
meta.get('chunk_id', ''),
|
||||
doc_text,
|
||||
kb_name
|
||||
kb_name,
|
||||
defer_chromadb=defer_chromadb
|
||||
)
|
||||
# 摘要作为补充信息
|
||||
ctx['summary'] = summary
|
||||
ctx['llm_enhanced'] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"表格摘要懒加载失败: {e}")
|
||||
|
||||
# 2. 表格有关联图片时,懒加载 VLM 描述
|
||||
if image_path and not meta.get('has_vlm_desc'):
|
||||
try:
|
||||
import re
|
||||
if summary:
|
||||
ctx['summary'] = summary
|
||||
ctx['llm_enhanced'] = True
|
||||
|
||||
# 2. 表格有关联图片时,懒加载 VLM 描述
|
||||
if image_path and not meta.get('has_vlm_desc'):
|
||||
# 提取表号(如 "表2.2"、"见表2.1")
|
||||
table_number = ""
|
||||
# 匹配 "表2.2"、"见表2.2"、"见表 2.2" 等
|
||||
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', doc_text)
|
||||
if table_match:
|
||||
table_number = table_match.group(1)
|
||||
|
||||
# 如果 doc 中没有,尝试从 section 中提取
|
||||
section = meta.get('section') or meta.get('section_path', '')
|
||||
if not table_number and section:
|
||||
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', section)
|
||||
if table_match:
|
||||
table_number = table_match.group(1)
|
||||
|
||||
# 构建表格图片元数据
|
||||
table_image_metadata = {
|
||||
'section': section,
|
||||
'page': meta.get('page'),
|
||||
'caption': meta.get('caption', ''),
|
||||
'source': meta.get('source', ''),
|
||||
'table_number': table_number, # 表号
|
||||
'figure_number': table_number, # 兼容字段
|
||||
'table_number': table_number,
|
||||
'figure_number': table_number,
|
||||
'doc_text': doc_text,
|
||||
'is_table': True # 标记为表格图片
|
||||
'is_table': True
|
||||
}
|
||||
vlm_desc = await lazy_vlm_description(
|
||||
meta.get('id', ''),
|
||||
meta.get('chunk_id', ''),
|
||||
image_path,
|
||||
kb_name,
|
||||
metadata=table_image_metadata
|
||||
metadata=table_image_metadata,
|
||||
defer_chromadb=defer_chromadb
|
||||
)
|
||||
# 表格图片描述作为补充信息
|
||||
ctx['image_description'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"表格图片 VLM 懒加载失败: {e}")
|
||||
if vlm_desc:
|
||||
ctx['image_description'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
|
||||
except Exception as e:
|
||||
chunk_id = ctx.get('meta', {}).get('chunk_id', '?')
|
||||
logger.warning(f"增强切片失败(chunk_id={chunk_id}): {e}")
|
||||
|
||||
@@ -29,11 +29,17 @@
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
try:
|
||||
import fcntl
|
||||
_HAS_FCNTL = True
|
||||
except ImportError:
|
||||
_HAS_FCNTL = False # Windows 环境无 fcntl
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
import chromadb
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# 从 base.py 导入基础类和常量
|
||||
from .base import (
|
||||
@@ -134,22 +140,36 @@ class KnowledgeBaseManager(
|
||||
logger.info(f"知识库管理器初始化完成,路径: {self.base_path},发现 {len(existing_kbs)} 个向量库: {existing_kbs}")
|
||||
|
||||
def _load_metadata(self) -> dict:
|
||||
"""加载元数据"""
|
||||
"""加载元数据(带文件锁,确保多 worker 进程间一致)"""
|
||||
metadata_path = os.path.join(self.base_path, KB_METADATA_FILE)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
return json.load(f)
|
||||
finally:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except Exception as e:
|
||||
logger.error(f"加载元数据失败: {e}")
|
||||
return {"collections": {}}
|
||||
|
||||
def _save_metadata(self):
|
||||
"""保存元数据"""
|
||||
"""保存元数据(带文件锁,防止并发写入数据覆盖)"""
|
||||
metadata_path = os.path.join(self.base_path, KB_METADATA_FILE)
|
||||
try:
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._metadata, f, ensure_ascii=False, indent=2)
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(self._metadata, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
finally:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except Exception as e:
|
||||
logger.error(f"保存元数据失败: {e}")
|
||||
|
||||
@@ -316,6 +336,7 @@ class KnowledgeBaseManager(
|
||||
"section": section_path,
|
||||
"status": "active",
|
||||
"version": "v1",
|
||||
"text_level": getattr(chunk, 'text_level', 0), # 标题级别(0=正文,1=h1,2=h2,3=h3),供检索管线层次感知
|
||||
}
|
||||
|
||||
if extra_metadata:
|
||||
@@ -328,6 +349,25 @@ class KnowledgeBaseManager(
|
||||
if hasattr(chunk, 'image_path') and chunk.image_path:
|
||||
metadata['image_path'] = chunk.image_path
|
||||
|
||||
# bbox 坐标(PDF 有,DOCX 无,需 None 保护)
|
||||
# _build_citation() 从 metadata 读取 bbox 做引用定位
|
||||
chunk_bbox = getattr(chunk, 'bbox', None)
|
||||
if chunk_bbox:
|
||||
metadata['bbox'] = json.dumps(chunk_bbox)
|
||||
|
||||
# MinerU 结构化元数据(表格类型、嵌套层级、图片子类型)
|
||||
chunk_table_type = getattr(chunk, 'table_type', '')
|
||||
if chunk_table_type:
|
||||
metadata['table_type'] = chunk_table_type
|
||||
|
||||
chunk_nest_level = getattr(chunk, 'table_nest_level', '')
|
||||
if chunk_nest_level:
|
||||
metadata['table_nest_level'] = str(chunk_nest_level)
|
||||
|
||||
chunk_sub_type = getattr(chunk, 'sub_type', '')
|
||||
if chunk_sub_type:
|
||||
metadata['sub_type'] = chunk_sub_type
|
||||
|
||||
# 生成向量
|
||||
try:
|
||||
embedding = embedding_model.encode(semantic_content).tolist()
|
||||
@@ -393,6 +433,11 @@ class KnowledgeBaseManager(
|
||||
if len(chunks) < 2:
|
||||
return chunks
|
||||
|
||||
# 检测页码是否可靠:若所有 chunk 的 page_start 相同(如 Word 文档 page_idx 全为 0),
|
||||
# 则页码信息不可用,需要启用降级合并规则
|
||||
page_values = set(getattr(c, 'page_start', 0) for c in chunks)
|
||||
pages_unavailable = len(page_values) <= 1
|
||||
|
||||
merged_chunks = []
|
||||
i = 0
|
||||
merge_count = 0
|
||||
@@ -405,6 +450,7 @@ class KnowledgeBaseManager(
|
||||
# 查找下一个表格(跳过中间的"续表"文本)
|
||||
next_table_idx = None
|
||||
next_chunk = None
|
||||
intermediate_texts = [] # 收集中间文本用于降级判断
|
||||
|
||||
for j in range(i + 1, min(i + 4, len(chunks))): # 最多向前看3个切片
|
||||
candidate = chunks[j]
|
||||
@@ -419,7 +465,11 @@ class KnowledgeBaseManager(
|
||||
elif candidate_type == 'text' and ('续表' in candidate_title or '续表' in candidate_content):
|
||||
# 遇到"续表"文本,继续查找下一个表格
|
||||
continue
|
||||
elif candidate_type not in ('text',):
|
||||
elif candidate_type == 'text':
|
||||
# 非"续表"文本,收集后停止查找
|
||||
intermediate_texts.append(candidate)
|
||||
break
|
||||
else:
|
||||
# 遇到非文本类型,停止查找
|
||||
break
|
||||
|
||||
@@ -439,24 +489,59 @@ class KnowledgeBaseManager(
|
||||
# 获取内容(用于检测"续表")
|
||||
next_content = getattr(next_chunk, 'content', '')
|
||||
|
||||
# 通用/无意义标题集合,这些标题不能用于"标题相似"判定
|
||||
_GENERIC_TITLES = {'表格', 'table', '表格', ''}
|
||||
|
||||
# 判断是否为跨页表格
|
||||
is_cross_page = False
|
||||
|
||||
# 规则1: 页码连续(如果页码有效)
|
||||
page_valid = curr_page_end > 0 and next_page_start > 0
|
||||
if page_valid and curr_page_end + 1 == next_page_start:
|
||||
is_cross_page = True
|
||||
# 页码连续时,还需标题匹配或为通用标题才合并
|
||||
# 避免把不同页面上不相关的表格错误合并
|
||||
if curr_title == next_title or curr_title in _GENERIC_TITLES and next_title in _GENERIC_TITLES:
|
||||
is_cross_page = True
|
||||
elif curr_title and next_title:
|
||||
clean_next_r1 = next_title.replace('续表', '').strip()
|
||||
if curr_title in clean_next_r1 or clean_next_r1 in curr_title:
|
||||
is_cross_page = True
|
||||
|
||||
# 规则2: 第二个表格标题或内容包含"续表"
|
||||
elif '续表' in next_title or '续表' in next_content:
|
||||
is_cross_page = True
|
||||
|
||||
# 规则3: 标题相似(去掉"续表"后比较)
|
||||
elif curr_title and next_title:
|
||||
# 排除通用标题(如"表格"),防止把所有标题为"表格"的相邻表格都误合并
|
||||
elif (curr_title and next_title
|
||||
and curr_title not in _GENERIC_TITLES
|
||||
and next_title not in _GENERIC_TITLES):
|
||||
clean_next = next_title.replace('续表', '').strip()
|
||||
if curr_title in clean_next or clean_next in curr_title:
|
||||
if clean_next and (curr_title in clean_next or clean_next in curr_title):
|
||||
is_cross_page = True
|
||||
|
||||
# 规则4(降级): 页码不可用(如 Word 文档 page_idx 全为 0)
|
||||
# 仅当页码信息缺失时才启用此规则,避免 PDF 正常页码时被误合并
|
||||
if (not is_cross_page
|
||||
and pages_unavailable
|
||||
and curr_title in _GENERIC_TITLES
|
||||
and next_title in _GENERIC_TITLES):
|
||||
# 检查中间文本是否暗示跨页延续(空、短文本、续表标记等)
|
||||
has_separating_content = False
|
||||
for text_chunk in intermediate_texts:
|
||||
tc = (getattr(text_chunk, 'content', '') or '').strip()
|
||||
tt = (getattr(text_chunk, 'title', '') or '').strip()
|
||||
if not tc:
|
||||
continue # 空文本不算分隔
|
||||
if '续表' in tc or '续表' in tt:
|
||||
continue # 续表标记,说明是跨页
|
||||
# 有实质性中间内容(如分类标题"A3类:xxx"),不合并
|
||||
has_separating_content = True
|
||||
break
|
||||
if not has_separating_content:
|
||||
is_cross_page = True
|
||||
logger.debug(f"降级合并(页码不可用): '{curr_title}' + '{next_title}'")
|
||||
|
||||
if is_cross_page:
|
||||
# 执行合并
|
||||
merge_count += 1
|
||||
@@ -466,17 +551,61 @@ class KnowledgeBaseManager(
|
||||
curr_html = getattr(current, 'table_html', '') or ''
|
||||
next_html = getattr(next_chunk, 'table_html', '') or ''
|
||||
if curr_html and next_html:
|
||||
# 合并两个表格的 HTML
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
# 正确合并两个表格的 HTML:
|
||||
# 将第二个表格的 <tr> 行追加到第一个表格中
|
||||
# (而非简单拼接两个 <table>,否则 html_table_to_markdown
|
||||
# 的 soup.find('table') 只能找到第一个表格)
|
||||
try:
|
||||
soup1 = BeautifulSoup(curr_html, 'html.parser')
|
||||
soup2 = BeautifulSoup(next_html, 'html.parser')
|
||||
table1 = soup1.find('table')
|
||||
table2 = soup2.find('table')
|
||||
if table1 and table2:
|
||||
# 从第二个表格提取数据行
|
||||
next_rows = table2.find_all('tr')
|
||||
# 跳过与第一个表格表头重复的行
|
||||
# 对比第一行而非所有 th(find_all('th') 会匹配
|
||||
# 整个表格的 th,无法与单行做列表比较)
|
||||
first_row_t1 = table1.find('tr')
|
||||
if first_row_t1 and next_rows:
|
||||
row1_texts = [c.get_text(strip=True) for c in first_row_t1.find_all(['th', 'td'])]
|
||||
row2_texts = [c.get_text(strip=True) for c in next_rows[0].find_all(['th', 'td'])]
|
||||
if row1_texts and row2_texts and row1_texts == row2_texts:
|
||||
next_rows = next_rows[1:]
|
||||
logger.debug("跨页表格合并: 跳过了重复的表头行")
|
||||
for row in next_rows:
|
||||
table1.append(row)
|
||||
current.table_html = str(soup1)
|
||||
logger.debug(f"跨页表格 HTML 合并成功: 追加了 {len(next_rows)} 行")
|
||||
else:
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
except Exception as e:
|
||||
logger.warning(f"跨页表格 HTML 合并异常: {e},回退到简单拼接")
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
elif not curr_html and next_html:
|
||||
current.table_html = next_html
|
||||
|
||||
# 合并 image_path 到 images
|
||||
# 合并 image_path 和嵌入图片到 images
|
||||
curr_img = getattr(current, 'image_path', None)
|
||||
next_img = getattr(next_chunk, 'image_path', None)
|
||||
merged_images = []
|
||||
if curr_img:
|
||||
curr_images = getattr(current, 'images', None) or []
|
||||
next_images = getattr(next_chunk, 'images', None) or []
|
||||
|
||||
# 合并两个表格的所有图片(image_path + 嵌入图片)
|
||||
merged_images = list(curr_images) # 保留当前表格的嵌入图片
|
||||
# 添加 image_path 图片(如果不在列表中)
|
||||
existing_ids = {img.get('id', '') for img in merged_images if isinstance(img, dict)}
|
||||
if curr_img and curr_img not in existing_ids:
|
||||
merged_images.append({'id': curr_img, 'page': curr_page_end})
|
||||
if next_img:
|
||||
existing_ids.add(curr_img)
|
||||
for img in next_images: # 添加下一个表格的嵌入图片
|
||||
img_id = img.get('id', '') if isinstance(img, dict) else ''
|
||||
if img_id and img_id not in existing_ids:
|
||||
merged_images.append(img)
|
||||
existing_ids.add(img_id)
|
||||
if next_img and next_img not in existing_ids:
|
||||
merged_images.append({'id': next_img, 'page': next_page_start})
|
||||
|
||||
if merged_images:
|
||||
current.images = merged_images
|
||||
# 保留第一个图片作为主 image_path
|
||||
@@ -525,7 +654,7 @@ class KnowledgeBaseManager(
|
||||
try:
|
||||
from config import get_llm_client, DASHSCOPE_MODEL
|
||||
client = get_llm_client()
|
||||
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=100)
|
||||
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=2048)
|
||||
return summary.strip() if summary else ""
|
||||
except Exception as e:
|
||||
logger.warning(f"生成表格摘要失败: {e}")
|
||||
@@ -584,13 +713,31 @@ class KnowledgeBaseManager(
|
||||
]
|
||||
}
|
||||
],
|
||||
max_tokens=200
|
||||
max_tokens=2048 # mimo-v2.5 推理模型思考链消耗 ~1000 token,需留足输出空间
|
||||
)
|
||||
|
||||
description = response.choices[0].message.content
|
||||
|
||||
# 推理模型兼容:content 为空时从 reasoning_content 提取
|
||||
if not description or not description.strip():
|
||||
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||
if reasoning and reasoning.strip():
|
||||
import re
|
||||
# 尝试从思考链中提取有用文本(去掉 <think> 标签后的内容)
|
||||
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
|
||||
if cleaned:
|
||||
logger.info(f"VLM content为空,从reasoning_content提取描述: {image_path}")
|
||||
description = cleaned
|
||||
else:
|
||||
description = reasoning.strip()
|
||||
|
||||
if not description:
|
||||
logger.warning(f"VLM 返回空描述: {image_path}")
|
||||
return ""
|
||||
|
||||
# 缓存结果
|
||||
import hashlib
|
||||
import re as _re
|
||||
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
|
||||
cache_dir = Path('.data/cache/vlm')
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -283,7 +283,7 @@ class KnowledgeBaseRouter:
|
||||
content = call_llm(
|
||||
self.llm_client, prompt, MODEL,
|
||||
temperature=0.1,
|
||||
max_tokens=100
|
||||
max_tokens=512
|
||||
)
|
||||
|
||||
if content is None:
|
||||
|
||||
@@ -664,6 +664,17 @@ class KnowledgeSyncService:
|
||||
except Exception as e:
|
||||
logger.warning(f"递增缓存版本号失败: {e}")
|
||||
|
||||
# 语义缓存无版本号机制,文档变更后必须清空,
|
||||
# 否则可能返回过时的 images/sources/citations(如已删除的图片 404)
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
_sc = get_semantic_cache()
|
||||
if _sc:
|
||||
_sc.clear()
|
||||
logger.debug(f"已清空语义缓存(文档变更触发): {kb_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清空语义缓存失败: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
374
parsers/heading_rules.py
Normal file
374
parsers/heading_rules.py
Normal file
@@ -0,0 +1,374 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
标题识别规则引擎
|
||||
|
||||
将 _detect_heading_level 的硬编码正则提取为可配置的规则列表。
|
||||
规则按优先级从高到低排序,第一个匹配即返回。
|
||||
|
||||
MinerU 解析 DOCX 等 Office 格式时通常不提供 text_level(全部为 0),
|
||||
此时需要启发式识别标题层级。本模块提供可配置的规则引擎替代原来的
|
||||
硬编码 if-elif 链。
|
||||
|
||||
设计要点:
|
||||
- HeadingRule 数据类支持正向匹配(pattern)和反向排除(exclude_pattern)
|
||||
- 长度约束(min_length / max_length)可精确控制匹配范围
|
||||
- 规则可单独禁用(enabled=False),便于调试
|
||||
- 全局单例通过 config.py 覆盖默认值
|
||||
|
||||
MinerU v2 格式备注:
|
||||
content_list_v2.json 中的 paragraph_content 包含 style=["bold"] 信息,
|
||||
layout.json 中的 spans 也有 style 信息。这些信息比正则匹配 **加粗** 更可靠,
|
||||
但当前代码使用 v1 格式(content_list.json),暂不利用 v2 的 style。
|
||||
HeadingRuleEngine.detect() 签名预留了 style 参数,未来切换到 v2 格式后
|
||||
可直接利用 style 信息辅助判断。
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeadingRule:
|
||||
"""
|
||||
标题识别规则
|
||||
|
||||
每条规则定义一个文本模式到标题级别的映射。
|
||||
规则引擎按列表顺序逐条匹配,第一个命中即返回。
|
||||
|
||||
Attributes:
|
||||
pattern: 编译后的正则(match 语义,从文本开头匹配)
|
||||
level: 匹配时返回的标题级别 (1=h1, 2=h2, 3=h3)
|
||||
name: 规则名称(用于日志和配置覆盖)
|
||||
max_length: 文本最大长度,0=不限
|
||||
min_length: 文本最小长度,0=不限
|
||||
enabled: 是否启用
|
||||
exclude_pattern: 匹配此模式则排除(反向过滤)
|
||||
|
||||
Example:
|
||||
>>> rule = HeadingRule(
|
||||
... pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'),
|
||||
... level=1,
|
||||
... name="chinese_chapter",
|
||||
... )
|
||||
>>> rule.match("第一章 总则")
|
||||
1
|
||||
>>> rule.match("这是正文")
|
||||
0
|
||||
"""
|
||||
|
||||
pattern: re.Pattern
|
||||
level: int
|
||||
name: str
|
||||
max_length: int = 0
|
||||
min_length: int = 0
|
||||
enabled: bool = True
|
||||
exclude_pattern: Optional[re.Pattern] = None
|
||||
|
||||
def match(self, text: str) -> int:
|
||||
"""
|
||||
检查文本是否匹配此规则
|
||||
|
||||
Args:
|
||||
text: 待检测文本(调用前应已 strip)
|
||||
|
||||
Returns:
|
||||
标题级别,0 表示不匹配
|
||||
"""
|
||||
if not self.enabled:
|
||||
return 0
|
||||
if self.min_length > 0 and len(text) < self.min_length:
|
||||
return 0
|
||||
if self.max_length > 0 and len(text) > self.max_length:
|
||||
return 0
|
||||
if self.exclude_pattern and self.exclude_pattern.search(text):
|
||||
return 0
|
||||
if self.pattern.match(text):
|
||||
return self.level
|
||||
return 0
|
||||
|
||||
|
||||
# 默认规则列表(按优先级从高到低)
|
||||
#
|
||||
# 注意事项:
|
||||
# - 数字三级标题 (1.1.1) 必须在二级 (1.1) 之前,因为 1.1.1 也匹配 ^\d+\.\d+
|
||||
# - short_chinese_heading 是最宽泛的规则,放在最后作为兜底
|
||||
# - 第 9 条规则相比原版增加了 exclude_pattern,排除以句末标点结尾的短文本
|
||||
DEFAULT_HEADING_RULES: List[HeadingRule] = [
|
||||
# 1. 中文章节标题 -> h1
|
||||
# 匹配:第一章、第二章、第十节、第三篇 等
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'),
|
||||
level=1,
|
||||
name="chinese_chapter",
|
||||
),
|
||||
# 2. 中文条款编号 -> h2
|
||||
# 匹配:第一条、第三款 等(仅短标题,长正文段落不算标题)
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'),
|
||||
level=2,
|
||||
name="chinese_article",
|
||||
max_length=30,
|
||||
),
|
||||
# 3. 数字三级标题 -> h3(必须在二级之前匹配)
|
||||
# 匹配:1.1.1 背景、2.3.4 方案 等
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^\d+\.\d+\.\d+[\.、\s]'),
|
||||
level=3,
|
||||
name="numeric_level3",
|
||||
max_length=100,
|
||||
),
|
||||
# 4. 数字二级标题 -> h2(必须在一级之前匹配)
|
||||
# 匹配:1.1 背景、2.3 方案、2.1运行调度(无空格) 等
|
||||
# 使用负向前瞻排除三级标题(由 numeric_level3 处理)
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^\d+\.\d+(?!\.\d)'),
|
||||
level=2,
|
||||
name="numeric_level2",
|
||||
max_length=80,
|
||||
),
|
||||
# 5. 数字一级标题 -> h1
|
||||
# 匹配:1. 概述、2、背景 等
|
||||
# 排除:以 ;;。,、: 结尾的文本(这些是编号列表项/子条目,不是独立标题)
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^\d+[\.、\s]'),
|
||||
level=1,
|
||||
name="numeric_level1",
|
||||
max_length=50,
|
||||
exclude_pattern=re.compile(r'[;;。,、::]$'),
|
||||
),
|
||||
# 6. 英文章节标题 -> h1
|
||||
# 匹配:Chapter 1、Section 2、Part 3 等
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', re.IGNORECASE),
|
||||
level=1,
|
||||
name="english_chapter",
|
||||
),
|
||||
# 7. 分类标题 -> h3(必须在 bold_short_text 之前,否则 **A2类:** 会被加粗规则抢先匹配)
|
||||
# 匹配:A1类:公园、**A2类**:各类卫生医疗机构、**B1类:** 道路 等
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[::]'),
|
||||
level=3,
|
||||
name="category_heading",
|
||||
),
|
||||
# 8. 加粗短文本 -> h2
|
||||
# 匹配:**重要通知**、**概述** 等(Markdown 加粗标记)
|
||||
# 注意:**A2类:** 已被分类标题规则优先匹配,不会误判为 h2
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^\*\*.+\*\*$'),
|
||||
level=2,
|
||||
name="bold_short_text",
|
||||
max_length=50,
|
||||
),
|
||||
# 9. 短中文文本 -> h2(替代原"任何 <20 字符含中文"规则)
|
||||
# 关键改进:排除以句末标点结尾的文本
|
||||
# 原规则将 "这是一段正文。" 也识别为 h2,导致大量误判
|
||||
# 新规则:包含中文 + 长度 2-20 + 不以句末标点结尾 → h2
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'[一-鿿]'),
|
||||
level=2,
|
||||
name="short_chinese_heading",
|
||||
max_length=20,
|
||||
min_length=2,
|
||||
exclude_pattern=re.compile(r'[。!?;…]$'),
|
||||
enabled=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class HeadingRuleEngine:
|
||||
"""
|
||||
标题识别规则引擎
|
||||
|
||||
按规则列表顺序逐条匹配,第一个命中即返回标题级别。
|
||||
支持从 config.py 加载自定义规则或覆盖默认规则参数。
|
||||
|
||||
Example:
|
||||
>>> engine = HeadingRuleEngine()
|
||||
>>> engine.detect("第一章 总则")
|
||||
(1, 'chinese_chapter')
|
||||
>>> engine.detect("这是普通正文。")
|
||||
(0, None)
|
||||
"""
|
||||
|
||||
def __init__(self, rules: Optional[List[HeadingRule]] = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
rules: 规则列表,None 则使用默认规则的深拷贝
|
||||
"""
|
||||
if rules is not None:
|
||||
self.rules: List[HeadingRule] = rules
|
||||
else:
|
||||
import copy
|
||||
self.rules = copy.deepcopy(DEFAULT_HEADING_RULES)
|
||||
|
||||
def _validate_level(self, level: int, text: str, rule_name=None):
|
||||
"""各级别标题长度防护:超长文本不应作为标题,降为正文。
|
||||
H1 > 40字, H2 > 60字, H3 > 50字 → 降为正文。
|
||||
统一覆盖 v1 常规匹配、v2 style 匹配、bold_short_text 兜底所有返回路径。"""
|
||||
text_len = len(text)
|
||||
if level == 1 and text_len > 40:
|
||||
logger.debug(f"标题识别: '{text[:30]}...' H1 但超长({text_len}字),降为正文")
|
||||
return 0, None
|
||||
if level == 2 and text_len > 60:
|
||||
logger.debug(f"标题识别: '{text[:30]}...' H2 但超长({text_len}字),降为正文")
|
||||
return 0, None
|
||||
if level == 3 and text_len > 50:
|
||||
logger.debug(f"标题识别: '{text[:30]}...' H3 但超长({text_len}字),降为正文")
|
||||
return 0, None
|
||||
return level, rule_name
|
||||
|
||||
def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]:
|
||||
"""
|
||||
检测文本的标题级别
|
||||
|
||||
Args:
|
||||
text: 待检测文本
|
||||
style: MinerU v2 格式中的 style 信息(如 ["bold"]),
|
||||
当文本标记为 bold 且较短时,可直接判定为标题,
|
||||
无需依赖 Markdown **...** 标记。
|
||||
|
||||
Returns:
|
||||
(level, rule_name): 标题级别和匹配的规则名
|
||||
level=0 表示不是标题
|
||||
"""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0, None
|
||||
|
||||
# v2 style 信息:如果文本标记为 bold 且较短,优先尝试加粗规则
|
||||
if style and 'bold' in style and 2 <= len(text) <= 50:
|
||||
# 先检查是否匹配更高优先级的分类标题规则
|
||||
for rule in self.rules:
|
||||
if rule.name == 'category_heading' and rule.enabled:
|
||||
level = rule.match(text)
|
||||
if level > 0:
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
|
||||
return self._validate_level(level, text, rule.name)
|
||||
|
||||
# 再检查是否匹配中文章节/条款等高优先级规则
|
||||
for rule in self.rules:
|
||||
if rule.name in ('chinese_chapter', 'chinese_article', 'numeric_level3',
|
||||
'numeric_level2', 'numeric_level1', 'english_chapter') and rule.enabled:
|
||||
level = rule.match(text)
|
||||
if level > 0:
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
|
||||
return self._validate_level(level, text, rule.name)
|
||||
|
||||
# 最后兜底:加粗短文本 → h2
|
||||
# 但需先检查所有规则的 exclude_pattern,防止编号列表项被误判为标题
|
||||
# 例如 "3.完全满足品规。指..." 虽有 bold 样式,但属于列表项而非标题
|
||||
for rule in self.rules:
|
||||
if rule.enabled and rule.exclude_pattern and rule.exclude_pattern.search(text):
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' 被 {rule.name} 的 exclude_pattern 排除")
|
||||
return 0, None
|
||||
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)")
|
||||
return 2, 'bold_short_text'
|
||||
|
||||
# 常规规则匹配(v1 格式或无 style 信息时)
|
||||
for rule in self.rules:
|
||||
level = rule.match(text)
|
||||
if level > 0:
|
||||
logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})")
|
||||
return self._validate_level(level, text, rule.name)
|
||||
|
||||
return 0, None
|
||||
|
||||
|
||||
# ==================== 全局单例 ====================
|
||||
|
||||
_engine: Optional[HeadingRuleEngine] = None
|
||||
|
||||
|
||||
def get_heading_engine() -> HeadingRuleEngine:
|
||||
"""获取全局标题识别引擎(延迟初始化,线程安全)"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = _create_engine_from_config()
|
||||
return _engine
|
||||
|
||||
|
||||
def _create_engine_from_config() -> HeadingRuleEngine:
|
||||
"""
|
||||
从 config 创建引擎(支持配置覆盖)
|
||||
|
||||
优先级:
|
||||
1. config.HEADING_RULES_CONFIG 不为 None → 使用自定义规则
|
||||
2. config 细粒度参数覆盖默认规则(如 HEADING_SHORT_TEXT_ENABLED)
|
||||
3. 使用默认规则
|
||||
"""
|
||||
# 尝试加载完整自定义规则
|
||||
try:
|
||||
from config import HEADING_RULES_CONFIG
|
||||
if HEADING_RULES_CONFIG is not None:
|
||||
rules = _build_rules_from_config(HEADING_RULES_CONFIG)
|
||||
logger.info(f"使用自定义标题规则: {len(rules)} 条")
|
||||
return HeadingRuleEngine(rules)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 使用默认规则,应用细粒度配置覆盖
|
||||
rules = list(DEFAULT_HEADING_RULES)
|
||||
try:
|
||||
from config import HEADING_SHORT_TEXT_ENABLED
|
||||
for rule in rules:
|
||||
if rule.name == "short_chinese_heading":
|
||||
rule.enabled = HEADING_SHORT_TEXT_ENABLED
|
||||
logger.debug(f"配置覆盖: short_chinese_heading.enabled={HEADING_SHORT_TEXT_ENABLED}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from config import HEADING_SHORT_TEXT_MAX_LENGTH
|
||||
for rule in rules:
|
||||
if rule.name == "short_chinese_heading":
|
||||
rule.max_length = HEADING_SHORT_TEXT_MAX_LENGTH
|
||||
logger.debug(f"配置覆盖: short_chinese_heading.max_length={HEADING_SHORT_TEXT_MAX_LENGTH}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return HeadingRuleEngine(rules)
|
||||
|
||||
|
||||
def _build_rules_from_config(config: list) -> List[HeadingRule]:
|
||||
"""
|
||||
从配置字典列表构建规则列表
|
||||
|
||||
Args:
|
||||
config: 规则配置列表,每项为 dict,包含:
|
||||
- pattern (str): 正则表达式字符串
|
||||
- level (int): 标题级别
|
||||
- name (str): 规则名称
|
||||
- max_length (int, 可选): 文本最大长度
|
||||
- min_length (int, 可选): 文本最小长度
|
||||
- enabled (bool, 可选): 是否启用
|
||||
- exclude_pattern (str, 可选): 排除正则
|
||||
|
||||
Returns:
|
||||
规则列表
|
||||
"""
|
||||
rules = []
|
||||
for item in config:
|
||||
exclude = None
|
||||
if 'exclude_pattern' in item:
|
||||
exclude = re.compile(item['exclude_pattern'])
|
||||
rules.append(HeadingRule(
|
||||
pattern=re.compile(item['pattern']),
|
||||
level=item['level'],
|
||||
name=item['name'],
|
||||
max_length=item.get('max_length', 0),
|
||||
min_length=item.get('min_length', 0),
|
||||
enabled=item.get('enabled', True),
|
||||
exclude_pattern=exclude,
|
||||
))
|
||||
return rules
|
||||
|
||||
|
||||
def reset_heading_engine() -> None:
|
||||
"""重置引擎(用于测试)"""
|
||||
global _engine
|
||||
_engine = None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -616,7 +616,7 @@ class FeedbackService:
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.7,
|
||||
max_tokens=200
|
||||
max_tokens=512
|
||||
)
|
||||
|
||||
if not response:
|
||||
|
||||
@@ -159,7 +159,7 @@ class SessionManager:
|
||||
SELECT id, role, content, metadata, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
''', (session_id, limit))
|
||||
|
||||
|
||||
150
tests/test_exam_file_status_response.py
Normal file
150
tests/test_exam_file_status_response.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from exam_pkg.api import exam_bp
|
||||
from exam_pkg.manager import check_file_indexed
|
||||
|
||||
|
||||
class ExamFileStatusApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(exam_bp, url_prefix="/exam")
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch("exam_pkg.api.generate_questions_from_file")
|
||||
def test_generate_returns_409_when_file_is_not_indexed(self, generate):
|
||||
generate.return_value = {
|
||||
"success": False,
|
||||
"request_id": "req-1",
|
||||
"error_code": "FILE_NOT_INDEXED",
|
||||
"message": "文件未向量化",
|
||||
"file_status": "not_found",
|
||||
"chunk_count": 0,
|
||||
"questions": [],
|
||||
"total": 0,
|
||||
"source_chunks_used": 0,
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/exam/generate",
|
||||
json={
|
||||
"file_path": "missing.pdf",
|
||||
"collection": "public_kb",
|
||||
"question_types": {"single_choice": 1},
|
||||
"request_id": "req-1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
body = response.get_json()
|
||||
self.assertFalse(body["success"])
|
||||
self.assertEqual(body["error_code"], "FILE_NOT_INDEXED")
|
||||
self.assertEqual(body["status_code"], 4016)
|
||||
self.assertEqual(body["data"]["request_id"], "req-1")
|
||||
|
||||
@patch("exam_pkg.manager.analyze_file_for_exam")
|
||||
@patch("exam_pkg.api.check_file_indexed")
|
||||
def test_generate_smart_stops_before_ai_analysis(self, check_status, analyze):
|
||||
check_status.return_value = {
|
||||
"indexed": False,
|
||||
"chunk_count": 0,
|
||||
"status": "not_found",
|
||||
"message": "文件未找到",
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/exam/generate-smart",
|
||||
json={
|
||||
"file_path": "missing.pdf",
|
||||
"collection": "public_kb",
|
||||
"request_id": "req-smart",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.get_json()["status_code"], 4016)
|
||||
analyze.assert_not_called()
|
||||
|
||||
@patch("exam_pkg.api.generate_questions_from_file")
|
||||
def test_generate_success_response_is_unchanged(self, generate):
|
||||
generate.return_value = {
|
||||
"success": True,
|
||||
"request_id": "req-ok",
|
||||
"questions": [{"question_type": "single_choice"}],
|
||||
"total": 1,
|
||||
"source_chunks_used": 1,
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/exam/generate",
|
||||
json={
|
||||
"file_path": "ready.pdf",
|
||||
"collection": "public_kb",
|
||||
"question_types": {"single_choice": 1},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.get_json()
|
||||
self.assertTrue(body["success"])
|
||||
self.assertEqual(body["status_code"], 2020)
|
||||
self.assertTrue(body["data"]["success"])
|
||||
|
||||
@patch("exam_pkg.api.generate_questions_from_file")
|
||||
def test_status_check_error_uses_existing_exam_error(self, generate):
|
||||
generate.return_value = {
|
||||
"success": False,
|
||||
"request_id": "req-error",
|
||||
"error_code": "STATUS_CHECK_ERROR",
|
||||
"message": "检查状态异常",
|
||||
"file_status": "error",
|
||||
"chunk_count": 0,
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/exam/generate",
|
||||
json={
|
||||
"file_path": "document.pdf",
|
||||
"collection": "public_kb",
|
||||
"question_types": {"single_choice": 1},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
body = response.get_json()
|
||||
self.assertEqual(body["error_code"], "EXAM_ERROR")
|
||||
self.assertEqual(body["status_code"], 5020)
|
||||
|
||||
|
||||
class FilePathNormalizationTest(unittest.TestCase):
|
||||
@patch("core.engine.get_engine")
|
||||
def test_windows_path_is_matched_by_filename(self, get_engine):
|
||||
collection = MagicMock()
|
||||
collection.get.return_value = {"ids": ["chunk-1"]}
|
||||
engine = MagicMock()
|
||||
engine.search_knowledge.return_value = {
|
||||
"documents": [["content"]],
|
||||
}
|
||||
engine.kb_manager.get_collection.return_value = collection
|
||||
get_engine.return_value = engine
|
||||
|
||||
result = check_file_indexed(
|
||||
r"public_kb\\folder\\ready.pdf",
|
||||
"public_kb",
|
||||
)
|
||||
|
||||
self.assertTrue(result["indexed"])
|
||||
engine.search_knowledge.assert_called_once_with(
|
||||
query="document",
|
||||
collections=["public_kb"],
|
||||
source_filter="ready.pdf",
|
||||
top_k=1,
|
||||
)
|
||||
collection.get.assert_called_once_with(where={"source": "ready.pdf"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user