1334 lines
53 KiB
Python
1334 lines
53 KiB
Python
import asyncio
|
||
import os
|
||
import time
|
||
import traceback
|
||
import weakref
|
||
from dataclasses import MISSING, dataclass, field, fields
|
||
from functools import partial
|
||
from typing import Any
|
||
|
||
from pymilvus import (
|
||
AnnSearchRequest,
|
||
Collection,
|
||
CollectionSchema,
|
||
DataType,
|
||
FieldSchema,
|
||
Function,
|
||
FunctionType,
|
||
WeightedRanker,
|
||
connections,
|
||
db,
|
||
utility,
|
||
)
|
||
|
||
from yuxi.knowledge.base import FileStatus, KnowledgeBase
|
||
from yuxi.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
|
||
from yuxi.knowledge.chunking.ragflow_like.nlp import count_tokens
|
||
from yuxi.knowledge.parser.unified import Parser
|
||
from yuxi.knowledge.utils.kb_utils import resolve_processing_params
|
||
from yuxi.models.providers.cache import model_cache
|
||
from yuxi.repositories.knowledge_chunk_repository import KnowledgeChunkRepository
|
||
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||
from yuxi.utils import hashstr, logger
|
||
|
||
MILVUS_AVAILABLE = True
|
||
CONTENT_SPARSE_FIELD = "content_sparse"
|
||
CONTENT_ANALYZER_PARAMS = {"type": "chinese"}
|
||
VECTOR_METRIC_TYPE = "COSINE"
|
||
MILVUS_CHUNK_EMBED_BATCH_SIZE = 200
|
||
MILVUS_QUERY_OFFLOAD_LIMIT = 8
|
||
_milvus_query_offload_semaphore_refs: dict[
|
||
int,
|
||
tuple[weakref.ReferenceType[asyncio.AbstractEventLoop], weakref.ReferenceType[asyncio.Semaphore]],
|
||
] = {}
|
||
|
||
|
||
def _get_milvus_query_offload_semaphore() -> asyncio.Semaphore:
|
||
loop = asyncio.get_running_loop()
|
||
loop_id = id(loop)
|
||
entry = _milvus_query_offload_semaphore_refs.get(loop_id)
|
||
if entry is not None:
|
||
loop_ref, semaphore_ref = entry
|
||
semaphore = semaphore_ref()
|
||
if loop_ref() is loop and semaphore is not None:
|
||
return semaphore
|
||
|
||
semaphore = asyncio.Semaphore(MILVUS_QUERY_OFFLOAD_LIMIT)
|
||
|
||
def cleanup(ref, stale_loop_id=loop_id):
|
||
current_entry = _milvus_query_offload_semaphore_refs.get(stale_loop_id)
|
||
if current_entry is not None and current_entry[1] is ref:
|
||
_milvus_query_offload_semaphore_refs.pop(stale_loop_id, None)
|
||
|
||
_milvus_query_offload_semaphore_refs[loop_id] = (weakref.ref(loop), weakref.ref(semaphore, cleanup))
|
||
return semaphore
|
||
|
||
|
||
async def _run_milvus_query_io(func, /, *args, **kwargs):
|
||
semaphore = _get_milvus_query_offload_semaphore()
|
||
await semaphore.acquire()
|
||
task = asyncio.create_task(asyncio.to_thread(func, *args, **kwargs))
|
||
|
||
def release_capacity(completed_task: asyncio.Task):
|
||
semaphore.release()
|
||
if completed_task.cancelled():
|
||
return
|
||
completed_task.exception()
|
||
|
||
task.add_done_callback(release_capacity)
|
||
return await asyncio.shield(task)
|
||
|
||
|
||
@dataclass(kw_only=True)
|
||
class MilvusRetrievalConfig:
|
||
search_mode: str = field(
|
||
default="vector",
|
||
metadata={
|
||
"label": "检索模式",
|
||
"type": "select",
|
||
"options": [
|
||
{"value": "vector", "label": "向量检索", "description": "仅使用向量相似度检索"},
|
||
{"value": "keyword", "label": "BM25 全文检索", "description": "仅使用 Milvus BM25 检索"},
|
||
{"value": "hybrid", "label": "混合检索", "description": "Milvus 向量检索与 BM25 融合检索"},
|
||
],
|
||
"description": "选择检索模式",
|
||
},
|
||
)
|
||
final_top_k: int = field(
|
||
default=10,
|
||
metadata={
|
||
"label": "最终返回 Chunk 数",
|
||
"type": "number",
|
||
"min": 1,
|
||
"max": 100,
|
||
"description": "重排序后返回给前端的文档数量",
|
||
},
|
||
)
|
||
similarity_threshold: float = field(
|
||
default=0.0,
|
||
metadata={
|
||
"label": "相似度阈值(0-1)",
|
||
"type": "number",
|
||
"min": 0.0,
|
||
"max": 1.0,
|
||
"step": 0.1,
|
||
"description": "过滤相似度低于此值的结果",
|
||
},
|
||
)
|
||
bm25_top_k: int = field(
|
||
default=50,
|
||
metadata={
|
||
"label": "BM25 召回数量",
|
||
"type": "number",
|
||
"min": 1,
|
||
"max": 200,
|
||
"description": "BM25 全文检索和混合检索中的 BM25 候选数量",
|
||
},
|
||
)
|
||
vector_weight: float = field(
|
||
default=0.7,
|
||
metadata={
|
||
"label": "向量检索权重",
|
||
"type": "number",
|
||
"min": 0.0,
|
||
"max": 1.0,
|
||
"step": 0.1,
|
||
"description": "混合检索中向量召回结果的融合权重",
|
||
},
|
||
)
|
||
bm25_weight: float = field(
|
||
default=0.3,
|
||
metadata={
|
||
"label": "BM25 权重",
|
||
"type": "number",
|
||
"min": 0.0,
|
||
"max": 1.0,
|
||
"step": 0.1,
|
||
"description": "混合检索中 BM25 召回结果的融合权重",
|
||
},
|
||
)
|
||
bm25_drop_ratio_search: float = field(
|
||
default=0.0,
|
||
metadata={
|
||
"label": "BM25 稀疏项丢弃比例",
|
||
"type": "number",
|
||
"min": 0.0,
|
||
"max": 1.0,
|
||
"step": 0.1,
|
||
"description": "BM25 检索时丢弃低分稀疏项的比例,数值越大检索越快但可能降低召回",
|
||
},
|
||
)
|
||
include_distances: bool = field(
|
||
default=True,
|
||
metadata={"label": "显示相似度", "type": "boolean", "description": "在结果中显示相似度分数"},
|
||
)
|
||
use_graph_retrieval: bool = field(
|
||
default=False,
|
||
metadata={"label": "启用图检索", "type": "boolean", "description": "是否启用实体和三元组扩散检索"},
|
||
)
|
||
graph_entity_top_k: int = field(
|
||
default=10,
|
||
metadata={
|
||
"label": "图实体召回数量",
|
||
"type": "number",
|
||
"min": 1,
|
||
"max": 100,
|
||
"depend_on": ("use_graph_retrieval", True),
|
||
"description": "通过 Query 召回的实体数量",
|
||
},
|
||
)
|
||
graph_triple_top_k: int = field(
|
||
default=10,
|
||
metadata={
|
||
"label": "图三元组召回数量",
|
||
"type": "number",
|
||
"min": 1,
|
||
"max": 100,
|
||
"depend_on": ("use_graph_retrieval", True),
|
||
"description": "通过 Query 召回的三元组数量",
|
||
},
|
||
)
|
||
graph_max_nodes: int = field(
|
||
default=10000,
|
||
metadata={
|
||
"label": "图检索最大节点数",
|
||
"type": "number",
|
||
"min": 100,
|
||
"max": 50000,
|
||
"depend_on": ("use_graph_retrieval", True),
|
||
"description": "2-hop 扩散子图最多读取的节点数量",
|
||
},
|
||
)
|
||
graph_top_k: int = field(
|
||
default=20,
|
||
metadata={
|
||
"label": "图召回 Chunk 数",
|
||
"type": "number",
|
||
"min": 1,
|
||
"max": 200,
|
||
"depend_on": ("use_graph_retrieval", True),
|
||
"description": "PPR 后从图谱路径召回的 Chunk 数量",
|
||
},
|
||
)
|
||
graph_weight: float = field(
|
||
default=1.0,
|
||
metadata={
|
||
"label": "图检索融合权重",
|
||
"type": "number",
|
||
"min": 0.0,
|
||
"max": 5.0,
|
||
"step": 0.1,
|
||
"depend_on": ("use_graph_retrieval", True),
|
||
"description": "排名融合时图检索结果的权重",
|
||
},
|
||
)
|
||
ppr_damping: float = field(
|
||
default=0.85,
|
||
metadata={
|
||
"label": "PPR 阻尼系数",
|
||
"type": "number",
|
||
"min": 0.1,
|
||
"max": 0.99,
|
||
"step": 0.01,
|
||
"depend_on": ("use_graph_retrieval", True),
|
||
"description": "Personalized PageRank 的阻尼系数",
|
||
},
|
||
)
|
||
use_reranker: bool = field(
|
||
default=False,
|
||
metadata={"label": "启用重排序", "type": "boolean", "description": "是否使用精排模型对检索结果进行重排序"},
|
||
)
|
||
reranker_model: str = field(
|
||
default="",
|
||
metadata={
|
||
"label": "重排序模型",
|
||
"type": "select",
|
||
"depend_on": ("use_reranker", True),
|
||
"description": "选择用于本次查询的重排序模型",
|
||
"options_provider": "rerank_models",
|
||
},
|
||
)
|
||
recall_top_k: int = field(
|
||
default=50,
|
||
metadata={
|
||
"label": "召回数量",
|
||
"type": "number",
|
||
"min": 10,
|
||
"max": 200,
|
||
"depend_on": ("use_reranker", True),
|
||
"description": "向量检索或混合检索保留的候选数量(启用重排序时有效)",
|
||
},
|
||
)
|
||
|
||
|
||
def _retrieval_config_options() -> list[dict[str, Any]]:
|
||
options = []
|
||
for config_field in fields(MilvusRetrievalConfig):
|
||
metadata = dict(config_field.metadata)
|
||
options_provider = metadata.pop("options_provider", None)
|
||
default = None if config_field.default is MISSING else config_field.default
|
||
option = {
|
||
"key": config_field.name,
|
||
"default": default,
|
||
**metadata,
|
||
}
|
||
if options_provider == "rerank_models":
|
||
option["options"] = [
|
||
{"label": info.display_name, "value": info.spec} for info in model_cache.get_all_specs("rerank")
|
||
]
|
||
options.append(option)
|
||
return options
|
||
|
||
|
||
class MilvusKB(KnowledgeBase):
|
||
"""基于 Milvus 的生产级向量库"""
|
||
|
||
kb_type = "milvus"
|
||
name = "Milvus"
|
||
description = "基于 Milvus 的生产级向量知识库,适合高性能部署"
|
||
|
||
def __init__(self, work_dir: str, **kwargs):
|
||
"""
|
||
初始化 Milvus 知识库
|
||
|
||
Args:
|
||
work_dir: 工作目录
|
||
**kwargs: 其他配置参数
|
||
"""
|
||
super().__init__(work_dir)
|
||
|
||
if not MILVUS_AVAILABLE:
|
||
raise ImportError("pymilvus is not installed. Please install it with: pip install pymilvus")
|
||
|
||
# Milvus 配置
|
||
# self.milvus_host = kwargs.get('milvus_host', os.getenv('MILVUS_HOST', 'localhost'))
|
||
# self.milvus_port = kwargs.get('milvus_port', int(os.getenv('MILVUS_PORT', '19530')))
|
||
self.milvus_token = kwargs.get("milvus_token", os.getenv("MILVUS_TOKEN") or "")
|
||
self.milvus_uri = kwargs.get("milvus_uri", os.getenv("MILVUS_URI") or "http://localhost:19530")
|
||
self.milvus_db = kwargs.get("milvus_db") or "yuxi"
|
||
|
||
# 连接名称
|
||
self.connection_alias = f"milvus_{hashstr(work_dir, 6)}"
|
||
|
||
# 存储集合映射 {kb_id: Collection}
|
||
self.collections: dict[str, Any] = {}
|
||
|
||
# 初始化连接
|
||
self._init_connection()
|
||
|
||
logger.info("MilvusKB initialized")
|
||
|
||
def _init_connection(self):
|
||
"""初始化 Milvus 连接"""
|
||
try:
|
||
# 连接到 Milvus
|
||
connections.connect(alias=self.connection_alias, uri=self.milvus_uri, token=self.milvus_token)
|
||
|
||
# 创建数据库(如果不存在)
|
||
try:
|
||
if self.milvus_db not in db.list_database():
|
||
db.create_database(self.milvus_db)
|
||
db.using_database(self.milvus_db)
|
||
except Exception as e:
|
||
logger.warning(f"Database operation failed, using default: {e}")
|
||
|
||
logger.info(f"Connected to Milvus at {self.milvus_uri}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to connect to Milvus: {e}")
|
||
raise
|
||
|
||
async def _create_kb_instance(self, kb_id: str, kb_config: dict) -> Any:
|
||
"""创建 Milvus 集合"""
|
||
logger.info(f"Creating Milvus collection for {kb_id}")
|
||
|
||
if not (metadata := self.databases_meta.get(kb_id)):
|
||
raise ValueError(f"Database {kb_id} not found")
|
||
|
||
embedding_model_spec = metadata.get("embedding_model_spec")
|
||
if not embedding_model_spec:
|
||
raise ValueError(f"Embedding model spec not found for database {kb_id}")
|
||
|
||
embedding_info = model_cache.get_model_info(embedding_model_spec)
|
||
if not embedding_info or embedding_info.model_type != "embedding":
|
||
raise ValueError(f"Unsupported embedding model: {embedding_model_spec}")
|
||
|
||
collection_name = kb_id
|
||
|
||
try:
|
||
# 检查集合是否存在
|
||
if utility.has_collection(collection_name, using=self.connection_alias):
|
||
collection = Collection(name=collection_name, using=self.connection_alias)
|
||
|
||
# 检查嵌入模型是否匹配
|
||
description = collection.description
|
||
expected_model = embedding_info.model_id
|
||
|
||
if expected_model not in description:
|
||
logger.warning(
|
||
f"Collection {collection_name} model mismatch: "
|
||
f"expected='{expected_model}', found_in_description='{description}'"
|
||
)
|
||
utility.drop_collection(collection_name, using=self.connection_alias)
|
||
return self._create_new_collection(collection_name, embedding_info, kb_id)
|
||
|
||
if not self._collection_supports_bm25(collection):
|
||
logger.warning(f"Collection {collection_name} schema does not support BM25, recreating")
|
||
utility.drop_collection(collection_name, using=self.connection_alias)
|
||
return self._create_new_collection(collection_name, embedding_info, kb_id)
|
||
|
||
logger.info(f"Retrieved existing collection: {collection_name}")
|
||
return collection
|
||
else:
|
||
logger.info(f"Collection {collection_name} not found, creating new one")
|
||
return self._create_new_collection(collection_name, embedding_info, kb_id)
|
||
|
||
except (connections.MilvusException, RuntimeError) as e:
|
||
logger.error(f"Error checking collection {collection_name}: {e}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Unexpected error while managing collection {collection_name}: {e}")
|
||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||
raise
|
||
|
||
def _create_new_collection(self, collection_name: str, embedding_info: Any, kb_id: str) -> Collection:
|
||
"""创建新的 Milvus 集合"""
|
||
embedding_dim = embedding_info.dimension or 1024
|
||
model_name = embedding_info.model_id
|
||
|
||
# 定义集合Schema
|
||
fields = [
|
||
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=100, is_primary=True),
|
||
FieldSchema(
|
||
name="content",
|
||
dtype=DataType.VARCHAR,
|
||
max_length=65535,
|
||
enable_analyzer=True,
|
||
analyzer_params=CONTENT_ANALYZER_PARAMS,
|
||
),
|
||
FieldSchema(name="chunk_id", dtype=DataType.VARCHAR, max_length=100),
|
||
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=100),
|
||
FieldSchema(name="chunk_index", dtype=DataType.INT64),
|
||
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=embedding_dim),
|
||
FieldSchema(name=CONTENT_SPARSE_FIELD, dtype=DataType.SPARSE_FLOAT_VECTOR),
|
||
]
|
||
bm25_function = Function(
|
||
name="content_bm25",
|
||
input_field_names=["content"],
|
||
output_field_names=[CONTENT_SPARSE_FIELD],
|
||
function_type=FunctionType.BM25,
|
||
)
|
||
|
||
schema = CollectionSchema(
|
||
fields=fields,
|
||
description=f"Knowledge base collection for {kb_id} using {model_name}",
|
||
functions=[bm25_function],
|
||
)
|
||
|
||
# 创建集合
|
||
collection = Collection(name=collection_name, schema=schema, using=self.connection_alias)
|
||
|
||
# 创建索引
|
||
index_params = {"metric_type": VECTOR_METRIC_TYPE, "index_type": "IVF_FLAT", "params": {"nlist": 1024}}
|
||
collection.create_index("embedding", index_params)
|
||
sparse_index_params = {
|
||
"metric_type": "BM25",
|
||
"index_type": "SPARSE_INVERTED_INDEX",
|
||
"params": {"inverted_index_algo": "DAAT_MAXSCORE"},
|
||
}
|
||
collection.create_index(CONTENT_SPARSE_FIELD, sparse_index_params)
|
||
|
||
logger.info(f"Created new Milvus collection: {collection_name} '{model_name=}', {embedding_dim=}")
|
||
|
||
return collection
|
||
|
||
def _collection_supports_bm25(self, collection: Collection) -> bool:
|
||
"""检查集合是否具备 Milvus 内置 BM25 所需的 schema。"""
|
||
fields = {field.name: field for field in collection.schema.fields}
|
||
content_field = fields.get("content")
|
||
sparse_field = fields.get(CONTENT_SPARSE_FIELD)
|
||
if not content_field or content_field.dtype != DataType.VARCHAR:
|
||
return False
|
||
if content_field.params.get("enable_analyzer") is not True:
|
||
return False
|
||
if not sparse_field or sparse_field.dtype != DataType.SPARSE_FLOAT_VECTOR:
|
||
return False
|
||
|
||
for function in collection.schema.functions:
|
||
if (
|
||
function.type == FunctionType.BM25
|
||
and function.input_field_names == ["content"]
|
||
and function.output_field_names == [CONTENT_SPARSE_FIELD]
|
||
):
|
||
return True
|
||
return False
|
||
|
||
async def _initialize_kb_instance(self, instance: Any) -> None:
|
||
"""初始化 Milvus 集合(加载到内存)"""
|
||
try:
|
||
instance.load()
|
||
logger.info("Milvus collection loaded into memory")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to load collection into memory: {e}")
|
||
|
||
def _get_embedding_function(self, embedding_model_spec: str, *, sync: bool = False):
|
||
"""获取 embedding 编码函数。sync=True 返回同步版本,否则返回异步版本。"""
|
||
from yuxi.models.embed import select_embedding_model
|
||
|
||
model = select_embedding_model(embedding_model_spec)
|
||
batch_size = int(getattr(model, "batch_size", 40) or 40)
|
||
method = model.batch_encode if sync else model.abatch_encode
|
||
return partial(method, batch_size=batch_size)
|
||
|
||
async def _get_milvus_collection(self, kb_id: str):
|
||
"""获取或创建 Milvus 集合"""
|
||
if kb_id in self.collections:
|
||
return self.collections[kb_id]
|
||
|
||
if kb_id not in self.databases_meta:
|
||
return None
|
||
|
||
try:
|
||
# 创建集合
|
||
collection = await self._create_kb_instance(kb_id, {})
|
||
await self._initialize_kb_instance(collection)
|
||
|
||
self.collections[kb_id] = collection
|
||
return collection
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to create Milvus collection for {kb_id}: {e}")
|
||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||
return None
|
||
|
||
def _split_text_into_chunks(self, text: str, file_id: str, filename: str, params: dict) -> list[dict]:
|
||
"""将文本分割成块"""
|
||
return chunk_markdown(text, file_id, filename, params)
|
||
|
||
def _calculate_chunk_stats(self, chunks: list[dict]) -> dict[str, int]:
|
||
return {
|
||
"chunk_count": len(chunks),
|
||
"token_count": sum(count_tokens(chunk["content"]) for chunk in chunks),
|
||
}
|
||
|
||
def _build_chunk_pg_records(self, kb_id: str, chunks: list[dict]) -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"chunk_id": chunk["chunk_id"],
|
||
"file_id": chunk["file_id"],
|
||
"kb_id": kb_id,
|
||
"chunk_index": chunk["chunk_index"],
|
||
"content": chunk["content"],
|
||
"start_char_pos": chunk.get("start_char_pos"),
|
||
"end_char_pos": chunk.get("end_char_pos"),
|
||
"start_token_pos": chunk.get("start_token_pos"),
|
||
"end_token_pos": chunk.get("end_token_pos"),
|
||
"graph_indexed": bool(chunk.get("graph_indexed", False)),
|
||
"ent_ids": chunk.get("ent_ids"),
|
||
"tags": chunk.get("tags"),
|
||
"extraction_result": chunk.get("extraction_result"),
|
||
}
|
||
for chunk in chunks
|
||
]
|
||
|
||
async def _insert_chunks_to_stores(
|
||
self,
|
||
kb_id: str,
|
||
file_id: str,
|
||
collection: Collection,
|
||
chunks: list[dict],
|
||
embeddings: list,
|
||
) -> None:
|
||
if not chunks:
|
||
return
|
||
|
||
entities = [
|
||
[chunk["id"] for chunk in chunks],
|
||
[chunk["content"] for chunk in chunks],
|
||
[chunk["chunk_id"] for chunk in chunks],
|
||
[chunk["file_id"] for chunk in chunks],
|
||
[chunk["chunk_index"] for chunk in chunks],
|
||
embeddings,
|
||
]
|
||
chunk_repo = KnowledgeChunkRepository()
|
||
|
||
def _insert_milvus_records():
|
||
collection.insert(entities)
|
||
|
||
pg_task = chunk_repo.batch_upsert(self._build_chunk_pg_records(kb_id, chunks))
|
||
milvus_task = asyncio.to_thread(_insert_milvus_records)
|
||
results = await asyncio.gather(pg_task, milvus_task, return_exceptions=True)
|
||
errors = [result for result in results if isinstance(result, Exception)]
|
||
if not errors:
|
||
return
|
||
|
||
logger.error(f"Chunk double-write failed for file {file_id}, rolling back PostgreSQL and Milvus chunks")
|
||
try:
|
||
await chunk_repo.delete_by_file_id(file_id)
|
||
except Exception as cleanup_error:
|
||
logger.error(f"Failed to rollback PostgreSQL chunks for {file_id}: {cleanup_error}")
|
||
try:
|
||
await self._delete_file_chunks_from_milvus(collection, file_id)
|
||
except Exception as cleanup_error:
|
||
logger.error(f"Failed to rollback Milvus chunks for {file_id}: {cleanup_error}")
|
||
raise errors[0]
|
||
|
||
async def _embed_and_store_chunks(
|
||
self,
|
||
kb_id: str,
|
||
file_id: str,
|
||
collection: Collection,
|
||
chunks: list[dict],
|
||
embedding_function,
|
||
*,
|
||
chunk_batch_size: int = MILVUS_CHUNK_EMBED_BATCH_SIZE,
|
||
) -> None:
|
||
"""对 chunks 进行分批嵌入并存储到 Milvus 和 PostgreSQL"""
|
||
if not chunks:
|
||
return
|
||
|
||
chunk_batch_size = max(int(chunk_batch_size), 1)
|
||
for start in range(0, len(chunks), chunk_batch_size):
|
||
batch_chunks = chunks[start : start + chunk_batch_size]
|
||
texts = [chunk["content"] for chunk in batch_chunks]
|
||
embeddings = await embedding_function(texts)
|
||
await self._insert_chunks_to_stores(
|
||
kb_id,
|
||
file_id,
|
||
collection,
|
||
batch_chunks,
|
||
embeddings,
|
||
)
|
||
|
||
async def _delete_file_chunks_from_milvus(self, collection: Collection, file_id: str) -> None:
|
||
expr = f'file_id == "{file_id}"'
|
||
results = collection.query(expr=expr, output_fields=["id"], limit=1)
|
||
|
||
if not results:
|
||
logger.info(f"File {file_id} not found in Milvus, skipping delete operation")
|
||
return
|
||
|
||
def _delete_from_milvus():
|
||
collection.delete(expr)
|
||
logger.info(f"Deleted chunks for file {file_id} from Milvus")
|
||
|
||
await asyncio.to_thread(_delete_from_milvus)
|
||
|
||
async def _hydrate_chunk_sources(self, kb_id: str, chunks: list[dict]) -> None:
|
||
file_ids = sorted(
|
||
{str(file_id) for chunk in chunks if (file_id := (chunk.get("metadata") or {}).get("file_id"))}
|
||
)
|
||
if not file_ids:
|
||
return
|
||
|
||
filenames = await KnowledgeFileRepository().get_filenames_by_file_ids(kb_id=kb_id, file_ids=file_ids)
|
||
for chunk in chunks:
|
||
metadata = chunk.get("metadata")
|
||
if not isinstance(metadata, dict):
|
||
continue
|
||
metadata["source"] = filenames.get(str(metadata.get("file_id") or ""), "") or "未知来源"
|
||
|
||
async def _build_file_name_expr(self, kb_id: str, file_name: str | None) -> str | None:
|
||
if not file_name:
|
||
return None
|
||
|
||
matched_file_ids = await KnowledgeFileRepository().list_file_ids_by_filename_contains(
|
||
kb_id=kb_id,
|
||
filename_pattern=file_name,
|
||
)
|
||
if not matched_file_ids:
|
||
return 'file_id == "__no_matching_file__"'
|
||
escaped_ids = [file_id.replace('"', '\\"') for file_id in matched_file_ids]
|
||
if len(escaped_ids) == 1:
|
||
return f'file_id == "{escaped_ids[0]}"'
|
||
joined_ids = '", "'.join(escaped_ids)
|
||
return f'file_id in ["{joined_ids}"]'
|
||
|
||
async def index_file(
|
||
self, kb_id: str, file_id: str, operator_id: str | None = None, params: dict | None = None
|
||
) -> dict:
|
||
"""
|
||
Index parsed file (Status: INDEXING -> INDEXED/ERROR_INDEXING)
|
||
|
||
Args:
|
||
kb_id: Database ID
|
||
file_id: File ID
|
||
operator_id: ID of the user performing the operation
|
||
params: Override processing params to apply during indexing (merged on top of stored params)
|
||
|
||
Returns:
|
||
Updated file metadata
|
||
"""
|
||
if kb_id not in self.databases_meta:
|
||
raise ValueError(f"Database {kb_id} not found")
|
||
|
||
# Get/Create collection
|
||
collection = await self._get_milvus_collection(kb_id)
|
||
if not collection:
|
||
raise ValueError(f"Failed to get Milvus collection for {kb_id}")
|
||
|
||
embedding_model_spec = self.databases_meta[kb_id].get("embedding_model_spec")
|
||
embedding_function = self._get_embedding_function(embedding_model_spec)
|
||
|
||
file_meta = await self._load_file_meta(kb_id, file_id)
|
||
allowed_statuses = {
|
||
FileStatus.PARSED,
|
||
FileStatus.ERROR_INDEXING,
|
||
FileStatus.INDEXED,
|
||
"done",
|
||
}
|
||
params = resolve_processing_params(
|
||
kb_additional_params=self.databases_meta.get(kb_id, {}).get("metadata"),
|
||
file_processing_params=file_meta.get("processing_params"),
|
||
request_params=params,
|
||
)
|
||
|
||
claim_data = {
|
||
"status": FileStatus.INDEXING,
|
||
"processing_params": params,
|
||
"error_message": None,
|
||
}
|
||
if operator_id:
|
||
claim_data["updated_by"] = operator_id
|
||
|
||
claimed_record = await KnowledgeFileRepository().update_fields_if_status(
|
||
kb_id=kb_id,
|
||
file_id=file_id,
|
||
allowed_statuses=allowed_statuses,
|
||
data=claim_data,
|
||
)
|
||
if claimed_record is None:
|
||
current_meta = await self._load_file_meta(kb_id, file_id)
|
||
current_status = current_meta.get("status")
|
||
raise ValueError(
|
||
f"Cannot index file with status '{current_status}'. "
|
||
f"File must be parsed first (status should be one of: {', '.join(allowed_statuses)})"
|
||
)
|
||
|
||
file_meta = self._file_record_to_meta(claimed_record)
|
||
if not file_meta.get("markdown_file"):
|
||
await self._mark_file_unparsed(kb_id, file_id, operator_id)
|
||
raise ValueError("File has not been parsed yet (no markdown_file)")
|
||
|
||
logger.debug(f"[index_file] file_id={file_id}, processing_params={params}")
|
||
|
||
try:
|
||
# Read markdown
|
||
markdown_content = await self._read_markdown_from_minio(file_meta["markdown_file"])
|
||
filename = file_meta.get("filename")
|
||
|
||
# Split
|
||
chunks = self._split_text_into_chunks(markdown_content, file_id, filename, params)
|
||
logger.info(
|
||
f"Split {filename} into {len(chunks)} chunks with params: "
|
||
f"chunk_preset_id={params.get('chunk_preset_id')}, "
|
||
f"chunk_parser_config={params.get('chunk_parser_config')}"
|
||
)
|
||
|
||
chunk_stats = self._calculate_chunk_stats(chunks)
|
||
|
||
# Clean up existing chunks if any (for re-indexing)
|
||
await self.delete_file_chunks_only(kb_id, file_id)
|
||
|
||
if chunks:
|
||
await self._embed_and_store_chunks(kb_id, file_id, collection, chunks, embedding_function)
|
||
|
||
logger.info(f"Indexed file {file_id} into Milvus")
|
||
|
||
# Update status
|
||
update_data = {"status": FileStatus.INDEXED, "error_message": None, **chunk_stats}
|
||
if operator_id:
|
||
update_data["updated_by"] = operator_id
|
||
updated_record = await KnowledgeFileRepository().update_fields(
|
||
file_id=file_id,
|
||
kb_id=kb_id,
|
||
data=update_data,
|
||
)
|
||
result = (
|
||
self._file_record_to_meta(updated_record)
|
||
if updated_record is not None
|
||
else {
|
||
**file_meta,
|
||
**chunk_stats,
|
||
"status": FileStatus.INDEXED,
|
||
"error": None,
|
||
}
|
||
)
|
||
|
||
await self.refresh_database_stats(kb_id)
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Indexing failed for {file_id}: {e}")
|
||
update_data = {"status": FileStatus.ERROR_INDEXING, "error_message": str(e)}
|
||
if operator_id:
|
||
update_data["updated_by"] = operator_id
|
||
await KnowledgeFileRepository().update_fields(file_id=file_id, kb_id=kb_id, data=update_data)
|
||
raise
|
||
|
||
async def update_content(self, kb_id: str, file_ids: list[str], params: dict | None = None) -> list[dict]:
|
||
"""更新内容 - 根据file_ids重新解析文件并更新向量库"""
|
||
if kb_id not in self.databases_meta:
|
||
raise ValueError(f"Database {kb_id} not found")
|
||
|
||
collection = await self._get_milvus_collection(kb_id)
|
||
if not collection:
|
||
raise ValueError(f"Failed to get Milvus collection for {kb_id}")
|
||
|
||
embedding_model_spec = self.databases_meta[kb_id].get("embedding_model_spec")
|
||
embedding_function = self._get_embedding_function(embedding_model_spec)
|
||
|
||
# 处理默认参数
|
||
if params is None:
|
||
params = {}
|
||
processed_items_info = []
|
||
|
||
for file_id in file_ids:
|
||
try:
|
||
file_meta = await self._load_file_meta(kb_id, file_id)
|
||
except ValueError:
|
||
logger.warning(f"File {file_id} not found in metadata, skipping")
|
||
continue
|
||
|
||
file_path = file_meta.get("path")
|
||
filename = file_meta.get("filename")
|
||
|
||
if not file_path:
|
||
logger.warning(f"File path not found for {file_id}, skipping")
|
||
continue
|
||
|
||
try:
|
||
# 更新状态为处理中
|
||
resolved_params = resolve_processing_params(
|
||
kb_additional_params=self.databases_meta.get(kb_id, {}).get("metadata"),
|
||
file_processing_params=file_meta.get("processing_params"),
|
||
request_params=params,
|
||
)
|
||
file_meta["processing_params"] = resolved_params
|
||
file_meta["status"] = FileStatus.INDEXING
|
||
await KnowledgeFileRepository().update_fields(
|
||
file_id=file_id,
|
||
kb_id=kb_id,
|
||
data={"status": FileStatus.INDEXING, "processing_params": resolved_params},
|
||
)
|
||
|
||
# 重新解析文件为 markdown
|
||
parse_params = {**resolved_params, "image_bucket": "public", "image_prefix": f"{kb_id}/kb-images"}
|
||
markdown_content = await Parser.aparse(source=file_path, params=parse_params)
|
||
|
||
# 重新生成 chunks
|
||
chunks = self._split_text_into_chunks(markdown_content, file_id, filename, resolved_params)
|
||
logger.info(f"Split {filename} into {len(chunks)} chunks")
|
||
chunk_stats = self._calculate_chunk_stats(chunks)
|
||
|
||
# 先删除现有 chunks,保留文件元数据
|
||
await self.delete_file_chunks_only(kb_id, file_id)
|
||
|
||
if chunks:
|
||
await self._embed_and_store_chunks(kb_id, file_id, collection, chunks, embedding_function)
|
||
|
||
logger.info(f"Updated file {file_path} in Milvus. Done.")
|
||
|
||
# 更新元数据状态
|
||
file_meta["status"] = FileStatus.INDEXED
|
||
file_meta.update(chunk_stats)
|
||
await KnowledgeFileRepository().update_fields(
|
||
file_id=file_id,
|
||
kb_id=kb_id,
|
||
data={"status": FileStatus.INDEXED, "error_message": None, **chunk_stats},
|
||
)
|
||
await self.refresh_database_stats(kb_id)
|
||
|
||
# 返回更新后的文件信息
|
||
updated_file_meta = file_meta.copy()
|
||
updated_file_meta["status"] = FileStatus.INDEXED
|
||
updated_file_meta.update(chunk_stats)
|
||
updated_file_meta["file_id"] = file_id
|
||
processed_items_info.append(updated_file_meta)
|
||
|
||
except Exception as e:
|
||
logger.error(f"更新file {file_path} 失败: {e}, {traceback.format_exc()}")
|
||
await KnowledgeFileRepository().update_fields(
|
||
file_id=file_id,
|
||
kb_id=kb_id,
|
||
data={"status": FileStatus.ERROR_INDEXING, "error_message": str(e)},
|
||
)
|
||
|
||
# 返回失败的文件信息
|
||
failed_file_meta = file_meta.copy()
|
||
failed_file_meta["status"] = FileStatus.ERROR_INDEXING
|
||
failed_file_meta["error"] = str(e)
|
||
failed_file_meta["file_id"] = file_id
|
||
processed_items_info.append(failed_file_meta)
|
||
|
||
return processed_items_info
|
||
|
||
def _build_chunk_from_hit(
|
||
self,
|
||
hit: Any,
|
||
score: float,
|
||
include_distances: bool,
|
||
score_field: str | None = None,
|
||
) -> dict:
|
||
"""将 Milvus Hit 转成知识库统一返回的 Chunk 结构。"""
|
||
entity = hit.entity
|
||
file_id = entity.get("file_id")
|
||
metadata = {
|
||
"source": "未知来源",
|
||
"chunk_id": entity.get("chunk_id"),
|
||
"file_id": file_id,
|
||
"chunk_index": entity.get("chunk_index"),
|
||
}
|
||
chunk = {"content": entity.get("content", ""), "metadata": metadata, "score": float(score or 0.0)}
|
||
if score_field:
|
||
chunk[score_field] = float(score or 0.0)
|
||
if include_distances:
|
||
chunk["distance"] = hit.distance
|
||
return chunk
|
||
|
||
async def aquery(self, query_text: str, kb_id: str, agent_call: bool = False, **kwargs) -> list[dict]:
|
||
"""异步查询知识库"""
|
||
collection = await self._get_milvus_collection(kb_id)
|
||
if not collection:
|
||
raise ValueError(f"Database {kb_id} not found")
|
||
|
||
query_params = self._get_query_params(kb_id)
|
||
# 合并查询参数:kwargs(临时参数)优先级高于 query_params(持久化参数)
|
||
# 这样允许用户在单次查询中临时覆盖持久化配置
|
||
merged_kwargs = {**query_params, **kwargs}
|
||
|
||
try:
|
||
# 查询参数(从 merged_kwargs 读取)
|
||
logger.debug(f"Query params: {merged_kwargs}")
|
||
final_top_k = int(merged_kwargs.get("final_top_k", 10))
|
||
final_top_k = max(final_top_k, 1)
|
||
similarity_threshold = float(merged_kwargs.get("similarity_threshold", 0.2))
|
||
metric_type = VECTOR_METRIC_TYPE
|
||
include_distances = bool(merged_kwargs.get("include_distances", True))
|
||
search_mode = str(merged_kwargs.get("search_mode", "vector")).lower()
|
||
if search_mode not in {"vector", "keyword", "hybrid"}:
|
||
search_mode = "vector"
|
||
|
||
use_reranker = bool(merged_kwargs.get("use_reranker", False))
|
||
use_graph_retrieval = bool(merged_kwargs.get("use_graph_retrieval", False))
|
||
if use_reranker or use_graph_retrieval:
|
||
recall_top_k = int(merged_kwargs.get("recall_top_k", 50))
|
||
recall_top_k = max(recall_top_k, final_top_k)
|
||
else:
|
||
recall_top_k = final_top_k
|
||
|
||
file_expr = await self._build_file_name_expr(kb_id, merged_kwargs.get("file_name"))
|
||
if file_expr:
|
||
logger.debug(f"Using filter expression: {file_expr}")
|
||
|
||
output_fields = ["content", "chunk_id", "file_id", "chunk_index"]
|
||
retrieved_chunks: list[dict] = []
|
||
if search_mode == "vector":
|
||
embedding_model_spec = self.databases_meta[kb_id].get("embedding_model_spec")
|
||
embedding_function = self._get_embedding_function(embedding_model_spec, sync=True)
|
||
query_embedding = await _run_milvus_query_io(embedding_function, [query_text])
|
||
|
||
search_params = {"metric_type": metric_type, "params": {"nprobe": 10}}
|
||
|
||
results = await _run_milvus_query_io(
|
||
collection.search,
|
||
data=query_embedding,
|
||
anns_field="embedding",
|
||
param=search_params,
|
||
limit=recall_top_k,
|
||
expr=file_expr,
|
||
output_fields=output_fields,
|
||
)
|
||
|
||
if results and len(results) > 0 and len(results[0]) > 0:
|
||
for hit in results[0]:
|
||
similarity = hit.distance if metric_type == VECTOR_METRIC_TYPE else 1 / (1 + hit.distance)
|
||
if similarity < similarity_threshold:
|
||
continue
|
||
|
||
retrieved_chunks.append(self._build_chunk_from_hit(hit, similarity, include_distances))
|
||
|
||
logger.debug(
|
||
f"Milvus vector query response: {len(retrieved_chunks)} chunks found (after similarity filtering)"
|
||
)
|
||
|
||
elif search_mode == "keyword":
|
||
bm25_top_k = int(merged_kwargs.get("bm25_top_k", recall_top_k))
|
||
bm25_top_k = max(bm25_top_k, 1)
|
||
bm25_drop_ratio_search = float(merged_kwargs.get("bm25_drop_ratio_search", 0.0))
|
||
bm25_search_params = {
|
||
"metric_type": "BM25",
|
||
"params": {"drop_ratio_search": bm25_drop_ratio_search},
|
||
}
|
||
|
||
results = await _run_milvus_query_io(
|
||
collection.search,
|
||
data=[query_text],
|
||
anns_field=CONTENT_SPARSE_FIELD,
|
||
param=bm25_search_params,
|
||
limit=bm25_top_k,
|
||
expr=file_expr,
|
||
output_fields=output_fields,
|
||
)
|
||
|
||
if results and len(results) > 0 and len(results[0]) > 0:
|
||
for hit in results[0]:
|
||
retrieved_chunks.append(
|
||
self._build_chunk_from_hit(hit, hit.distance, include_distances, score_field="bm25_score")
|
||
)
|
||
|
||
logger.debug(f"Milvus BM25 query response: {len(retrieved_chunks)} chunks found")
|
||
else:
|
||
embedding_model_spec = self.databases_meta[kb_id].get("embedding_model_spec")
|
||
embedding_function = self._get_embedding_function(embedding_model_spec, sync=True)
|
||
query_embedding = await _run_milvus_query_io(embedding_function, [query_text])
|
||
bm25_top_k = int(merged_kwargs.get("bm25_top_k", recall_top_k))
|
||
bm25_top_k = max(bm25_top_k, 1)
|
||
bm25_drop_ratio_search = float(merged_kwargs.get("bm25_drop_ratio_search", 0.0))
|
||
vector_weight = float(merged_kwargs.get("vector_weight", 0.7))
|
||
bm25_weight = float(merged_kwargs.get("bm25_weight", 0.3))
|
||
|
||
vector_request = AnnSearchRequest(
|
||
data=query_embedding,
|
||
anns_field="embedding",
|
||
param={"metric_type": metric_type, "params": {"nprobe": 10}},
|
||
limit=recall_top_k,
|
||
expr=file_expr,
|
||
)
|
||
bm25_request = AnnSearchRequest(
|
||
data=[query_text],
|
||
anns_field=CONTENT_SPARSE_FIELD,
|
||
param={
|
||
"metric_type": "BM25",
|
||
"params": {"drop_ratio_search": bm25_drop_ratio_search},
|
||
},
|
||
limit=bm25_top_k,
|
||
expr=file_expr,
|
||
)
|
||
results = await _run_milvus_query_io(
|
||
collection.hybrid_search,
|
||
reqs=[vector_request, bm25_request],
|
||
rerank=WeightedRanker(vector_weight, bm25_weight),
|
||
limit=recall_top_k,
|
||
output_fields=output_fields,
|
||
)
|
||
if results and len(results) > 0 and len(results[0]) > 0:
|
||
for hit in results[0]:
|
||
score = float(hit.distance or 0.0)
|
||
if score < similarity_threshold:
|
||
continue
|
||
retrieved_chunks.append(
|
||
self._build_chunk_from_hit(hit, score, include_distances, score_field="hybrid_score")
|
||
)
|
||
|
||
logger.debug(f"Milvus hybrid query response: {len(retrieved_chunks)} chunks found")
|
||
|
||
if use_graph_retrieval:
|
||
graph_chunks = await self._retrieve_graph_chunks(query_text, kb_id, retrieved_chunks, merged_kwargs)
|
||
if graph_chunks:
|
||
graph_weight = float(merged_kwargs.get("graph_weight", 1.0))
|
||
retrieved_chunks = self._fuse_chunk_rankings(retrieved_chunks, graph_chunks, graph_weight)
|
||
|
||
if not retrieved_chunks:
|
||
return []
|
||
|
||
await self._hydrate_chunk_sources(kb_id, retrieved_chunks)
|
||
|
||
if not use_reranker:
|
||
return retrieved_chunks[:final_top_k]
|
||
|
||
# 使用重排序模型
|
||
reranker_model = merged_kwargs.get("reranker_model")
|
||
if not reranker_model:
|
||
raise ValueError(
|
||
"Reranker model must be specified when use_reranker=True. "
|
||
"Please provide reranker_model in query parameters."
|
||
)
|
||
|
||
try:
|
||
from yuxi.models.rerank import get_reranker
|
||
|
||
reranker = get_reranker(reranker_model)
|
||
try:
|
||
rerank_start = time.time()
|
||
documents_text = [chunk["content"] for chunk in retrieved_chunks]
|
||
rerank_scores = await reranker.acompute_score([query_text, documents_text], normalize=True)
|
||
|
||
for chunk, rerank_score in zip(retrieved_chunks, rerank_scores):
|
||
chunk["rerank_score"] = float(rerank_score)
|
||
|
||
retrieved_chunks.sort(
|
||
key=lambda item: item.get("rerank_score", item.get("score", 0.0)), reverse=True
|
||
)
|
||
elapsed = time.time() - rerank_start
|
||
logger.info(f"Reranking completed for {kb_id} in {elapsed:.3f}s with model {reranker_model}")
|
||
finally:
|
||
await reranker.aclose()
|
||
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.error(f"Reranking failed: {exc}, falling back to vector scores")
|
||
|
||
# 统一返回结果
|
||
return retrieved_chunks[:final_top_k]
|
||
|
||
except Exception as e:
|
||
logger.error(f"Milvus query error: {e}, {traceback.format_exc()}")
|
||
return []
|
||
|
||
async def _retrieve_graph_chunks(
|
||
self,
|
||
query_text: str,
|
||
kb_id: str,
|
||
base_chunks: list[dict],
|
||
query_params: dict[str, Any],
|
||
) -> list[dict]:
|
||
try:
|
||
from yuxi.knowledge.graphs.milvus_graph_service import MilvusGraphService
|
||
from yuxi.knowledge.graphs.milvus_graph_vector_store import MilvusGraphVectorStore
|
||
|
||
embedding_model_spec = self.databases_meta[kb_id].get("embedding_model_spec")
|
||
if not embedding_model_spec:
|
||
return []
|
||
|
||
entity_top_k = max(int(query_params.get("graph_entity_top_k", 10)), 1)
|
||
triple_top_k = max(int(query_params.get("graph_triple_top_k", 10)), 1)
|
||
graph_top_k = max(int(query_params.get("graph_top_k", 20)), 1)
|
||
graph_max_nodes = max(int(query_params.get("graph_max_nodes", 10000)), 1)
|
||
|
||
vector_store = await _run_milvus_query_io(MilvusGraphVectorStore)
|
||
entity_hits, triple_hits = await asyncio.gather(
|
||
vector_store.search_entities(
|
||
kb_id=kb_id,
|
||
query_text=query_text,
|
||
embedding_model_spec=embedding_model_spec,
|
||
top_k=entity_top_k,
|
||
),
|
||
vector_store.search_triples(
|
||
kb_id=kb_id,
|
||
query_text=query_text,
|
||
embedding_model_spec=embedding_model_spec,
|
||
top_k=triple_top_k,
|
||
),
|
||
)
|
||
seed_weights = await self._build_graph_seed_weights(kb_id, base_chunks, entity_hits, triple_hits)
|
||
if not seed_weights:
|
||
return []
|
||
|
||
graph_service = MilvusGraphService()
|
||
graph_scores = await graph_service.query_and_rank_chunks_by_ppr(
|
||
kb_id,
|
||
seed_weights,
|
||
max_nodes=graph_max_nodes,
|
||
top_k=graph_top_k,
|
||
damping=float(query_params.get("ppr_damping", 0.85)),
|
||
)
|
||
if not graph_scores:
|
||
return []
|
||
|
||
chunks = await KnowledgeChunkRepository().list_by_chunk_ids([chunk_id for chunk_id, _ in graph_scores])
|
||
score_by_chunk_id = dict(graph_scores)
|
||
return [
|
||
self._build_chunk_from_record(chunk, score_by_chunk_id[chunk.chunk_id], score_field="graph_score")
|
||
for chunk in chunks
|
||
]
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.error(f"Graph retrieval failed for {kb_id}: {exc}")
|
||
return []
|
||
|
||
async def _build_graph_seed_weights(
|
||
self,
|
||
kb_id: str,
|
||
base_chunks: list[dict],
|
||
entity_hits: list[dict[str, Any]],
|
||
triple_hits: list[dict[str, Any]],
|
||
) -> dict[str, float]:
|
||
seed_weights: dict[str, float] = {}
|
||
|
||
def add_seed(entity_id: str | None, score: float, weight: float) -> None:
|
||
if not entity_id:
|
||
return
|
||
seed_weights[entity_id] = seed_weights.get(entity_id, 0.0) + max(float(score or 0.0), 0.0) * weight
|
||
|
||
for hit in entity_hits:
|
||
add_seed(hit.get("id"), hit.get("score", 0.0), 1.0)
|
||
|
||
for hit in triple_hits:
|
||
score = float(hit.get("score") or 0.0)
|
||
add_seed(hit.get("source_id"), score, 0.8)
|
||
add_seed(hit.get("target_id"), score, 0.8)
|
||
|
||
chunk_scores = {
|
||
chunk.get("metadata", {}).get("chunk_id"): float(chunk.get("score") or 0.0)
|
||
for chunk in base_chunks
|
||
if chunk.get("metadata", {}).get("chunk_id")
|
||
}
|
||
if chunk_scores:
|
||
chunks = await KnowledgeChunkRepository().list_by_chunk_ids(list(chunk_scores))
|
||
for chunk in chunks:
|
||
for entity_id in chunk.ent_ids or []:
|
||
add_seed(entity_id, chunk_scores.get(chunk.chunk_id, 0.0), 0.3)
|
||
|
||
total = sum(seed_weights.values())
|
||
if total <= 0:
|
||
return {}
|
||
return {entity_id: weight / total for entity_id, weight in seed_weights.items()}
|
||
|
||
def _build_chunk_from_record(self, chunk: Any, score: float, score_field: str | None = None) -> dict:
|
||
metadata = {
|
||
"source": "未知来源",
|
||
"chunk_id": chunk.chunk_id,
|
||
"file_id": chunk.file_id,
|
||
"chunk_index": chunk.chunk_index,
|
||
}
|
||
result = {"content": chunk.content, "metadata": metadata, "score": float(score or 0.0)}
|
||
if score_field:
|
||
result[score_field] = float(score or 0.0)
|
||
return result
|
||
|
||
def _fuse_chunk_rankings(
|
||
self,
|
||
base_chunks: list[dict],
|
||
graph_chunks: list[dict],
|
||
graph_weight: float,
|
||
) -> list[dict]:
|
||
fused: dict[str, dict[str, Any]] = {}
|
||
rrf_k = 60.0
|
||
|
||
def merge_chunk(chunk: dict, rank: int, weight: float, source: str) -> None:
|
||
chunk_id = chunk.get("metadata", {}).get("chunk_id")
|
||
if not chunk_id:
|
||
return
|
||
score = weight / (rrf_k + rank)
|
||
existing = fused.get(chunk_id)
|
||
if existing is None:
|
||
existing = {**chunk, "fusion_score": 0.0, "fusion_sources": []}
|
||
fused[chunk_id] = existing
|
||
existing["fusion_score"] += score
|
||
existing["score"] = existing["fusion_score"]
|
||
existing["fusion_sources"].append(source)
|
||
if source == "graph" and "graph_score" in chunk:
|
||
existing["graph_score"] = chunk["graph_score"]
|
||
|
||
for rank, chunk in enumerate(base_chunks, start=1):
|
||
merge_chunk(chunk, rank, 1.0, "chunk")
|
||
for rank, chunk in enumerate(graph_chunks, start=1):
|
||
merge_chunk(chunk, rank, max(graph_weight, 0.0), "graph")
|
||
|
||
return sorted(fused.values(), key=lambda item: item.get("fusion_score", 0.0), reverse=True)
|
||
|
||
async def delete_file_chunks_only(self, kb_id: str, file_id: str) -> None:
|
||
"""仅删除文件的chunks数据,保留元数据(用于更新操作)"""
|
||
chunk_repo = KnowledgeChunkRepository()
|
||
if await chunk_repo.count_graph_indexed_by_file_id(file_id):
|
||
from yuxi.knowledge.graphs.milvus_graph_service import MilvusGraphService
|
||
|
||
try:
|
||
await MilvusGraphService().delete_file_graph(kb_id, file_id)
|
||
except Exception as e:
|
||
logger.error(f"Failed to delete graph data for file {file_id}: {e}")
|
||
await chunk_repo.delete_by_file_id(file_id)
|
||
collection = await self._get_milvus_collection(kb_id)
|
||
|
||
if collection:
|
||
# 先查询文件是否存在,避免不必要的删除操作
|
||
try:
|
||
await self._delete_file_chunks_from_milvus(collection, file_id)
|
||
except Exception as e:
|
||
logger.error(f"Error checking file existence in Milvus: {e}")
|
||
await KnowledgeFileRepository().update_fields(
|
||
file_id=file_id,
|
||
kb_id=kb_id,
|
||
data={"chunk_count": 0, "token_count": 0},
|
||
)
|
||
await self.refresh_database_stats(kb_id)
|
||
|
||
async def delete_file(self, kb_id: str, file_id: str) -> None:
|
||
"""删除文件(包括元数据)"""
|
||
# 先删除 Milvus 中的 chunks 数据
|
||
await self.delete_file_chunks_only(kb_id, file_id)
|
||
|
||
await KnowledgeFileRepository().delete(file_id)
|
||
await self.refresh_database_stats(kb_id)
|
||
|
||
async def get_file_basic_info(self, kb_id: str, file_id: str) -> dict:
|
||
"""获取文件基本信息(仅元数据)"""
|
||
return {"meta": await self._load_file_meta(kb_id, file_id)}
|
||
|
||
async def _get_file_content_from_meta(self, file_id: str, file_meta: dict) -> dict:
|
||
content_info = {"lines": []}
|
||
try:
|
||
chunks = await KnowledgeChunkRepository().list_by_file_id(file_id)
|
||
content_info["lines"] = [
|
||
{
|
||
"id": chunk.chunk_id,
|
||
"content": chunk.content,
|
||
"chunk_order_index": chunk.chunk_index,
|
||
"start_char_pos": chunk.start_char_pos,
|
||
"end_char_pos": chunk.end_char_pos,
|
||
"start_token_pos": chunk.start_token_pos,
|
||
"end_token_pos": chunk.end_token_pos,
|
||
"graph_indexed": chunk.graph_indexed,
|
||
"ent_ids": chunk.ent_ids,
|
||
"tags": chunk.tags,
|
||
"extraction_result": chunk.extraction_result,
|
||
}
|
||
for chunk in chunks
|
||
]
|
||
except Exception as e:
|
||
logger.error(f"Failed to get file content from PostgreSQL: {e}")
|
||
|
||
if not content_info["lines"]:
|
||
logger.warning(f"No chunks found in PostgreSQL for file {file_id}, file may not have been indexed")
|
||
|
||
# Try to read markdown content if available
|
||
if file_meta.get("markdown_file"):
|
||
try:
|
||
content = await self._read_markdown_from_minio(file_meta["markdown_file"])
|
||
content_info["content"] = content
|
||
except Exception as e:
|
||
logger.error(f"Failed to read markdown file for {file_id}: {e}")
|
||
|
||
return content_info
|
||
|
||
async def get_file_content(self, kb_id: str, file_id: str) -> dict:
|
||
"""获取文件内容信息(chunks和lines)"""
|
||
file_meta = await self._load_file_meta(kb_id, file_id)
|
||
return await self._get_file_content_from_meta(file_id, file_meta)
|
||
|
||
async def get_file_info(self, kb_id: str, file_id: str) -> dict:
|
||
"""获取文件完整信息(基本信息+内容信息)"""
|
||
file_meta = await self._load_file_meta(kb_id, file_id)
|
||
content_info = await self._get_file_content_from_meta(file_id, file_meta)
|
||
return {"meta": file_meta, **content_info}
|
||
|
||
def delete_database(self, kb_id: str) -> dict:
|
||
"""删除数据库,同时清除Milvus中的集合"""
|
||
# Drop Milvus collection
|
||
try:
|
||
if utility.has_collection(kb_id, using=self.connection_alias):
|
||
utility.drop_collection(kb_id, using=self.connection_alias)
|
||
logger.info(f"Dropped Milvus collection for {kb_id}")
|
||
else:
|
||
logger.info(f"Milvus collection {kb_id} does not exist, skipping")
|
||
except Exception as e:
|
||
logger.error(f"Failed to drop Milvus collection {kb_id}: {e}")
|
||
|
||
from yuxi.knowledge.graphs.milvus_graph_vector_store import MilvusGraphVectorStore
|
||
|
||
MilvusGraphVectorStore().drop_graph_collections(kb_id)
|
||
|
||
# Call base method to delete local files and metadata
|
||
return super().delete_database(kb_id)
|
||
|
||
def get_query_params_config(self, kb_id: str, **kwargs) -> dict:
|
||
"""获取 Milvus 知识库的查询参数配置"""
|
||
return {"type": "milvus", "options": _retrieval_config_options()}
|
||
|
||
def __del__(self):
|
||
"""清理连接"""
|
||
try:
|
||
if hasattr(self, "connection_alias"):
|
||
connections.disconnect(self.connection_alias)
|
||
except Exception: # noqa: S110
|
||
pass
|