Files
zhenxun_bot/zhenxun/services/ai/context/rag/ingestion.py
T
80fc5b86a7 ✨ 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>
2026-07-03 08:53:56 +08:00

522 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
]