11 Commits

Author SHA1 Message Date
lacerate551
8f75c51c59 feat(server-release): 从 main 同步子章节级图片过滤 + VLM/LLM 推理模型兼容
- chat_routes: 子章节级图片过滤(section_path 传递、叶子节点匹配、发散检测)
- chat_routes: _FIGURE_ANSWER_KEYWORDS 移除单字"图""表",正则图号兜底
- lazy_enhance: defer_chromadb 参数避免后台线程 SQLite 写锁竞争
- lazy_enhance: 缓存内容校验(>=5字)和空结果保护
- llm_utils: reasoning_content 兜底(剥离 <think> 标签)+ 流式 reasoning 支持
- intent_analyzer: SYSTEM_PROMPT 增加追问补全逻辑
- manager: VLM 描述/摘要 max_tokens 提升至 2048 + Windows fcntl 兼容

不改变端口、API 接口和响应数据字段。
2026-06-20 20:27:01 +08:00
lacerate551
87f3fae1aa feat(server-release): 从 main 迁移检索增强(章节聚类提升 + BM25 追加修复)
从 main 分支选择性提取检索质量增强代码,不含端口/API 配置变更:

- knowledge/base.py: BM25Index.add_documents 改为追加+去重模式,
  修复多文件上传后仅保留最后文件切片的覆盖 bug
- core/engine.py: 章节聚类提升(_section_cluster_boost)、词法匹配
  辅助种子资格(_chunk_lexical_score)、扩展参数优化
  (CONTEXT_EXPANSION_AFTER 5→8, MAX_EXPANDED_NEIGHBORS 4→8)
- api/chat_routes.py: 路由层词法匹配救援(_rescue_lexical_match)、
  章节聚类救援安全网(_rescue_section_cluster)

部署后需重建 BM25 索引:KnowledgeBaseManager().rebuild_bm25_index('public_kb')
2026-06-17 17:49:36 +08:00
lacerate551
fc11b11dda fix(server-release): 修复 MMR Jaccard 去重使用 jieba 词级分词替代字符级集合
旧实现 set(text) 对中文文本按单字拆分,导致不同文档间字符集合高度
重叠(共享 "的""是""在" 等常用字),去重过于激进,大量相关文档被误杀。

新实现:
- 使用 jieba 分词提取 2 字及以上实词
- 词级 Jaccard 相似度区分度大幅提升
- 预分词优化:对所有候选文档一次性分词,避免重复调用
- 内容窗口从 200 字符扩大到 500 字符

性能对比(同一问题):
- MMR_USE_EMBEDDING=True: 57s,回答 3707 字
- 旧 Jaccard(字符级): 10s,回答 56 字(几乎无内容)
- 新 Jaccard(词级):   22s,回答 1594 字(含表格、引用、图片)
2026-06-12 12:15:12 +08:00
lacerate551
99f5cf519e feat(server-release): 从 main 迁移 RAG 检索质量增强
- 新增 5 个辅助函数:_strip_semantic_prefix(语义前缀清理)、
  _process_table_doc(表格文档处理)、_section_similarity(章节相似度)、
  _rescue_table_chunks(表格救援)、_filter_images_by_answer(图片后置过滤)
- 替换 _order_text_contexts_for_prompt(添加表格保护逻辑:passing_sections + table_floor)
- 替换 _build_context_with_budget(集成 _process_table_doc + section headers)
- rag() 函数添加 5 个调用点:语义缓存检查/写入、strip_semantic_prefix、
  rescue_table_chunks、filter_images_by_answer
