多库检索与存储修复: - RRF 融合去重改用 (collection, chunk_id) 复合键,修复同名文件结果被吞 - DocStore 存储路径加 collection 前缀,修复跨库同名切片数据覆盖 - search_multiple 去重改用复合键 - chunk_id 解析改用 rsplit 兼容下划线文件名 上传与版本管理修复: - 同名文件上传改为覆盖模式,自动清理旧切片 - 修复首次上传不创建版本记录 - 修复覆盖上传版本号回退到 v1 - sync ADDED 分支改用动态版本号生成 - _generate_version_id 改为基于全部版本递增 - 废止/恢复操作同步 SQLite 版本记录 - mark_document_as_superseded 改为仅更新 SQLite 删除清理修复: - 删除文档时同步清理 SQLite 版本记录和变更日志 - 删除向量库时同步清理该库所有版本记录 - cleanup 改为清理 SQLite 记录而非 ChromaDB 测试: - test_version_management.py: 27 条版本管理单元测试 - test_edge_cases.py: 28 条边界用例测试 - test_upload_dedup.py: 5 条上传去重测试 - e2e_risk_test.py: 27 条端到端风险测试 文档: - 新增风险边界问题修复注意事项.md(面向后端的对接文档) - 新增向量库边界风险分析.md - 更新多篇现有文档
409 lines
14 KiB
Python
409 lines
14 KiB
Python
"""
|
||
知识库管理器 - 集合管理 Mixin
|
||
|
||
提供向量库(ChromaDB Collection)的创建、删除、查询等管理功能。
|
||
每个向量库对应一个独立的 ChromaDB 集合,支持按部门隔离。
|
||
|
||
主要方法:
|
||
- get_collection: 获取或创建向量库集合
|
||
- create_collection: 创建新向量库
|
||
- delete_collection: 删除向量库
|
||
- list_collections: 列出所有向量库
|
||
- collection_exists: 检查向量库是否存在
|
||
"""
|
||
|
||
import os
|
||
import gc
|
||
import time
|
||
import logging
|
||
from typing import List, Optional, Tuple
|
||
from datetime import datetime
|
||
|
||
import chromadb
|
||
from chromadb import Collection
|
||
|
||
from .base import (
|
||
CollectionInfo, PUBLIC_KB_NAME
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class CollectionMixin:
|
||
"""
|
||
集合管理 Mixin
|
||
|
||
提供 ChromaDB 向量库集合的管理功能,包括:
|
||
- 集合的创建与删除
|
||
- 集合元数据管理
|
||
- 集合列表查询
|
||
|
||
依赖属性(需由主类提供):
|
||
- self.base_path: 向量库存储根路径
|
||
- self.bm25_base_path: BM25 索引存储路径
|
||
- self._collections: 集合缓存字典
|
||
- self._clients: 客户端缓存字典
|
||
- self._bm25_indexes: BM25 索引缓存字典
|
||
- self._metadata: 元数据字典
|
||
- self._lock: 线程锁
|
||
"""
|
||
|
||
def _get_client(self, kb_name: str) -> chromadb.PersistentClient:
|
||
"""
|
||
获取或创建 ChromaDB 客户端
|
||
|
||
每个向量库使用独立的客户端实例,客户端会被缓存以避免重复创建。
|
||
|
||
Args:
|
||
kb_name: 向量库名称
|
||
|
||
Returns:
|
||
ChromaDB PersistentClient 实例
|
||
"""
|
||
if kb_name not in self._clients:
|
||
db_path = os.path.join(self.base_path, kb_name)
|
||
os.makedirs(db_path, exist_ok=True)
|
||
self._clients[kb_name] = chromadb.PersistentClient(path=db_path)
|
||
return self._clients[kb_name]
|
||
|
||
def get_collection(self, kb_name: str) -> Optional[Collection]:
|
||
"""
|
||
获取或创建向量库集合
|
||
|
||
如果集合已存在于缓存中,直接返回缓存实例。
|
||
否则从 ChromaDB 获取或创建新集合。
|
||
|
||
Args:
|
||
kb_name: 向量库名称(只能包含字母、数字和下划线)
|
||
|
||
Returns:
|
||
ChromaDB Collection 对象;失败时返回 None
|
||
|
||
Note:
|
||
使用 cosine 相似度作为向量距离度量。
|
||
sync_threshold 设置为 100000 以支持大批量写入。
|
||
"""
|
||
with self._lock:
|
||
if kb_name in self._collections:
|
||
return self._collections[kb_name]
|
||
try:
|
||
client = self._get_client(kb_name)
|
||
collection = client.get_or_create_collection(
|
||
name=kb_name,
|
||
metadata={
|
||
"hnsw:space": "cosine",
|
||
"hnsw:sync_threshold": 100000
|
||
}
|
||
)
|
||
self._collections[kb_name] = collection
|
||
logger.info(f"获取向量库: {kb_name}, 文档数: {collection.count()}")
|
||
return collection
|
||
except Exception as e:
|
||
logger.error(f"获取向量库失败: {kb_name}, 错误: {e}")
|
||
return None
|
||
|
||
def create_collection(
|
||
self,
|
||
kb_name: str,
|
||
display_name: str = "",
|
||
department: str = "",
|
||
description: str = ""
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
创建新向量库
|
||
|
||
创建一个新的 ChromaDB 集合,同时初始化对应的 BM25 索引。
|
||
|
||
Args:
|
||
kb_name: 向量库名称(只能包含字母、数字和下划线)
|
||
display_name: 显示名称(用于 UI 展示)
|
||
department: 所属部门(用于权限控制)
|
||
description: 向量库描述
|
||
|
||
Returns:
|
||
元组 (success, message):
|
||
- success: 创建是否成功
|
||
- message: 成功或失败的描述信息
|
||
|
||
Example:
|
||
>>> success, msg = kb_manager.create_collection(
|
||
... "dept_finance",
|
||
... display_name="财务部知识库",
|
||
... department="finance"
|
||
... )
|
||
"""
|
||
from .base import BM25Index
|
||
|
||
if not kb_name or not kb_name.replace('_', '').isalnum():
|
||
return False, "向量库名称只能包含字母、数字和下划线"
|
||
|
||
if kb_name in self._metadata.get("collections", {}):
|
||
return False, f"向量库 '{kb_name}' 已存在"
|
||
|
||
chroma_dir = os.path.join(self.base_path, kb_name)
|
||
if os.path.exists(chroma_dir):
|
||
import shutil
|
||
shutil.rmtree(chroma_dir)
|
||
logger.warning(f"清理残留向量库文件夹: {chroma_dir}")
|
||
|
||
try:
|
||
collection = self.get_collection(kb_name)
|
||
if not collection:
|
||
return False, "创建向量库失败"
|
||
|
||
self._bm25_indexes[kb_name] = BM25Index()
|
||
|
||
if "collections" not in self._metadata:
|
||
self._metadata["collections"] = {}
|
||
|
||
self._metadata["collections"][kb_name] = {
|
||
"display_name": display_name or kb_name,
|
||
"department": department,
|
||
"description": description,
|
||
"created_at": datetime.now().isoformat()
|
||
}
|
||
self._save_metadata()
|
||
|
||
logger.info(f"创建向量库: {kb_name}")
|
||
return True, f"向量库 '{kb_name}' 创建成功"
|
||
|
||
except Exception as e:
|
||
logger.error(f"创建向量库失败: {e}")
|
||
return False, f"创建失败: {str(e)}"
|
||
|
||
def update_collection_metadata(
|
||
self,
|
||
kb_name: str,
|
||
display_name: str = None,
|
||
description: str = None
|
||
) -> bool:
|
||
"""
|
||
更新向量库元数据
|
||
|
||
仅更新 display_name 和 description,不影响集合中的数据。
|
||
|
||
Args:
|
||
kb_name: 向量库名称
|
||
display_name: 新的显示名称(None 表示不更新)
|
||
description: 新的描述(None 表示不更新)
|
||
|
||
Returns:
|
||
更新成功返回 True,向量库不存在返回 False
|
||
"""
|
||
collections = self._metadata.get("collections", {})
|
||
if kb_name not in collections:
|
||
return False
|
||
|
||
if display_name is not None:
|
||
collections[kb_name]["display_name"] = display_name
|
||
if description is not None:
|
||
collections[kb_name]["description"] = description
|
||
|
||
self._save_metadata()
|
||
logger.info(f"更新向量库元数据: {kb_name}")
|
||
return True
|
||
|
||
def delete_collection(self, kb_name: str, delete_documents: bool = False) -> Tuple[bool, str]:
|
||
"""
|
||
删除向量库
|
||
|
||
删除向量库及其所有数据,包括:
|
||
- ChromaDB 集合
|
||
- BM25 索引文件
|
||
- 向量库文件夹
|
||
- 可选:原始文档文件
|
||
|
||
Args:
|
||
kb_name: 向量库名称
|
||
delete_documents: 是否同时删除原始文档文件
|
||
|
||
Returns:
|
||
元组 (success, message):
|
||
- success: 删除是否成功
|
||
- message: 成功或失败的描述信息
|
||
|
||
Warning:
|
||
公开知识库 (public_kb) 不能被删除。
|
||
删除操作不可逆,请谨慎使用。
|
||
"""
|
||
import shutil
|
||
|
||
if kb_name == PUBLIC_KB_NAME:
|
||
return False, "公开知识库不能删除"
|
||
|
||
if kb_name not in self._metadata.get("collections", {}):
|
||
return False, f"向量库 '{kb_name}' 不存在"
|
||
|
||
try:
|
||
client = self._clients.get(kb_name)
|
||
if client:
|
||
try:
|
||
client.delete_collection(kb_name)
|
||
except Exception as e:
|
||
logger.debug(f"删除集合失败: {e}")
|
||
try:
|
||
client.close()
|
||
except Exception as e:
|
||
logger.debug(f"关闭客户端失败: {e}")
|
||
|
||
if kb_name in self._collections:
|
||
del self._collections[kb_name]
|
||
if kb_name in self._bm25_indexes:
|
||
del self._bm25_indexes[kb_name]
|
||
if kb_name in self._clients:
|
||
del self._clients[kb_name]
|
||
|
||
gc.collect()
|
||
time.sleep(1)
|
||
|
||
bm25_path = os.path.join(self.bm25_base_path, f"{kb_name}.pkl")
|
||
if os.path.exists(bm25_path):
|
||
os.remove(bm25_path)
|
||
|
||
chroma_dir = os.path.join(self.base_path, kb_name)
|
||
if os.path.exists(chroma_dir):
|
||
for attempt in range(3):
|
||
try:
|
||
shutil.rmtree(chroma_dir)
|
||
logger.info(f"删除向量库文件夹: {chroma_dir}")
|
||
break
|
||
except PermissionError:
|
||
if attempt < 2:
|
||
logger.warning(f"删除文件夹失败,重试 {attempt + 1}/3")
|
||
gc.collect()
|
||
time.sleep(1)
|
||
else:
|
||
logger.error(f"删除文件夹失败,文件被占用: {chroma_dir}")
|
||
raise
|
||
|
||
if delete_documents:
|
||
from config import DOCUMENTS_PATH
|
||
docs_dir = os.path.join(DOCUMENTS_PATH, kb_name)
|
||
if os.path.exists(docs_dir):
|
||
shutil.rmtree(docs_dir)
|
||
logger.info(f"删除文档文件夹: {docs_dir}")
|
||
|
||
# 删除该向量库所有文档的哈希记录
|
||
try:
|
||
from knowledge.sync import SyncDatabase
|
||
sync_db = SyncDatabase()
|
||
# 获取所有以 kb_name/ 开头的哈希记录
|
||
all_hashes = sync_db.get_all_document_hashes()
|
||
deleted_count = 0
|
||
for doc_id in list(all_hashes.keys()):
|
||
if doc_id.startswith(f"{kb_name}/"):
|
||
sync_db.delete_document_hash(doc_id)
|
||
deleted_count += 1
|
||
if deleted_count > 0:
|
||
logger.info(f"清理向量库哈希记录: {kb_name}, 共 {deleted_count} 条")
|
||
except Exception as e:
|
||
logger.warning(f"清理哈希记录失败: {e}")
|
||
|
||
# 清理该向量库的 SQLite 版本记录和变更日志
|
||
try:
|
||
from data.db import get_connection
|
||
with get_connection("knowledge") as conn:
|
||
cursor1 = conn.execute(
|
||
"DELETE FROM document_versions WHERE collection = ?",
|
||
(kb_name,)
|
||
)
|
||
cursor2 = conn.execute(
|
||
"DELETE FROM version_change_logs WHERE collection = ?",
|
||
(kb_name,)
|
||
)
|
||
conn.commit()
|
||
ver_cleaned = cursor1.rowcount
|
||
log_cleaned = cursor2.rowcount
|
||
if ver_cleaned > 0 or log_cleaned > 0:
|
||
logger.info(
|
||
f"清理向量库版本记录: {kb_name}, "
|
||
f"版本 {ver_cleaned} 条, 日志 {log_cleaned} 条"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"清理版本记录失败: {e}")
|
||
|
||
if kb_name in self._metadata.get("collections", {}):
|
||
del self._metadata["collections"][kb_name]
|
||
self._save_metadata()
|
||
|
||
logger.info(f"删除向量库: {kb_name}")
|
||
return True, f"向量库 '{kb_name}' 已删除"
|
||
|
||
except Exception as e:
|
||
logger.error(f"删除向量库失败: {e}")
|
||
return False, f"删除失败: {str(e)}"
|
||
|
||
def list_collections(self) -> List[CollectionInfo]:
|
||
"""
|
||
列出所有向量库
|
||
|
||
返回所有已创建的向量库信息,包括名称、文档数量、创建时间等。
|
||
|
||
Returns:
|
||
CollectionInfo 对象列表,每个对象包含:
|
||
- name: 向量库名称
|
||
- display_name: 显示名称
|
||
- document_count: 文档数量
|
||
- created_at: 创建时间
|
||
- department: 所属部门
|
||
- description: 描述
|
||
"""
|
||
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()
|
||
|
||
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", "")
|
||
))
|
||
|
||
return result
|
||
|
||
def collection_exists(self, kb_name: str) -> bool:
|
||
"""
|
||
检查向量库是否存在
|
||
|
||
Args:
|
||
kb_name: 向量库名称
|
||
|
||
Returns:
|
||
存在返回 True,不存在返回 False
|
||
"""
|
||
return kb_name in self._metadata.get("collections", {})
|