ForcePilot/src/core/lightrag_based_kb.py

509 lines
20 KiB
Python
Raw Normal View History

import os
import json
import time
import traceback
import shutil
import asyncio
from pathlib import Path
from typing import Dict, List, Any, Optional
from datetime import datetime
from lightrag import LightRAG, QueryParam
from lightrag.llm.openai import openai_complete_if_cache, openai_embed
from lightrag.utils import EmbeddingFunc, setup_logger
from lightrag.kg.shared_storage import initialize_pipeline_status
from src import config
from src.utils import logger, hashstr, get_docker_safe_url
from src.plugins import ocr
work_dir = os.path.join(config.save_dir, "lightrag_data")
log_dir = os.path.join(work_dir, "logs", "lightrag")
setup_logger("lightrag", log_file_path=os.path.join(log_dir, f"lightrag_{datetime.now().strftime('%Y-%m-%d')}.log"))
class LightRagBasedKB:
"""基于 LightRAG 的知识库管理类"""
def __init__(self) -> None:
# 存储 LightRAG 实例映射 {db_id: LightRAG}
self.instances: Dict[str, LightRAG] = {}
# 数据库元信息存储 {db_id: metadata}
self.databases_meta: Dict[str, dict] = {}
# 文件信息存储 {file_id: file_info}
self.files_meta: Dict[str, dict] = {}
# 工作目录
self.work_dir = os.path.join(config.save_dir, "lightrag_data")
os.makedirs(self.work_dir, exist_ok=True)
# 加载已有的元数据
self._load_metadata()
logger.info("LightRagBasedKB initialized")
def _load_metadata(self):
"""加载元数据"""
meta_file = os.path.join(self.work_dir, "metadata.json")
if os.path.exists(meta_file):
try:
with open(meta_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.databases_meta = data.get("databases", {})
self.files_meta = data.get("files", {})
logger.info(f"Loaded metadata for {len(self.databases_meta)} databases")
except Exception as e:
logger.error(f"Failed to load metadata: {e}")
def _save_metadata(self):
"""保存元数据"""
# 确保工作目录存在
os.makedirs(self.work_dir, exist_ok=True)
meta_file = os.path.join(self.work_dir, "metadata.json")
try:
data = {
"databases": self.databases_meta,
"files": self.files_meta
}
with open(meta_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Failed to save metadata: {e}")
async def _get_lightrag_instance(self, db_id: str) -> Optional[LightRAG]:
"""获取或创建 LightRAG 实例"""
logger.info(f"Getting or creating LightRAG instance for {db_id}")
if db_id in self.instances:
return self.instances[db_id]
if db_id not in self.databases_meta:
return None
llm_info = self.databases_meta[db_id].get("llm_info", {})
embed_info = self.databases_meta[db_id].get("embed_info", {})
logger.info(f"LLM info: {llm_info}")
logger.info(f"Embed info: {embed_info}")
# 创建 LightRAG 实例
working_dir = os.path.join(self.work_dir, db_id)
os.makedirs(working_dir, exist_ok=True)
try:
# 使用配置的 LLM 和 embedding 函数
rag = LightRAG(
working_dir=working_dir,
llm_model_func=self._get_llm_func(llm_info),
embedding_func=self._get_embedding_func(embed_info),
vector_storage="MilvusVectorDBStorage",
kv_storage="JsonKVStorage",
graph_storage="PGGraphStorage",
doc_status_storage="JsonDocStatusStorage",
log_file_path=os.path.join(self.work_dir, db_id, "lightrag.log"),
)
# 异步初始化存储
await self._initialize_rag_storages(rag)
self.instances[db_id] = rag
return rag
except Exception as e:
logger.error(f"Failed to create LightRAG instance for {db_id}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return None
async def _initialize_rag_storages(self, rag: LightRAG):
"""异步初始化 LightRAG 存储"""
logger.info(f"Initializing LightRAG storages for {rag.working_dir}")
await rag.initialize_storages()
await initialize_pipeline_status()
def _get_llm_func(self, llm_info: dict):
"""获取 LLM 函数"""
# llm_info = llm_info | {
# "model_name": "qwen3-1.7b",
# "provider": "custom"
# }
# provider_info = config.model_names[llm_info.get("provider")]
# api_key = os.getenv(provider_info.get("env")[0] or "OPENAI_API_KEY") or "no_api_key"
# base_url = get_docker_safe_url(provider_info.get("base_url", "http://localhost:8081/v1"))
from src.models import get_custom_model
llm_info = get_custom_model("qwen3:32b-RFnC")
async def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs):
return await openai_complete_if_cache(
llm_info.get("name", "qwen3-1.7b"),
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
api_key=llm_info.get("api_key"),
base_url=get_docker_safe_url(llm_info.get("api_base")),
extra_body={"enable_thinking": False},
**kwargs,
)
return llm_model_func
def _get_embedding_func(self, embed_info: dict):
"""获取 embedding 函数"""
api_key = os.getenv(embed_info.get("api_key", "OPENAI_API_KEY")) or "no_api_key"
base_url = embed_info.get("base_url", "http://localhost:8081/v1").replace("/embeddings", "")
return EmbeddingFunc(
embedding_dim=embed_info.get("dimension") or 1024,
max_token_size=4096,
func=lambda texts: openai_embed(
texts=texts,
model=embed_info.get("model_name") or "Qwen3-Embedding-0.6B",
api_key=api_key,
base_url=get_docker_safe_url(base_url)
),
)
async def _process_file_to_markdown(self, file_path: str, params: dict | None = None) -> str:
"""将不同类型的文件转换为 markdown 格式"""
file_path_obj = Path(file_path)
file_ext = file_path_obj.suffix.lower()
if file_ext == '.pdf':
# 使用 OCR 处理 PDF
from src.core.indexing import parse_pdf_async
text = await parse_pdf_async(str(file_path_obj), params=params)
return f"Using OCR to process {file_path_obj.name}\n\n{text}"
elif file_ext in ['.txt', '.md']:
# 直接读取文本文件
with open(file_path_obj, 'r', encoding='utf-8') as f:
content = f.read()
return f"# {file_path_obj.name}\n\n{content}"
elif file_ext in ['.doc', '.docx']:
# 处理 Word 文档
from docx import Document # type: ignore
doc = Document(file_path_obj)
text = '\n'.join([para.text for para in doc.paragraphs])
return f"# {file_path_obj.name}\n\n{text}"
elif file_ext in ['.jpg', '.jpeg', '.png', '.bmp']:
# 使用 OCR 处理图片
text = ocr.process_image(str(file_path_obj))
return f"# {file_path_obj.name}\n\n{text}"
else:
# 尝试作为文本文件读取
import textract # type: ignore
text = textract.process(file_path_obj)
return f"# {file_path_obj.name}\n\n{text}"
async def _process_url_to_markdown(self, url: str, params: dict | None = None) -> str:
"""将 URL 转换为 markdown 格式"""
import requests
from bs4 import BeautifulSoup
response = requests.get(url, timeout=30)
soup = BeautifulSoup(response.content, 'html.parser')
text_content = soup.get_text()
return f"# {url}\n\n{text_content}"
# =============================================================================
# data_router.py 中使用的核心方法
# =============================================================================
def get_databases(self):
"""获取所有数据库信息 - data_router.py 使用"""
databases = []
for db_id, meta in self.databases_meta.items():
db_dict = meta.copy()
db_dict["db_id"] = db_id
# 获取文件信息
db_files = {}
for file_id, file_info in self.files_meta.items():
if file_info.get("database_id") == db_id:
db_files[file_id] = {
"file_id": file_id,
"filename": file_info.get("filename", ""),
"path": file_info.get("path", ""),
"type": file_info.get("file_type", ""),
"status": file_info.get("status", "done"),
"created_at": file_info.get("created_at", time.time())
}
db_dict["files"] = db_files
db_dict["row_count"] = len(db_files)
db_dict["status"] = "已连接"
databases.append(db_dict)
return {"databases": databases}
def create_database(self, database_name, description, embed_info: dict | None = None, **kwargs):
"""创建数据库 - data_router.py 使用"""
db_id = f"kb_{hashstr(database_name, with_salt=True)}"
# 创建数据库记录
self.databases_meta[db_id] = {
"name": database_name,
"description": description,
"embed_info": embed_info,
"metadata": kwargs,
"created_at": datetime.now().isoformat()
}
self._save_metadata()
# 创建工作目录
working_dir = os.path.join(self.work_dir, db_id)
os.makedirs(working_dir, exist_ok=True)
# 返回数据库信息
db_dict = self.databases_meta[db_id].copy()
db_dict["db_id"] = db_id
db_dict["files"] = {}
return db_dict
def delete_database(self, db_id):
"""删除数据库 - data_router.py 使用"""
# TODO 删除数据库时,需要删除文件记录,并删除 LightRAG 中的文件
if db_id in self.databases_meta:
# 删除相关文件记录
files_to_delete = [fid for fid, finfo in self.files_meta.items()
if finfo.get("database_id") == db_id]
for file_id in files_to_delete:
del self.files_meta[file_id]
# 删除数据库记录
del self.databases_meta[db_id]
# 删除 LightRAG 实例
if db_id in self.instances:
del self.instances[db_id]
self._save_metadata()
# 删除工作目录
working_dir = os.path.join(self.work_dir, db_id)
if os.path.exists(working_dir):
try:
shutil.rmtree(working_dir)
except Exception as e:
logger.error(f"Error deleting working directory {working_dir}: {e}")
return {"message": "删除成功"}
async def add_content(self, db_id, items, params: dict | None = None):
"""通用的内容添加方法 - 支持文件和URL"""
if db_id not in self.databases_meta:
raise ValueError(f"Database {db_id} not found")
rag = await self._get_lightrag_instance(db_id)
if not rag:
raise ValueError(f"Failed to get LightRAG instance 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)
logger.info(f"Markdown content: {markdown_content[:100].replace('\n', ' ')}...")
else: # URL
markdown_content = await self._process_url_to_markdown(item, params=params)
# 使用 LightRAG 插入内容
await rag.ainsert(
input=markdown_content,
ids=file_id,
file_paths=item_path
)
logger.info(f"Inserted {content_type} {item} into LightRAG. 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
def get_database_info(self, db_id):
"""获取数据库详细信息 - data_router.py 使用"""
if db_id not in self.databases_meta:
return None
meta = self.databases_meta[db_id].copy()
meta["db_id"] = db_id
# 获取文件信息
db_files = {}
for file_id, file_info in self.files_meta.items():
if file_info.get("database_id") == db_id:
db_files[file_id] = {
"file_id": file_id,
"filename": file_info.get("filename", ""),
"path": file_info.get("path", ""),
"type": file_info.get("file_type", ""),
"status": file_info.get("status", "done"),
"created_at": file_info.get("created_at", time.time())
}
meta["files"] = db_files
meta["row_count"] = len(db_files)
meta["status"] = "已连接"
return meta
async def delete_file(self, db_id, file_id):
"""删除文件 - data_router.py 使用"""
# TODO 删除文件时,需要删除文件记录,并删除 LightRAG 中的文件
rag = await self._get_lightrag_instance(db_id)
if rag:
try:
# 使用 LightRAG 删除文档
await rag.adelete_by_doc_id(file_id)
except Exception as e:
logger.error(f"Error deleting file {file_id} from LightRAG: {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, file_id):
"""获取文件信息和其 chunks - data_router.py 使用"""
if file_id not in self.files_meta:
raise Exception(f"File not found: {file_id}")
# 使用 LightRAG 获取 chunks
rag = await self._get_lightrag_instance(db_id)
if rag:
try:
# 获取文档的所有 chunks
assert hasattr(rag.text_chunks, 'get_all'), "text_chunks does not have get_all method"
all_chunks = await rag.text_chunks.get_all()
# 筛选属于该文档的 chunks
doc_chunks = []
for chunk_id, chunk_data in all_chunks.items():
if isinstance(chunk_data, dict) and chunk_data.get("full_doc_id") == file_id:
chunk_data["id"] = chunk_id
chunk_data["content_vector"] = []
doc_chunks.append(chunk_data)
# 按 chunk_order_index 排序
doc_chunks.sort(key=lambda x: x.get("chunk_order_index", 0))
# logger.debug(f"All chunks: {doc_chunks}")
return {"lines": doc_chunks}
except Exception as e:
logger.error(f"Error getting chunks for file {file_id}: {e}")
return {"lines": []}
def get_db_upload_path(self, db_id=None):
"""获取数据库上传路径 - data_router.py 使用"""
if db_id:
uploads_folder = os.path.join(self.work_dir, db_id, "uploads")
os.makedirs(uploads_folder, exist_ok=True)
return uploads_folder
general_uploads = os.path.join(self.work_dir, "uploads")
os.makedirs(general_uploads, exist_ok=True)
return general_uploads
def update_database(self, db_id, name, description):
"""更新数据库 - data_router.py 使用"""
if db_id not in self.databases_meta:
raise ValueError(f"数据库 {db_id} 不存在")
self.databases_meta[db_id]["name"] = name
self.databases_meta[db_id]["description"] = description
self._save_metadata()
# 返回更新后的数据库信息
return self.get_database_info(db_id)
# =============================================================================
# 为了系统兼容性需要的其他方法
# =============================================================================
def query(self, query_text, db_id, **kwargs):
logger.warning("query is deprecated, use aquery instead")
return asyncio.run(self.aquery(query_text, db_id, **kwargs))
async def aquery(self, query_text, db_id, **kwargs):
"""查询知识库 - 用于检索器"""
rag = await self._get_lightrag_instance(db_id)
if not rag:
raise ValueError(f"Database {db_id} not found")
try:
# 设置查询参数
params_dict = {
"mode": "mix",
"only_need_context": True,
"top_k": 10,
} | kwargs
param = QueryParam(**params_dict)
# 执行查询
response = await rag.aquery(query_text, param)
logger.debug(f"Query response: {response}")
return response
except Exception as e:
logger.error(f"Query error: {e}, {traceback.format_exc()}")
return ""
def get_retrievers(self):
"""获取所有检索器 - 用于工具系统"""
retrievers = {}
for db_id, meta in self.databases_meta.items():
def make_retriever(db_id):
async def retriever(query_text):
return await self.aquery(query_text, db_id)
return retriever
retrievers[db_id] = {
"name": meta["name"],
"description": meta["description"],
"retriever": make_retriever(db_id),
}
return retrievers