Files
zhenxun_bot/zhenxun/services/ai/context/knowledge/vector.py
T
52f7dbdedf ♻️ refactor(core): 重构 AI 编排框架与记忆及 RAG 子系统 (#2149)
* ♻️ refactor(core): 重构 AI 编排框架与记忆及 RAG 子系统

- 【重构】重构 `BaseRunnable` 并引入统一的 `RunIntent` 意图载体,规范 Agent、Team 和 Workflow 的执行流
- 【解耦】将中期记忆槽和长期向量记忆从 `MemoryConfig` 中解耦,转为独立的能力组件与工具箱进行管理
- 【记忆】移除 `MemoryReader` 和 `MemoryWriter`,统一封装为 `SessionMemoryContext` 会话记忆门面
- 【RAG】重构检索器与存储后端接口,统一采用 `QueryRequest` 进行多维度联合检索,并引入 `InMemoryScorer` 提升打分性能
- 【事件】优化 `EventBus` 异步事件分发机制,引入队列机制确保事件按序处理,避免并发竞态问题
- 【依赖注入】移除 `memory` 注入项,优化 `DependencyInjector` 的签名解析缓存以提升性能

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-14 16:48:33 +08:00

336 lines
13 KiB
Python

from pathlib import Path
from typing import Any, Literal
import anyio
from nonebot.adapters import Bot, Event
from pydantic import BaseModel, Field
from zhenxun.services.ai.context.rag.backends import StorageBackend
from zhenxun.services.ai.context.rag.engine import ScopedRAGClient
from zhenxun.services.ai.context.rag.models import BaseRecord
from zhenxun.services.ai.core.messages import LLMMessage
from zhenxun.services.ai.llm.api import generate_structured
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.core.decorators import tool
from zhenxun.services.ai.tools.models import ToolkitConfig, ToolResult
from zhenxun.services.ai.utils.logger import log_knowledge as logger
from .base import BaseKnowledge
from .readers import (
BaseReader,
CSVReader,
TextReader,
)
class QueryAnalysis(BaseModel):
"""大模型结构化提取查询意图"""
keywords: list[str] = Field(
description="提取出1~3个极其简短的搜索短语或名词,严格去除所有客套话、修饰词和标点。如果用户意图跨度较大,可以拆分为多个短语。"
)
class VectorKnowledge(BaseKnowledge):
"""
原生语义向量知识库。
将长文档切分、向量化并存入关系型/向量数据库,
向大模型提供语义检索 (Semantic Search) 工具。
"""
default_instructions = (
"## 语义知识库\n"
"你拥有访问外部语义向量知识库的权限。请遵循以下规则:\n"
"1. **优先检索**:在回答专业或背景问题时,务必使用 `search_knowledge` 工具。\n"
"2. **语义搜索**:你可以直接输入完整的问题或描述作为检索词,"
"系统会自动进行语义匹配。\n"
"3. **精确过滤**:如果你需要查阅特定范围,可以在 filters 参数中"
"传入 JSON 字典进行精确匹配(如 {'source': 'local_file'})。\n"
"4. **基于事实**:必须仅根据检索到的内容回答,严禁编造信息。"
)
default_auto_inject_template = (
"### 📚 [本地知识库自动检索结果]\n"
"基于用户的最新提问,系统后台已自动为你检索了以下参考资料。"
"请你务必优先结合以下资料回答用户的问题,严禁编造:\n\n"
"{knowledge_text}"
)
_global_storage: StorageBackend | None = None
def __init__(
self,
rag_client: ScopedRAGClient | None = None,
injection_mode: Literal["tool", "auto", "smart"] = "tool",
query_rewrite_model: str | None = None,
auto_inject_template: str | None = None,
query_rewrite_prompt: str | None = None,
query_rewrite_instruction: str | None = None,
search_limit: int = 8,
inject_limit: int = 12,
**kwargs: Any,
):
"""
初始化向量语义知识库工具箱。
参数:
rag_client: RAG 基础设施客户端实例,默认 None。
injection_mode: 知识库的介入模式。
- "tool": 纯工具模式 (默认)。大模型需自主思考并显式调用 `search_knowledge` 工具获取信息。
- "auto": 自动注入模式。向大模型隐藏检索工具,直接使用用户的原始输入去数据库粗筛并静默注入。
- "smart": 智能查询模式。向大模型隐藏检索工具,先利用 LLM 对用户的提问进行改写,再查库注入,准确率最高。
query_rewrite_model: 在 "smart" 模式下,指定用于重写查询词的大模型名称(为空则跟随当前主模型)。
auto_inject_template: 自动/智能注入模式下向大模型提示词注入的模板字符串,默认 None。
query_rewrite_prompt: 智能模式下对查询词进行改写时的提示词,默认 None。
query_rewrite_instruction: 智能模式下进行查询词改写的大模型 System 提示词说明,默认 None。
search_limit: 单次库检索的返回记录数限制,默认 8。
inject_limit: 最终合并去重后注入给大模型的上下文片段数上限,默认 12。
**kwargs: 传递给父类的其他关键字参数。
""" # noqa: E501
self.injection_mode = injection_mode
self.query_rewrite_model = query_rewrite_model
self.auto_inject_template = (
auto_inject_template or self.default_auto_inject_template
)
self.query_rewrite_prompt = (
query_rewrite_prompt
or "用户原始提问:{query}\n\n请提取核心搜索词用于专业知识库向量检索。"
)
self.query_rewrite_instruction = (
query_rewrite_instruction or "你是一个资深的数据检索架构师。"
)
self.search_limit = search_limit
self.inject_limit = inject_limit
if injection_mode in ("auto", "smart"):
config = kwargs.get("config")
if not config:
config = ToolkitConfig()
kwargs["config"] = config
if config.exclude is None:
config.exclude = []
config.exclude.append("search_knowledge")
super().__init__(**kwargs)
if rag_client is None:
from zhenxun.services.ai.context.rag.backends import DictStorageBackend
from zhenxun.services.ai.context.rag.builder import RAGBuilder
rag_client = RAGBuilder(DictStorageBackend()).build()
self.rag_client = rag_client
self.readers: dict[str, BaseReader] = {}
txt_reader = TextReader()
for ext in [".txt", ".md", ".json", ".log", ".yaml", ".yml", ".ini"]:
self.readers[ext] = txt_reader
self.readers[".csv"] = CSVReader()
@classmethod
def from_event(
cls,
event: Event | None = None,
bot: Bot | None = None,
isolation: Literal["group", "user"] = "group",
**kwargs,
) -> "VectorKnowledge":
"""
根据 NoneBot 的 Event 自动推导并创建一个物理隔离的向量知识库实例。
"""
from zhenxun.services.ai.context.memory.types import Isolation
from zhenxun.services.ai.context.rag.backends import DictStorageBackend
from zhenxun.services.ai.context.rag.builder import RAGBuilder
from zhenxun.services.ai.run.context import NoneBotDeps
from zhenxun.services.ai.utils import ContextUtils
if not bot or not event:
deps = NoneBotDeps.get_current()
bot = bot or (deps.bot if deps else None)
event = event or (deps.event if deps else None)
if not bot or not event:
raise ValueError(
"无法隐式获取当前对话的 Bot 或 Event 上下文,"
"如果您在定时任务或后台线程中使用,请显式传入 bot 和 event 参数。"
)
scope_builder = (
Isolation.GROUP_SHARED()
if isolation == "group"
else Isolation.USER_GLOBAL()
)
session_meta = ContextUtils.generate_session_meta(
bot=bot, event=event, scope_builder=scope_builder, namespace="auto_kb"
)
if cls._global_storage is None:
cls._global_storage = DictStorageBackend()
client = (
RAGBuilder(cls._global_storage)
.with_scope(session_meta.accessible_scopes)
.build()
)
return cls(rag_client=client, **kwargs)
def register_reader(
self, ext: str | list[str], reader: BaseReader
) -> "VectorKnowledge":
"""挂载自定义后缀文件解析器 (如 PDF, Docx),支持链式调用"""
exts = [ext] if isinstance(ext, str) else ext
for e in exts:
e = e.lower()
if not e.startswith("."):
e = f".{e}"
self.readers[e] = reader
return self
def get_instructions(self) -> str | None:
"""
如果是自动/智能注入模式,对大模型完全隐藏检索提示词,防止其误调用。
"""
if self.injection_mode != "tool":
return None
return super().get_instructions()
async def before_llm_request(
self, context: RunContext, messages: list[LLMMessage]
) -> None:
"""
生命周期钩子:在向底层 LLM 发起请求前触发。
负责执行 "auto" 或 "smart" 模式下的前置主动检索与上下文注入。
"""
if self.injection_mode == "tool":
return
user_input = context.run.user_input
if not user_input:
return
queries_to_search = [user_input]
if self.injection_mode == "smart":
try:
model_to_use = self.query_rewrite_model or context.run.current_model
prompt = self.query_rewrite_prompt.format(query=user_input)
res = await generate_structured(
message=prompt,
response_model=QueryAnalysis,
model=model_to_use,
instruction=self.query_rewrite_instruction,
)
if res.keywords:
logger.info(
f"✨ [Smart Knowledge] 搜索词改写成功: '{user_input}' -> "
f"{res.keywords}"
)
queries_to_search = res.keywords
except Exception as e:
logger.warning(f"[Smart Knowledge] Query 改写失败,降级使用原词: {e}")
all_results = []
seen_ids = set()
for q in queries_to_search:
results = await self.rag_client.search(query=q, limit=self.search_limit)
for res in results:
if res.record.id not in seen_ids:
seen_ids.add(res.record.id)
all_results.append(res)
if not all_results:
return
all_results.sort(key=lambda x: x.score, reverse=True)
all_results = all_results[: self.inject_limit]
formatted_results = []
for result in all_results:
doc_name = result.record.metadata.get("name", "未命名文档")
formatted_results.append(
f"📄 来源: {doc_name}\n内容片段:\n{result.record.content}"
)
knowledge_text = "\n\n======\n\n".join(formatted_results)
system_prompt = self.auto_inject_template.format(knowledge_text=knowledge_text)
messages.insert(0, LLMMessage.system(system_prompt))
async def add_document(self, document: BaseRecord) -> int:
"""
通过注入的 Ingestion Pipeline 处理并入库文档
返回成功入库的 Chunk 数量。
"""
return await self.rag_client.ingest([document])
async def add_file(self, file_path: str | Path) -> int:
"""
读取并注入单个文件。
"""
aio_path = anyio.Path(file_path)
std_path = Path(file_path)
if not await aio_path.is_file():
logger.error(f"[VectorKnowledge] 文件不存在: {std_path}")
return 0
ext = std_path.suffix.lower()
reader = self.readers.get(ext)
if not reader:
logger.warning(f"当前知识库未配置支持解析文件后缀: {ext}")
return 0
doc = await reader.read(std_path)
if not doc:
return 0
return await self.rag_client.ingest([doc])
async def add_directory(self, dir_path: str | Path) -> int:
"""扫描目录并批量注入所有支持的文件"""
aio_path = anyio.Path(dir_path)
docs_to_ingest = []
async for p in aio_path.rglob("*"):
if await p.is_file():
std_path = Path(p)
ext = std_path.suffix.lower()
reader = self.readers.get(ext)
if not reader:
logger.warning(f"当前知识库未配置支持解析文件后缀: {ext}")
continue
doc = await reader.read(std_path)
if doc:
docs_to_ingest.append(doc)
if not docs_to_ingest:
return 0
return await self.rag_client.ingest(docs_to_ingest)
@tool(
name="search_knowledge",
description=(
"在语义知识库中搜索最相关的内容片段。可以通过 filters 字典进行额外过滤。"
),
)
async def search_knowledge(
self, query: str, filters: dict[str, Any] | None = None, limit: int = 5
) -> ToolResult:
results = await self.rag_client.search(
query=query, limit=limit, metadata_filters=filters
)
if not results:
return ToolResult(output=f"知识库中未找到与 '{query}' 紧密相关的内容。")
formatted_results = []
for result in results:
doc_name = result.record.metadata.get("name", "未命名文档")
formatted_results.append(
f"📄 来源: {doc_name}\n片段内容:\n{result.record.content}"
)
final_text = "\n\n======\n\n".join(formatted_results)
return ToolResult(output=final_text)