ForcePilot/src/core/chroma_kb.py

470 lines
18 KiB
Python
Raw Normal View History

import os
import time
import traceback
import json
from pathlib import Path
from typing import Optional, Dict, List, Any
from datetime import datetime
try:
import chromadb
from chromadb.config import Settings
from chromadb.api.types import EmbeddingFunction, Documents, Embeddings
except ImportError:
chromadb = None
EmbeddingFunction = None
Documents = None
Embeddings = None
from src.core.knowledge_base import KnowledgeBase
from src.utils import logger, hashstr
from src import config
if EmbeddingFunction is not None:
class OpenAIEmbeddingFunction(EmbeddingFunction):
"""
符合 ChromaDB 0.4.16+ 接口的 OpenAI 兼容嵌入函数
"""
def __init__(self, model: str, api_key: str, base_url: str):
self.model = model
self.api_key = api_key
self.base_url = base_url.replace("/embeddings", "")
def __call__(self, input: Documents) -> Embeddings:
"""
生成文档嵌入向量
Args:
input: 文档列表字符串列表
Returns:
Embeddings: 嵌入向量列表
"""
import asyncio
import concurrent.futures
from lightrag.llm.openai import openai_embed
# 确保输入是列表格式
if isinstance(input, str):
texts = [input]
else:
texts = list(input)
# 在新线程中运行异步函数,避免事件循环冲突
def run_embedding():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
openai_embed(
texts=texts,
model=self.model,
api_key=self.api_key,
base_url=self.base_url,
)
)
finally:
loop.close()
# 使用线程池执行异步函数
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_embedding)
embeddings = future.result()
return embeddings
else:
# 如果 ChromaDB 没有安装,提供一个空的类
class OpenAIEmbeddingFunction:
def __init__(self, *args, **kwargs):
pass
class ChromaKB(KnowledgeBase):
"""基于 ChromaDB 的向量知识库实现"""
def __init__(self, work_dir: str, **kwargs):
"""
初始化 ChromaDB 知识库
Args:
work_dir: 工作目录
**kwargs: 其他配置参数
"""
super().__init__(work_dir)
if chromadb is None:
raise ImportError("chromadb is not installed. Please install it with: pip install chromadb")
# ChromaDB 配置
self.chroma_db_path = os.path.join(work_dir, "chromadb")
os.makedirs(self.chroma_db_path, exist_ok=True)
# 初始化 ChromaDB 客户端
self.chroma_client = chromadb.PersistentClient(
path=self.chroma_db_path,
settings=Settings(anonymized_telemetry=False)
)
# 存储集合映射 {db_id: collection}
self.collections: Dict[str, Any] = {}
# 分块配置
self.chunk_size = kwargs.get('chunk_size', 1000)
self.chunk_overlap = kwargs.get('chunk_overlap', 200)
logger.info("ChromaKB initialized")
@property
def kb_type(self) -> str:
"""知识库类型标识"""
return "chroma"
async def _create_kb_instance(self, db_id: str, config: Dict) -> Any:
"""创建向量数据库集合"""
logger.info(f"Creating ChromaDB collection for {db_id}")
if db_id not in self.databases_meta:
raise ValueError(f"Database {db_id} not found")
embed_info = self.databases_meta[db_id].get("embed_info", {})
embedding_function = self._get_embedding_function(embed_info)
# 创建或获取集合
collection_name = f"kb_{db_id}"
try:
# 尝试获取现有集合
collection = self.chroma_client.get_collection(name=collection_name)
logger.info(f"Retrieved existing collection: {collection_name}")
# 检查现有集合的配置是否匹配当前的 embed_info
expected_model = embed_info.get("name") if embed_info else "default"
collection_metadata = collection.metadata or {}
current_model = collection_metadata.get("embedding_model", "unknown")
# 如果模型不匹配,删除现有集合并重新创建
if current_model != expected_model:
logger.warning(f"Collection {collection_name} uses model '{current_model}', but expected '{expected_model}'. Recreating collection.")
self.chroma_client.delete_collection(name=collection_name)
raise Exception("Model mismatch, recreating collection")
except Exception as e:
# 创建新集合
logger.info(f"Creating new collection with embedding model: {embed_info.get('name', 'default')}")
collection_metadata = {
"db_id": db_id,
"created_at": datetime.now().isoformat(),
"embedding_model": embed_info.get("name") if embed_info else "default"
}
collection = self.chroma_client.create_collection(
name=collection_name,
embedding_function=embedding_function,
metadata=collection_metadata
)
logger.info(f"Created new collection: {collection_name}")
return collection
async def _initialize_kb_instance(self, instance: Any) -> None:
"""初始化向量数据库集合(无需特殊初始化)"""
pass
def _get_embedding_function(self, embed_info: Dict):
"""获取 embedding 函数"""
if embed_info:
model = embed_info["name"]
api_key = os.getenv(embed_info["api_key"], embed_info["api_key"])
base_url = embed_info["base_url"]
else:
from src.models import select_embedding_model
default_model = select_embedding_model(config.embed_model)
model = default_model.model
api_key = default_model.api_key
base_url = default_model.base_url
# 返回符合 ChromaDB 0.4.16+ 接口的 EmbeddingFunction 实例
return OpenAIEmbeddingFunction(
model=model,
api_key=api_key,
base_url=base_url
)
async def _get_chroma_collection(self, db_id: str):
"""获取或创建 ChromaDB 集合"""
if db_id in self.collections:
return self.collections[db_id]
if db_id not in self.databases_meta:
return None
try:
# 创建集合
collection = await self._create_kb_instance(db_id, {})
await self._initialize_kb_instance(collection)
self.collections[db_id] = collection
return collection
except Exception as e:
logger.error(f"Failed to create vector collection for {db_id}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return None
def _split_text_into_chunks(self, text: str, file_id: str, filename: str) -> List[Dict]:
"""将文本分割成块"""
chunks = []
# 简单的分块策略:按段落和长度分割
paragraphs = text.split('\n\n')
current_chunk = ""
chunk_index = 0
for paragraph in paragraphs:
paragraph = paragraph.strip()
if not paragraph:
continue
# 如果当前块加上新段落会超过限制,保存当前块
if len(current_chunk) + len(paragraph) > self.chunk_size and current_chunk:
chunks.append({
"id": f"{file_id}_chunk_{chunk_index}",
"content": current_chunk.strip(),
"file_id": file_id,
"filename": filename,
"chunk_index": chunk_index,
"metadata": {
"source": filename,
"chunk_id": f"{file_id}_chunk_{chunk_index}",
"full_doc_id": file_id
}
})
# 开始新块,包含重叠内容
if len(current_chunk) > self.chunk_overlap:
current_chunk = current_chunk[-self.chunk_overlap:] + "\n\n" + paragraph
else:
current_chunk = paragraph
chunk_index += 1
else:
if current_chunk:
current_chunk += "\n\n" + paragraph
else:
current_chunk = paragraph
# 添加最后一块
if current_chunk.strip():
chunks.append({
"id": f"{file_id}_chunk_{chunk_index}",
"content": current_chunk.strip(),
"file_id": file_id,
"filename": filename,
"chunk_index": chunk_index,
"metadata": {
"source": filename,
"chunk_id": f"{file_id}_chunk_{chunk_index}",
"full_doc_id": file_id
}
})
return chunks
async def add_content(self, db_id: str, items: List[str],
params: Optional[Dict] = None) -> List[Dict]:
"""添加内容(文件/URL"""
if db_id not in self.databases_meta:
raise ValueError(f"Database {db_id} not found")
collection = await self._get_chroma_collection(db_id)
if not collection:
raise ValueError(f"Failed to get ChromaDB collection for {db_id}")
content_type = params.get('content_type', 'file') if params else 'file'
processed_items_info = []
for item in items:
# 根据内容类型生成不同的ID和文件名
if content_type == "file":
file_path = Path(item)
file_id = f"file_{hashstr(str(file_path) + str(time.time()), 6)}"
file_type = file_path.suffix.lower().replace(".", "")
filename = file_path.name
item_path = str(file_path)
else: # URL
file_id = f"url_{hashstr(item + str(time.time()), 6)}"
file_type = "url"
filename = f"webpage_{hashstr(item, 6)}.md"
item_path = item
# 添加文件记录
file_record = {
"database_id": db_id,
"filename": filename,
"path": item_path,
"file_type": file_type,
"status": "processing",
"created_at": time.time()
}
self.files_meta[file_id] = file_record
self._save_metadata()
# 添加 file_id 到返回数据
file_record = file_record.copy()
file_record["file_id"] = file_id
try:
# 根据内容类型处理内容
if content_type == "file":
markdown_content = await self._process_file_to_markdown(item, params=params)
else: # URL
markdown_content = await self._process_url_to_markdown(item, params=params)
# 分割文本成块
chunks = self._split_text_into_chunks(markdown_content, file_id, filename)
logger.info(f"Split {filename} into {len(chunks)} chunks")
# 准备向量数据库插入的数据
if chunks:
documents = [chunk["content"] for chunk in chunks]
metadatas = [chunk["metadata"] for chunk in chunks]
ids = [chunk["id"] for chunk in chunks]
# 插入到 ChromaDB
collection.add(
documents=documents,
metadatas=metadatas,
ids=ids
)
logger.info(f"Inserted {content_type} {item} into ChromaDB. Done.")
# 更新状态为完成
self.files_meta[file_id]["status"] = "done"
self._save_metadata()
file_record['status'] = "done"
except Exception as e:
logger.error(f"处理{content_type} {item} 失败: {e}, {traceback.format_exc()}")
self.files_meta[file_id]["status"] = "failed"
self._save_metadata()
file_record['status'] = "failed"
processed_items_info.append(file_record)
return processed_items_info
async def aquery(self, query_text: str, db_id: str, **kwargs) -> str:
"""异步查询知识库"""
collection = await self._get_chroma_collection(db_id)
if not collection:
raise ValueError(f"Database {db_id} not found")
try:
# 设置查询参数 - ChromaDB 知识库特有的参数
top_k = kwargs.get("top_k", 10)
similarity_threshold = kwargs.get("similarity_threshold", 0.0) # 相似度阈值
include_distances = kwargs.get("include_distances", True) # 是否包含距离信息
# 执行相似性搜索
results = collection.query(
query_texts=[query_text],
n_results=top_k,
include=["documents", "metadatas", "distances"] if include_distances else ["documents", "metadatas"]
)
# 处理结果
if results and results.get("documents") and results["documents"][0]:
documents = results["documents"][0]
metadatas = results["metadatas"][0] if results.get("metadatas") else []
distances = results["distances"][0] if results.get("distances") else []
# 构建上下文,应用相似度阈值过滤
contexts = []
for i, doc in enumerate(documents):
# 计算相似度(距离越小相似度越高)
similarity = 1 - distances[i] if i < len(distances) else 1.0
# 应用相似度阈值过滤
if similarity < similarity_threshold:
continue
context = f"[文档片段 {i+1}]:\n{doc}\n"
if i < len(metadatas) and metadatas[i]:
source = metadatas[i].get("source", "未知来源")
chunk_id = metadatas[i].get("chunk_id", f"chunk_{i}")
context += f"来源: {source} ({chunk_id})\n"
if include_distances and i < len(distances):
context += f"相似度: {similarity:.3f}\n"
contexts.append(context)
response = "\n".join(contexts)
logger.debug(f"ChromaDB query response: {len(contexts)} chunks found (after similarity filtering)")
return response
return ""
except Exception as e:
logger.error(f"ChromaDB query error: {e}, {traceback.format_exc()}")
return ""
async def delete_file(self, db_id: str, file_id: str) -> None:
"""删除文件"""
collection = await self._get_chroma_collection(db_id)
if collection:
try:
# 查找所有相关的chunks
results = collection.get(
where={"full_doc_id": file_id},
include=["metadatas"]
)
# 删除所有相关chunks
if results and results.get("ids"):
collection.delete(ids=results["ids"])
logger.info(f"Deleted {len(results['ids'])} chunks for file {file_id}")
except Exception as e:
logger.error(f"Error deleting file {file_id} from ChromaDB: {e}")
# 删除文件记录
if file_id in self.files_meta:
del self.files_meta[file_id]
self._save_metadata()
async def get_file_info(self, db_id: str, file_id: str) -> Dict:
"""获取文件信息和chunks"""
if file_id not in self.files_meta:
raise Exception(f"File not found: {file_id}")
# 使用 ChromaDB 获取chunks
collection = await self._get_chroma_collection(db_id)
if collection:
try:
# 获取文档的所有chunks
results = collection.get(
where={"full_doc_id": file_id},
include=["documents", "metadatas"]
)
# 构建chunks数据
doc_chunks = []
if results and results.get("ids"):
for i, chunk_id in enumerate(results["ids"]):
chunk_data = {
"id": chunk_id,
"content": results["documents"][i] if i < len(results["documents"]) else "",
"metadata": results["metadatas"][i] if i < len(results["metadatas"]) else {},
"chunk_order_index": results["metadatas"][i].get("chunk_index", i) if i < len(results["metadatas"]) else i
}
doc_chunks.append(chunk_data)
# 按 chunk_order_index 排序
doc_chunks.sort(key=lambda x: x.get("chunk_order_index", 0))
return {"lines": doc_chunks}
except Exception as e:
logger.error(f"Error getting chunks for file {file_id}: {e}")
return {"lines": []}