init: RAG 知识库服务初始提交

- 后端 API(Flask + Gunicorn)
- RAG 引擎(混合检索 + 云端 Reranker + 引用溯源)
- 文档解析(MinerU + 多格式支持)
- Docker 生产部署配置
- 排除前端项目、敏感配置、模型文件
This commit is contained in:
lacerate551
2026-06-04 17:35:27 +08:00
commit 100d1a06eb
158 changed files with 64534 additions and 0 deletions

717
knowledge/manager.py Normal file
View File

@@ -0,0 +1,717 @@
"""
多向量库管理器 - 支持按部门/权限隔离的向量知识库
功能:
1. 多向量库管理 - 创建、删除、列举向量库
2. 多 BM25 索引管理 - 每个向量库独立的 BM25 索引
3. 权限过滤 - 根据用户角色和部门返回可访问的向量库
4. 并行检索 - 支持同时检索多个向量库
向量库命名规范:
- public_kb: 公开知识库,所有人可访问
- dept_{部门名}: 部门知识库,如 dept_finance, dept_hr, dept_tech
使用方式:
from knowledge.manager import KnowledgeBaseManager
kb_manager = KnowledgeBaseManager()
# 获取向量库
collection = kb_manager.get_collection("dept_finance")
# 列出所有向量库
collections = kb_manager.list_collections()
# 获取用户可访问的向量库
accessible = kb_manager.get_accessible_collections("manager", "finance")
"""
import os
import json
import threading
from typing import List, Dict, Optional, Tuple
from pathlib import Path
import logging
import chromadb
# 从 base.py 导入基础类和常量
from .base import (
BM25Index,
CollectionInfo,
SearchResult,
_get_doc_type,
_extract_figure_number,
_extract_section,
_build_semantic_content_for_text,
_build_semantic_content_for_table,
CHROMA_DB_BASE_PATH,
BM25_INDEX_BASE_PATH,
KB_METADATA_FILE,
PUBLIC_KB_NAME,
DEFAULT_DEPARTMENTS,
DEPARTMENT_NAME_MAP,
normalize_department_name,
)
# 导入 Mixin 类
from .collection import CollectionMixin
from .document import DocumentMixin
from .chunk import ChunkMixin
from .index import IndexMixin
from .search import SearchMixin
from .processing import ProcessingMixin
from .permission import PermissionMixin
# 导入 LLM 工具函数
from core.llm_utils import call_llm
# 设置日志
logger = logging.getLogger(__name__)
# ==================== 多向量库管理器 ====================
class KnowledgeBaseManager(
CollectionMixin,
DocumentMixin,
ChunkMixin,
IndexMixin,
SearchMixin,
ProcessingMixin,
PermissionMixin
):
"""
多向量库管理器
管理多个独立的 ChromaDB 集合,每个集合对应一个知识库。
支持按部门隔离,每个部门有独立的向量库和 BM25 索引。
通过 Mixin 模式组合功能:
- CollectionMixin: 向量库管理
- DocumentMixin: 文档管理
- ChunkMixin: 切片管理
- IndexMixin: BM25 索引管理
- SearchMixin: 检索功能
- ProcessingMixin: 图片/表格处理
- PermissionMixin: 权限控制
"""
def __init__(self, base_path: str = None, bm25_base_path: str = None):
"""
初始化
Args:
base_path: 向量库存储路径
bm25_base_path: BM25 索引存储路径
"""
self.base_path = base_path or CHROMA_DB_BASE_PATH
self.bm25_base_path = bm25_base_path or BM25_INDEX_BASE_PATH
# 缓存
self._collections: Dict[str, chromadb.Collection] = {}
self._bm25_indexes: Dict[str, BM25Index] = {}
self._clients: Dict[str, chromadb.PersistentClient] = {}
self._lock = threading.Lock()
# 确保目录存在
os.makedirs(self.base_path, exist_ok=True)
os.makedirs(self.bm25_base_path, exist_ok=True)
# 加载元数据
self._metadata = self._load_metadata()
# 初始化公开知识库
self._ensure_public_kb()
# 扫描并列出所有已存在的向量库
existing_kbs = []
if os.path.exists(self.base_path):
for item in os.listdir(self.base_path):
if os.path.isdir(os.path.join(self.base_path, item)) and not item.startswith('.'):
if os.path.exists(os.path.join(self.base_path, item, 'chroma.sqlite3')):
existing_kbs.append(item)
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)
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)
except Exception as e:
logger.error(f"保存元数据失败: {e}")
def _ensure_public_kb(self):
"""确保公开知识库存在"""
if PUBLIC_KB_NAME not in self._metadata.get("collections", {}):
self.create_collection(
PUBLIC_KB_NAME,
display_name="公开知识库",
department="",
description="所有人可访问的公开文档"
)
# ==================== 文件添加 ====================
def add_file_to_kb(
self,
kb_name: str,
filepath: str,
embedding_model=None,
extra_metadata: dict = None,
enable_table_summary: bool = True,
enable_image_description: bool = False,
file_content: bytes = None
) -> int:
"""
添加文件到指定向量库v6 支持企业文件系统)
使用统一的 parse_document() 入口,支持:
- PDF/DOCX/PPTX/图片 → MinerU 解析
- XLSX/XLS → Pandas 解析
- TXT → 文本解析
Args:
kb_name: 向量库名称
filepath: 文件路径(相对路径或绝对路径)
embedding_model: 向量模型(可选,默认使用 engine 的)
extra_metadata: 额外的元数据(如 status, version 等)
enable_table_summary: 是否启用表格摘要管道LLM 生成摘要)
enable_image_description: 是否启用图片描述管道VLM 生成描述)
file_content: 文件二进制内容(可选,用于企业文件系统集成)
Returns:
添加的片段数量
"""
collection = self.get_collection(kb_name)
if not collection:
raise ValueError(f"向量库 '{kb_name}' 不存在")
# 解析文档
from parsers import parse_document
result = parse_document(filepath, file_content=file_content)
if not result:
logger.warning(f"文档解析结果为空: {filepath}")
return 0
chunks = result.get('chunks', [])
if not chunks:
logger.warning(f"文档解析后无有效切片: {filepath}")
return 0
# 合并跨页表格
chunks = self._merge_cross_page_tables(chunks)
# 准备向量模型
if embedding_model is None:
from core.engine import get_engine
engine = get_engine()
if not engine._initialized:
engine.initialize()
embedding_model = engine.embedding_model
# 处理切片
ids = []
documents = []
metadatas = []
embeddings = []
filename = Path(filepath).name
doc_type = _get_doc_type(filename)
for i, chunk in enumerate(chunks):
chunk_id = f"{filename}_{i}"
# 获取切片类型(优先从 chunk 直接获取,兼容 MinerUChunk 和其他格式)
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')
# 获取页码信息(兼容 MinerUChunk 和 page_info 格式)
page_start = getattr(chunk, 'page_start', None)
page_end = getattr(chunk, 'page_end', None)
if page_start is None:
page_info = getattr(chunk, 'page_info', {}) or {}
page_start = page_info.get('page', 0)
page_end = page_info.get('page_end', page_start)
page_start = page_start or 0
page_end = page_end or page_start
# 获取章节信息
section_path = getattr(chunk, 'section_path', '') or ''
if not section_path:
page_info = getattr(chunk, 'page_info', {}) or {}
section_path = page_info.get('section_path', '') or page_info.get('section', '')
if chunk_type == 'table':
# 表格内容优先使用 table_html完整表格fallback 到 content标题
table_md = getattr(chunk, 'table_html', None) or chunk.content
semantic_content = _build_semantic_content_for_table(
table_md, {'page': page_start, 'page_end': page_end, 'section_path': section_path}, chunk, doc_type
)
elif chunk_type in ('image', 'chart'):
semantic_content = self.generate_lightweight_image_description(
chunk.content, chunk, {'page': page_start, 'page_end': page_end, 'section_path': section_path}
)
else:
semantic_content = _build_semantic_content_for_text(
chunk, {'page': page_start, 'page_end': page_end, 'section_path': section_path}, doc_type
)
# 构建元数据
metadata = {
"chunk_id": chunk_id,
"chunk_index": i,
"chunk_type": chunk_type,
"source": filename,
"collection": kb_name,
"doc_type": doc_type, # 文档类型(pdf/word/excel/ppt/other),驱动前端差异化溯源展示
"page": page_start,
"page_end": page_end,
"section": section_path,
"status": "active",
"version": "v1",
}
if extra_metadata:
metadata.update(extra_metadata)
# 序列化图片信息(修复图片召回断链)
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
# 生成向量
try:
embedding = embedding_model.encode(semantic_content).tolist()
except Exception as e:
logger.error(f"生成向量失败: {chunk_id}, 错误: {e}")
continue
ids.append(chunk_id)
documents.append(semantic_content)
metadatas.append(metadata)
embeddings.append(embedding)
# 处理表格摘要
if chunk_type == 'table' and enable_table_summary:
table_md = chunk.content
summary = self._generate_table_summary(table_md, chunk)
if summary:
# 存储原始表格到 DocStore
self._store_original_table(chunk_id, table_md, metadata)
# 处理图片描述
if chunk_type in ('image', 'chart') and enable_image_description:
image_path = chunk.content
if self.should_process_image(image_path, "", page_info.get('caption', '')):
desc = self._generate_image_description(image_path, metadata)
if desc:
self._store_image_reference(chunk_id, image_path, metadata)
if not ids:
return 0
# 批量添加到向量库
collection.add(
ids=ids,
documents=documents,
metadatas=metadatas,
embeddings=embeddings
)
# 更新 BM25 索引
bm25 = self.get_bm25_index(kb_name)
if bm25:
bm25.add_documents(ids, documents, metadatas)
self.save_bm25_index(kb_name)
logger.info(f"添加文件: {filename} -> {kb_name}, 片段数: {len(ids)}")
return len(ids)
def _merge_cross_page_tables(self, chunks: list) -> list:
"""
合并跨页表格
检测规则:
1. 表格切片后面跟着"续表"文本或另一个表格
2. 页码连续或无法判断时,通过标题匹配
3. 第二个表格标题包含"续表"或标题相似
合并操作:
1. 合并 table_html
2. 将两个 image_path 存入 images 字段
3. 更新 page_end
"""
if len(chunks) < 2:
return chunks
merged_chunks = []
i = 0
merge_count = 0
while i < len(chunks):
current = chunks[i]
# 检查是否为表格类型
if getattr(current, 'chunk_type', '') == 'table':
# 查找下一个表格(跳过中间的"续表"文本)
next_table_idx = None
next_chunk = None
for j in range(i + 1, min(i + 4, len(chunks))): # 最多向前看3个切片
candidate = chunks[j]
candidate_type = getattr(candidate, 'chunk_type', '')
candidate_title = getattr(candidate, 'title', '') or ''
candidate_content = getattr(candidate, 'content', '')
if candidate_type == 'table':
next_table_idx = j
next_chunk = candidate
break
elif candidate_type == 'text' and ('续表' in candidate_title or '续表' in candidate_content):
# 遇到"续表"文本,继续查找下一个表格
continue
elif candidate_type not in ('text',):
# 遇到非文本类型,停止查找
break
if next_chunk is not None:
# 获取页码信息
curr_page_end = getattr(current, 'page_end', getattr(current, 'page_start', 0))
next_page_start = getattr(next_chunk, 'page_start', 0)
# 获取标题
curr_title = getattr(current, 'title', '') or ''
next_title = getattr(next_chunk, 'title', '') or ''
if isinstance(curr_title, list):
curr_title = curr_title[0] if curr_title else ''
if isinstance(next_title, list):
next_title = next_title[0] if next_title else ''
# 获取内容(用于检测"续表"
next_content = getattr(next_chunk, 'content', '')
# 判断是否为跨页表格
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
# 规则2: 第二个表格标题或内容包含"续表"
elif '续表' in next_title or '续表' in next_content:
is_cross_page = True
# 规则3: 标题相似(去掉"续表"后比较)
elif curr_title and next_title:
clean_next = next_title.replace('续表', '').strip()
if curr_title in clean_next or clean_next in curr_title:
is_cross_page = True
if is_cross_page:
# 执行合并
merge_count += 1
logger.info(f"合并跨页表格: {curr_title} (页{curr_page_end}) + {next_title} (页{next_page_start})")
# 合并 table_html
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
# 合并 image_path 到 images
curr_img = getattr(current, 'image_path', None)
next_img = getattr(next_chunk, 'image_path', None)
merged_images = []
if curr_img:
merged_images.append({'id': curr_img, 'page': curr_page_end})
if next_img:
merged_images.append({'id': next_img, 'page': next_page_start})
if merged_images:
current.images = merged_images
# 保留第一个图片作为主 image_path
current.image_path = curr_img
# 更新页码范围
current.page_end = getattr(next_chunk, 'page_end', next_page_start)
# 添加合并后的切片,跳过中间所有切片
merged_chunks.append(current)
i = next_table_idx + 1
continue
# 不需要合并,直接添加
merged_chunks.append(current)
i += 1
if merge_count > 0:
logger.info(f"跨页表格合并完成: {merge_count} 组表格被合并")
return merged_chunks
def _generate_table_summary(self, table_md: str, chunk) -> str:
"""生成表格摘要LLM"""
# 提取表头
lines = table_md.split('\n')
headers = []
for line in lines:
if line.startswith('|') and '---' not in line:
headers = [h.strip() for h in line.split('|') if h.strip()]
break
if not headers:
return ""
# 构建提示词
prompt = f"""请用一句话总结以下表格的主要内容不超过50字。
表头:{', '.join(headers)}
表格内容前5行
{chr(10).join(lines[:6])}
摘要:"""
try:
from config import get_llm_client, DASHSCOPE_MODEL
client = get_llm_client()
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=100)
return summary.strip() if summary else ""
except Exception as e:
logger.warning(f"生成表格摘要失败: {e}")
return ""
@staticmethod
def _extract_table_title(table_md: str) -> str:
"""从表格 Markdown 中提取标题"""
lines = table_md.split('\n')
for line in lines[:3]:
if line.startswith('#'):
return line.lstrip('#').strip()
return ""
def _generate_image_description(self, image_path: str, metadata: dict = None) -> str:
"""生成图片描述VLM"""
# 检查缓存
cached = self._get_vlm_cache(image_path)
if cached:
return cached
# 调用 VLM
try:
import base64
from pathlib import Path
# 读取图片
img_path = Path(image_path)
if not img_path.exists():
return ""
img_data = base64.b64encode(img_path.read_bytes()).decode()
# 构建提示词
prompt = """请描述这张图片的内容,包括:
1. 图片类型(如流程图、架构图、数据图表等)
2. 主要内容和关键信息
3. 如果是图表,描述数据趋势或关键数值
描述应简洁不超过100字。"""
# 调用 VLM需要支持视觉的模型
from config import DASHSCOPE_API_KEY, DASHSCOPE_BASE_URL, VLM_MODEL
from openai import OpenAI
client = OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
response = client.chat.completions.create(
model=VLM_MODEL,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_data}"}}
]
}
],
max_tokens=200
)
description = response.choices[0].message.content
# 缓存结果
import hashlib
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
cache_dir = Path('.data/cache/vlm')
cache_dir.mkdir(parents=True, exist_ok=True)
(cache_dir / f'{img_hash}.txt').write_text(description, encoding='utf-8')
return description
except Exception as e:
logger.warning(f"生成图片描述失败: {e}")
return ""
def update_image_descriptions(self, kb_name: str) -> dict:
"""更新向量库中所有图片的描述"""
collection = self.get_collection(kb_name)
if not collection:
return {"success": False, "error": "向量库不存在"}
result = collection.get(where={"chunk_type": {"$in": ["image", "chart"]}})
if not result['ids']:
return {"success": True, "updated": 0, "message": "没有图片切片"}
updated = 0
failed = 0
for chunk_id, doc, meta in zip(
result['ids'],
result['documents'],
result['metadatas']
):
image_path = meta.get('image_path', '')
if not image_path:
continue
description = self._generate_image_description(image_path, meta)
if description:
try:
collection.update(
ids=[chunk_id],
documents=[description],
metadatas=[{**meta, 'image_description': description}]
)
updated += 1
except Exception as e:
logger.warning(f"更新图片描述失败: {chunk_id}, {e}")
failed += 1
# 重建 BM25 索引
self.rebuild_bm25_index(kb_name)
return {
"success": True,
"updated": updated,
"failed": failed,
"total": len(result['ids'])
}
# ==================== 文档生命周期 ====================
def mark_document_as_superseded(
self,
kb_name: str,
old_filename: str,
new_filename: str,
reason: str = "版本更新"
) -> Dict:
"""标记文档为已替代版本"""
collection = self.get_collection(kb_name)
if not collection:
return {"success": False, "error": "向量库不存在"}
result = collection.get(where={"source": old_filename})
if not result['ids']:
return {"success": False, "error": "旧文档不存在"}
from datetime import datetime
superseded_date = datetime.now().isoformat()
updated_metadatas = [
{
**m,
"status": "superseded",
"superseded_by": new_filename,
"superseded_date": superseded_date,
"superseded_reason": reason
}
for m in result['metadatas']
]
collection.update(
ids=result['ids'],
metadatas=updated_metadatas
)
self.rebuild_bm25_index(kb_name)
logger.info(f"标记文档替代: {old_filename} -> {new_filename}")
return {
"success": True,
"superseded_chunks": len(result['ids']),
"old_document": old_filename,
"new_document": new_filename,
"collection": kb_name
}
# ==================== 辅助检索方法 ====================
def search_with_status_filter(
self,
kb_name: str,
query_vector: List[float],
top_k: int = 5,
status_filter: str = None
) -> Optional[SearchResult]:
"""带状态过滤的检索"""
collection = self.get_collection(kb_name)
if not collection:
return None
where_filter = None
if status_filter:
where_filter = {"status": status_filter}
result = collection.query(
query_embeddings=[query_vector],
n_results=top_k,
where=where_filter
)
return SearchResult(
ids=result['ids'][0] if result['ids'] else [],
documents=result['documents'][0] if result['documents'] else [],
metadatas=result['metadatas'][0] if result['metadatas'] else [],
distances=result['distances'][0] if result['distances'] else [],
collection_name=kb_name
)
def _rebuild_bm25_index(self, kb_name: str):
"""重建 BM25 索引(私有方法,兼容旧代码)"""
return self.rebuild_bm25_index(kb_name)
# ==================== 全局实例 ====================
_kb_manager: Optional[KnowledgeBaseManager] = None
def get_kb_manager() -> KnowledgeBaseManager:
"""获取全局知识库管理器实例"""
global _kb_manager
if _kb_manager is None:
_kb_manager = KnowledgeBaseManager()
return _kb_manager