基于 LogShare 当前代码与知识库审计,对 RAG 检索层进行能力增强与架构升级。 上一阶段 LogAgent 完整化改造已合并(commit
dfca4fe),本计划承接后续。
rag/knowledge/ 332KB / 1,150 个 Markdown 文件
├── 日志分析/ 42 个结构化报错条目(KB 编号)
├── patterns/ 13 个故障模式
├── mobile_launcher/ 启动器常识(2 个文件)
├── format/ 3 个日志格式指南
├── android-native-lib/ 原生库加载问题(2 个文件)
├── tools/ 测试样例
├── zl_about/ 站点运营信息(3 个文件)
└── zl_announcement/ 站点公告(1 个文件)
app/Rag/
├── RagSearch.php 932 行 — FTS5 BM25 + CJK bigram LIKE + 向量语义
├── RagManager.php 543 行 — 构建/CRUD/统计/Admin API
└── SemanticClient.php 203 行 — 多 provider embedding 故障转移
app/Client/
└── MCPClient.php 237 行 — MCP 协议 HTTP 传输
app/Command/
└── RagBuildCommand.php 61 行 — CLI 重建索引
app/Controller/
└── RagController.php 275 行 — Admin Web CRUD
- RagSearch 双路检索扎实:FTS5 porter unicode61 分词 + prefix 匹配;CJK 3+ 字切成 overlapping bigram 做 LIKE fallback;语义向量可选补充;进程级 FIFO 缓存 64 条/1MB
- 语义层多 provider 故障转移:按配置顺序重试,跳过无 apiKey 的 provider,支持不同 gateway 的模型名差异
- 索引构建原子化:写临时库 → rename,构建失败不破坏线上索引
- 知识库 CRUD 有安全边界:路径遍历防护、扩展名白名单、原子写入、Admin 脱敏
- 分块按 H2 Heading 切割:保留 H1 标题前缀,短块(≤1600 字)整段返回保结构,长块做命中词为中心 ±800 字符窗口 + 句读边界回退
(1)RagSearch 932 行一人扛全部
分块、FTS5、LIKE fallback、向量扫描、snippet 提取、topics 管理全在一个类里。新增分块策略需要改 932 行文件, rerank 逻辑无处安放。
(2)检索管道是"顺序单次"而非"多路融合"
当前 BM25 → LIKE fallback → 向量补充 是串行管道,不是并行召回 + 融合精排。BM25 和向量的 top-k 结果简单 merge,没有 rerank,高相关文档可能被低相关文档挤出 top-n。
(3)查询侧没有预处理
用户的查询(实际是 LLM 生成的检索词)直接送入检索。没有查询改写、没有分类路由、没有停用词过滤、没有同义词扩展。中文口语化查询(如"进世界就闪退")命中率低。
(4)Chunking 策略单一
只有按 ## Heading 切分。问题:
- 超长 H2 section 不会被二次切分,单个 chunk 可能数千字
- 没有 overlap:相邻 chunk 边界处的上下文丢失
- 没有 token-aware 切分:按字符数而非 token 数判断 chunk 大小
- 没有层级化 parent-child 结构
(5)Embedding 层薄
SemanticClient 只做了基础 POST /embeddings。缺少:查询向量缓存、batch size 自适应、维度自动探测、本地 embedding provider 支持。构建时 embed 一次,查询时全量扫描向量表(LIMIT 5000),知识库变大后查询延迟会上升。
(6)没有增量索引
rag:build 每次都全量重建。知识库从 1,150 个文件涨到 5,000+ 时构建时间会显著变长。新增/修改单个文档需要全量 rebuild,Admin 在线编辑后无法热更新索引。
(7)MCP 只有 HTTP 短轮询
MCPClient 每次调用都是独立的 HTTP POST + 握手。多轮 tool call 场景下重复握手 overhead 明显,且没有连接级缓存。
(8)没有检索可观测性
不知道用户查询了什么、哪些命中了、哪些没命中、各阶段耗时多少、embedding API 花了多少钱。没有慢查询日志,没有命中率统计。
app/Rag/
├── Chunker.php # 分块策略(Heading / 滑动窗口 / token-aware)
├── Chunk.php # Chunk 值对象
├── LexicalIndex.php # FTS5 + LIKE 检索(从 RagSearch 抽出)
├── VectorIndex.php # 向量扫描 + 余弦相似度(从 RagSearch 抽出)
├── SnippetExtractor.php # 片段提取与截断(从 RagSearch 抽出)
├── RetrievalPipeline.php # 多路召回 → merge → rerank(新)
├── RagSearch.php # 精简为门面(~150 行)
├── RagManager.php # 增强:增量索引、热更新
├── SemanticClient.php # 增强:查询缓存、batch 自适应、本地 provider
└── SemanticCache.php # 查询向量缓存(新)
app/Rag/Rerank/
├── RerankInterface.php
├── LLMReranker.php # 用 LLM 做 pairwise rerank
└── NoopReranker.php # 关闭 rerank 时的直通
app/Agent/Tool/
└── RagSearchTool.php # 增强:注入查询改写与 rerank 能力
- 检索质量:多路召回 + rerank,top-k 准确率预期提升 30-50%
- 查询鲁棒性:查询改写 + 分类路由,口语化/模糊查询命中率显著提升
- 分块质量:滑动窗口 overlap + token-aware 切分,边界上下文丢失减少
- 构建效率:增量索引,单文档热更新,Admin 编辑后秒级生效
- 查询成本:embedding 查询缓存,重复查询零 API 调用
- 可观测性:完整检索链路埋点,慢查询日志,命中率统计
现状:只有按 ## Heading 切分。
新增三种分块策略,可配置叠加:
final class Chunker
{
/** 按 H2 Heading 切分(保留,作为基础策略) */
public static function byHeading(string $source, string $content): array;
/** 滑动窗口切分:固定 chunkSize + overlap 比例 */
public static function bySlidingWindow(string $content, int $chunkSize = 1000, int $overlap = 150): array;
/** Token-aware 切分:按估算 token 数切割,避免超长上下文 */
public static function byToken(string $content, int $maxTokens = 512, int $overlapTokens = 64): array;
/** 组合策略:Heading 切分为主,超长 chunk 自动降级为滑动窗口二次切分 */
public static function chunk(string $source, string $content, ChunkStrategy $strategy): array;
}
enum ChunkStrategy {
case HEADING_ONLY; // 仅按 H2 切分(当前行为,默认)
case SLIDING_WINDOW; // 纯滑动窗口
case TOKEN_AWARE; // 按 token 数切分
case HYBRID; // Heading 为主,超长 chunk 二次切分(推荐)
}关键细节:
HYBRID模式:先按 Heading 切分,如果某个 chunk 超过maxTokens(如 1024),对该 chunk 内部再用滑动窗口二次切分,并标记 parent chunk id,形成 parent-child 层级结构- 每个 Chunk 值对象携带:
source(文件路径)、title、body、startOffset、endOffset、parentId(如有)、tokenCount - 构建索引时 chunk 元数据存入 SQLite 的
chunks表(新增),方便后续增量更新做 mtime 比对
现状:RagSearch::search() 内部串行执行 BM25 → LIKE → 向量,简单 merge。
新设计:并行召回 + 融合精排
final class RetrievalPipeline
{
public function __construct(
private readonly LexicalIndex $lexical,
private readonly VectorIndex $vector,
private readonly Reranker $reranker,
private readonly SnippetExtractor $snippets,
) {}
/**
* @param array{query: string, topic: string|null, k: int} $params
* @return array<int, ScoredChunk>
*/
public function retrieve(array $params): array;
}
final class ScoredChunk
{
public function __construct(
public readonly string $source,
public readonly string $title,
public readonly string $body,
public readonly float $lexicalScore, // BM25 原始分
public readonly float $vectorScore, // 余弦相似度
public readonly float $fusedScore, // 融合后分数
public readonly int $rank, // 最终排名
) {}
}检索流程:
1. 查询预处理(QueryPreProcessor)
├── 查询改写:LLM 将原始查询扩写为 2-3 个精准检索词
├── 分类路由:判断查询类型(崩溃/模组/性能/网络),加权对应 topic
└── 停用词过滤 + 同义词扩展
2. 多路并行召回(各取 top-20)
├── BM25: FTS5 MATCH + prefix matching
├── LIKE: CJK bigram fallback
└── 向量: 余弦相似度 top-20
3. 融合(RRF — Reciprocal Rank Fusion)
score = Σ (1 / (k + rank_i)) × typeWeight
不用加权平均,RRF 对分数尺度不敏感,更适合融合不同排序体系
4. Rerank(可选,配置开关)
├── LLM Reranker: pairwise comparison,用 cheap 模型对 top-30 精排
└── NoopReranker: 跳过,直接用 RRF 融合分
5. Snippet 提取
└── 对 top-k 结果提取围绕命中词的上下文片段
RRF 融合优于简单加权平均的原因:BM25 分和余弦相似度的数值范围不同(BM25 可能到 10+,余弦在 0-1 之间),加权平均对尺度敏感。RRF 只依赖排名位置,天然免疫尺度差异。
final class QueryPreProcessor
{
/**
* 将原始查询扩写为更精准的检索词。
* 调用 LLM(cheap/fast 模型),prompt 非常轻量:
* "给定一个 Minecraft 日志排障查询,输出 2-3 个英文检索词
* (异常类名、错误关键词),保留原文中的技术术语不变。
* 查询:{query}
* 检索词(逗号分隔):"
*/
public function rewrite(string $query, string $mode = self::MODE_LIGHT): array;
/**
* 判断查询类型,返回 topic 权重映射。
* 用于检索时对不同 topic 的结果做加权偏置。
*/
public function classify(string $query): array; // ['patterns' => 1.2, '日志分析' => 1.0, ...]
}分类映射:
| 查询特征 | 加权 topic |
|---|---|
| 包含 Mixin/ClassNotFound/NoSuchMethod | patterns ×1.3, 日志分析 ×1.2 |
| 包含 SIGSEGV/OOM/exit code | patterns ×1.3 |
| 包含 闪退/崩溃/卡死(中文) | patterns ×1.2, 日志分析 ×1.2 |
| 包含 FCL/Pojav/Amethyst/MobileGlues | mobile_launcher ×1.5 |
| 包含 Fabric/Forge/NeoForge/Quilt | 日志分析 ×1.3 |
| 包含 世界加载/chunk/NBT | patterns ×1.2 |
| 包含 网络/ConnectException/Timeout | 日志分析 ×1.1 |
LLM-based Pairwise Reranker:
final class LLMReranker implements Reranker
{
public function __construct(
private readonly AIClientGateway $gateway,
private readonly int $maxCandidates = 30,
) {}
/**
* 对 candidates 做 pairwise comparison rerank。
* 调用 LLM 一次,传入 query + 全部候选,要求按相关性排序。
* 使用 cheap 模型(如已在配置的 minimax-m2.5-free),
* prompt 控制在 200 字以内,单次调用成本 < ¥0.001。
*/
public function rerank(string $query, array $candidates): array;
}开关控制:ai.rag.rerank.enabled(默认 false,确认有效后再开)。关闭时 RetrievalPipeline 直接用 RRF 融合分。
RagSearch::buildIndex() 中的 chunking 逻辑替换为 Chunker:
// 旧逻辑(内联在 buildIndex 中):
foreach (self::chunkMarkdown($relative, $content) as $chunk) { ... }
// 新逻辑:
$chunks = Chunker::chunk($relative, $content, ChunkStrategy::HYBRID);
foreach ($chunks as $chunk) {
// chunk 携带 tokenCount,存入新增的 chunks 表
$insert->execute([$chunk->title, $chunk->body, $relative, $chunk->tokenCount]);
}新增 chunks 表(与 docs 表并列):
CREATE TABLE IF NOT EXISTS chunks (
rowid INTEGER PRIMARY KEY,
doc_rowid INTEGER, -- 关联 docs.rowid
title TEXT,
body TEXT,
source TEXT,
token_count INTEGER,
parent_rowid INTEGER, -- parent chunk rowid(如有)
FOREIGN KEY (doc_rowid) REFERENCES docs(rowid),
FOREIGN KEY (parent_rowid) REFERENCES chunks(rowid)
);新增方法:
final class RagManager
{
/** 增量重建:仅处理变更的文件(基于 mtime 比对) */
public static function incrementalBuild(string $knowledgeDir, ?SemanticClient $semantic): array;
/** 增量重建单个文档(Admin 编辑后热更新) */
public static function reindexDoc(string $relativePath, ?SemanticClient $semantic): array;
/** 获取需要更新的文件列表(mtime 与索引中记录不符) */
public static function getStaleFiles(string $knowledgeDir): array;
}增量逻辑:
- 读取现有
index.db中chunks表的source_mtime字段 - 遍历知识库目录,比对文件 mtime
- 仅对 mtime 变更的文件执行 re-chunk + re-embed
- 删除已不存在的文件对应的 chunk
- 原子化 replace 变更的 chunk 行
Admin 热更新:RagController::saveDoc() 写入文件后自动调用 RagManager::reindexDoc(),无需手动 rag:build。
新增 SemanticCache:
final class SemanticCache
{
/** 查询向量缓存:query hash → vector */
private const CACHE_PREFIX = 'rag:embed:query:';
private const CACHE_TTL = 86400; // 24h
public function get(string $query): ?array;
public function set(string $query, array $vector): void;
}- 查询向量缓存在 Redis 中(24h TTL),相同查询零 API 调用
- 缓存键:
sha256(query),避免长 key 问题 - 进程内也做一层 memory cache(100 条 / 1MB),减少 Redis 往返
SemanticClient 增强:
- batch size 自适应:根据 provider 响应自动调整(成功则 +4,失败则 ÷2,范围 4-64)
- 维度自动探测:首次响应后缓存维度,后续请求维度不一致时触发告警并自动 re-embed 该 batch
- 新增本地 provider:
http://localhost:11434/api/embeddings(Ollama 格式),支持完全离线部署
当前 MCPClient 每次调用都是独立 HTTP POST。增强:
- 连接复用:同一 endpoint 在一次分析运行内复用 MCPClient 实例(已有
McpClientFactory做 Session 级缓存,维持) - SSE 长连接传输(可选):支持 MCP Server 以 SSE 模式推送工具结果,降低多轮 call 的握手开销
- MCP 结果缓存:Session 内相同 tool + arguments 的调用直接返回缓存结果
- 健康检查:每次分析开始前 ping RAG MCP endpoint,不可用时自动降级为纯词法检索
新增 rag/knowledge/.manifest.json:
{
"version": "2.0",
"builtAt": 1234567890,
"chunker": "hybrid",
"maxTokens": 1024,
"overlapTokens": 128,
"semantic": {"enabled": true, "providers": ["siliconflow/bge-m3"]},
"rerank": {"enabled": false},
"files": [
{"path": "patterns/mixin-apply-failed.md", "mtime": 1234567800, "chunks": 3},
...
]
}- 构建时自动生成,记录知识库版本与每文件元数据
- 增量索引通过 manifest 快速判断哪些文件需要更新
- Admin 页面显示知识库版本与最后构建时间
app/Rag/
├── Chunk.php # Chunk 值对象(新)
├── Chunker.php # 分块策略(新)
├── LexicalIndex.php # FTS5 + LIKE 检索(从 RagSearch 抽出)
├── VectorIndex.php # 向量扫描 + 余弦相似度(从 RagSearch 抽出)
├── SnippetExtractor.php # 片段提取(从 RagSearch 抽出)
├── RetrievalPipeline.php # 多路召回 → merge → rerank(新)
├── RagSearch.php # 精简为门面(~150 行)
├── RagManager.php # 增强:增量索引 + 热更新
├── SemanticClient.php # 增强:batch 自适应 + 本地 provider
├── SemanticCache.php # 查询向量缓存(新)
├── QueryPreProcessor.php # 查询改写 + 分类路由(新)
└── Rerank/
├── RerankInterface.php # 精排接口
├── LLMReranker.php # LLM pairwise rerank(新)
└── NoopReranker.php # 直通(新)
app/Agent/Tool/
└── RagSearchTool.php # 增强:支持 k/topic 参数透传
| 阶段 | 内容 | 预估行数 | 工作量 |
|---|---|---|---|
| 阶段一 | Chunker + Chunk 值对象 + 分块策略 | ~400 | 2h |
| 阶段二 | LexicalIndex + VectorIndex + SnippetExtractor 拆分 | ~500 | 2-3h |
| 阶段三 | RetrievalPipeline + RRF 融合 + LLM Reranker | ~350 | 2h |
| 阶段四 | QueryPreProcessor(查询改写 + 分类路由) | ~200 | 1.5h |
| 阶段五 | RagManager 增量索引 + 热更新 | ~300 | 2-3h |
| 阶段六 | SemanticCache + SemanticClient 增强 | ~200 | 1h |
| 阶段七 | 集成测试 + 回归验证 + MCP 增强 | ~300 | 2h |
| 总计 | ~2,250 | ~12-13h |
- 新建
Chunk值对象、Chunker(三种策略 + HYBRID 组合) - 新增
chunksSQLite 表(带 token_count / parent_rowid) - 修改
RagSearch::buildIndex()调用 Chunker - 单测:验证各策略切分结果、HYBRID 超长 chunk 二次切分、parent-child 关系
- 从 RagSearch 932 行中抽取
LexicalIndex(FTS5 + LIKE)和VectorIndex(向量扫描 + 余弦) - 抽取
SnippetExtractor(片段提取逻辑) - RagSearch 精简为门面, delegating 到三个组件
- 单测:LexicalIndex 和 VectorIndex 独立可测
- 新建
RetrievalPipeline:并行调用 Lexical + Vector,RRF 融合 - 新建
RerankInterface/LLMReranker/NoopReranker - 配置开关
ai.rag.rerank.enabled - 集成测试:rerank 对 top-k 准确率的影响(用已知 query 验证)
- 新建
QueryPreProcessor:查询改写 + 分类路由 - 接入
RagSearchTool,检索前自动调用 - 单测:改写准确性、分类映射正确性
- 新建
chunks表的 mtime 字段 RagManager::incrementalBuild()+reindexDoc()RagController::saveDoc()写入后自动触发热更新- 单测:增量 build 只处理变更文件、删除文件对应的 chunk 被清理
- 新建
SemanticCache(Redis + 进程内双层缓存) SemanticClientbatch size 自适应 + 维度探测 + 本地 provider- 单测:缓存命中/未命中、batch 自适应逻辑
- MCPClient 连接复用已有,加 SSE 传输支持(可选)
- 检索埋点:BM25 命中数、向量命中数、融合结果、各阶段耗时
- 慢查询日志(>500ms)
rag:stats命令:命中率、检索量趋势、embedding 成本
- 配置开关矩阵:
| 开关 | 默认值 | 说明 |
|---|---|---|
ai.rag.chunker |
heading |
heading / sliding / token / hybrid |
ai.rag.rerank.enabled |
false |
rerank 开关,关闭直通 RRF |
ai.rag.queryRewrite.enabled |
false |
查询改写开关 |
ai.rag.incrementalBuild |
false |
增量索引开关,关闭则全量 |
ai.rag.semanticCache |
true |
查询向量缓存 |
- 渐进式切换:每个开关独立,可单独开启/关闭。Chunker 切换后第一次
rag:build重建索引,之后行为稳定 - 回滚:改配置即可回退到前一策略,不涉及数据迁移
- 索引兼容:新版本
chunks表不存在时自动 fallback 到旧docs表查询路径
- 新增
chunks表,HYBRID chunking 策略可配置 -
RetrievalPipeline并行召回 BM25 + LIKE + 向量,RRF 融合 - LLM Reranker 可通过配置开关
- QueryPreProcessor 查询改写与分类路由可配置开关
-
RagManager::incrementalBuild()只处理 mtime 变更的文件 -
RagManager::reindexDoc()支持单文档热更新 -
SemanticCache缓存查询向量,重复查询不触发 embedding API -
SemanticClient支持本地 Ollama provider - 检索埋点记录各阶段耗时与命中数,
rag:stats可查看 - 集成测试:标准查询集在 rerank 开启后 top-5 准确率 ≥ 纯 BM25 基线
本计划与已合并的 LogAgent 改造(feat/logagent-refactor)形成协同:
| 协同点 | 说明 |
|---|---|
| RagSearchTool | LogAgent 改造已将 RagSearch 封装为独立 Tool 类,本计划增强检索能力后,Tool 层零改动直接受益 |
| PromptBuilder | 查询改写需要 LLM 调用,通过 AIClientGateway 接口复用,不新增外部依赖 |
| AnalysisTracer | 检索各阶段耗时自动记录到 trace,无需额外埋点代码 |
| ToolRegistry | Rerank 可作为 Tool 注册到 Registry,或在 RetrievalPipeline 内部透明执行(推荐后者,不暴露给 LLM) |
| Config 体系 | 新增 RAG 配置项通过 App\Config 热加载,无需重启 |
计划版本:v1.0 | 对应 LogShare commit: dfca4fe