✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)

* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架

- 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片
- 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式
- 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统
- 【新增】引入基于 Docker 的安全代码执行沙箱环境
- 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务
- 【新增】引入输入输出安全合规护栏与自愈反思机制
- 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等)
- 【优化】优化日志脱敏与 Token 预估机制
- 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令

* 🔧 chore(deps): 更新项目依赖与配置

- 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt
- 在 pyright 配置中设置 reportMissingImports 为 none
- 调整 .gitignore 中 resources 目录的忽略规则

* ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出

- 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束
- 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用
- 调整指令处理器(Directive)的返回值为 `tool_res.output`
- 修复部分类型检查警告并优化联合类型判断语法

* ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制

- 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用
- 移除通过 `context.state` 传递魔术变量的隐式控制流设计
- 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult`
- 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令
- 补全 `MessageBuilder` 中部分核心方法的文档注释

* 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑

-【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值
-【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题
-【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求
-【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建
-【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常

* 📝 docs(core): 补充核心模块初始化方法的文档注释

* 🚨 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>
This commit is contained in:
Rumio
2026-07-03 08:53:56 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bdc1374848
commit 80fc5b86a7
223 changed files with 41885 additions and 10006 deletions
+20
View File
@@ -0,0 +1,20 @@
"""
Zhenxun AI - 上下文、记忆与知识管理子系统门面 (Context, Memory & Knowledge Facade)
"""
from .knowledge import FileSystemKnowledge, VectorKnowledge
from .memory import (
AgentSessionFacade,
MemoryBuilder,
memory_manager,
)
from .rag import RAGBuilder
__all__ = [
"AgentSessionFacade",
"FileSystemKnowledge",
"MemoryBuilder",
"RAGBuilder",
"VectorKnowledge",
"memory_manager",
]
@@ -0,0 +1,7 @@
from .filesystem import FileSystemKnowledge
from .vector import VectorKnowledge
__all__ = [
"FileSystemKnowledge",
"VectorKnowledge",
]
@@ -0,0 +1,10 @@
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
class BaseKnowledge(BaseToolkit):
"""
知识库基类。
继承自 BaseToolkit,允许知识库向 Agent 提供自定义的检索工具(如搜索、读取)。
"""
pass
@@ -0,0 +1,131 @@
import os
from pathlib import Path
import re
from typing import Any
from zhenxun.services.ai.tools.core.decorators import tool
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.log import logger
from .base import BaseKnowledge
class FileSystemKnowledge(BaseKnowledge):
"""
纯文本文件系统知识库。
零依赖,无需向量数据库。通过大模型原生工具 (grep, list, read) 让其自主翻阅本地文件。
"""
default_instructions = (
"## 本地文件知识库\n"
"你拥有访问本地专业文档的权限。在回答问题前,请遵循以下流程:\n"
"1. **搜索**:优先使用 `search_knowledge_files` 通过关键词查找相关内容。\n"
"2. **概览**:如果需要了解文件结构,使用 `list_knowledge_files`。\n"
"3. **阅读**:找到目标后,使用 `read_knowledge_file` 获取完整上下文。\n"
"**核心原则**:严禁凭空捏造事实,必须基于文件内容提取信息。"
)
def __init__(
self,
base_dir: str | Path,
allowed_extensions: tuple[str, ...] | None = None,
**kwargs: Any,
):
"""
初始化文本文件系统知识库。
参数:
base_dir: 知识库对应的本地根目录路径。
allowed_extensions: 允许读取和检索的文件后缀元组,
默认支持 txt, md, json, csv, yaml, log。
**kwargs: 透传给父类 BaseKnowledge 的额外参数。
"""
super().__init__(**kwargs)
self.base_dir = Path(base_dir).resolve()
self.allowed_extensions = allowed_extensions or (
".txt",
".md",
".json",
".csv",
".yaml",
".log",
)
if not self.base_dir.exists():
logger.warning(f"[FileSystemKnowledge] 警告:目录不存在 {self.base_dir}")
self.base_dir.mkdir(parents=True, exist_ok=True)
def _is_safe_path(self, target_path: Path) -> bool:
"""安全检查:防止跨目录访问"""
try:
return target_path.resolve().is_relative_to(self.base_dir)
except Exception:
return False
@tool(
name="search_knowledge_files",
description="在知识库中搜索包含指定关键词的文件内容和上下文。",
)
async def search_knowledge_files(self, keyword: str) -> ToolResult:
"""扫描所有文本文件,返回包含关键词的行及上下文。"""
results = []
try:
pattern = re.compile(re.escape(keyword), re.IGNORECASE)
except Exception:
return ToolResult(output=f"无效的搜索关键词: {keyword}").as_error()
for root, _, files in os.walk(self.base_dir):
for file in files:
if not file.endswith(self.allowed_extensions):
continue
file_path = Path(root) / file
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
lines = content.splitlines()
matches = []
for i, line in enumerate(lines):
if pattern.search(line):
start = max(0, i - 1)
end = min(len(lines), i + 2)
context = "\n".join(lines[start:end])
matches.append(context)
if matches:
rel_path = file_path.relative_to(self.base_dir).as_posix()
match_text = "\n---\n".join(matches[:5])
results.append(f"📁 文件: {rel_path}\n{match_text}")
except Exception:
continue
if not results:
return ToolResult(output=f"未找到包含 '{keyword}' 的内容。尝试更换关键词。")
final_output = "\n\n======\n\n".join(results[:10])
return ToolResult(output=final_output)
@tool(name="list_knowledge_files", description="列出知识库中所有可用的文档路径。")
async def list_knowledge_files(self) -> ToolResult:
files = []
for root, _, filenames in os.walk(self.base_dir):
for filename in filenames:
file_path = Path(root) / filename
rel_path = file_path.relative_to(self.base_dir).as_posix()
files.append(rel_path)
if not files:
return ToolResult(output="知识库当前为空。")
return ToolResult(output="可用文件列表:\n" + "\n".join(files))
@tool(
name="read_knowledge_file", description="读取知识库中指定文件的完整文本内容。"
)
async def read_knowledge_file(self, file_path: str) -> ToolResult:
target = (self.base_dir / file_path).resolve()
if not self._is_safe_path(target):
return ToolResult(output="❌ 安全拦截:越权访问").as_error()
if not target.is_file():
return ToolResult(output=f"❌ 文件不存在: {file_path}").as_error()
content = target.read_text(encoding="utf-8", errors="ignore")
return ToolResult(output=content)
@@ -0,0 +1,74 @@
import asyncio
import csv
from pathlib import Path
from zhenxun.services.ai.context.rag.models import BaseRecord
from zhenxun.services.log import logger
class BaseReader:
"""读取器基类"""
async def read(self, file_path: Path) -> BaseRecord | None:
raise NotImplementedError
class TextReader(BaseReader):
"""处理 .txt, .md, .json 等纯文本"""
async def read(self, file_path: Path) -> BaseRecord | None:
try:
import aiofiles
async with aiofiles.open(file_path, encoding="utf-8", errors="ignore") as f:
content = await f.read()
enriched_content = f"文档名称:{file_path.stem}\n文档内容:\n{content}"
return BaseRecord(
content=enriched_content,
metadata={
"name": file_path.name,
"extension": file_path.suffix.lower(),
},
)
except Exception as e:
logger.error(f"[TextReader] 读取文件失败 {file_path}: {e}")
return None
class CSVReader(BaseReader):
"""
处理 .csv 报表
将其标准化为逗号分隔的文本块,便于后续的 RowChunking 处理。
"""
async def read(self, file_path: Path) -> BaseRecord | None:
try:
def _read_csv():
with open(file_path, encoding="utf-8", errors="ignore") as f:
reader = csv.reader(f)
return [
",".join(
[
str(cell).replace("\n", " ").replace("\r", "")
for cell in row
]
)
for row in reader
]
lines = await asyncio.to_thread(_read_csv)
content = "\n".join(lines)
enriched_content = f"数据表名称:{file_path.stem}\n数据内容:\n{content}"
return BaseRecord(
content=enriched_content,
metadata={
"name": file_path.name,
"extension": file_path.suffix.lower(),
},
)
except Exception as e:
logger.error(f"[CSVReader] 读取 CSV 失败 {file_path}: {e}")
return None
@@ -0,0 +1,319 @@
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.knowledge.base import BaseKnowledge
from zhenxun.services.ai.context.knowledge.readers import (
BaseReader,
CSVReader,
TextReader,
)
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.log import logger
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: Any = 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[Any]
) -> 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:
"""扫描目录并注入所有支持的文件"""
total_chunks = 0
aio_path = anyio.Path(dir_path)
async for p in aio_path.rglob("*"):
if await p.is_file():
total_chunks += await self.add_file(Path(p))
return total_chunks
@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" f"片段内容:\n{result.record.content}"
)
final_text = "\n\n======\n\n".join(formatted_results)
return ToolResult(output=final_text)
@@ -0,0 +1,23 @@
from .builder import MemoryBuilder
from .compression import MemoryPolicy
from .facades import AgentSessionFacade
from .manager import memory_manager
from .models import (
BaseMemoryIngestionMiddleware,
MemoryConfig,
)
from .types import (
Isolation,
SessionMetadata,
)
__all__ = [
"AgentSessionFacade",
"BaseMemoryIngestionMiddleware",
"Isolation",
"MemoryBuilder",
"MemoryConfig",
"MemoryPolicy",
"SessionMetadata",
"memory_manager",
]
@@ -0,0 +1,270 @@
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from typing_extensions import Self
from pydantic import BaseModel
if TYPE_CHECKING:
from zhenxun.services.ai.context.memory.models import MemorySlot
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseChatContext,
BaseMemoryIngestionMiddleware,
BaseSlotContext,
)
from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend
from zhenxun.services.ai.context.rag.engine import ScopedRAGClient
from zhenxun.services.ai.context.memory.compression import MemoryPolicy
from zhenxun.services.ai.context.memory.models import (
ContextCompressionConfig,
IngestionConfig,
LongTermConfig,
MemoryConfig,
ShortTermConfig,
SlotMemoryConfig,
)
from zhenxun.services.ai.context.memory.types import (
AutoRecallPolicy,
)
from zhenxun.services.ai.utils.scope import ScopeBuilder
class MemoryBuilder:
"""
记忆配置的链式构建器 (Fluent Builder)。
"""
def __init__(self):
"""
初始化 MemoryBuilder 实例。
创建一个默认关闭短期和长期记忆,并包含默认上下文压缩配置的构建器。
"""
self._config = MemoryConfig(
short_term=ShortTermConfig(enable=False),
slots=SlotMemoryConfig(enable=False),
long_term=LongTermConfig(enable=False),
compression=ContextCompressionConfig(),
ingestion=IngestionConfig(),
)
@classmethod
def auto(cls) -> "MemoryBuilder":
"""
创建一个开箱即用的默认记忆配置构建器。
默认开启隔离的短期记忆,并使用 LLM 对话摘要进行上下文压缩。
"""
return cls().with_short_term(enable=True).with_llm_summary()
@classmethod
def resolve(
cls, memory: bool | MemoryConfig | "MemoryBuilder" | None
) -> MemoryConfig:
if isinstance(memory, MemoryConfig):
return memory
if isinstance(memory, cls):
return memory.build()
if isinstance(memory, bool):
return MemoryConfig(
short_term=ShortTermConfig(enable=memory),
long_term=LongTermConfig(enable=memory),
)
return MemoryConfig(short_term=ShortTermConfig(enable=False))
def with_base_isolation(self, isolation: ScopeBuilder) -> Self:
"""设置顶层基准隔离级别,短期/中期/长期记忆将默认继承此级别"""
self._config.base_isolation = isolation
self._config.short_term.isolation = isolation
return self
def with_short_term(
self,
enable: bool = True,
isolation: ScopeBuilder | None = None,
backend: "str | BaseChatContext | None" = None,
) -> Self:
"""
配置短期对话历史记忆。
参数:
enable: 是否开启短期记忆。
isolation: 记忆隔离级别 (ScopeBuilder),决定会话历史记录的区分范围。
backend: 短期记忆存储后端实例,如果为 None 则使用全局默认后端。
"""
self._config.short_term.enable = enable
if isolation is not None:
self._config.base_isolation = isolation
self._config.short_term.isolation = isolation
if backend is not None:
self._config.short_term.backend = backend
return self
def with_slots(
self,
enable: bool = True,
scopes: dict[str, ScopeBuilder] | None = None,
default_slots: list["MemorySlot"] | None = None,
backend: "str | BaseSlotContext | None" = None,
instructions: str | None = None,
) -> Self:
"""
配置核心槽位记忆 (Memory Slots)。
参数:
enable: 是否启用槽位记忆。
scopes: 语义化作用域映射字典。如果只有一个键值对,则大模型不可见该参数。
default_slots: 首次初始化时自动写入的默认槽位列表。
backend: 槽位记忆存储后端,如果为 None 则使用全局默认后端。
instructions: 覆写内置槽位管理工具箱的默认系统提示词规则。
"""
self._config.slots.enable = enable
if scopes is not None:
self._config.slots.scopes = scopes
if default_slots is not None:
self._config.slots.default_slots = default_slots
if backend is not None:
self._config.slots.backend = backend
if instructions is not None:
self._config.slots.instructions = instructions
return self
def with_long_term(
self,
enable: bool = True,
scopes: dict[str, ScopeBuilder] | None = None,
engine: "ScopedRAGClient | None" = None,
backend: "str | StorageBackend | None" = None,
embedder: "Embedder | str | None" = None,
agentic: bool = True,
auto_recall: AutoRecallPolicy = False,
instructions: str | None = None,
) -> Self:
"""
配置长期向量记忆与 RAG 设定。
参数:
enable: 是否启用长期记忆。
scopes: 语义化作用域映射字典。如果只有一个键值对,则大模型不可见该参数。
engine: 高级 RAG 检索引擎实例 (推荐)。若提供,将接管记忆的底层检索、混合与重排。
backend: 长期记忆存储后端。
embedder: 用于向量化的文本嵌入模型实例。
agentic: 是否开启主动智能体记忆管理 (增删改查工具自动注入)。
auto_recall: 长期记忆的自动召回策略,支持 bool 或 Callable 函数。
instructions: 覆写内置长期记忆工具箱的默认系统提示词规则。
""" # noqa: E501
self._config.long_term.enable = enable
self._config.long_term.engine = engine
if scopes is not None:
self._config.long_term.scopes = scopes
self._config.long_term.backend = backend
self._config.long_term.embedder = embedder
self._config.long_term.agentic = agentic
self._config.long_term.auto_recall = auto_recall
if instructions is not None:
self._config.long_term.instructions = instructions
return self
def with_multimodal_window(self, window_size: int = 5) -> Self:
"""
配置多模态历史视窗大小。
超出此窗口的图片/视频等富媒体消息会自动转换为纯文本占位符,以节省 Token 预算。
参数:
window_size: 允许保留多模态信息的最新的对话轮数。
"""
self._config.compression.vision_window = window_size
return self
def with_llm_summary(
self,
trigger_tokens: int = 4000,
max_turns: int = 0,
keep_recent_turns: int = 0,
summarization_model: str | None = None,
summarization_prompt: str = "请概括以下对话内容,保留关键的约束条件、用户偏好、已完成的任务状态和未解决的问题。", # noqa: E501
) -> Self:
"""
配置使用大模型自然语言总结作为上下文压缩策略。
参数:
trigger_tokens: 触发压缩的 Token 门槛。
max_turns: 压缩策略作用的最大历史对话轮数上限。
keep_recent_turns: 在大模型总结之外,强制保留的最近原始对话轮数。
summarization_model: 负责生成总结的大模型名称。
summarization_prompt: 生成总结时所使用的系统提示词。
"""
self._config.compression.policy = MemoryPolicy.llm_summarize(
trigger_tokens=trigger_tokens,
max_turns=max_turns,
keep_recent_turns=keep_recent_turns,
summarization_model=summarization_model,
summarization_prompt=summarization_prompt,
)
return self
def with_structured_summary(
self,
trigger_tokens: int = 4000,
max_turns: int = 0,
keep_recent_turns: int = 0,
summarization_model: str | None = None,
response_model: type[BaseModel] | None = None,
prompt_template: str | None = None,
format_callback: Callable[[Any], str] | None = None,
) -> Self:
"""
配置使用自定义结构化 JSON 提取作为上下文压缩策略。
参数:
trigger_tokens: 触发压缩的 Token 门槛。
max_turns: 压缩策略作用的最大历史对话轮数上限。
keep_recent_turns: 强制保留的最近原始对话轮数。
summarization_model: 负责生成结构化总结的大模型名称。
response_model: (可选) 自定义的 Pydantic 数据模型,用于指导提取的结构。
prompt_template: (可选) 提取提示词模板,
支持 {prev_summary} 和 {dialogue} 变量。
format_callback: (可选) 将提取出的 Pydantic 实例格式化为字符串的回调函数。
"""
self._config.compression.policy = MemoryPolicy.structured_summarize(
trigger_tokens=trigger_tokens,
max_turns=max_turns,
keep_recent_turns=keep_recent_turns,
summarization_model=summarization_model,
response_model=response_model,
prompt_template=prompt_template,
format_callback=format_callback,
)
return self
def unlimited(self) -> Self:
"""
配置为不进行任何截断和压缩的策略。
适用于短程会话或者具备超长上下文窗口的底层语言模型。
"""
self._config.compression.policy = MemoryPolicy.unlimited()
return self
def with_ingestion_middlewares(
self, *middlewares: "BaseMemoryIngestionMiddleware"
) -> Self:
"""
配置记忆入库管线中间件。
用于在消息正式落盘前进行实体消解、隐私脱敏、自动打标签等操作。
"""
self._config.ingestion.middlewares.extend(middlewares)
return self
def build(self) -> MemoryConfig:
"""
生成最终构建好的 MemoryConfig 配置对象。
"""
if not self._config.slots.scopes:
self._config.slots.scopes = {"私有": self._config.base_isolation}
if not self._config.long_term.scopes:
self._config.long_term.scopes = {"私有": self._config.base_isolation}
return self._config
@@ -0,0 +1,52 @@
from typing import Any
from zhenxun.services.ai.capabilities.base import AbstractCapability
from zhenxun.services.ai.context.memory.models import MemoryConfig
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.providers.builtin.memory import MemoryManagementToolkit
class AgenticMemoryCapability(AbstractCapability):
"""
智能体主动记忆管理能力 (Agentic Memory Management)。
当 `MemoryConfig.long_term.enable == True` 且 `agentic == True` 时隐式挂载,
在运行时动态组装并向大模型提供 `MemoryManagementToolkit` 工具箱。
"""
def __init__(self, memory_config: MemoryConfig, namespace: str):
self.memory_config = memory_config
self.namespace = namespace
async def get_tools(self, context: RunContext) -> list[Any]:
kwargs = self.memory_config.long_term.toolkit_kwargs.copy()
kwargs["memory_config"] = self.memory_config
kwargs["namespace"] = self.namespace
if self.memory_config.long_term.instructions is not None:
kwargs["instructions"] = self.memory_config.long_term.instructions
toolkit = MemoryManagementToolkit(**kwargs)
return [toolkit]
class SlotMemoryCapability(AbstractCapability):
"""
槽位记忆能力组件。
当 `MemoryConfig.slots.enable == True` 时隐式挂载,
在运行时动态组装并向大模型提供 `MemorySlotToolkit` 工具箱。
"""
def __init__(self, memory_config: MemoryConfig, namespace: str):
self.memory_config = memory_config
self.namespace = namespace
async def get_tools(self, context: RunContext) -> list[Any]:
from zhenxun.services.ai.tools.providers.builtin.slots import MemorySlotToolkit
kwargs = self.memory_config.slots.toolkit_kwargs.copy()
kwargs["memory_config"] = self.memory_config
kwargs["namespace"] = self.namespace
if self.memory_config.slots.instructions is not None:
kwargs["instructions"] = self.memory_config.slots.instructions
toolkit = MemorySlotToolkit(**kwargs)
return [toolkit]
@@ -0,0 +1,688 @@
from abc import abstractmethod
from collections.abc import Callable
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseMemoryReducer,
)
from zhenxun.services.ai.core.engine.token_counter import token_counter
from zhenxun.services.ai.core.messages import (
AudioPart,
FilePart,
ImagePart,
LLMMessage,
SystemMessage,
TextPart,
VideoPart,
)
from zhenxun.services.ai.llm.manager import get_default_model
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_copy
class MultimodalPlaceholderReducer(BaseMemoryReducer):
"""视觉媒体降级:将超过一定轮数的老图片/视频替换为 <图片> 占位符文本"""
def __init__(self, window_size: int = 5):
"""
初始化多模态占位符修剪器。
参数:
window_size: 多模态视窗大小。在保留最近的指定数量的多模态消息对后,
超出的旧消息中多模态内容(如图片、视频等)将被替换为占位文本。
"""
self.window_size = window_size
@staticmethod
def apply_multimodal_placeholder(message: LLMMessage) -> LLMMessage:
sanitized_message = model_copy(message, deep=False)
new_content_parts = []
for part in sanitized_message.content:
if isinstance(part, ImagePart):
new_content_parts.append(TextPart(text="<图片>"))
elif isinstance(part, AudioPart):
new_content_parts.append(TextPart(text="<音频>"))
elif isinstance(part, VideoPart):
new_content_parts.append(TextPart(text="<视频>"))
elif isinstance(part, FilePart):
new_content_parts.append(TextPart(text="<文件>"))
elif isinstance(part, TextPart) and "[多模态内容:" in part.text:
new_content_parts.append(TextPart(text="<图片>"))
else:
new_content_parts.append(part)
merged_parts = []
for part in new_content_parts:
if (
isinstance(part, TextPart)
and merged_parts
and isinstance(merged_parts[-1], TextPart)
):
new_text = (merged_parts[-1].text or "") + " " + (part.text or "")
merged_parts[-1] = TextPart(text=new_text.strip())
else:
merged_parts.append(part)
sanitized_message.content = merged_parts
sanitized_message.token_cost = None
return sanitized_message
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
if self.window_size <= 0:
return messages, False, current_tokens
processed_messages = []
user_multimodal_count = 0
changed = False
for msg in reversed(messages):
has_multimodal = False
if isinstance(msg.content, list):
has_multimodal = any(
isinstance(p, ImagePart | AudioPart | VideoPart | FilePart)
or (isinstance(p, TextPart) and "[多模态内容:" in p.text)
for p in msg.content
)
if has_multimodal:
if msg.role == "user":
user_multimodal_count += 1
if user_multimodal_count > self.window_size:
processed_messages.append(self.apply_multimodal_placeholder(msg))
changed = True
else:
processed_messages.append(msg)
else:
processed_messages.append(msg)
if not changed:
return messages, False, current_tokens
processed_messages.reverse()
new_tokens = token_counter.count_context(
processed_messages, model_name, base_overhead
)
return processed_messages, True, new_tokens
class MessageDropper(BaseMemoryReducer):
"""消息丢弃器:在 Token 超过阈值时丢弃最早的非置顶消息对。"""
def __init__(self, trigger_tokens: int = 4000):
"""
初始化消息丢弃器。
参数:
trigger_tokens: 触发丢弃策略的 Token 阈值上限。
当当前对话 Token 总数超过此值时,将触发硬截断。
"""
self.trigger_tokens = trigger_tokens
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
if current_tokens <= self.trigger_tokens:
return messages, False, current_tokens
logger.info(
"✂️ [MemoryCompression] 触发硬截断丢弃策略 | 原因: "
f"当前 Token 预估 ({current_tokens}) 仍超过硬性上限 ({self.trigger_tokens})," # noqa: E501
"开始丢弃最旧的历史对话..."
)
new_messages = list(messages)
changed = False
while current_tokens > self.trigger_tokens:
user_indices = [
i
for i, m in enumerate(new_messages)
if m.role == "user"
and not (m.metadata and m.metadata.get("pinned", False))
]
if len(user_indices) < 2:
break
start_idx = user_indices[0]
end_idx = user_indices[1]
del new_messages[start_idx:end_idx]
changed = True
current_tokens = token_counter.count_context(
new_messages, model_name, base_overhead
)
return new_messages, changed, current_tokens
class ToolPrunerReducer(BaseMemoryReducer):
"""工具结果修剪器:纯粹计算工具输出的 Token 和轮数,超标时剔除老旧工具返回结果"""
def __init__(
self,
keep_recent_turns: int = 3,
trigger_tokens: int = 4000,
max_turns: int = 0,
):
"""
初始化工具结果修剪器。
参数:
keep_recent_turns: 保留最近的工具调用轮数(不进行内容截断的轮数)。
trigger_tokens: 触发工具修剪策略的工具总 Token 阈值上限。
max_turns: 触发工具修剪的最大工具调用轮数上限。若为 0,则不限制轮数。
"""
self.keep_recent_turns = keep_recent_turns
self.trigger_tokens = trigger_tokens
self.max_turns = max_turns
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
tool_msgs = [m for m in messages if m.role == "tool"]
tool_turns = len(tool_msgs)
if tool_turns == 0:
return messages, False, current_tokens
tool_tokens = sum(token_counter.count_message(m, model_name) for m in tool_msgs)
is_token_exceeded = tool_tokens > self.trigger_tokens
is_turn_exceeded = self.max_turns > 0 and tool_turns > self.max_turns
if not (is_token_exceeded or is_turn_exceeded):
return messages, False, current_tokens
reasons = []
if is_token_exceeded:
reasons.append(f"工具Token超标 ({tool_tokens} > {self.trigger_tokens})")
if is_turn_exceeded:
reasons.append(f"工具调用轮数超限 ({tool_turns} > {self.max_turns})")
logger.info(
f"✂️ [MemoryCompression] 触发工具结果修剪策略 | 原因: {' 且 '.join(reasons)}"
)
from zhenxun.services.ai.core.messages import ToolReturnPart
new_messages = []
tools_kept = 0
changed = False
for msg in reversed(messages):
if msg.role != "tool":
new_messages.append(msg)
continue
if tools_kept < self.keep_recent_turns:
tools_kept += 1
new_messages.append(msg)
continue
new_content = []
part_changed = False
for p in msg.content:
if isinstance(p, ToolReturnPart):
old_len = len(str(p.output))
new_p = model_copy(
p,
update={
"output": f"[数据过载自动截断 - 原长度: {old_len} 字符]"
},
)
new_content.append(new_p)
part_changed = True
changed = True
else:
new_content.append(p)
if part_changed:
new_msg = model_copy(
msg, update={"content": new_content, "token_cost": None}
)
new_messages.append(new_msg)
else:
new_messages.append(msg)
if not changed:
return messages, False, current_tokens
new_messages.reverse()
new_total = token_counter.count_context(new_messages, model_name, base_overhead)
return new_messages, True, new_total
class AbstractSummarizerReducer(BaseMemoryReducer):
"""抽象总结压缩器:提取阈值判断与上下文分流的公共逻辑"""
def __init__(
self,
strategy_name: str,
keep_recent_turns: int = 0,
trigger_tokens: int = 4000,
max_turns: int | None = None,
summarization_model: str | None = None,
):
"""
初始化抽象总结压缩基类。
参数:
strategy_name: 压缩策略的名称,用于日志输出和追踪。
keep_recent_turns: 压缩时需要保留的最新的对话轮数(不参与总结的轮数)。
trigger_tokens: 触发总结策略的 Token 阈值上限。
max_turns: 触发总结策略的最大对话轮数上限。
summarization_model: 用于执行总结压缩大模型请求的模型名称,若为 None 则使用默认模型。
""" # noqa: E501
self.strategy_name = strategy_name
self.keep_recent_turns = keep_recent_turns
self.trigger_tokens = trigger_tokens
self.max_turns = max_turns
self.summarization_model = summarization_model
@abstractmethod
async def _execute_summarization(
self, to_summarize: list[LLMMessage], prev_summary: str
) -> LLMMessage | None:
"""由子类实现具体的 LLM 调用逻辑,返回新的总结消息"""
pass
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
user_turns = sum(
1
for m in messages
if m.role == "user"
and not (m.metadata and m.metadata.get("is_summary", False))
)
is_token_exceeded = current_tokens > self.trigger_tokens
is_turn_exceeded = (
self.max_turns is not None
and self.max_turns > 0
and user_turns > self.max_turns
)
if not (is_token_exceeded or is_turn_exceeded):
return messages, False, current_tokens
reasons = []
if is_token_exceeded:
reasons.append(f"Token 预估超限 ({current_tokens} > {self.trigger_tokens})")
if is_turn_exceeded:
reasons.append(f"有效对话轮次超限 ({user_turns} > {self.max_turns})")
logger.info(
f"🔄 [MemoryCompression] 触发{self.strategy_name}策略 | 原因: "
f"{' 且 '.join(reasons)}"
)
pinned_msgs, working_msgs, prev_summary = [], [], ""
for msg in messages:
is_pinned = isinstance(msg, SystemMessage) or (
msg.metadata and msg.metadata.get("pinned", False)
)
if msg.metadata and msg.metadata.get("is_summary", False):
prev_summary = msg.extract_text
elif is_pinned:
pinned_msgs.append(msg)
else:
working_msgs.append(msg)
user_indices = [i for i, m in enumerate(working_msgs) if m.role == "user"]
if len(user_indices) <= self.keep_recent_turns:
return messages, False, current_tokens
split_idx = (
user_indices[-self.keep_recent_turns]
if self.keep_recent_turns > 0
else len(working_msgs)
)
to_summarize = working_msgs[:split_idx]
to_keep = working_msgs[split_idx:]
new_summary_msg = await self._execute_summarization(to_summarize, prev_summary)
if not new_summary_msg:
return messages, False, current_tokens
new_messages = [*pinned_msgs, new_summary_msg, *to_keep]
return (
new_messages,
True,
token_counter.count_context(new_messages, model_name, base_overhead),
)
class LLMSummarizerReducer(AbstractSummarizerReducer):
"""大模型总结压缩器:将较早的历史对话记录通过 LLM 压缩合并为一段文本摘要。"""
def __init__(
self,
keep_recent_turns: int = 0,
trigger_tokens: int = 4000,
max_turns: int | None = None,
summarization_model: str | None = None,
summarization_prompt: str = (
"请概括以下对话内容,保留关键的约束条件、用户偏好、"
"已完成的任务状态和未解决的问题。"
),
):
"""
初始化大模型总结压缩器。
参数:
keep_recent_turns: 压缩时需要保留的最新的对话轮数。
trigger_tokens: 触发总结策略的 Token 阈值上限。
max_turns: 触发总结策略的最大对话轮数上限。
summarization_model: 用于执行总结压缩的大模型名称。
summarization_prompt: 发送给大模型的总结引导 Prompt 提示词。
"""
super().__init__(
strategy_name="历史对话合并总结",
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
)
self.summarization_prompt = summarization_prompt
async def _execute_summarization(
self, to_summarize: list[LLMMessage], prev_summary: str
) -> LLMMessage | None:
prompt_text = f"### 📋 [对话摘要任务]\n{self.summarization_prompt}\n\n"
if prev_summary:
prompt_text += "#### önceki_summary (参考先前的快照):\n"
prompt_text += f"> {prev_summary}\n\n"
prompt_text += "#### 待处理的历史消息流:\n"
for m in to_summarize:
c_str = m.extract_text[:1500]
speaker = m.source_name if m.source_name else m.role.capitalize()
prompt_text += f"[{speaker}]: {c_str}\n"
prompt_text += "</需要合并的旧对话记录>\n"
from zhenxun.services.ai.llm.api import chat
try:
model_to_use = self.summarization_model or get_default_model("chat")
response = await chat(
prompt_text,
model=model_to_use,
instruction="你是后台记忆整理引擎。请客观、简明输出当前对话全局摘要。",
)
new_summary_msg = LLMMessage.assistant_text_response(
f"【历史对话摘要记忆】\n{response.text}"
)
new_summary_msg.metadata = {"is_summary": True, "pinned": True}
return new_summary_msg
except Exception as e:
logger.error(
f"[{self.__class__.__name__}] 压缩总结调用失败,已跳过本次压缩: {e}"
)
return None
_T_Summary = TypeVar("_T_Summary", bound=BaseModel)
class StructuredSummaryReducer(AbstractSummarizerReducer, Generic[_T_Summary]):
"""结构化总结压缩器:基于 JSON Schema 格式化抽取长上下文状态信息并合并"""
def __init__(
self,
response_model: type[_T_Summary],
prompt_template: str,
format_callback: Callable[[_T_Summary], str],
keep_recent_turns: int = 0,
trigger_tokens: int = 4000,
max_turns: int | None = None,
summarization_model: str | None = None,
instruction: str = (
"请提取并合并先前的状态和最新的对话内容,保持精简,不要编造事实"
),
):
"""
初始化结构化总结压缩器。
参数:
response_model: 接收结构化输出的 Pydantic 模型类,需继承自 BaseModel。
prompt_template: 用于抽取合并状态的 Prompt 模板,包含 {prev_summary} 和 {dialogue} 占位符。
format_callback: 格式化回调函数,用于将结构化 Pydantic 响应对象转换为便于大模型阅读的字符串。
keep_recent_turns: 压缩时需要保留的最新的对话轮数。
trigger_tokens: 触发总结策略的 Token 阈值上限。
max_turns: 触发总结策略的最大对话轮数上限。
summarization_model: 用于执行总结压缩的大模型名称。
instruction: 指导大模型生成结构化数据时的系统指令说明。
""" # noqa: E501
super().__init__(
strategy_name="结构化状态抽取压缩",
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
)
self.response_model = response_model
self.prompt_template = prompt_template
self.format_callback = format_callback
self.instruction = instruction
async def _execute_summarization(
self, to_summarize: list[LLMMessage], prev_summary: str
) -> LLMMessage | None:
dialogue_text = ""
for m in to_summarize:
c_str = m.extract_text[:1500]
speaker = m.source_name if m.source_name else m.role.capitalize()
dialogue_text += f"[{speaker}]: {c_str}\n"
prompt_text = self.prompt_template.format(
prev_summary=prev_summary, dialogue=dialogue_text
)
from zhenxun.services.ai.llm.api import generate_structured
try:
model_to_use = self.summarization_model or get_default_model("chat")
summary_obj = await generate_structured(
prompt_text,
response_model=self.response_model,
model=model_to_use,
instruction=self.instruction,
)
summary_text = self.format_callback(summary_obj)
new_summary_msg = LLMMessage.assistant_text_response(
f"【历史状态摘要记忆】\n{summary_text}"
)
new_summary_msg.metadata = {"is_summary": True, "pinned": True}
return new_summary_msg
except Exception as e:
logger.error(
f"[{self.__class__.__name__}] 结构化总结失败,已跳过本次压缩: {e}"
)
return None
class CondenserPipeline:
"""上下文压缩流水线:按顺序依次执行各阶段的记忆压缩减项。"""
def __init__(self, reducers: list[BaseMemoryReducer]):
"""
初始化上下文压缩流水线。
参数:
reducers: 压缩减项器列表,将按顺序对记忆进行多阶段修剪和压缩。
"""
self.reducers = reducers
@classmethod
def create_from_configs(
cls, memory_config: Any, model_name: str
) -> "CondenserPipeline":
"""基于全局和局部配置组装压缩管线工厂方法"""
from zhenxun.services.ai.config import get_llm_config
from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities
config = get_llm_config().context_settings
pipeline_reducers = []
caps = get_model_capabilities(model_name)
vw = config.vision_window_size
if memory_config and memory_config.compression.vision_window is not None:
vw = memory_config.compression.vision_window
if vw > 0:
pipeline_reducers.append(MultimodalPlaceholderReducer(window_size=vw))
tp = config.tool_pruning
if tp.enable:
tp_limit = (
int(caps.max_input_tokens * tp.trigger_threshold)
if tp.trigger_threshold <= 1.0
else int(tp.trigger_threshold)
)
pipeline_reducers.append(
ToolPrunerReducer(
keep_recent_turns=tp.keep_recent_turns,
trigger_tokens=tp_limit,
max_turns=tp.max_history_turns,
)
)
policy = memory_config.compression.policy if memory_config else None
if policy is not None:
pipeline_reducers.extend(policy)
else:
threshold = config.llm_summary.trigger_threshold
if memory_config and memory_config.compression.threshold is not None:
threshold = memory_config.compression.threshold
limit = (
int(caps.max_input_tokens * threshold)
if threshold <= 1.0
else int(threshold)
)
max_turns = config.llm_summary.max_history_turns
if (
memory_config
and memory_config.compression.max_history_turns is not None
):
max_turns = memory_config.compression.max_history_turns
if config.llm_summary.enable:
pipeline_reducers.extend(
MemoryPolicy.llm_summarize(
trigger_tokens=limit,
max_turns=max_turns,
keep_recent_turns=config.llm_summary.keep_recent_turns,
summarization_model=config.llm_summary.summarization_model,
summarization_prompt=config.llm_summary.summarization_prompt,
)
)
else:
pipeline_reducers.extend(MemoryPolicy.unlimited())
return cls(pipeline_reducers)
async def run(
self, messages, model_name, base_overhead=0
) -> tuple[list[LLMMessage], bool]:
current_tokens = token_counter.count_context(
messages, model_name, base_overhead
)
current_messages = messages
any_changed = False
for reducer in self.reducers:
current_messages, changed, current_tokens = await reducer.reduce(
current_messages,
current_tokens,
model_name,
base_overhead,
)
if changed:
any_changed = True
return current_messages, any_changed
class MemoryPolicy:
"""
记忆策略工厂 (Strategy Factory Facade)。
为开发者提供开箱即用的上下文压缩管线组装方案。
"""
@staticmethod
def unlimited() -> list[BaseMemoryReducer]:
"""无限制模式。不进行任何形式的截断和总结,适用于短对话或纯 Agent 内部流转。"""
return []
@staticmethod
def llm_summarize(
trigger_tokens: int = 4000,
max_turns: int | None = None,
keep_recent_turns: int = 0,
summarization_model: str | None = None,
summarization_prompt: str = (
"请概括以下对话内容,保留关键的约束条件、用户偏好、"
"已完成的任务状态和未解决的问题。"
),
) -> list[BaseMemoryReducer]:
"""LLM 总结压缩模式。Token 达标后,自动将历史对话合并为一段 Summary。"""
return [
LLMSummarizerReducer(
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
summarization_prompt=summarization_prompt,
),
MessageDropper(trigger_tokens=trigger_tokens),
]
@staticmethod
def structured_summarize(
trigger_tokens: int = 4000,
max_turns: int | None = None,
keep_recent_turns: int = 0,
summarization_model: str | None = None,
response_model: type[BaseModel] | None = None,
prompt_template: str | None = None,
format_callback: Callable[[Any], str] | None = None,
) -> list[BaseMemoryReducer]:
"""结构化总结压缩模式。使用 JSON Schema 强制大模型提取核心状态。"""
class DefaultStateSummary(BaseModel):
user_context: str = Field(
description="用户的核心意图、诉求、人设或长期记忆规则。"
)
completed_tasks: str = Field(description="已完成的操作或已经确认的情节。")
pending_tasks: str = Field(description="正在进行中的任务或尚未解答的问题。")
current_state: str = Field(
description="当前状态,如重要变量、玩家血量、关键物品坐标等。"
)
def default_format(obj: DefaultStateSummary) -> str:
return (
f"👤 用户上下文: {obj.user_context}\n"
f"✅ 已完成/确认: {obj.completed_tasks}\n"
f"⏳ 待处理/疑问: {obj.pending_tasks}\n"
f"📌 当前状态: {obj.current_state}"
)
default_prompt = (
"你是一个专门用于长上下文状态压缩的引擎。请阅读以下先前的总结和旧对话,"
"提取核心状态信息,并合并它们。\n\n"
"<之前的状态摘要>\n{prev_summary}\n</之前的状态摘要>\n\n"
"<需要合并的旧对话记录>\n"
"{dialogue}"
"</需要合并的旧对话记录>\n"
)
return [
StructuredSummaryReducer(
response_model=response_model or DefaultStateSummary,
prompt_template=prompt_template or default_prompt,
format_callback=format_callback or default_format,
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
),
MessageDropper(trigger_tokens=trigger_tokens),
]
@@ -0,0 +1,260 @@
from collections.abc import Sequence
from typing import Any, cast
from zhenxun.services.ai.context.memory.compression import (
CondenserPipeline,
)
from zhenxun.services.ai.context.memory.manager import memory_manager
from zhenxun.services.ai.context.memory.models import MemoryConfig
from zhenxun.services.ai.context.memory.types import SessionMetadata
from zhenxun.services.ai.core.engine.context_renderer import ContextConverter
from zhenxun.services.ai.core.messages import AgentMessage
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_copy
class MemoryReader:
"""
记忆读取器 (Memory Reader)。
负责从数据库中提取短期上下文历史,召回长期的背景知识,并执行自动压缩。
"""
def __init__(
self, session_meta: SessionMetadata, memory_config: MemoryConfig | None
):
"""
初始化记忆读取器。
参数:
session_meta: 会话元数据,包含 Namespace 与作用域映射等上下文信息。
memory_config: 记忆系统的配置对象,控制长期、短期及槽位记忆的启用与逻辑。
"""
self.session_meta = session_meta
self.memory_config = memory_config
async def get_long_term_context(self, user_input: str) -> str:
"""
基于用户输入召回长期记忆(RAG),返回格式化后的背景提示词。
"""
if (
not self.memory_config
or not self.memory_config.long_term.enable
or not user_input
):
return ""
policy = self.memory_config.long_term.auto_recall
should_recall = False
if isinstance(policy, bool):
should_recall = policy
elif callable(policy):
import inspect
try:
res = policy(user_input, self.session_meta)
if inspect.isawaitable(res):
should_recall = await res
else:
should_recall = bool(res)
except Exception as e:
logger.error(f"[MemoryReader] 自定义 auto_recall 函数执行失败: {e}")
should_recall = False
if not should_recall:
return ""
ltm_scope = memory_manager.get_long_term_memory(
self.memory_config,
namespace=self.session_meta.selector.namespace or "global",
)
if not ltm_scope:
return ""
matches = await ltm_scope.recall(session=self.session_meta, query=user_input)
if matches:
logger.debug(f"🧠 [MemoryReader] 长期记忆召回详情 (Query: '{user_input}'):")
for i, m in enumerate(matches):
logger.debug(
f" [{i + 1}] 得分: {m.score:.4f} | 内容: {m.record.content}"
)
threshold = self.memory_config.long_term.recall_threshold
valid_matches = [m for m in matches if m.score >= threshold]
if not valid_matches:
logger.debug("🧠 [MemoryReader] 召回的记忆均未达到相关性阈值,已丢弃。")
return ""
fact_str = "\n".join(f"- {m.record.content}" for m in valid_matches)
logger.debug(
f"🧠 [MemoryReader]"
f"成功截取并注入 {len(valid_matches)} 条高价值长期记忆。"
)
return f"[系统补充:有关用户的长期记忆设定]\n{fact_str}"
return ""
async def get_slots_context(self) -> str:
"""
读取并组装核心槽位记忆 (Memory Slots),返回 XML 格式字符串供大模型使用。
"""
if not self.memory_config or not self.memory_config.slots.enable:
return ""
slot_ctx = memory_manager.get_slot_context(
self.memory_config,
namespace=self.session_meta.selector.namespace or "global",
)
if not slot_ctx:
return ""
if self.memory_config.slots.default_slots:
for default_slot in self.memory_config.slots.default_slots:
existing = await slot_ctx.get_slot(
self.session_meta, default_slot.label
)
if not existing:
await slot_ctx.set_slot(self.session_meta, default_slot)
slots = await slot_ctx.list_pinned_slots(self.session_meta)
if not slots:
return ""
show_scope = False
if (
self.memory_config
and self.memory_config.slots.scopes
and len(self.memory_config.slots.scopes) > 1
):
show_scope = True
xml_parts = ["<memory_slots>"]
for slot in slots:
if show_scope:
semantic_name = self.session_meta.scope_name_mapping.get(
slot.scope, "未知"
)
xml_parts.append(
f' <slot name="{slot.label}" scope="{semantic_name}">\n'
f" {slot.content}\n"
" </slot>"
)
else:
xml_parts.append(
f' <slot name="{slot.label}">\n {slot.content}\n </slot>'
)
xml_parts.append("</memory_slots>")
return "\n".join(xml_parts)
async def get_short_term_context(
self,
model_name: str,
override_history: Sequence[AgentMessage] | None = None,
) -> list[AgentMessage]:
"""
拉取短期对话历史,并执行 Token 压缩。
"""
current_history: list[AgentMessage] = []
if override_history is not None:
current_history = list(override_history)
chat_context = memory_manager.get_chat_context(
self.memory_config,
namespace=self.session_meta.selector.namespace or "global",
)
if self.memory_config and self.memory_config.short_term.enable and chat_context:
if override_history is not None:
flattened_override = ContextConverter.flatten_to_llm_messages(
override_history
)
await chat_context.set_messages(self.session_meta, flattened_override)
else:
current_history = cast(
list[AgentMessage],
await chat_context.get_messages(self.session_meta),
)
pipeline = CondenserPipeline.create_from_configs(
self.memory_config, model_name
)
if pipeline.reducers:
flattened_to_reduce = ContextConverter.flatten_to_llm_messages(
current_history
)
new_history, changed = await pipeline.run(
flattened_to_reduce, model_name=model_name, base_overhead=0
)
if changed:
await chat_context.set_messages(self.session_meta, new_history)
logger.info(
"💾 [MemoryReader] 压缩截断完毕,已同步覆写数据库。"
f"压缩后条数: {len(new_history)}"
)
current_history = cast(list[AgentMessage], new_history)
return current_history
class MemoryWriter:
"""
记忆写入器 (Memory Writer)。
负责将对话增量安全地写入数据库。
"""
def __init__(
self,
session_meta: SessionMetadata,
memory_config: MemoryConfig | None,
context: Any = None,
):
"""
初始化记忆写入器。
参数:
session_meta: 会话元数据,包含 Namespace 与作用域映射等上下文信息。
memory_config: 记忆系统的配置对象,控制记忆存入的逻辑。
context: 运行时上下文环境,作为可选参数传入,供中间件使用,默认 None。
"""
self.session_meta = session_meta
self.memory_config = memory_config
self.context = context
async def save_new_messages(
self,
new_messages: Sequence[AgentMessage],
):
"""将新产生的对话增量保存到数据库"""
if not new_messages:
return
messages_to_save = new_messages
if self.memory_config and self.memory_config.ingestion.middlewares:
messages_to_save = [model_copy(m, deep=True) for m in new_messages]
for middleware in self.memory_config.ingestion.middlewares:
try:
messages_to_save = await middleware.process(
messages_to_save, self.context
)
except Exception as e:
logger.error(
f"[MemoryIngestion] 中间件 {middleware.__class__.__name__} "
f"执行失败: {e}",
e=e,
)
if not messages_to_save:
return
chat_ctx = memory_manager.get_chat_context(
self.memory_config,
namespace=self.session_meta.selector.namespace or "global",
)
flattened_msgs = ContextConverter.flatten_to_llm_messages(
messages_to_save, self.context
)
if chat_ctx and self.memory_config and self.memory_config.short_term.enable:
if flattened_msgs:
await chat_ctx.add_messages(self.session_meta, flattened_msgs)
@@ -0,0 +1,128 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING, Literal
from zhenxun.services.ai.context.memory.types import (
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage
if TYPE_CHECKING:
from zhenxun.services.ai.context.memory.manager import GlobalMemoryManager
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseChatContext,
BaseSlotContext,
)
class ChatHistoryFacade:
"""短期对话历史门面"""
def __init__(self, manager: "GlobalMemoryManager", session_meta: SessionMetadata):
self.manager = manager
self.session_meta = session_meta
@property
def _backend(self) -> "BaseChatContext | None":
return self.manager.get_chat_context(
None, self.session_meta.namespace or "global"
)
async def get(self, limit: int | None = None) -> list[LLMMessage]:
"""获取当前会话的历史消息"""
if not self._backend:
return []
msgs = await self._backend.get_messages(self.session_meta)
return msgs[-limit:] if limit else msgs
async def add(self, messages: Sequence[AgentMessage] | AgentMessage) -> None:
"""向当前会话追加一条或多条历史消息"""
if not self._backend:
return
from zhenxun.services.ai.core.engine.context_renderer import ContextConverter
msgs = messages if isinstance(messages, Sequence) else [messages]
flattened = ContextConverter.flatten_to_llm_messages(msgs)
if flattened:
await self._backend.add_messages(self.session_meta, flattened)
async def clear(self) -> None:
"""清空当前会话的短期对话历史"""
if not self._backend:
return
await self._backend.clear(self.session_meta)
class SlotFacade:
"""中期记忆槽门面"""
def __init__(self, manager: "GlobalMemoryManager", session_meta: SessionMetadata):
self.manager = manager
self.session_meta = session_meta
@property
def _backend(self) -> "BaseSlotContext | None":
"""获取底层槽位存储后端"""
return self.manager.get_slot_context(
None, self.session_meta.namespace or "global"
)
async def get(self, label: str) -> str | None:
"""获取指定标识的槽位记忆内容"""
if not self._backend:
return None
slot = await self._backend.get_slot(self.session_meta, label)
return slot.content if slot else None
async def set(
self,
label: str,
content: str,
scope: Literal["session", "global"] = "session",
size_limit: int = 2000,
pinned: bool = True,
) -> None:
"""设置或更新指定的槽位记忆"""
if not self._backend:
return
slot = MemorySlot(
label=label,
content=content,
scope=scope,
size_limit=size_limit,
pinned=pinned,
)
await self._backend.set_slot(self.session_meta, slot)
async def delete(
self, label: str, scope: Literal["session", "global"] = "session"
) -> None:
"""删除指定的槽位记忆"""
if not self._backend:
return
await self._backend.delete_slot(self.session_meta, label, scope)
async def list_all(self) -> dict[str, str]:
"""获取当前会话所有被置顶的槽位记忆"""
if not self._backend:
return {}
slots = await self._backend.list_pinned_slots(self.session_meta)
return {s.label: s.content for s in slots}
class AgentSessionFacade:
"""
提供给第三方开发者的会话记忆访问聚合门面 (Facade)。
"""
def __init__(self, manager: "GlobalMemoryManager", session_meta: SessionMetadata):
self.manager = manager
self.session_meta = session_meta
self.history = ChatHistoryFacade(manager, session_meta)
self.slots = SlotFacade(manager, session_meta)
async def clear_all(self) -> None:
"""一键清空当前会话下的短期对话历史与记忆槽"""
cleaner = self.manager.cleaner().session(self.session_meta.session_id)
await cleaner.clear_short_term()
await cleaner.clear_slots()
@@ -0,0 +1,211 @@
from collections.abc import Callable
from typing import Any, cast
from zhenxun.services.ai.context.memory.models import MemoryConfig
from zhenxun.services.ai.context.memory.storage.backends import (
InMemoryChatContext,
MemoryScope,
)
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseChatContext,
BaseSlotContext,
)
from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend
from zhenxun.services.ai.utils.scope import BaseScopeBuilder
from zhenxun.utils.utils import infer_plugin_namespace
class MemoryCleaner(BaseScopeBuilder["MemoryCleaner"]):
"""
声明式记忆清理构建器 (Query Builder)。
为第三方开发者提供极端友好的链式 API,彻底屏蔽底层前缀逻辑。
"""
def __init__(self, manager: "GlobalMemoryManager"):
super().__init__()
self.manager = manager
self._config: Any = None
def config(self, cfg: Any):
"""指定私有记忆配置(自动识别未全局注册 of 第三方私有数据库实例)"""
self._config = cfg.build() if hasattr(cfg, "build") else cfg
return self
async def clear_short_term(self):
"""一键清理目标范围下的短期对话历史记忆"""
if self._config and self._config.short_term.backend:
await self._config.short_term.backend.clear_by_query(self._selector)
else:
for backend in self.manager._chat_backends.values():
await backend.clear_by_query(self._selector)
async def clear_slots(self):
"""一键清理目标范围下的中期记忆槽 (Memory Slots)"""
if self._config and self._config.slots.backend:
await self._config.slots.backend.clear_by_query(self._selector)
else:
for backend in self.manager._slot_backends.values():
await backend.clear_by_query(self._selector)
async def clear_long_term(self):
"""一键清理目标范围下的长期向量记忆 (RAG Vector Database)"""
if self._config and self._config.long_term.backend:
from zhenxun.services.ai.context.rag.backends import StorageBackend
storage = cast(StorageBackend, self._config.long_term.backend)
await storage.clear_by_query(self._selector)
else:
for factory in self.manager._storage_factories.values():
storage = factory()
if hasattr(storage, "clear_by_query"):
await storage.clear_by_query(self._selector)
else:
await storage.delete(scope_prefix=self._selector.scope_prefix)
async def clear_all(self):
"""一键清理指定范围下的所有生命周期记忆(对话、槽位、RAG)"""
from zhenxun.services.log import logger
await self.clear_short_term()
await self.clear_slots()
await self.clear_long_term()
logger.info(
f"🧹 [MemoryCleaner] 成功清理作用域 '{self._selector.scope_prefix}'"
"下的所有记忆痕迹!"
)
class GlobalMemoryManager:
"""
全局记忆大管家 (IoC 容器)。
使用现代化依赖注入机制管理短/长期记忆引擎的默认实例。
"""
def __init__(self):
self._chat_backends: dict[str, BaseChatContext] = {
"global": InMemoryChatContext()
}
self._slot_backends: dict[str, BaseSlotContext] = {}
from zhenxun.services.ai.context.rag.backends import DictStorageBackend
self._storage_factories: dict[str, Callable[[], StorageBackend]] = {
"global": lambda: DictStorageBackend()
}
def register_chat_backend(
self, backend: BaseChatContext, scope: str | None = None
) -> None:
"""注册特定命名空间的短期记忆存储后端。"""
ns = scope if scope is not None else infer_plugin_namespace()
self._chat_backends[ns] = backend
def register_slot_backend(
self, backend: BaseSlotContext, scope: str | None = None
) -> None:
"""注册特定命名空间的中期记忆槽存储后端。"""
ns = scope if scope is not None else infer_plugin_namespace()
self._slot_backends[ns] = backend
def register_storage_factory(
self, factory: Callable[[], StorageBackend], scope: str | None = None
) -> None:
"""注册特定命名空间的长期记忆向量存储工厂。"""
ns = scope if scope is not None else infer_plugin_namespace()
self._storage_factories[ns] = factory
def cleaner(self) -> MemoryCleaner:
"""获取声明式记忆清理构建器,供第三方开发者极速清理指定记忆"""
return MemoryCleaner(self)
def get_embedder(self, embedder_val: "Embedder | str | None") -> Embedder | None:
"""获取向量化引擎实例。如果传入的是字符串,则视为 API 模型名称。"""
if not embedder_val:
return None
if isinstance(embedder_val, str):
from zhenxun.services.ai.context.rag.backends.embedders import (
DefaultEmbedder,
)
return DefaultEmbedder(model_name=embedder_val)
return embedder_val
def get_chat_context(
self, config: MemoryConfig | None, namespace: str = "global"
) -> BaseChatContext | None:
"""根据配置分配对应的短期对话历史实例"""
if not config or not config.short_term.enable:
return None
backend_cfg = config.short_term.backend
if backend_cfg is not None:
return cast(BaseChatContext, backend_cfg)
return self._chat_backends.get(namespace) or self._chat_backends["global"]
def get_slot_context(
self, config: MemoryConfig | None, namespace: str = "global"
) -> BaseSlotContext | None:
"""根据配置分配对应的槽位记忆实例"""
if not config or not config.slots.enable:
return None
backend_cfg = config.slots.backend
if backend_cfg is not None:
return cast(BaseSlotContext, backend_cfg)
return self._slot_backends.get(namespace) or self._slot_backends["global"]
def get_long_term_memory(
self, config: MemoryConfig | None, namespace: str = "global"
) -> MemoryScope | None:
"""根据声明式配置动态组装长期向量记忆实例"""
if not config or not config.long_term.enable:
return None
if config.long_term.engine is not None:
return MemoryScope(
rag_client=config.long_term.engine,
)
storage_instance = None
backend_cfg = config.long_term.backend
if backend_cfg is not None:
storage_instance = cast(StorageBackend, backend_cfg)
else:
factory = (
self._storage_factories.get(namespace)
or self._storage_factories["global"]
)
storage_instance = factory()
embedder = self.get_embedder(config.long_term.embedder)
from zhenxun.services.ai.context.rag.builder import RAGBuilder
builder = RAGBuilder(storage_instance).with_scope("/")
if embedder:
builder.with_embedder(embedder)
from zhenxun.services.ai.context.memory.models import MemoryScoringConfig
scoring_cfg = MemoryScoringConfig()
builder.enable_lifecycle_scoring(
half_life_days=scoring_cfg.recency_half_life_days,
decay_weight=scoring_cfg.recency_weight,
semantic_weight=scoring_cfg.semantic_weight,
importance_weight=scoring_cfg.importance_weight,
reinforcement_weight=scoring_cfg.reinforcement_weight,
)
client = builder.build()
return MemoryScope(
rag_client=client,
)
memory_manager = GlobalMemoryManager()
@@ -0,0 +1,173 @@
"""
记忆域类型定义
"""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseChatContext,
BaseMemoryIngestionMiddleware,
BaseMemoryReducer,
BaseSlotContext,
)
from zhenxun.services.ai.context.memory.types import (
AutoRecallPolicy,
Isolation,
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend
from zhenxun.services.ai.context.rag.engine import ScopedRAGClient
from zhenxun.services.ai.utils.scope import ScopeBuilder
class SlotMemoryConfig(BaseModel):
"""槽位记忆 (Memory Slots) 配置"""
model_config = ConfigDict(arbitrary_types_allowed=True)
enable: bool = Field(default=False)
"""是否启用中期记忆槽"""
scopes: dict[str, ScopeBuilder] | None = Field(default=None)
"""语义化作用域映射字典,供大模型作为 Literal 选择。如果只有一个,则自动隐藏参数"""
default_slots: list[MemorySlot] = Field(default_factory=list)
"""首次初始化时自动写入的默认槽位列表"""
backend: str | BaseSlotContext | None = Field(default=None)
"""
指定底层槽位记忆数据库注册名称,或直接传入 BaseSlotContext 实例。
为空则使用全局默认
"""
instructions: str | None = Field(default=None)
"""覆写内置槽位管理工具箱的系统提示词"""
toolkit_kwargs: dict[str, Any] = Field(default_factory=dict)
"""透传给底层 MemorySlotToolkit 的高级参数 (如 prefix, exclude, shared_options)"""
class MemoryScoringConfig(BaseModel):
"""长期记忆的复合打分与检索配置"""
recency_weight: float = Field(default=0.3)
"""时间衰减权重"""
semantic_weight: float = Field(default=0.5)
"""语义相似度权重"""
importance_weight: float = Field(default=0.2)
"""重要性权重"""
recency_half_life_days: int = Field(default=30)
"""时间衰减的半衰期(天)"""
reinforcement_weight: float = Field(default=0.2)
"""访问强化的加权权重 (被检索越多得分越高)"""
class ShortTermConfig(BaseModel):
"""短期对话记忆配置"""
model_config = ConfigDict(arbitrary_types_allowed=True)
enable: bool = Field(default=True)
"""是否启用短期对话记忆上下文"""
backend: str | BaseChatContext | None = Field(default=None)
"""
指定底层短期记忆数据库注册名称,或直接传入 BaseChatContext 实例。
为空则使用全局默认
"""
isolation: ScopeBuilder = Field(default_factory=Isolation.AGENT_USER)
"""单一的记忆隔离级别 (ScopeBuilder),决定短期记忆存储边界"""
class LongTermConfig(BaseModel):
"""长期向量记忆配置"""
model_config = ConfigDict(arbitrary_types_allowed=True)
enable: bool = Field(default=False)
"""是否启用长期记忆(开启后自动赋予 Agent 存取记忆的工具,并附加 RAG 召回能力)"""
engine: ScopedRAGClient | None = Field(default=None)
"""
[推荐] 指定底层的高级 RAG 检索引擎实例。若传入此项,将覆盖默认的 backend
和 embedder 配置。
"""
backend: str | StorageBackend | None = Field(default=None)
"""
指定底层长期向量数据库 (Storage) 注册名称,或直接传入 StorageBackend 实例。
为空则使用全局默认
"""
scopes: dict[str, ScopeBuilder] | None = Field(default=None)
"""语义化作用域映射字典,决定长期记忆存储边界。如果只有一个,则自动隐藏参数"""
embedder: str | Embedder | None = Field(default=None)
"""
指定底层向量化引擎 (Embedder) 实例,若为字符串则视为 API 模型名称。
为空则使用全局默认
"""
agentic: bool = Field(default=True)
"""是否赋予大模型主动管理记忆的能力 (Agentic Memory)"""
auto_recall: AutoRecallPolicy = Field(default=False)
"""长期记忆的自动召回策略,默认 False (从不自动召回),由大模型自主
决定调用搜索工具"""
recall_threshold: float = Field(default=0.5)
"""长期记忆召回的最低余弦相似度要求"""
instructions: str | None = Field(default=None)
"""覆写内置长期记忆管理工具箱的系统提示词"""
toolkit_kwargs: dict[str, Any] = Field(default_factory=dict)
"""透传给底层 MemoryManagementToolkit 的高级参数"""
class ContextCompressionConfig(BaseModel):
"""上下文压缩与管理配置"""
model_config = ConfigDict(arbitrary_types_allowed=True)
threshold: float | None = Field(default=None)
"""(局部重写) 触发记忆压缩的 Token 阈值"""
max_history_turns: int | None = Field(default=None)
"""(局部重写) 触发记忆压缩的对话轮数上限。设为 0 表示不限制轮数。"""
vision_window: int | None = Field(default=None)
"""多模态滑动窗口大小。0表示关闭该功能,>0表示仅保留最近N轮包含多模态数据的消息,None表示跟随全局配置。"""
policy: list[BaseMemoryReducer] | None = Field(default=None)
"""核心记忆压缩策略管线 (List[BaseMemoryReducer])。为 None 时将应用全局默认策略。"""
class IngestionConfig(BaseModel):
"""记忆入库管线配置"""
model_config = ConfigDict(arbitrary_types_allowed=True)
middlewares: list[BaseMemoryIngestionMiddleware] = Field(default_factory=list)
"""入库中间件列表(按顺序依次执行清洗过滤)"""
class MemoryConfig(BaseModel):
"""统一的记忆配置项声明"""
model_config = ConfigDict(arbitrary_types_allowed=True)
base_isolation: ScopeBuilder = Field(default_factory=Isolation.AGENT_USER)
"""顶层基准隔离级别,短期/中期/长期记忆将默认继承此级别"""
short_term: ShortTermConfig = Field(default_factory=ShortTermConfig)
"""短期对话记忆配置"""
slots: SlotMemoryConfig = Field(default_factory=SlotMemoryConfig)
"""槽位记忆配置"""
long_term: LongTermConfig = Field(default_factory=LongTermConfig)
"""长期向量记忆配置"""
compression: ContextCompressionConfig = Field(
default_factory=ContextCompressionConfig
)
"""上下文压缩与管理配置"""
ingestion: IngestionConfig = Field(default_factory=IngestionConfig)
"""记忆入库前的清洗与过滤管线配置"""
__all__ = [
"AutoRecallPolicy",
"BaseMemoryIngestionMiddleware",
"ContextCompressionConfig",
"IngestionConfig",
"Isolation",
"LongTermConfig",
"MemoryConfig",
"MemoryScoringConfig",
"SessionMetadata",
"ShortTermConfig",
]
@@ -0,0 +1,21 @@
from .backends import (
AbstractMemoryRecord,
AbstractSlotRecord,
InMemoryChatContext,
MemoryScope,
TortoiseChatContext,
TortoiseSlotContext,
get_orm_chat_context,
get_orm_slot_context,
)
__all__ = [
"AbstractMemoryRecord",
"AbstractSlotRecord",
"InMemoryChatContext",
"MemoryScope",
"TortoiseChatContext",
"TortoiseSlotContext",
"get_orm_chat_context",
"get_orm_slot_context",
]
@@ -0,0 +1,465 @@
import asyncio
import base64
from collections.abc import Callable
import datetime
import time
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from zhenxun.services.ai.context.rag.engine import ScopedRAGClient
from nonebot.utils import is_coroutine_callable
from tortoise import fields
from tortoise.timezone import now
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseChatContext,
BaseSlotContext,
)
from zhenxun.services.ai.context.memory.types import (
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.context.rag.models import BaseRecord, SearchResult
from zhenxun.services.ai.core.messages import (
AssistantMessage,
LLMContentPart,
LLMMessage,
SystemMessage,
ToolMessage,
UserMessage,
)
from zhenxun.services.ai.utils.scope import ScopeSelector
from zhenxun.services.db_context import Model
from zhenxun.utils.pydantic_compat import TypeAdapter, model_dump
class DBMessageSerializer:
"""将 LLMMessage 与数据库 JSON 格式进行序列化/反序列化的帮助类"""
@staticmethod
def deserialize_content(content_raw: Any) -> list[LLMContentPart]:
from zhenxun.services.ai.core.messages import TextPart
content_parts: list[LLMContentPart] = []
if isinstance(content_raw, list):
adapter = TypeAdapter(LLMContentPart)
for p in content_raw:
if isinstance(p, dict):
for k in list(p.keys()):
if k.startswith("_is_b64_"):
orig_k = k[8:]
if orig_k in p and isinstance(p[orig_k], str):
p[orig_k] = base64.b64decode(p[orig_k])
p.pop(k, None)
content_parts.append(adapter.validate_python(p))
elif isinstance(content_raw, str):
content_parts.append(TextPart(text=content_raw))
return content_parts
@staticmethod
def serialize_content(content_payload: Any) -> list[dict[str, Any]]:
from pathlib import Path
if isinstance(content_payload, str):
return [{"type": "text", "text": content_payload}]
elif isinstance(content_payload, list):
processed_content = []
for p in content_payload:
p_dump = (
model_dump(p, exclude_none=True)
if hasattr(p, "model_dump")
else (p.copy() if isinstance(p, dict) else p)
)
if isinstance(p_dump, dict):
for k, v in list(p_dump.items()):
if isinstance(v, bytes):
p_dump[k] = base64.b64encode(v).decode("utf-8")
p_dump[f"_is_b64_{k}"] = True
elif isinstance(v, Path):
p_dump[k] = str(v)
processed_content.append(p_dump)
return (
processed_content
if processed_content
else [
{"type": "text", "text": "[仅包含思维链或工具调度,无实质文本输出]"}
]
)
return []
class MemoryScope:
"""长期记忆的作用域视图与 RAG 管线。"""
def __init__(
self,
rag_client: "ScopedRAGClient",
):
self.rag_client = rag_client
self._background_tasks: set[Any] = set()
async def remember(
self,
session: SessionMetadata,
content: str,
importance: float = 0.5,
metadata: dict[str, Any] | None = None,
) -> None:
"""通过 RAG Ingestion Pipeline 完成记忆落盘"""
meta = metadata.copy() if metadata else {}
meta.update(
{
"scope": session.scope_prefix,
"importance": importance,
"created_at": time.time(),
}
)
record = BaseRecord(content=content, metadata=meta)
await self.rag_client.ingest([record])
async def recall(
self,
session: SessionMetadata,
query: str,
limit: int = 10,
metadata_filter: dict[str, Any] | None = None,
) -> list[SearchResult]:
"""委托至 Retriever 检索与重排,并触发读时惰性强化"""
matches = await self.rag_client.search(
query=query,
limit=limit,
scopes=session.accessible_scopes,
metadata_filters=metadata_filter,
)
if matches:
task = asyncio.create_task(
self._reinforce_memories([m.record for m in matches])
)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return matches
async def update(
self,
session: SessionMetadata,
record_id: str,
new_content: str,
importance: float = 0.5,
metadata: dict[str, Any] | None = None,
) -> bool:
"""原子更新:通过先删后插,确保底层向量(Embedding)能根据新文本被正确刷新"""
deleted_count = await self.forget(session, record_ids=[record_id])
if deleted_count > 0:
await self.remember(
session=session,
content=new_content,
importance=importance,
metadata=metadata,
)
return True
return False
async def forget(
self, session: SessionMetadata, record_ids: list[str] | None = None
) -> int:
return await self.rag_client.delete(
record_ids=record_ids,
)
async def _reinforce_memories(self, records: list[BaseRecord]):
import time
now = time.time()
for r in records:
r.metadata["access_count"] = r.metadata.get("access_count", 0) + 1
r.metadata["last_accessed_at"] = now
await self.rag_client.storage.update(r)
class InMemoryChatContext(BaseChatContext):
def __init__(self):
self._messages: dict[str, list[LLMMessage]] = {}
async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]:
return list(self._messages.get(session.session_id, []))
async def search(
self, query: str, session: SessionMetadata, limit: int = 10
) -> list[LLMMessage]:
results = []
for msg in self._messages.get(session.session_id, []):
if query in msg.extract_text:
results.append(msg)
if len(results) >= limit:
break
return results
async def add_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
if session.session_id not in self._messages:
self._messages[session.session_id] = []
self._messages[session.session_id].extend(messages)
async def set_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
self._messages[session.session_id] = list(messages)
async def clear(self, session: SessionMetadata) -> None:
self._messages.pop(session.session_id, None)
async def clear_by_query(self, query: ScopeSelector) -> None:
"""内存级:前缀匹配清理所有符合要求的短期会话"""
scope_prefix = query.scope_prefix
keys_to_delete = [
sid for sid in self._messages.keys() if sid.startswith(scope_prefix)
]
for sid in keys_to_delete:
self._messages.pop(sid, None)
class AbstractMemoryRecord(Model):
"""Tortoise ORM 短期记忆持久化基类 (Mixin)。"""
id = fields.UUIDField(pk=True, description="主键")
session_id = fields.CharField(max_length=255, index=True)
role = fields.CharField(max_length=32)
content = fields.JSONField()
api_context = fields.JSONField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
metadata = fields.JSONField(null=True)
class Meta: # type: ignore
abstract = True
class TortoiseChatContext(BaseChatContext):
def __init__(
self,
model_class: type[AbstractMemoryRecord],
custom_save_hook: Callable[
[AbstractMemoryRecord, LLMMessage, SessionMetadata], Any
]
| None = None,
):
self.model_class = model_class
self.custom_save_hook = custom_save_hook
def _row_to_message(self, row: AbstractMemoryRecord) -> LLMMessage:
content_parts = DBMessageSerializer.deserialize_content(row.content)
metadata: dict[str, Any] | None = (
row.metadata if isinstance(row.metadata, dict) else None
)
kwargs = {
"content": content_parts,
"metadata": metadata,
"created_at": row.created_at.timestamp() if row.created_at else time.time(),
}
role = row.role
if role == "system":
return cast(LLMMessage, SystemMessage(**kwargs))
elif role == "user":
return cast(LLMMessage, UserMessage(**kwargs))
elif role == "assistant":
return cast(LLMMessage, AssistantMessage(**kwargs))
elif role == "tool":
return cast(LLMMessage, ToolMessage(**kwargs))
return cast(LLMMessage, LLMMessage(role=role, **kwargs))
async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]:
rows = (
await self.model_class.filter(session_id=session.session_id)
.order_by("created_at")
.all()
)
return [self._row_to_message(row) for row in rows]
async def search(
self, query: str, session: SessionMetadata, limit: int = 10
) -> list[LLMMessage]:
rows = (
await self.model_class.filter(
session_id=session.session_id, content__icontains=query
)
.order_by("-created_at")
.limit(limit)
.all()
)
return [self._row_to_message(row) for row in reversed(rows)]
async def add_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
if not messages:
return
base_time = now()
last_msg = (
await self.model_class.filter(session_id=session.session_id)
.order_by("-created_at")
.first()
)
if last_msg and last_msg.created_at and last_msg.created_at >= base_time:
base_time = last_msg.created_at + datetime.timedelta(milliseconds=10)
orm_objects = []
for i, msg in enumerate(messages):
content_payload = DBMessageSerializer.serialize_content(msg.content)
msg_time = base_time + datetime.timedelta(milliseconds=i * 10)
orm_obj = self.model_class(
session_id=session.session_id,
role=msg.role,
content=content_payload,
api_context=None,
metadata=msg.metadata,
created_at=msg_time,
)
if self.custom_save_hook:
if is_coroutine_callable(self.custom_save_hook):
await self.custom_save_hook(orm_obj, msg, session)
else:
self.custom_save_hook(orm_obj, msg, session)
orm_objects.append(orm_obj)
if orm_objects:
await self.model_class.bulk_create(orm_objects)
async def set_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
await self.clear(session)
await self.add_messages(session, messages)
async def clear(self, session: SessionMetadata) -> None:
await self.model_class.filter(session_id=session.session_id).delete()
async def clear_by_query(self, query: ScopeSelector) -> None:
"""ORM 级:利用数据库 startswith 原生语法批量级联删除短期记忆"""
scope_prefix = query.scope_prefix
await self.model_class.filter(session_id__startswith=scope_prefix).delete()
def get_orm_chat_context(
model_class: type[AbstractMemoryRecord],
custom_save_hook: Callable[[AbstractMemoryRecord, LLMMessage, SessionMetadata], Any]
| None = None,
) -> TortoiseChatContext:
"""
[工厂方法] 供第三方开发者调用,
将 Tortoise ORM 表直接包装为对话历史记录系统。
"""
return TortoiseChatContext(
model_class=model_class, custom_save_hook=custom_save_hook
)
class AbstractSlotRecord(Model):
"""Tortoise ORM 记忆槽持久化基类 (Mixin)。"""
id = fields.CharField(
pk=True, max_length=128, description="复合主键: session_id + label"
)
session_id = fields.CharField(max_length=255, index=True)
label = fields.CharField(max_length=64, index=True)
content = fields.TextField()
size_limit = fields.IntField(default=2000)
pinned = fields.BooleanField(default=True)
scope = fields.CharField(max_length=255)
description = fields.CharField(max_length=255, default="")
created_at = fields.FloatField()
updated_at = fields.FloatField()
class Meta: # type: ignore
abstract = True
class TortoiseSlotContext(BaseSlotContext):
def __init__(self, model_class: type[AbstractSlotRecord]):
self.model_class = model_class
def _row_to_slot(self, row: AbstractSlotRecord) -> MemorySlot:
return MemorySlot(
label=row.label,
content=row.content,
size_limit=row.size_limit,
pinned=row.pinned,
scope=row.scope,
description=row.description,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def get_slot(self, session: SessionMetadata, label: str) -> MemorySlot | None:
rows = await self.model_class.filter(
session_id__in=session.accessible_scopes, label=label
).all()
row_map = {r.session_id: r for r in rows}
for scope in reversed(session.accessible_scopes):
if scope in row_map:
return self._row_to_slot(row_map[scope])
return None
async def set_slot(self, session: SessionMetadata, slot: MemorySlot) -> None:
composite_id = f"{slot.scope}_{slot.label}"
await self.model_class.update_or_create(
id=composite_id,
defaults={
"session_id": slot.scope,
"label": slot.label,
"content": slot.content,
"size_limit": slot.size_limit,
"pinned": slot.pinned,
"scope": slot.scope,
"description": slot.description,
"created_at": slot.created_at,
"updated_at": slot.updated_at,
},
)
async def delete_slot(
self, session: SessionMetadata, label: str, scope: str
) -> None:
composite_id = f"{scope}_{label}"
await self.model_class.filter(id=composite_id).delete()
async def list_pinned_slots(self, session: SessionMetadata) -> list[MemorySlot]:
rows = await self.model_class.filter(
session_id__in=session.accessible_scopes, pinned=True
).all()
merged = {}
for scope in session.accessible_scopes:
for row in rows:
if row.session_id == scope:
merged[row.label] = self._row_to_slot(row)
return [s for s in merged.values() if s.content.strip()]
async def list_all_slots(self, session: SessionMetadata) -> list[MemorySlot]:
rows = await self.model_class.filter(
session_id__in=session.accessible_scopes
).all()
merged = {}
for scope in session.accessible_scopes:
for row in rows:
if row.session_id == scope:
merged[row.label] = self._row_to_slot(row)
return list(merged.values())
async def clear_by_query(self, query: ScopeSelector) -> None:
scope_prefix = query.scope_prefix
await self.model_class.filter(session_id__startswith=scope_prefix).delete()
def get_orm_slot_context(model_class: type[AbstractSlotRecord]) -> TortoiseSlotContext:
"""
[工厂方法] 供第三方开发者调用,将 Tortoise ORM 表直接包装为记忆槽存储系统。
"""
return TortoiseSlotContext(model_class=model_class)
@@ -0,0 +1,110 @@
from abc import ABC, abstractmethod
from collections.abc import Sequence
from zhenxun.services.ai.context.memory.types import (
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.utils.scope import ScopeSelector
class BaseChatContext(ABC):
"""短期对话历史记忆接口"""
@abstractmethod
async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]:
"""获取当前会话的所有历史消息。"""
...
@abstractmethod
async def search(
self, query: str, session: SessionMetadata, limit: int = 10
) -> list[LLMMessage]:
"""根据查询词搜索当前会话的历史消息。"""
...
@abstractmethod
async def add_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
"""向当前会话追加消息。"""
...
@abstractmethod
async def set_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
"""重置并设置当前会话的消息列表。"""
...
@abstractmethod
async def clear(self, session: SessionMetadata) -> None:
"""清空当前会话的历史消息。"""
...
@abstractmethod
async def clear_by_query(self, query: ScopeSelector) -> None:
"""根据条件领域查询对象清理对话历史。"""
...
class BaseSlotContext(ABC):
"""中期记忆槽持久化接口"""
@abstractmethod
async def get_slot(self, session: SessionMetadata, label: str) -> MemorySlot | None:
"""获取指定会话下特定标签的记忆槽。"""
...
@abstractmethod
async def set_slot(self, session: SessionMetadata, slot: MemorySlot) -> None:
"""设置或更新指定会话下的记忆槽。"""
...
@abstractmethod
async def delete_slot(
self, session: SessionMetadata, label: str, scope: str
) -> None:
"""删除指定会话下特定标签和作用域的记忆槽。"""
...
@abstractmethod
async def list_pinned_slots(self, session: SessionMetadata) -> list[MemorySlot]:
"""列出当前会话所有固定的记忆槽。"""
...
@abstractmethod
async def list_all_slots(self, session: SessionMetadata) -> list[MemorySlot]:
"""列出当前会话的所有记忆槽(包括未置顶的)。"""
...
@abstractmethod
async def clear_by_query(self, query: ScopeSelector) -> None:
"""根据条件领域查询对象清理记忆槽。"""
...
class BaseMemoryReducer(ABC):
"""记忆压缩器基类"""
@abstractmethod
async def reduce(
self,
messages: list[LLMMessage],
current_tokens: int,
model_name: str,
base_overhead: int = 0,
) -> tuple[list[LLMMessage], bool, int]:
"""对消息列表进行压缩处理。"""
...
class BaseMemoryIngestionMiddleware(ABC):
"""记忆入库中间件基类,在写入数据库前拦截并修改/清洗消息"""
@abstractmethod
async def process(
self, messages: Sequence[AgentMessage], context: RunContext
) -> list[AgentMessage]: ...
+103
View File
@@ -0,0 +1,103 @@
from collections.abc import Awaitable, Callable
import time
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.utils.scope import ScopeBuilder, ScopeSelector
AutoRecallPolicy = bool | Callable[[str, "SessionMetadata"], Awaitable[bool] | bool]
"""长期记忆的自动召回策略"""
class MemorySlot(BaseModel):
"""可编辑的持久化记忆槽 (Mid-Term Memory)"""
label: str = Field(...)
"""槽位唯一标签标识 (如 persona, preferences)"""
content: str = Field(default="")
"""槽位存储的具体文本内容"""
size_limit: int = Field(default=2000)
"""槽位内容的最大字符数限制"""
pinned: bool = Field(default=True)
"""是否固定注入到大模型的每次系统提示词中"""
scope: str = Field(...)
"""作用域:表示隔离的路径前缀 (scope_prefix)"""
description: str = Field(default="")
"""该记忆槽的用途说明,便于大模型理解"""
created_at: float = Field(default_factory=time.time)
"""创建时间戳"""
updated_at: float = Field(default_factory=time.time)
"""最近更新时间戳"""
class Isolation:
"""预设策略工厂,提供友好的隔离级别声明式 API"""
@staticmethod
def _base() -> ScopeBuilder:
"""获取底座通用隔离级别(包含Bot、平台、命名空间、智能体)。"""
return ScopeBuilder().bot().platform().namespace().agent()
@classmethod
def GROUP_SHARED(cls) -> ScopeBuilder:
"""群组共享隔离:同群内共享金库与记忆。"""
return cls._base().group()
@classmethod
def USER_GLOBAL(cls) -> ScopeBuilder:
"""用户全局隔离:跨群、跨插件共享用户记忆。"""
return cls._base().user()
@classmethod
def GROUP_USER(cls) -> ScopeBuilder:
"""群组用户隔离:单群内单用户独立隔离。"""
return cls._base().group().user()
@classmethod
def AGENT_USER(cls) -> ScopeBuilder:
"""智能体用户隔离:单智能体单用户物理隔离。"""
return cls.GROUP_USER()
class SessionMetadata(BaseModel):
"""结构化会话元数据"""
model_config = ConfigDict(arbitrary_types_allowed=True)
session_id: str = Field(...)
"""核心会话标识符。"""
selector: ScopeSelector = Field(default_factory=ScopeSelector)
"""统一的作用域与实体资源选择器。"""
isolation_level: ScopeBuilder | None = Field(default=None)
"""生成此会话时的隔离级别。"""
scope_prefix: str = Field(default="/")
"""基于隔离级别生成的路径作用域,用于长期记忆 (RAG) 的向量检索前缀过滤。"""
accessible_scopes: list[str] = Field(default_factory=lambda: ["/"])
"""
当前会话有权访问的作用域列表,用于 Slice 联合检索。
"""
scope_name_mapping: dict[str, str] = Field(default_factory=dict)
"""物理路径到语义化名称的逆向映射字典,供大模型友好阅读"""
@property
def platform(self) -> str | None:
return self.selector.platform
@property
def group_id(self) -> str | None:
return self.selector.group_id
@property
def user_id(self) -> str | None:
return self.selector.user_id
@property
def namespace(self) -> str | None:
return self.selector.namespace
@property
def agent_name(self) -> str | None:
return self.selector.agent_name
def __str__(self) -> str:
return self.session_id
@@ -0,0 +1,26 @@
"""
Zhenxun AI - RAG (检索增强生成) 基础设施层
"""
from .backends.embedders import Embedder
from .backends.storages import (
AbstractVectorRecord,
StorageBackend,
TortoiseStorageBackend,
)
from .builder import RAGBuilder
from .configs import RAGConfig
from .engine import ScopedRAGClient
from .models import BaseRecord, SearchResult
__all__ = [
"AbstractVectorRecord",
"BaseRecord",
"Embedder",
"RAGBuilder",
"RAGConfig",
"ScopedRAGClient",
"SearchResult",
"StorageBackend",
"TortoiseStorageBackend",
]
@@ -0,0 +1,20 @@
from .embedders import DefaultEmbedder, Embedder
from .storages import (
AbstractVectorRecord,
DictStorageBackend,
LanceDBStorageBackend,
QdrantStorageBackend,
StorageBackend,
TortoiseStorageBackend,
)
__all__ = [
"AbstractVectorRecord",
"DefaultEmbedder",
"DictStorageBackend",
"Embedder",
"LanceDBStorageBackend",
"QdrantStorageBackend",
"StorageBackend",
"TortoiseStorageBackend",
]
@@ -0,0 +1,176 @@
from abc import ABC, abstractmethod
import asyncio
import threading
from typing import Any, Literal, Protocol, runtime_checkable
from zhenxun.services.ai.core.messages import EmbedBatch
from zhenxun.services.ai.llm.api import embed as api_embed
from zhenxun.services.ai.message_builder import MessageBuilder
from zhenxun.services.log import logger
EmbedTaskType = Literal[
"general", "query", "document", "similarity", "classification", "clustering"
]
@runtime_checkable
class Embedder(Protocol):
"""
向量化引擎协议 (Callable Protocol)。
任何实现了异步 __call__ 的对象或闭包函数均可作为 Embedder。
"""
async def __call__(
self, input_batch: Any, task: EmbedTaskType = "general", **kwargs
) -> list[list[float]]:
"""
将文本、多模态或预构建的 EmbedBatch 转换为向量列表。
"""
...
class DefaultEmbedder(Embedder):
"""系统默认的向量化引擎,调用大模型底座 API"""
def __init__(self, model_name: str | None = None, config: Any = None):
self.model_name = model_name
self.config = config
async def __call__(
self, input_batch: Any, task: EmbedTaskType = "general", **kwargs
) -> list[list[float]]:
if not input_batch:
return []
try:
res = await api_embed(
input_batch, model=self.model_name, task=task, config=self.config
)
return res.embeddings
except Exception as e:
logger.error(f"DefaultEmbedder 向量化失败: {e}", e=e)
return []
class BaseLocalEmbedder(Embedder, ABC):
"""本地向量化引擎基类,统一处理多模态降级与同步推理由协程包裹逻辑。"""
def __init__(self, model_name: str):
self.model_name = model_name
self._model_lock = threading.Lock()
@abstractmethod
def _encode_texts(self, texts: list[str]) -> list[list[float]]:
"""子类只需实现此同步的批量文本向量化方法即可。"""
pass
async def __call__(
self, input_batch: Any, task: EmbedTaskType = "general", **kwargs
) -> list[list[float]]:
if not input_batch:
return []
if isinstance(input_batch, EmbedBatch):
batch = input_batch
else:
batch = await MessageBuilder.normalize_to_embed_batch(input_batch)
texts = batch.to_text_only(f"本地模型 {self.model_name}")
if not texts:
return []
def _sync_embed():
return self._encode_texts(texts)
return await asyncio.to_thread(_sync_embed)
class FastEmbedder(BaseLocalEmbedder):
"""
基于 FastEmbed 的轻量级本地向量化引擎。
零 PyTorch 依赖,CPU 推理极快。
"""
def __init__(self, model_name: str | None = None):
super().__init__(model_name or "BAAI/bge-small-zh-v1.5")
self.model = None
import importlib.util
if importlib.util.find_spec("fastembed") is None:
raise ImportError(
"⚠️ 使用 FastEmbed 需要额外依赖,请在终端执行: pip install fastembed"
)
def _ensure_model_loaded(self):
"""线程安全的懒加载机制"""
if self.model is None:
with self._model_lock:
if self.model is None:
try:
from fastembed import TextEmbedding
except ImportError:
raise ImportError(
"⚠️ 使用 FastEmbed 需要额外依赖,"
"请在终端执行: pip install fastembed"
)
logger.info(
f"正在后台加载 FastEmbed 本地模型: {self.model_name} ... "
"(首次加载可能需要极长时间下载)"
)
self.model = TextEmbedding(model_name=self.model_name)
logger.info(f"FastEmbed 模型 {self.model_name} 加载完毕!")
def _encode_texts(self, texts: list[str]) -> list[list[float]]:
self._ensure_model_loaded()
assert self.model is not None
return [vec.tolist() for vec in self.model.embed(texts)]
class SentenceTransformerEmbedder(BaseLocalEmbedder):
"""
基于 Sentence-Transformers 的本地向量化引擎。
支持 GPU 加速,适合重度用户。
"""
def __init__(self, model_name: str | None = None):
super().__init__(model_name or "BAAI/bge-small-zh-v1.5")
self.model = None
import importlib.util
if importlib.util.find_spec("sentence_transformers") is None:
raise ImportError(
"⚠️ 使用 SentenceTransformers 需要额外依赖,"
"请在终端执行: pip install sentence-transformers"
)
def _ensure_model_loaded(self):
"""线程安全的懒加载机制"""
if self.model is None:
with self._model_lock:
if self.model is None:
try:
from sentence_transformers import (
SentenceTransformer,
)
except ImportError:
raise ImportError(
"⚠️ 使用 SentenceTransformers 需要额外依赖,"
"请在终端执行: pip install sentence-transformers"
)
logger.info(
"正在后台加载 SentenceTransformer "
f"本地模型: {self.model_name} ... "
"(首次加载可能需要极长时间下载)"
)
self.model = SentenceTransformer(self.model_name)
logger.info(
f"SentenceTransformer 模型 {self.model_name} 加载完毕!"
)
def _encode_texts(self, texts: list[str]) -> list[list[float]]:
self._ensure_model_loaded()
assert self.model is not None
embeddings = self.model.encode(texts)
return embeddings.tolist()
@@ -0,0 +1,628 @@
import os
from typing import ClassVar, Protocol, runtime_checkable
import uuid
import numpy as np
from tortoise import fields
from zhenxun.services.ai.context.rag.models import (
BaseRecord,
QueryRequest,
SearchResult,
)
from zhenxun.services.ai.context.rag.retrieval import FilterEvaluator
from zhenxun.services.ai.utils.scope import ScopeSelector
from zhenxun.services.db_context import Model
@runtime_checkable
class StorageBackend(Protocol):
"""纯粹的向量存储后端协议"""
async def save(self, records: list[BaseRecord]) -> None:
"""保存或更新数据块"""
...
async def search(
self, query: QueryRequest, scopes: list[str] | None = None
) -> list[SearchResult]:
"""按向量和前缀检索数据块"""
...
async def update(self, record: BaseRecord) -> None:
"""更新已有数据块"""
...
async def delete(
self, record_ids: list[str] | None = None, scope_prefix: str | None = None
) -> int:
"""删除数据块"""
...
async def clear_by_query(self, query: ScopeSelector) -> int:
"""根据统一领域查询对象清理数据块(在各实现中回退到 delete)"""
...
async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]:
"""获取作用域下所有记录(用于容量控制)"""
...
def normalize_vector(vec: list[float] | np.ndarray) -> np.ndarray:
"""将一维向量转化为 float32 数组并进行 L2 归一化"""
v = np.array(vec, dtype=np.float32)
norm = np.linalg.norm(v)
if norm == 0:
return v
return v / norm
def normalize_matrix(mat: np.ndarray) -> np.ndarray:
"""将二维矩阵的每一行进行 L2 归一化"""
norms = np.linalg.norm(mat, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return mat / norms
class DictStorageBackend(StorageBackend):
"""基于内存字典的轻量级纯净 RAG 存储实现"""
_shared_records: ClassVar[dict[str, BaseRecord]] = {}
_shared_vectors: ClassVar[dict[str, np.ndarray]] = {}
def __init__(self):
self._records = self._shared_records
self._vectors = self._shared_vectors
async def save(self, records: list[BaseRecord]) -> None:
for r in records:
self._records[r.id] = r
if r.embedding:
self._vectors[r.id] = normalize_vector(r.embedding)
else:
self._vectors.pop(r.id, None)
async def search(
self, query: QueryRequest, scopes: list[str] | None = None
) -> list[SearchResult]:
candidate_ids = []
for record in self._records.values():
if scopes is not None:
if record.metadata.get("scope", "/") not in scopes:
continue
if not FilterEvaluator.evaluate(record.metadata, query.metadata_filters):
continue
if not query.embedding and query.text and query.text not in record.content:
continue
candidate_ids.append(record.id)
if not candidate_ids:
return []
results = []
if query.search_type == "sparse":
import jieba
tokens = set(jieba.lcut_for_search(query.text.lower()))
for r_id in candidate_ids:
record = self._records[r_id]
content = record.content.lower()
matched_count = sum(1 for t in tokens if t in content)
if matched_count > 0:
score = matched_count / len(tokens)
results.append(SearchResult(record=record, score=score))
elif query.search_type == "dense" and query.embedding:
q_vec = normalize_vector(query.embedding)
valid_ids = [r_id for r_id in candidate_ids if r_id in self._vectors]
if valid_ids:
try:
mat = np.array([self._vectors[r_id] for r_id in valid_ids])
scores = mat @ q_vec
for r_id, score in zip(valid_ids, scores):
results.append(
SearchResult(record=self._records[r_id], score=float(score))
)
except ValueError as e:
from zhenxun.services.log import logger
logger.warning(
"⚠️ DictStorage 中缓存的向量维度与当前查询维度不匹配,"
f"跳过向量检索。原因: {e}"
)
missing_ids = [r_id for r_id in candidate_ids if r_id not in self._vectors]
for r_id in missing_ids:
results.append(SearchResult(record=self._records[r_id], score=0.1))
else:
for r_id in candidate_ids:
results.append(SearchResult(record=self._records[r_id], score=0.1))
results.sort(key=lambda x: x.score, reverse=True)
return results[: query.limit]
async def update(self, record: BaseRecord) -> None:
if record.id in self._records:
self._records[record.id] = record
if record.embedding:
self._vectors[record.id] = normalize_vector(record.embedding)
else:
self._vectors.pop(record.id, None)
async def delete(
self, record_ids: list[str] | None = None, scope_prefix: str | None = None
) -> int:
to_delete = []
for r_id, r in self._records.items():
if scope_prefix is not None:
if not r.metadata.get("scope", "/").startswith(scope_prefix):
continue
if record_ids and r_id not in record_ids:
continue
to_delete.append(r_id)
for r_id in to_delete:
del self._records[r_id]
self._vectors.pop(r_id, None)
return len(to_delete)
async def clear_by_query(self, query: ScopeSelector) -> int:
return await self.delete(scope_prefix=query.scope_prefix)
async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]:
res = []
for r in self._records.values():
if scope_prefix is not None:
if r.metadata.get("scope", "/") != scope_prefix:
continue
res.append(r)
return res
class AbstractVectorRecord(Model):
id = fields.CharField(pk=True, max_length=64)
scope = fields.CharField(max_length=255, index=True)
content = fields.TextField()
embedding = fields.JSONField(null=True)
meta_data = fields.JSONField(null=True)
class Meta: # type: ignore
abstract = True
class TortoiseStorageBackend(StorageBackend):
def __init__(self, model_class: type[AbstractVectorRecord]):
self.model_class = model_class
def _to_base_record(self, row: AbstractVectorRecord) -> BaseRecord:
return BaseRecord(
id=row.id,
content=row.content,
embedding=row.embedding if isinstance(row.embedding, list) else None,
metadata=row.meta_data if isinstance(row.meta_data, dict) else {},
)
async def save(self, records: list[BaseRecord]) -> None:
for r in records:
await self.model_class.update_or_create(
id=r.id,
defaults={
"content": r.content,
"scope": r.metadata.get("scope", "/"),
"embedding": r.embedding,
"meta_data": r.metadata,
},
)
async def search(
self, query: QueryRequest, scopes: list[str] | None = None
) -> list[SearchResult]:
query_orm = self.model_class.all()
if scopes is not None:
query_orm = query_orm.filter(scope__in=scopes)
if query.search_type == "sparse" and query.text:
import jieba
from tortoise.expressions import Q
tokens = [
t for t in jieba.lcut_for_search(query.text) if len(t.strip()) > 1
] or [query.text]
q_expr = Q()
for token in tokens:
q_expr |= Q(content__icontains=token)
query_orm = query_orm.filter(q_expr)
elif query.search_type == "dense" and not query.embedding and query.text:
query_orm = query_orm.filter(content__icontains=query.text)
rows = await query_orm
valid_rows = []
for row in rows:
row_meta = row.meta_data if isinstance(row.meta_data, dict) else {}
if not FilterEvaluator.evaluate(row_meta, query.metadata_filters):
continue
valid_rows.append(row)
if not valid_rows:
return []
results = []
if query.search_type == "sparse":
import jieba
tokens = set(jieba.lcut_for_search(query.text.lower()))
for row in valid_rows:
content = row.content.lower()
matched_count = sum(1 for t in tokens if t in content)
score = matched_count / len(tokens) if tokens else 0.1
results.append(
SearchResult(record=self._to_base_record(row), score=score)
)
elif query.search_type == "dense" and query.embedding:
q_vec = normalize_vector(query.embedding)
vec_rows = []
missing_rows = []
for row in valid_rows:
if isinstance(row.embedding, list):
vec_rows.append(row)
else:
missing_rows.append(row)
if vec_rows:
try:
raw_mat = np.array(
[r.embedding for r in vec_rows], dtype=np.float32
)
norm_mat = normalize_matrix(raw_mat)
scores = norm_mat @ q_vec
for row, score in zip(vec_rows, scores):
results.append(
SearchResult(
record=self._to_base_record(row), score=float(score)
)
)
except ValueError as e:
from zhenxun.services.log import logger
logger.warning(
"⚠️ 数据库中缓存的向量维度与当前模型查询维度不匹配,"
f"已安全跳过向量检索(降级为稀疏匹配)。原因: {e}"
)
for row in missing_rows:
results.append(
SearchResult(record=self._to_base_record(row), score=0.1)
)
else:
for row in valid_rows:
results.append(
SearchResult(record=self._to_base_record(row), score=0.1)
)
results.sort(key=lambda x: x.score, reverse=True)
return results[: query.limit]
async def update(self, record: BaseRecord) -> None:
await self.model_class.filter(id=record.id).update(
content=record.content,
scope=record.metadata.get("scope", "/"),
embedding=record.embedding,
meta_data=record.metadata,
)
async def delete(
self, record_ids: list[str] | None = None, scope_prefix: str | None = None
) -> int:
query = self.model_class.all()
if scope_prefix is not None:
query = query.filter(scope__startswith=scope_prefix)
if record_ids is not None:
if not record_ids:
return 0
query = query.filter(id__in=record_ids)
return await query.delete()
async def clear_by_query(self, query: ScopeSelector) -> int:
return await self.delete(scope_prefix=query.scope_prefix)
async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]:
query = self.model_class.all()
if scope_prefix is not None:
query = query.filter(scope=scope_prefix)
rows = await query
return [self._to_base_record(row) for row in rows]
class QdrantStorageBackend(StorageBackend):
"""Qdrant 向量数据库可选存储后端"""
def __init__(
self,
location: str = ":memory:",
collection_name: str = "zhenxun_rag",
**kwargs,
):
try:
from qdrant_client import AsyncQdrantClient
except ImportError:
raise ImportError(
"缺少 Qdrant 依赖!请执行 `pip install qdrant-client` 安装"
)
self.client = AsyncQdrantClient(location=location, **kwargs)
self.collection_name = collection_name
self._initialized = False
async def _ensure_collection(self, dim: int):
if self._initialized:
return
from qdrant_client.models import Distance, VectorParams
if not await self.client.collection_exists(self.collection_name):
await self.client.create_collection(
collection_name=self.collection_name,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
)
self._initialized = True
async def save(self, records: list[BaseRecord]) -> None:
if not records:
return
dim = len(records[0].embedding) if records[0].embedding else 1536
await self._ensure_collection(dim)
from qdrant_client.models import PointStruct
points = []
for r in records:
points.append(
PointStruct(
id=r.id
if len(r.id) == 36
else str(uuid.uuid5(uuid.NAMESPACE_DNS, r.id)),
vector=r.embedding or [],
payload={"content": r.content, "metadata": r.metadata},
)
)
await self.client.upsert(collection_name=self.collection_name, points=points)
async def search(
self, query: QueryRequest, scopes: list[str] | None = None
) -> list[SearchResult]:
if query.search_type == "dense" and not query.embedding:
return []
if query.embedding:
await self._ensure_collection(len(query.embedding))
from qdrant_client.models import FieldCondition, Filter, MatchText, MatchValue
must_conditions = []
if scopes is not None:
try:
from qdrant_client.models import MatchAny
must_conditions.append(
FieldCondition(key="metadata.scope", match=MatchAny(any=scopes))
)
except ImportError:
scope_conditions = [
FieldCondition(key="metadata.scope", match=MatchValue(value=s))
for s in scopes
]
must_conditions.append(Filter(should=scope_conditions))
if query.metadata_filters:
for k, v in query.metadata_filters.items():
must_conditions.append(
FieldCondition(key=f"metadata.{k}", match=MatchValue(value=v))
)
if query.search_type == "sparse":
must_conditions.append(
FieldCondition(key="content", match=MatchText(text=query.text))
)
query_filter = Filter(must=must_conditions) if must_conditions else None
if query.search_type == "sparse":
results = await self.client.scroll(
collection_name=self.collection_name,
scroll_filter=query_filter,
limit=query.limit,
with_payload=True,
)
return [
SearchResult(
record=BaseRecord(
id=str(r.id),
content=(r.payload or {}).get("content", ""),
metadata=(r.payload or {}).get("metadata", {}),
),
score=1.0,
)
for r in results[0]
]
results = await self.client.search( # type: ignore
collection_name=self.collection_name,
query_vector=query.embedding,
limit=query.limit,
query_filter=query_filter,
)
return [
SearchResult(
record=BaseRecord(
id=str(r.id),
content=r.payload.get("content", ""),
metadata=r.payload.get("metadata", {}),
),
score=r.score,
)
for r in results
]
async def update(self, record: BaseRecord) -> None:
await self.save([record])
async def delete(
self, record_ids: list[str] | None = None, scope_prefix: str | None = None
) -> int:
if not await self.client.collection_exists(self.collection_name):
return 0
from qdrant_client.models import FieldCondition, Filter, MatchText
query_filter = None
if scope_prefix is not None:
query_filter = Filter(
must=[
FieldCondition(
key="metadata.scope", match=MatchText(text=scope_prefix)
)
]
)
if query_filter:
await self.client.delete(
collection_name=self.collection_name, points_selector=query_filter
)
return 1
async def clear_by_query(self, query: ScopeSelector) -> int:
return await self.delete(scope_prefix=query.scope_prefix)
async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]:
if not await self.client.collection_exists(self.collection_name):
return []
from qdrant_client.models import FieldCondition, Filter, MatchText
q_filter = None
if scope_prefix and scope_prefix != "/":
q_filter = Filter(
must=[
FieldCondition(
key="metadata.scope", match=MatchText(text=scope_prefix)
)
]
)
res = await self.client.scroll(
collection_name=self.collection_name,
scroll_filter=q_filter,
limit=10000,
with_payload=True,
)
return [
BaseRecord(
id=str(r.id),
content=(r.payload or {}).get("content", ""),
metadata=(r.payload or {}).get("metadata", {}),
)
for r in res[0]
]
class LanceDBStorageBackend(StorageBackend):
"""LanceDB 向量数据库可选存储后端"""
def __init__(
self, uri: str = "./data/lancedb", table_name: str = "zhenxun_rag", **kwargs
):
try:
import lancedb
except ImportError:
raise ImportError("缺少 LanceDB 依赖!请执行 `pip install lancedb` 安装")
os.makedirs(
os.path.dirname(uri) if os.path.dirname(uri) else ".", exist_ok=True
)
self.db = lancedb.connect(uri)
self.table_name = table_name
async def save(self, records: list[BaseRecord]) -> None:
if not records:
return
data = []
dim = len(records[0].embedding) if records[0].embedding else 0
for r in records:
data.append(
{
"id": r.id,
"vector": r.embedding or [0.0] * dim,
"content": r.content,
"metadata": str(r.metadata),
}
)
if self.table_name not in self.db.table_names():
self.db.create_table(self.table_name, data=data)
else:
self.db.open_table(self.table_name).add(data)
async def search(
self, query: QueryRequest, scopes: list[str] | None = None
) -> list[SearchResult]:
if self.table_name not in self.db.table_names():
return []
if query.search_type == "dense" and not query.embedding:
return []
tbl = self.db.open_table(self.table_name)
if query.search_type == "sparse":
try:
results = (
tbl.search(query.text, query_type="fts")
.limit(query.limit)
.to_list()
)
except Exception as e:
from zhenxun.services.log import logger
logger.warning(f"LanceDB FTS 检索失败(可能是由于尚未创建FTS索引): {e}")
return []
else:
results = tbl.search(query.embedding).limit(query.limit).to_list()
import ast
return [
SearchResult(
record=BaseRecord(
id=r["id"],
content=r["content"],
metadata=ast.literal_eval(r["metadata"]) if "metadata" in r else {},
),
score=1.0 - r.get("_distance", 0.0),
)
for r in results
]
async def update(self, record: BaseRecord) -> None:
pass
async def delete(
self, record_ids: list[str] | None = None, scope_prefix: str | None = None
) -> int:
return 0
async def clear_by_query(self, query: ScopeSelector) -> int:
return await self.delete(scope_prefix=query.scope_prefix)
async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]:
if self.table_name not in self.db.table_names():
return []
tbl = self.db.open_table(self.table_name)
df = tbl.to_pandas()
import ast
res = []
for _, row in df.iterrows():
meta = ast.literal_eval(row["metadata"]) if "metadata" in row else {}
if scope_prefix is not None:
if meta.get("scope", "/") != scope_prefix:
continue
res.append(BaseRecord(id=row["id"], content=row["content"], metadata=meta))
return res
+272
View File
@@ -0,0 +1,272 @@
from typing import Any
from zhenxun.services.ai.context.rag.backends import StorageBackend
from zhenxun.services.ai.context.rag.configs import RAGConfig
from zhenxun.services.ai.context.rag.engine import ScopedRAGClient
from zhenxun.services.ai.context.rag.ingestion import (
ChunkingStrategy,
DedupNode,
DocumentChunking,
DynamicChunkingNode,
EmbeddingNode,
IndexPipeline,
StorageCommitNode,
)
from zhenxun.services.ai.context.rag.retrieval import (
BaseRetriever,
DatabaseSparseRetriever,
HybridRetriever,
LifecyclePostProcessor,
PipelineRetriever,
PostProcessor,
PreProcessor,
RerankRetriever,
VectorDBRetriever,
)
from zhenxun.services.log import logger
class RAGBuilder:
"""
RAG 管线组装构建器 (Fluent Builder Pattern)。
使用内部状态驱动模式 (The Memory Pattern),内部维护私有的 RAGConfig 实例。
"""
def __init__(
self, storage: StorageBackend | None = None, config: RAGConfig | None = None
):
self._config = config or RAGConfig()
if storage is not None:
self._config.storage = storage
def with_embedder(self, embedder: Any) -> "RAGBuilder":
"""
设置向量化引擎。
参数:
embedder: 实现了向量化协议的引擎实例 (如 DefaultEmbedder, FastEmbedder)。
"""
self._config.embedder = embedder
return self
def with_retriever(self, retriever: BaseRetriever) -> "RAGBuilder":
"""
替换底层的向量库查表算法,注入自定义召回器。
参数:
retriever: 实现了 BaseRetriever 协议的自定义召回器实例。
"""
self._config.custom_retriever = retriever
return self
def with_scope(self, scopes: str | list[str]) -> "RAGBuilder":
"""
设置数据隔离作用域。
参数:
scopes: 支持单作用域前缀(字符串)或联合检索多作用域(列表)。
如 "/" 或 ["/group_1", "/user_2"]。
"""
self._config.scopes = scopes
return self
def with_chunking(self, strategy: ChunkingStrategy) -> "RAGBuilder":
"""
设置文档切块策略。
参数:
strategy: 切块策略实例 (如 DocumentChunking, RecursiveCharacterChunking)。
"""
self._config.chunking.strategy = strategy
return self
def enable_dedup(self, threshold: float = 0.98) -> "RAGBuilder":
"""
开启批处理入库去重。
在文本分块入库前,通过对比向量相似度拦截高度重复的内容。
参数:
threshold: 去重相似度阈值,大于该值的块将被判定为重复并丢弃。
"""
self._config.dedup.enable = True
self._config.dedup.threshold = threshold
return self
def disable_dedup(self) -> "RAGBuilder":
"""
关闭批处理入库去重。
"""
self._config.dedup.enable = False
return self
def enable_rerank(self, model_name: str, top_n: int = 5) -> "RAGBuilder":
"""
开启大模型交叉注意力重排。
对初筛召回的结果使用专用的 Rerank 模型进行二次排序,极大提升召回准确率。
参数:
model_name: 用于重排序的模型名称 (如 'BAAI/bge-reranker-v2-m3')。
top_n: 重排后最终保留并返回的文档数量。
"""
self._config.rerank.enable = True
self._config.rerank.model_name = model_name
self._config.rerank.top_n = top_n
return self
def enable_hybrid_search(
self, dense_weight: float = 0.7, sparse_weight: float = 0.3
) -> "RAGBuilder":
"""
开启双轨混合检索 (Hybrid Search) 及 RRF 融合。
并发调用向量检索 (Dense) 和关键词检索 (Sparse),结合两者优势。
参数:
dense_weight: 稠密向量检索的分数计算权重。
sparse_weight: 稀疏关键词(BM25等)检索的分数计算权重。
"""
self._config.hybrid.enable = True
self._config.hybrid.dense_weight = dense_weight
self._config.hybrid.sparse_weight = sparse_weight
return self
def enable_lifecycle_scoring(
self,
half_life_days: int = 30,
decay_weight: float = 0.3,
semantic_weight: float = 0.7,
importance_weight: float = 0.0,
reinforcement_weight: float = 0.2,
) -> "RAGBuilder":
"""
开启生命周期打分后处理。
综合考虑记忆的时间新鲜度、基础语义相似度、重要性以及访问频次,模拟人类记忆遗忘曲线。
参数:
half_life_days: 时间衰减的半衰期(天)。经过这么多天后,时间得分衰减一半。
decay_weight: 时间衰减得分所占权重。
semantic_weight: 基础语义相似度所占权重。
importance_weight: 客观重要性所占权重。
reinforcement_weight: 访问强化(被回想次数越多越容易想起)所占权重。
"""
self._config.lifecycle.enable = True
self._config.lifecycle.half_life_days = half_life_days
self._config.lifecycle.decay_weight = decay_weight
self._config.lifecycle.semantic_weight = semantic_weight
self._config.lifecycle.importance_weight = importance_weight
self._config.lifecycle.reinforcement_weight = reinforcement_weight
return self
def add_pre_processor(self, processor: PreProcessor) -> "RAGBuilder":
"""
挂载自定义查询前处理器。
参数:
processor: 实现了 PreProcessor 协议的处理器实例。
"""
self._config.pre_processors.append(processor)
return self
def add_post_processor(self, processor: PostProcessor) -> "RAGBuilder":
"""
挂载自定义检索后处理器。
参数:
processor: 实现了 PostProcessor 协议的处理器实例。
"""
self._config.post_processors.append(processor)
return self
@classmethod
def resolve(cls, config: RAGConfig | dict | Any | None) -> RAGConfig:
"""
解析多种格式的配置,统一转换为 RAGConfig 对象。
参数:
config: RAGConfig 实例、字典或其他类型配置。
"""
if isinstance(config, RAGConfig):
return config
if isinstance(config, dict):
return RAGConfig(**config)
return RAGConfig()
def build(self) -> ScopedRAGClient:
"""
完成所有积木组装,输出最终的 RAG 客户端实体。
"""
cfg = self._config
storage = cfg.storage
if not storage:
raise ValueError("RAGBuilder 必须配置 storage 才能 build。")
embedder = cfg.embedder
if not embedder:
from zhenxun.services.ai.context.rag.backends import DefaultEmbedder
from zhenxun.services.ai.llm.manager import get_default_model
embedder = DefaultEmbedder(model_name=get_default_model("embedding"))
logger.debug("RAGBuilder: 未指定 Embedder,已使用系统默认 Embedder。")
chunking_strategy = cfg.chunking.strategy or DocumentChunking()
scopes = cfg.scopes
nodes = [
DynamicChunkingNode(chunking_strategy),
EmbeddingNode(embedder),
]
if cfg.dedup.enable:
nodes.append(DedupNode(cfg.dedup.threshold))
nodes.append(StorageCommitNode(storage))
pipeline = IndexPipeline(nodes)
base_retriever: BaseRetriever = cfg.custom_retriever or VectorDBRetriever(
storage,
embedder,
scopes[0] if isinstance(scopes, list) else scopes,
)
if cfg.hybrid.enable:
database_sparse_retriever = DatabaseSparseRetriever(
storage, scopes[0] if isinstance(scopes, list) else scopes
)
base_retriever = HybridRetriever(
dense_retriever=base_retriever,
sparse_retriever=database_sparse_retriever,
dense_weight=cfg.hybrid.dense_weight,
sparse_weight=cfg.hybrid.sparse_weight,
)
if cfg.rerank.enable and cfg.rerank.model_name:
base_retriever = RerankRetriever(
base_retriever, cfg.rerank.model_name, cfg.rerank.top_n
)
pre_processors = list(cfg.pre_processors)
post_processors = list(cfg.post_processors)
if cfg.lifecycle.enable:
post_processors.append(
LifecyclePostProcessor(
half_life_days=cfg.lifecycle.half_life_days,
decay_weight=cfg.lifecycle.decay_weight,
semantic_weight=cfg.lifecycle.semantic_weight,
importance_weight=cfg.lifecycle.importance_weight,
reinforcement_weight=cfg.lifecycle.reinforcement_weight,
)
)
if pre_processors or post_processors:
base_retriever = PipelineRetriever(
base_retriever,
pre_processors=pre_processors,
post_processors=post_processors,
)
return ScopedRAGClient(
storage=storage,
retriever=base_retriever,
pipeline=pipeline,
scopes=scopes,
config=cfg,
)
@@ -0,0 +1,86 @@
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend
from zhenxun.services.ai.context.rag.ingestion import ChunkingStrategy
from zhenxun.services.ai.context.rag.retrieval import (
BaseRetriever,
PostProcessor,
PreProcessor,
)
StorageConfigType = dict[str, Any]
class ChunkingConfig(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
strategy: ChunkingStrategy | None = None
"""文档切块策略"""
class DedupConfig(BaseModel):
enable: bool = True
"""是否开启入库去重"""
threshold: float = 0.98
"""去重相似度阈值"""
class RerankConfig(BaseModel):
enable: bool = False
"""是否开启重排"""
model_name: str | None = None
"""重排使用的模型名称"""
top_n: int = 5
"""重排后保留的文档数量"""
class HybridSearchConfig(BaseModel):
enable: bool = False
"""是否开启双轨混合检索及 RRF 融合"""
dense_weight: float = 0.7
"""向量检索权重"""
sparse_weight: float = 0.3
"""BM25 检索权重"""
class LifecycleConfig(BaseModel):
enable: bool = False
"""是否开启生命周期打分(时间衰减+访问强化)后处理"""
half_life_days: int = 30
"""半衰期天数"""
decay_weight: float = 0.3
"""时间衰减权重"""
semantic_weight: float = 0.7
"""语义相似度权重"""
importance_weight: float = 0.0
"""重要性打分权重"""
reinforcement_weight: float = 0.2
"""访问强化得分权重"""
class RAGConfig(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
storage: StorageBackend | None = None
"""存储后端"""
embedder: Embedder | None = None
"""向量化引擎"""
custom_retriever: BaseRetriever | None = None
"""自定义召回器"""
scopes: str | list[str] = "/"
"""数据隔离作用域"""
chunking: ChunkingConfig = Field(default_factory=ChunkingConfig)
"""文档切块配置"""
dedup: DedupConfig = Field(default_factory=DedupConfig)
"""去重配置"""
rerank: RerankConfig = Field(default_factory=RerankConfig)
"""重排配置"""
hybrid: HybridSearchConfig = Field(default_factory=HybridSearchConfig)
"""混合检索与 RRF 融合配置"""
lifecycle: LifecycleConfig = Field(default_factory=LifecycleConfig)
"""生命周期打分配置"""
pre_processors: list[PreProcessor] = Field(default_factory=list)
"""自定义查询前处理器列表"""
post_processors: list[PostProcessor] = Field(default_factory=list)
"""自定义检索后处理器列表"""
+96
View File
@@ -0,0 +1,96 @@
from typing import Any
from zhenxun.services.ai.context.rag.backends import (
StorageBackend,
)
from zhenxun.services.ai.context.rag.configs import RAGConfig
from zhenxun.services.ai.context.rag.ingestion import (
IndexPipeline,
)
from zhenxun.services.ai.context.rag.models import (
BaseRecord,
SearchResult,
)
from zhenxun.services.ai.context.rag.retrieval import (
BaseRetriever,
)
from zhenxun.services.ai.utils.scope import normalize_scope_path
class ScopedRAGClient:
"""
RAG 基础设施门面。
封装了存储后端、检索器和写入管线,将所有操作透明地限定在指定的作用域前缀下。
"""
def __init__(
self,
storage: StorageBackend,
retriever: BaseRetriever,
pipeline: IndexPipeline,
scopes: str | list[str] = "/",
config: RAGConfig | None = None,
):
"""
初始化 ScopedRAGClient 实例。
参数:
storage: 底层向量/文档存储后端。
retriever: 数据召回检索器。
pipeline: 数据入库和索引分块处理管线。
scopes: 数据隔离作用域,支持单作用域前缀或多作用域前缀列表。
config: 全局的 RAG 配置项。
"""
self.storage = storage
self.retriever = retriever
self.pipeline = pipeline
self.config = config or RAGConfig()
self._background_tasks = set()
if isinstance(scopes, str):
self.scopes = [normalize_scope_path(scopes)]
else:
self.scopes = [normalize_scope_path(s) for s in scopes]
self.scope_prefix = self.scopes[0] if self.scopes else "/"
async def ingest(self, records: list[BaseRecord]) -> int:
"""通过 RAG Ingestion Pipeline 处理并入库数据"""
for r in records:
if "scope" not in r.metadata:
r.metadata["scope"] = self.scope_prefix
res = await self.pipeline.run(records)
return len(res)
async def search(
self,
query: Any,
limit: int = 10,
scopes: str | list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""
多作用域联合切片视图检索 (Union Search)。
并发向多个独立的作用域发起检索,并对结果进行合并、去重和重排。
"""
target_scopes = self.scopes
if scopes is not None:
target_scopes = (
[normalize_scope_path(scopes)]
if isinstance(scopes, str)
else [normalize_scope_path(s) for s in scopes]
)
if not target_scopes:
return []
kwargs["scopes"] = target_scopes
return await self.retriever.retrieve(query, limit=limit, **kwargs)
async def update(self, record: BaseRecord) -> None:
record.metadata["scope"] = self.scope_prefix
await self.storage.update(record)
async def delete(self, record_ids: list[str] | None = None, **kwargs: Any) -> int:
kwargs["scope_prefix"] = self.scope_prefix
return await self.storage.delete(record_ids=record_ids, **kwargs)
@@ -0,0 +1,521 @@
from abc import ABC, abstractmethod
import asyncio
import re
from zhenxun.services.ai.context.rag.models import BaseRecord
from zhenxun.services.ai.context.rag.utils import cosine_similarity
from zhenxun.services.log import logger
class ChunkingStrategy(ABC):
@abstractmethod
def chunk(self, record: BaseRecord) -> list[BaseRecord]:
raise NotImplementedError
def clean_text(self, text: str) -> str:
cleaned_text = re.sub(r"\n+", "\n", text)
cleaned_text = re.sub(r"[ \t]+", " ", cleaned_text)
return cleaned_text.strip()
def _create_chunk_record(
self, original_record: BaseRecord, chunk_number: int, content: str
) -> BaseRecord:
meta_data = original_record.metadata.copy()
meta_data["chunk_index"] = chunk_number
meta_data["chunk_size"] = len(content)
meta_data["parent_id"] = original_record.id
return BaseRecord(
id=f"{original_record.id}_{chunk_number}",
content=content,
metadata=meta_data,
)
class DocumentChunking(ChunkingStrategy):
"""段落语义分块策略 (按双换行切分)"""
def __init__(self, chunk_size: int = 1000):
"""
初始化段落语义分块策略。
参数:
chunk_size: 单个分块的最大字符长度限制,默认 1000。
"""
self.chunk_size = chunk_size
def chunk(self, record: BaseRecord) -> list[BaseRecord]:
if len(record.content) <= self.chunk_size:
return [
self._create_chunk_record(record, 0, self.clean_text(record.content))
]
raw_paragraphs = record.content.split("\n\n")
paragraphs = [self.clean_text(para) for para in raw_paragraphs if para.strip()]
chunks: list[BaseRecord] = []
current_chunk_texts = []
current_length = 0
chunk_index = 0
for para in paragraphs:
para_len = len(para)
if current_length + para_len > self.chunk_size and current_chunk_texts:
chunk_content = "\n\n".join(current_chunk_texts)
chunks.append(
self._create_chunk_record(record, chunk_index, chunk_content)
)
chunk_index += 1
current_chunk_texts = []
current_length = 0
current_chunk_texts.append(para)
current_length += para_len + 2
if current_chunk_texts:
chunk_content = "\n\n".join(current_chunk_texts)
chunks.append(self._create_chunk_record(record, chunk_index, chunk_content))
return chunks
class RecursiveCharacterChunking(ChunkingStrategy):
"""递归字符分块策略"""
def __init__(
self,
chunk_size: int = 1000,
overlap: int = 100,
separators: list[str] | None = None,
):
"""
初始化递归字符分块策略。
参数:
chunk_size: 单个分块的最大字符长度限制,默认 1000。
overlap: 相邻分块之间的重叠字符长度,默认 100。
separators: 用于切分文本的候选分隔符列表,按优先级从高到低尝试,
默认包含段落、句子和常见标点。
"""
if overlap >= chunk_size:
raise ValueError(f"重叠长度 ({overlap}) 必须小于分块大小 ({chunk_size})")
self.chunk_size = chunk_size
self.overlap = overlap
self.separators = separators or [
"\n\n",
"\n",
"。",
"!",
"?",
";",
",",
" ",
"",
]
def _split_text(self, text: str, separators: list[str]) -> list[str]:
"""核心递归切分逻辑"""
final_chunks = []
separator = separators[-1]
new_separators = []
for i, _s in enumerate(separators):
if _s == "":
separator = _s
break
if _s in text:
separator = _s
new_separators = separators[i + 1 :]
break
if separator:
splits = [s for s in text.split(separator) if s]
else:
splits = list(text)
good_splits = []
for s in splits:
if len(s) < self.chunk_size:
good_splits.append(s)
else:
if good_splits:
merged_chunks = self._merge_splits(good_splits, separator)
final_chunks.extend(merged_chunks)
good_splits = []
if new_separators:
final_chunks.extend(self._split_text(s, new_separators))
else:
for i in range(0, len(s), self.chunk_size):
final_chunks.append(s[i : i + self.chunk_size])
if good_splits:
merged_chunks = self._merge_splits(good_splits, separator)
final_chunks.extend(merged_chunks)
return final_chunks
def _merge_splits(self, splits: list[str], separator: str) -> list[str]:
"""将零散的切片合并为符合 chunk_size 的块,并处理 Overlap"""
chunks = []
current_chunk = []
current_length = 0
for split in splits:
split_len = len(split)
sep_len = len(separator) if current_chunk else 0
if current_length + sep_len + split_len > self.chunk_size and current_chunk:
chunk_str = separator.join(current_chunk)
chunks.append(chunk_str)
while current_length > self.overlap or (
current_length + sep_len + split_len > self.chunk_size
and len(current_chunk) > 0
):
popped = current_chunk.pop(0)
current_length -= len(popped) + (
len(separator) if current_chunk else 0
)
sep_len = len(separator) if current_chunk else 0
current_chunk.append(split)
current_length += sep_len + split_len
if current_chunk:
chunk_str = separator.join(current_chunk)
chunks.append(chunk_str)
return chunks
def chunk(self, record: BaseRecord) -> list[BaseRecord]:
content = record.content.strip()
if len(content) <= self.chunk_size:
return [self._create_chunk_record(record, 0, content)]
text_chunks = self._split_text(content, self.separators)
chunks: list[BaseRecord] = []
for i, text_chunk in enumerate(text_chunks):
clean_chunk = text_chunk.strip()
if clean_chunk:
chunks.append(self._create_chunk_record(record, i, clean_chunk))
return chunks
class RowChunking(ChunkingStrategy):
"""
行数据分块策略 (专为 CSV/表格设计)
核心特性:自动识别表头,并将其附加到每一个被切分的 Chunk 首部,防止上下文丢失。
"""
def __init__(self, rows_per_chunk: int = 50):
"""
初始化表格行数据分块策略。
参数:
rows_per_chunk: 每个分块包含的数据行数(不含表头),默认 50。
"""
self.rows_per_chunk = rows_per_chunk
def chunk(self, record: BaseRecord) -> list[BaseRecord]:
lines = record.content.splitlines()
lines = [line for line in lines if line.strip()]
if not lines:
return []
header = lines[0]
data_lines = lines[1:]
if not data_lines:
return [self._create_chunk_record(record, 0, header)]
chunks: list[BaseRecord] = []
chunk_index = 0
for i in range(0, len(data_lines), self.rows_per_chunk):
chunk_lines = [header, *data_lines[i : i + self.rows_per_chunk]]
chunk_content = "\n".join(chunk_lines)
chunks.append(self._create_chunk_record(record, chunk_index, chunk_content))
chunk_index += 1
return chunks
class DeduplicationProcessor:
"""
入库批处理去重器 (Intra-batch Deduplication)。
在 Chunk 存入数据库前,通过对比向量相似度,拦截高度重复的内容(如群聊复读机内容)。
"""
def __init__(self, threshold: float = 0.98):
"""
初始化入库批处理去重处理器。
参数:
threshold: 余弦相似度重复阈值,超过该阈值的块将被判定为重复并过滤,
默认 0.98。
"""
self.threshold = threshold
async def process(self, records: list[BaseRecord]) -> list[BaseRecord]:
if not records or len(records) <= 1:
return records
kept_records: list[BaseRecord] = []
dropped_count = 0
for record in records:
if not record.embedding:
kept_records.append(record)
continue
is_duplicate = False
for kept in kept_records:
if not kept.embedding:
continue
sim = cosine_similarity(record.embedding, kept.embedding)
if sim >= self.threshold:
is_duplicate = True
dropped_count += 1
break
if not is_duplicate:
kept_records.append(record)
if dropped_count > 0:
logger.debug(
f"🧹 [入库管线] 触发批处理去重,已拦截 {dropped_count} "
f"个高度重复的 Chunk (阈值: {self.threshold})"
)
return kept_records
class BaseBatchNode(ABC):
"""批处理节点基类:一次性接收并处理全部记录"""
@abstractmethod
async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]: ...
class BaseMapNode(ABC):
"""单映射节点基类:接收单条记录,引擎负责并发调度,返回None代表丢弃该数据"""
@abstractmethod
async def process_one(
self, record: BaseRecord
) -> BaseRecord | list[BaseRecord] | None: ...
class DynamicChunkingNode(BaseBatchNode):
"""智能路由切块节点。根据记录的扩展名动态选择切块策略。"""
def __init__(
self,
default_strategy: ChunkingStrategy,
custom_strategies: dict[str, ChunkingStrategy] | None = None,
):
"""
初始化智能路由切块节点。
参数:
default_strategy: 默认的切块策略。
custom_strategies: 针对特定文件后缀的自定义切块策略映射表,
默认 CSV 文件使用 RowChunking。
"""
self.default_strategy = default_strategy
self.strategies = custom_strategies or {".csv": RowChunking(rows_per_chunk=30)}
async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]:
chunks = []
for record in records:
ext = record.metadata.get("extension", "")
strategy = self.strategies.get(ext, self.default_strategy)
chunks.extend(strategy.chunk(record))
return chunks
class BaseEmbeddingBatchNode(BaseBatchNode):
"""批量向量化抽象基类:提取文本、分批请求 API 并将结果写回的公共逻辑"""
def __init__(self, embedder, batch_size: int = 80):
"""
初始化批量向量化抽象基类。
参数:
embedder: 向量嵌入模型/函数,用于将文本生成向量。
batch_size: 向量化请求的单批次大小限制,默认 80。
"""
self.embedder = embedder
self.batch_size = batch_size
@abstractmethod
def _filter_target_records(self, records: list[BaseRecord]) -> list[BaseRecord]:
"""由子类实现:筛选出本次需要进行向量化的目标记录"""
pass
async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]:
if not self.embedder or not records:
return records
target_records = self._filter_target_records(records)
if not target_records:
return records
texts = [r.content for r in target_records]
try:
vecs = []
for i in range(0, len(texts), self.batch_size):
batch_texts = texts[i : i + self.batch_size]
batch_vecs = await self.embedder(batch_texts, task="document")
vecs.extend(batch_vecs)
for i, r in enumerate(target_records):
if vecs and i < len(vecs) and vecs[i]:
r.embedding = vecs[i]
except Exception as e:
logger.error(f"[{self.__class__.__name__}] 批量向量化失败: {e}")
return records
class EmbeddingNode(BaseEmbeddingBatchNode):
"""并发向量化初次构建节点"""
def _filter_target_records(self, records: list[BaseRecord]) -> list[BaseRecord]:
return [r for r in records if r.content.strip()]
class DedupNode(BaseBatchNode):
"""批次内查重节点"""
def __init__(self, threshold: float):
"""
初始化批次内查重节点。
参数:
threshold: 余弦相似度重复阈值,超过该阈值的块将被判定为重复并过滤。
"""
self.processor = DeduplicationProcessor(threshold=threshold)
async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]:
return await self.processor.process(records)
class StorageCommitNode(BaseBatchNode):
"""持久化事务提交节点 (Reduce)。统一收集意图并执行并发数据库 I/O。"""
def __init__(self, storage):
"""
初始化持久化事务提交节点。
参数:
storage: 存储后端,负责将记录存入或删除。
"""
self.storage = storage
async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]:
if not records:
return records
to_delete = set()
to_update = []
to_insert = []
for record in records:
if record.action == "delete":
to_delete.add(record.id)
elif record.action == "update":
to_update.append(record)
elif record.action == "insert":
to_insert.append(record)
if to_delete:
await self.storage.delete(record_ids=list(to_delete))
if to_update:
await asyncio.gather(*[self.storage.update(r) for r in to_update])
if to_insert:
await self.storage.save(to_insert)
logger.debug(
"💾 RAG 事务提交完成:插入 "
f"{len(to_insert)} 条, 更新 {len(to_update)} 条, "
f"删除 {len(to_delete)} 条。"
)
return to_insert + to_update
class IndexPipeline:
"""统一入库流水线 (Map-Reduce 范式并发调度引擎)"""
def __init__(
self,
nodes: list[BaseBatchNode | BaseMapNode] | None = None,
max_workers: int = 5,
):
"""
初始化统一入库流水线。
参数:
nodes: 管道节点列表,按顺序执行数据处理,默认 None。
max_workers: 最大并发工作协程数,用于 Map 节点的并发调度,默认 5。
"""
self.nodes = nodes or []
self.max_workers = max_workers
def add_node(self, node: BaseBatchNode | BaseMapNode):
self.nodes.append(node)
async def run(self, records: list[BaseRecord]) -> list[BaseRecord]:
if not records:
return []
current_records = records
for node in self.nodes:
if not current_records:
break
if isinstance(node, BaseBatchNode):
current_records = await node.process_batch(current_records)
elif isinstance(node, BaseMapNode):
sem = asyncio.Semaphore(self.max_workers)
map_node: BaseMapNode = node
async def _process_with_sem(r: BaseRecord):
async with sem:
return await map_node.process_one(r)
tasks = [_process_with_sem(r) for r in current_records]
results = await asyncio.gather(*tasks)
next_records = []
for r in results:
if isinstance(r, list):
next_records.extend(r)
elif r is not None:
next_records.append(r)
current_records = next_records
else:
raise ValueError(f"未知的管道节点类型: {type(node)}")
return current_records
__all__ = [
"BaseBatchNode",
"BaseMapNode",
"ChunkingStrategy",
"DedupNode",
"DeduplicationProcessor",
"DocumentChunking",
"DynamicChunkingNode",
"EmbeddingNode",
"IndexPipeline",
"RecursiveCharacterChunking",
"RowChunking",
"StorageCommitNode",
]
+47
View File
@@ -0,0 +1,47 @@
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class BaseRecord(BaseModel):
"""RAG 基础记录载体,没有任何业务属性"""
id: str = Field(default_factory=lambda: __import__("uuid").uuid4().hex)
"""记录的唯一标识符"""
content: str = Field(...)
"""数据块的文本内容"""
embedding: list[float] | None = Field(default=None)
"""数据块对应的向量嵌入"""
metadata: dict[str, Any] = Field(default_factory=dict)
"""数据块的元数据字典"""
action: Literal["insert", "update", "delete", "ignore"] = Field(default="insert")
"""数据块在索引管线中的操作意图"""
class SearchResult(BaseModel):
"""搜索结果"""
record: BaseRecord
"""检索到的基础记录"""
score: float
"""检索相似度得分"""
class QueryRequest(BaseModel):
"""通用检索请求"""
model_config = ConfigDict(arbitrary_types_allowed=True)
text: str = Field(default="")
"""原始查询文本"""
embedding: list[float] | None = Field(default=None)
"""用于向量检索的数组"""
search_type: Literal["dense", "sparse", "hybrid"] = Field(default="dense")
"""检索类型标识:稠密向量、稀疏关键词或混合"""
metadata_filters: dict[str, Any] | None = Field(default=None)
"""元数据精确匹配字典"""
limit: int = Field(default=10)
"""返回的最大条数"""
StorageConfigType = dict[str, Any]
@@ -0,0 +1,439 @@
from abc import abstractmethod
import asyncio
import time
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
from zhenxun.services.ai.context.rag.models import QueryRequest, SearchResult
from zhenxun.services.log import logger
if TYPE_CHECKING:
from zhenxun.services.ai.context.rag.backends.storages import StorageBackend
def normalize_query_text(query: Any) -> str:
"""辅助函数:提取各种输入形式(如字符串、平台Message对象)的纯文本用于检索"""
if isinstance(query, str):
return query
if hasattr(query, "extract_plain_text"):
return query.extract_plain_text()
return str(query) if query is not None else ""
@runtime_checkable
class BaseRetriever(Protocol):
"""
检索器核心协议。
任何实现了 retrieve 方法的对象均可作为检索器
(不仅限于向量检索,也可包含 BM25、SQL 搜索等)。
"""
@abstractmethod
async def retrieve(
self, query: Any, limit: int = 10, **kwargs: Any
) -> list[SearchResult]: ...
@runtime_checkable
class PostProcessor(Protocol):
"""后处理器协议(如重排、时间衰减打分等)。"""
@abstractmethod
async def process(
self, results: list[SearchResult], query: str
) -> list[SearchResult]: ...
@runtime_checkable
class PreProcessor(Protocol):
"""预处理器协议(如 LLM Query 改写、意图提取等)。"""
@abstractmethod
async def process(self, query: str) -> list[str]:
"""接收原始查询,返回一个或多个处理/改写后的查询词"""
...
class FilterEvaluator:
"""纯 Python 内存求值器,用于为轻量级 Storage 提供字典精确匹配过滤"""
@classmethod
def evaluate(
cls, metadata: dict[str, Any], filter_dict: dict[str, Any] | None
) -> bool:
if filter_dict is None:
return True
return all(metadata.get(k) == v for k, v in filter_dict.items())
class VectorDBRetriever(BaseRetriever):
"""基于向量数据库的标准检索器"""
def __init__(
self,
storage: "StorageBackend",
embedder: Any,
scope_prefix: str | None = None,
score_threshold: float = 0.4,
):
"""
初始化向量数据库检索器。
参数:
storage: 存储后端,用于执行向量相似度搜索。
embedder: 向量嵌入模型/函数,用于将文本转换为向量。
scope_prefix: 作用域前缀,用于限制检索范围,默认 None。
score_threshold: 分数阈值,过滤掉相似度低于该值的检索结果,默认 0.4。
"""
self.storage = storage
self.embedder = embedder
self.scope_prefix = scope_prefix
self.score_threshold = score_threshold
async def retrieve(
self, query: Any, limit: int = 10, **kwargs: Any
) -> list[SearchResult]:
text_query = normalize_query_text(query)
vecs = await self.embedder(query, task="query")
query_vec = vecs[0] if vecs else None
if not text_query.strip() and not query_vec:
return []
req = QueryRequest(
text=text_query,
embedding=query_vec,
limit=limit * 2,
search_type="dense",
metadata_filters=kwargs.get("metadata_filters"),
)
effective_scopes = kwargs.get(
"scopes", [self.scope_prefix] if self.scope_prefix else None
)
results = await self.storage.search(req, scopes=effective_scopes)
return [r for r in results if r.score >= self.score_threshold][:limit]
class DatabaseSparseRetriever(BaseRetriever):
"""纯数据库下沉的稀疏检索器 (Keyword/FTS)"""
def __init__(
self,
storage: "StorageBackend",
scope_prefix: str | None = None,
score_threshold: float = 0.0,
):
"""
初始化数据库稀疏检索器。
参数:
storage: 存储后端,用于执行全文检索/关键词检索。
scope_prefix: 作用域前缀,用于限制检索范围,默认 None。
score_threshold: 分数阈值,过滤掉相关度低于该值的检索结果,默认 0.0。
"""
self.storage = storage
self.scope_prefix = scope_prefix
self.score_threshold = score_threshold
async def retrieve(
self, query: Any, limit: int = 10, **kwargs: Any
) -> list[SearchResult]:
text_query = normalize_query_text(query)
if not text_query.strip():
return []
req = QueryRequest(
text=text_query,
limit=limit * 2,
search_type="sparse",
metadata_filters=kwargs.get("metadata_filters"),
)
effective_scopes = kwargs.get(
"scopes", [self.scope_prefix] if self.scope_prefix else None
)
results = await self.storage.search(req, scopes=effective_scopes)
return [r for r in results if r.score > self.score_threshold][:limit]
class RerankRetriever(BaseRetriever):
"""带大模型交叉注意力重排的高阶检索器 (Decorator Pattern)"""
def __init__(
self,
base_retriever: BaseRetriever,
model_name: str | None = None,
top_n: int = 5,
oversample_factor: int = 2,
min_oversample: int = 20,
):
"""
初始化重排检索器。
参数:
base_retriever: 基础检索器,用于初筛。
model_name: 重排模型的名称,默认 None。
top_n: 重排后保留的前 N 个文档数,默认 5。
oversample_factor: 过采样系数,决定初筛检索的文档数量倍数,默认 2。
min_oversample: 最小过采样文档数,默认 20。
"""
self.base_retriever = base_retriever
self.model_name = model_name
self.top_n = top_n
self.oversample_factor = oversample_factor
self.min_oversample = min_oversample
async def retrieve(
self, query: Any, limit: int = 10, **kwargs: Any
) -> list[SearchResult]:
oversample_limit = max(limit * self.oversample_factor, self.min_oversample)
initial_results = await self.base_retriever.retrieve(
query, limit=oversample_limit, **kwargs
)
if not initial_results:
return []
text_query = normalize_query_text(query)
docs: list[str | dict[str, str]] = [
res.record.content for res in initial_results
]
from zhenxun.services.ai.llm.api import rerank
try:
reranked = await rerank(
query=text_query,
documents=docs,
top_n=min(limit, self.top_n),
model=self.model_name,
)
except Exception as e:
logger.warning(f"Rerank 重排请求失败,将降级返回初筛结果: {e}")
return initial_results[:limit]
final_results = []
for rr in reranked:
original_res = initial_results[rr.index]
original_res.score = rr.relevance_score
final_results.append(original_res)
return final_results
class PipelineRetriever(BaseRetriever):
"""支持挂载多个后处理器的流水线检索器"""
def __init__(
self,
base_retriever: BaseRetriever,
post_processors: list[PostProcessor] | None = None,
pre_processors: list[PreProcessor] | None = None,
):
"""
初始化流水线检索器。
参数:
base_retriever: 基础检索器,执行最初的检索过程。
post_processors: 后处理器列表,用于对检索到的结果进行重排、过滤等后处理,默认 None。
pre_processors: 预处理器列表,用于对查询词进行改写、扩展等预处理,默认 None。
""" # noqa: E501
self.base_retriever = base_retriever
self.post_processors = post_processors or []
self.pre_processors = pre_processors or []
async def retrieve(
self, query: Any, limit: int = 10, **kwargs: Any
) -> list[SearchResult]:
text_query = normalize_query_text(query)
queries_to_search = [query]
if text_query.strip():
processed_texts = [text_query]
for pp in self.pre_processors:
new_texts = []
for t in processed_texts:
new_texts.extend(await pp.process(t))
processed_texts = new_texts
if len(processed_texts) > 1 or (
len(processed_texts) == 1 and processed_texts[0] != text_query
):
queries_to_search.extend(processed_texts)
all_results = []
seen_ids = set()
for q in queries_to_search:
res = await self.base_retriever.retrieve(q, limit=limit * 2, **kwargs)
for r in res:
if r.record.id not in seen_ids:
seen_ids.add(r.record.id)
all_results.append(r)
results = sorted(all_results, key=lambda x: x.score, reverse=True)
for pp in self.post_processors:
results = await pp.process(results, query)
return results[:limit]
class LifecyclePostProcessor(PostProcessor):
"""生命周期后处理器(融合时间衰减与惰性访问强化)"""
def __init__(
self,
half_life_days: int = 30,
decay_weight: float = 0.3,
semantic_weight: float = 0.7,
importance_weight: float = 0.0,
reinforcement_weight: float = 0.2,
):
"""
初始化生命周期后处理器。
参数:
half_life_days: 记忆衰减半衰期天数,控制信息随时间的降权速度,默认 30。
decay_weight: 时间衰减得分的权重,默认 0.3。
semantic_weight: 语义相关度得分的权重,默认 0.7。
importance_weight: 信息重要性得分的权重,默认 0.0。
reinforcement_weight: 惰性访问强化(如访问次数得分)的权重,默认 0.2。
"""
self.half_life_days = half_life_days
self.decay_weight = decay_weight
self.semantic_weight = semantic_weight
self.importance_weight = importance_weight
self.reinforcement_weight = reinforcement_weight
async def process(
self, results: list[SearchResult], query: str
) -> list[SearchResult]:
now = time.time()
import math
for res in results:
created_at = res.record.metadata.get("created_at", now)
importance = res.record.metadata.get("importance", 0.5)
access_count = res.record.metadata.get("access_count", 0)
last_accessed_at = res.record.metadata.get("last_accessed_at", created_at)
age_days = max(0.0, (now - last_accessed_at) / 86400.0)
decay = 0.5 ** (age_days / self.half_life_days)
access_score = min(1.0, math.log1p(access_count) / 5.0)
res.score = (
(self.semantic_weight * res.score)
+ (self.decay_weight * decay)
+ (self.importance_weight * importance)
+ (self.reinforcement_weight * access_score)
)
results.sort(key=lambda x: x.score, reverse=True)
return results
class HybridRetriever(BaseRetriever):
"""
双轨混合检索器 (Hybrid Search Engine)。
并发调用 Dense (VectorDB) 和 Sparse (BM25),并使用倒数秩融合 (RRF) 算法合并结果。
"""
def __init__(
self,
dense_retriever: BaseRetriever,
sparse_retriever: BaseRetriever,
dense_weight: float = 0.7,
sparse_weight: float = 0.3,
rrf_k: int = 60,
oversample_factor: int = 2,
min_oversample: int = 20,
):
"""
初始化双轨混合检索器。
参数:
dense_retriever: 稠密向量检索器,用于语义召回。
sparse_retriever: 稀疏文本检索器,用于关键词召回(如 BM25)。
dense_weight: 稠密向量检索的加权权重,默认 0.7。
sparse_weight: 稀疏文本检索的加权权重,默认 0.3。
rrf_k: 倒数秩融合(RRF)算法中的常数参数,默认 60。
"""
self.dense_retriever = dense_retriever
self.sparse_retriever = sparse_retriever
self.dense_weight = dense_weight
self.sparse_weight = sparse_weight
self.rrf_k = rrf_k
self.oversample_factor = oversample_factor
self.min_oversample = min_oversample
async def retrieve(
self, query: Any, limit: int = 10, **kwargs: Any
) -> list[SearchResult]:
oversample_limit = max(limit * self.oversample_factor, self.min_oversample)
results = await asyncio.gather(
self.dense_retriever.retrieve(query, limit=oversample_limit, **kwargs),
self.sparse_retriever.retrieve(query, limit=oversample_limit, **kwargs),
return_exceptions=True,
)
for res in results:
if isinstance(res, ImportError):
raise res
dense_res = (
cast(list[SearchResult], results[0])
if not isinstance(results[0], BaseException)
else []
)
sparse_res = (
cast(list[SearchResult], results[1])
if not isinstance(results[1], BaseException)
else []
)
if isinstance(results[0], BaseException):
logger.error(f"[HybridSearch] 向量检索异常: {results[0]}")
if isinstance(results[1], BaseException):
logger.error(f"[HybridSearch] BM25 检索异常: {results[1]}")
rrf_scores: dict[str, float] = {}
merged_records = {}
for rank, res in enumerate(dense_res):
record_id = res.record.id
merged_records[record_id] = res.record
rrf_score = 1.0 / (self.rrf_k + rank + 1)
rrf_scores[record_id] = rrf_scores.get(record_id, 0.0) + (
self.dense_weight * rrf_score
)
for rank, res in enumerate(sparse_res):
record_id = res.record.id
merged_records[record_id] = res.record
rrf_score = 1.0 / (self.rrf_k + rank + 1)
rrf_scores[record_id] = rrf_scores.get(record_id, 0.0) + (
self.sparse_weight * rrf_score
)
max_possible_score = (self.dense_weight * (1.0 / (self.rrf_k + 1))) + (
self.sparse_weight * (1.0 / (self.rrf_k + 1))
)
final_results = []
for record_id, score in sorted(
rrf_scores.items(), key=lambda x: x[1], reverse=True
):
normalized_score = (
score / max_possible_score if max_possible_score > 0 else 0.0
)
final_results.append(
SearchResult(record=merged_records[record_id], score=normalized_score)
)
logger.debug(
f"⚖️ [HybridSearch] 融合完成: "
f"Dense({len(dense_res)}) + Sparse({len(sparse_res)}) "
f"-> Merged({len(final_results)}), 截取 Top {limit}"
)
return final_results[:limit]
+12
View File
@@ -0,0 +1,12 @@
import numpy as np
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""使用 numpy 计算两组向量的余弦相似度"""
if not vec1 or not vec2 or len(vec1) != len(vec2):
return 0.0
v1, v2 = np.array(vec1), np.array(vec2)
norm1, norm2 = np.linalg.norm(v1), np.linalg.norm(v2)
if norm1 == 0 or norm2 == 0:
return 0.0
return float(np.dot(v1, v2) / (norm1 * norm2))