- 新增 retrieval_query 变量(使用改写后的查询替代原始 message)
- 不涉及任何 API 端点变更
2026-06-10 13:54:38 +08:00
lacerate551
15c0aec9a6 fix(engine): 补充缺失的 import re,修复 generate_answer_stream 的 NameError 2026-06-10 12:34:49 +08:00
lacerate551
8c7a6eb3fa fix(config): config.example.py 补充 RERANK 云服务配置项
补充 RERANK_BACKEND/CLOUD_MODEL/CLOUD_API_KEY/CLOUD_BASE_URL/
CLOUD_TIMEOUT 五个配置项,防止新部署时 engine.py 初始化失败。
2026-06-10 12:23:05 +08:00
lacerate551
fda1b2f049 feat: 从 main 分支迁移解析器与检索优化(不影响 API 接口)
- 新增 heading_rules.py 标题规则引擎,替换硬编码正则链
- 重写 mineru_parser.py:v2 格式兼容、表单自动检测
- manager.py: 跨页表格合并规则收紧(防误合并)+ LLM token 上限提升
- engine.py: embedding 缓存 + 同 section 表格邻居扩展 + prompt 改进
- router.py: 路由分类 token 上限提升至 512
2026-06-10 12:10:22 +08:00
lacerate551
148559ee3c fix(knowledge): 全量修复多 worker 元数据竞态问题
1. collection.py: create/delete/update/exists 方法开头加 _load_metadata()
2. list_collections: 去掉磁盘扫描自动补充和 stale 清理逻辑,
   改为以元数据为唯一真相源,异常集合只跳过不删不改
3. manager.py: _load/_save_metadata 加 fcntl 文件锁防并发写覆盖,
   Windows 兼容(fcntl try/import 包裹)
2026-06-10 11:17:57 +08:00
lacerate551
431af0217a fix(knowledge): list_collections 每次从磁盘重载元数据,修复多 worker 返回数量不一致
GUNICORN_WORKERS=2 时各 worker 进程的 self._metadata 独立,
一个 worker 清理失效条目后保存 kb_metadata.json,另一个 worker
内存仍是旧版本,导致 /collections 返回数量波动(3 或 5)。
在 list_collections() 开头调用 _load_metadata() 重新加载,
保证每次请求基于最新的磁盘状态。
2026-06-09 13:29:49 +08:00
lacerate551
aa04bb94a6 chore(deploy): 添加 .dockerignore 排除大体积数据目录,加速镜像构建
排除 models/、vector_store/、documents/、.data/ 等数据目录,
这些通过 volume 挂载,不需要打进镜像。
2026-06-09 12:07:43 +08:00
lacerate551
84a8be0ce8 fix(knowledge): list_collections 增加异常保护,防止 ChromaDB 数据丢失导致 /collections 500
遍历 kb_metadata.json 时对每个集合的 get_collection/count 操作包裹
try/except,捕获 NotFoundError/InternalError 等异常后跳过并记录警告,
循环结束后自动清理失效的元数据条目。
2026-06-09 11:43:34 +08:00
14 changed files with 3839 additions and 1953 deletions

23
.dockerignore Normal file
View 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*

File diff suppressed because it is too large Load Diff

View File

@@ -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 融合 -----

View File

