Files
qihuaiflow/backend/python/app/kb_router.py
T

263 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
知识库路由层
============
将检索请求智能路由到本地BM25、向量检索、或外部KB服务(Dify)。
根据知识库配置自动选择最优检索策略。
使用方式:
from app.kb_router import kb_router_search
results = await kb_router_search(query, kb_id, top_k=5)
"""
import logging
from typing import List, Dict, Optional
logger = logging.getLogger("aiflow.kb_router")
async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
"""获取已配置的外部KB服务(仅 dify)"""
from sqlalchemy import text
try:
query = "SELECT * FROM external_kb_services"
if enabled_only:
query += " WHERE is_enabled = 1"
query += " ORDER BY created_at DESC"
result = db_session.execute(text(query))
columns = result.keys()
return [dict(zip(columns, row)) for row in result.fetchall()]
except Exception as e:
logger.error(f"Failed to get external services: {e}")
return []
async def _search_dify(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
"""检索 Dify 知识库(走 app.dify_kb 客户端)"""
try:
from app.dify_kb import dify_retrieve
api_url = service.get("api_url", "")
api_key = service.get("api_key", "")
dataset_name = None
cfg = service.get("config")
if isinstance(cfg, str):
try:
import json
cfg = json.loads(cfg)
except Exception:
cfg = None
if isinstance(cfg, dict):
dataset_name = cfg.get("dataset_name")
hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
return hits
except Exception as e:
logger.error(f"Dify search error: {e}")
return []
async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
"""路由到对应的外部KB服务(当前仅支持 dify)"""
service_type = service.get("service_type", "")
if service_type == "dify":
return await _search_dify(service, query, top_k)
else:
logger.warning(f"Unknown external KB type: {service_type}")
return []
async def _get_web_services(db_session, enabled_only: bool = True) -> List[Dict]:
"""获取已启用的 AnySearch 联网检索服务配置。"""
from sqlalchemy import text
try:
query = "SELECT * FROM external_kb_services WHERE service_type='anysearch'"
if enabled_only:
query += " AND is_enabled = 1"
query += " ORDER BY created_at DESC"
result = db_session.execute(text(query))
columns = result.keys()
return [dict(zip(columns, row)) for row in result.fetchall()]
except Exception as e:
logger.error(f"Failed to get AnySearch services: {e}")
return []
async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
"""调用 AnySearch 联网检索(走 app.anysearch_client)。"""
try:
from app.anysearch_client import anysearch_search
api_key = service.get("api_key", "")
api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
cfg = service.get("config")
if isinstance(cfg, str):
try:
import json
cfg = json.loads(cfg)
except Exception:
cfg = None
cfg = cfg or {}
zone = cfg.get("zone", "cn")
freshness = cfg.get("freshness")
k = int(cfg.get("top_k", top_k))
return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
except Exception as e:
logger.error(f"AnySearch search error: {e}")
return []
async def kb_router_search(
query: str,
kb_id: Optional[str] = None,
kb_ids: Optional[List[str]] = None,
top_k: int = 5,
bm25_results: Optional[List[Dict]] = None,
db_session=None,
use_vector: bool = True,
use_external: bool = True,
use_tree: bool = True,
use_web: bool = False,
) -> List[Dict]:
"""
知识库路由层 — 混合检索
检索策略:
1. 本地 BM25 检索 (MySQL, 已有)
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
4. 外部KB检索 (Dify, 按配置)
5. 联网检索 (AnySearch, 可选 use_web)
融合方式: Reciprocal Rank Fusion (RRF)
Args:
query: 查询文本
kb_id: 知识库ID
top_k: 返回结果数
bm25_results: BM25检索结果
db_session: 数据库会话(用于获取外部KB配置与分片段检索)
use_vector: 是否使用向量检索
use_external: 是否使用外部KB检索
use_tree: 是否使用 TreeSearch 结构化分片段检索
use_web: 是否使用 AnySearch 联网检索
Returns:
融合后的检索结果列表
"""
all_results = []
# 1. BM25 结果
if bm25_results:
for rank, item in enumerate(bm25_results):
all_results.append({
"content": item.get("content", item.get("text", "")),
"score": item.get("score", 1.0 / (60 + rank + 1)),
"source": "local_bm25",
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
# 2. 向量检索
if use_vector:
try:
from app.pgvector_search import vector_search
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
vec_results = []
for _kid in kb_targets:
try:
vec_results.extend(await vector_search(query, _kid, top_k))
except Exception as e:
logger.warning(f"Vector search failed for kb={_kid}: {e}")
for rank, item in enumerate(vec_results):
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.5),
"source": "vector_pg",
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
except ImportError:
logger.debug("pgvector_search not available, skipping vector search")
except Exception as e:
logger.warning(f"Vector search failed: {e}")
# 2.5 结构化分片段检索 (TreeSearch, Level 2)
if use_tree and db_session is not None:
try:
from app.tree_search import tree_search
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
tree_hits = await tree_search(
db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
)
for rank, item in enumerate(tree_hits):
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.5),
"source": "tree_chunk",
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
except Exception as e:
logger.warning(f"TreeSearch failed: {e}")
# 3. 外部KB检索 (Dify)
if use_external and db_session:
services = await _get_external_services(db_session)
for service in services:
if service.get("status") == "connected":
ext_results = await _search_external(service, query, top_k)
for rank, item in enumerate(ext_results):
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.5),
"source": item.get("source", "external_dify"),
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
# 4. 联网检索 (AnySearch, 可选)
if use_web and db_session is not None:
try:
web_services = await _get_web_services(db_session)
for svc in web_services:
web_hits = await _search_anysearch(svc, query, top_k)
for rank, item in enumerate(web_hits):
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.6),
"source": item.get("source", "web_anysearch"),
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
except Exception as e:
logger.warning(f"AnySearch web search failed: {e}")
if not all_results:
return []
# RRF 融合
rrf_k = 60
score_map = {}
for item in all_results:
content_key = item["content"][:200] # 用内容前200字符作为去重键
rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
if content_key in score_map:
score_map[content_key]["score"] += rrf_score
score_map[content_key]["sources"].append(item["source"])
else:
score_map[content_key] = {
"content": item["content"],
"score": rrf_score,
"sources": [item["source"]],
"metadata": item.get("metadata", {}),
}
# 按融合分数排序
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
# 标准化分数到 0-1
if merged:
max_score = merged[0]["score"]
for item in merged:
item["score"] = item["score"] / max_score if max_score > 0 else 0
item["sources"] = list(set(item["sources"]))
return merged[:top_k]