@@ -29,6 +29,7 @@ RAG 核心引擎
import os
import gc
import re
import time
import logging
import threading
@@ -76,6 +77,10 @@ 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,
# FAQ 与黑名单
@@ -101,10 +106,18 @@ 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
@@ -625,7 +638,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)
@@ -732,11 +745,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})
@@ -811,6 +832,76 @@ class RAGEngine:
logger.warning(f"FAQ 集合查询失败: {e}")
return get_empty_result()
def _encode_cached(self, text):
"""
带缓存的 embedding 编码
优先从 Embedding CacheLRU读取未命中再调用模型编码并写入缓存。
支持单文本和批量文本输入。
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图片独立召回通道
@@ -1091,13 +1182,14 @@ class RAGEngine:
return self.collection
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 +1219,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
]
@@ -1139,7 +1231,11 @@ class RAGEngine:
# Phase 3跳过分数低于阈值的种子仅当 min_score > 0 时生效)
if min_score > 0 and seed_dist < min_score:
continue
# 词法匹配豁免CrossEncoder 低分但关键词重叠度高时仍允许作为种子
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', '')
@@ -1171,6 +1267,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 +1293,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:
@@ -1219,6 +1341,130 @@ class RAGEngine:
expanded[key] = results[key]
return expanded
@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:
修改后的 resultsdistances 被调整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 _search_with_sub_queries(
self, original_query, sub_queries, top_k=5, allowed_levels=None,
role=None, department=None, collections=None, source_filter=None
@@ -1387,7 +1633,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)
@@ -1530,11 +1776,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})
@@ -1739,12 +1993,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 = []
@@ -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

View File

@@ -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字符为宜

View File

@@ -72,16 +72,35 @@ def call_llm(
content = response.choices[0].message.content
# 推理模型兼容content 为空时尝试从 reasoning_content 提取
# 推理模型兼容mimo-v2.5 等):
# 推理模型思考链消耗大量 token~1000max_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 +114,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 +123,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: 其他参数
@@ -135,9 +155,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 +396,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

View File

@@ -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]
# ==================== 测试 ====================

View File

@@ -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]]:

View File

@@ -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, "公开知识库不能删除"
@@ -348,50 +357,36 @@ class CollectionMixin:
- department: 所属部门
- description: 描述
"""
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
result = []
# 扫描 base_path 下的所有子目录作为向量库
# 每个向量库使用独立目录base_path/my_ky, base_path/public_kb 等
actual_collections = []
try:
if os.path.exists(self.base_path):
for item in os.listdir(self.base_path):
item_path = os.path.join(self.base_path, item)
if os.path.isdir(item_path) and not item.startswith('.'):
# 检查是否包含 chroma.sqlite3有效的向量库目录
if os.path.exists(os.path.join(item_path, 'chroma.sqlite3')):
actual_collections.append(item)
except Exception as e:
logger.warning(f"扫描向量库目录失败: {e}")
# 如果扫描失败,回退到元数据中的集合列表
if not actual_collections:
actual_collections = list(self._metadata.get("collections", {}).keys())
for name in actual_collections:
if name not in self._metadata.get("collections", {}):
if "collections" not in self._metadata:
self._metadata["collections"] = {}
self._metadata["collections"][name] = {
"display_name": name,
"department": "",
"description": "",
"created_at": datetime.now().isoformat()
}
logger.info(f"自动补充向量库元数据: {name}")
self._save_metadata()
# 以元数据为唯一真相源,不再扫描磁盘自动补充
# (扫描磁盘会导致其他 worker 刚创建/删除的集合被误操作)
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:
# 只跳过不修改元数据(可能是其他 worker 刚创建的集合)
logger.warning(
f"跳过异常向量库 '{name}': {e}"
)
result.append(CollectionInfo(
name=name,
display_name=info.get("display_name", name),
document_count=0,
created_at=info.get("created_at", ""),
department=info.get("department", ""),
description=info.get("description", "")
))
return result
@@ -405,4 +400,6 @@ class CollectionMixin:
Returns:
存在返回 True不存在返回 False
"""
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
self._metadata = self._load_metadata()
return kb_name in self._metadata.get("collections", {})

View File

@@ -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}")

View File

@@ -29,6 +29,11 @@
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
@@ -134,22 +139,36 @@ class KnowledgeBaseManager(
logger.info(f"知识库管理器初始化完成,路径: {self.base_path},发现 {len(existing_kbs)} 个向量库: {existing_kbs}")
def _load_metadata(self) -> dict:
"""加载元数据"""
"""加载元数据(带文件锁)"""
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}")
@@ -393,6 +412,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 +429,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 +444,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 +468,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
@@ -469,14 +533,27 @@ class KnowledgeBaseManager(
# 合并两个表格的 HTML
current.table_html = curr_html + '\n' + 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 +602,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 +661,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)

View File

@@ -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:

347
parsers/heading_rules.py Normal file
View File

@@ -0,0 +1,347 @@
# -*- 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",
),
# 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 方案 等
HeadingRule(
pattern=re.compile(r'^\d+\.\d+[\.、\s]'),
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,
),
# 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 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 level, 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 level, rule.name
# 否则作为加粗短文本 → h2与 bold_short_text 规则对齐,但不依赖 **...** 标记)
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 level, 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