项目地址 :gitee.com/guoxuezha/e...
博客七:日志 RAG 引擎------蒸馏、索引与精准检索
1. 为什么需要 RAG?
SSH + grep 模式有一个天然的限制:它只能"精确匹配",不能"语义理解"。
比如,当异常堆栈中包含一个 TooManyResultsException,但日志里的时间戳与用户提供的发生时间有几秒偏差时,grep 就会扑空。又比如,某些异常的根因隐藏在 INFO 级别的日志中(如 "连接池已满" 的警告信息),但 grep 只匹配了 error.log,完全看不到这些线索。
在这种情况下,我们需要一种更强大的检索方式:把完整的日志文件索引起来,支持语义搜索和关键字模糊匹配。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)的价值。
2. RAG 引擎架构
我们的 RAG 引擎基于 Emabel 框架内置的 Lucene 全文检索(BM25 算法),整体架构如下:
scss
日志文件(.log / .tar.gz)
│
▼ (SSH + gzip 压缩传输)
下载到本地
│
▼ (LogParser 正则解析)
List<ErrorLogRecord>
│
▼ (按 requestId 分组)
Map<requestId, List<ErrorLogRecord>>
│
▼ (TraceDocumentBuilder 构建文档)
MaterializedDocument (层级化)
├── LeafSection (service-A 的日志)
│ ├── 语义摘要(蒸馏后)
│ ├── 原始日志(元数据)
│ └── 元数据: requestId, hasError, timestamps...
└── LeafSection (service-B 的日志)
│
▼ (LuceneSearchOperations 分块索引)
Lucene Index (BM25 全文检索)
3. 日志解析:从文本到结构化记录
日志解析是整个 RAG 管线的第一步。我们的日志格式遵循 Logback 的标准 pattern:
ini
[request-id] [user-account] 2026-06-30 10:30:15.123 [http-nio-8080-exec-1] ERROR c.g.d.MyService - [doQuery,42] - 查询失败
LogParser 使用精心设计的正则表达式逐行解析,并处理多行堆栈的情况:
java
private static final String LOG_LINE_REGEX =
"^\[([^\]]+)\]\s+\[([^\]]+)\]\s+" + // request-id 和 user-account
"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+" + // 时间戳
"\[([^\]]+)\]\s+" + // 线程名
"(\w+)\s+" + // 日志级别
"([^\s]+)\s+-\s+" + // Logger 名称
"\[([^,]+),(\d+)\]\s+-\s+" + // 方法名和行号
"(.*)$"; // 消息
关键设计点:
- 多行合并 :非匹配行(如 Java 堆栈的
at ...行)会追加到前一条记录的msg中。 - request-id 过滤 :
EMPTY的记录被直接跳过,因为它们无法关联到具体的请求链路。 - GZIP 支持 :同时支持读取
.log和.gz压缩文件。
4. 日志蒸馏:从噪音中提取信号
原始日志通常包含大量框架噪音(Spring、MyBatis、Hibernate 的堆栈行),直接使用会稀释检索的精准度。我们的 LogDistiller 对日志进行"蒸馏"------只保留最有价值的信息:
java
public String distillSingleRecord(ErrorLogRecord record) {
String exceptionType = extractExceptionType(msg); // 提取异常类型
String errorMsgSummary = extractErrorMessage(msg); // 提取错误消息摘要
String stack = distillStack(msg); // 蒸馏堆栈(仅保留应用包路径前3行)
String params = extractParams(msg); // 提取业务关键参数
return String.format("[%s] %s.%s | %s | %s | %s%s",
record.timestamp(),
record.logger(), record.method(),
exceptionType, errorMsgSummary, stack,
params.isEmpty() ? "" : " | params: " + params
);
}
蒸馏后的日志示例:
蒸馏前(约 200 行):
less
[abc-123] [admin] 2026-06-30 10:30:15.123 [http-nio-8080-exec-1] ERROR c.g.d.OrderService - [queryOrder,42] - 查询订单失败
com.guo.common.exception.BaseException: 未找到订单信息
at com.guo.doctor.service.OrderService.queryOrder(OrderService.java:42)
at com.guo.doctor.controller.OrderController.get(OrderController.java:18)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:897)
... 45 more
蒸馏后(1 行):
yaml
[2026-06-30T10:30:15.123] c.g.d.OrderService.queryOrder | BaseException | 查询订单失败 com.guo.common.exception.BaseException: 未找到订单信息 | com.guo.doctor.service.OrderService.queryOrder -> no-app-stack
蒸馏的关键策略:
- 堆栈过滤 :只保留
com.guo.*开头的应用包路径行,过滤掉所有框架堆栈。 - 异常类型提取 :使用正则
(\w+Exception|\w+Error)[:\s]提取异常类名。 - 业务参数提取 :匹配
orderId=xxx、userId=yyy等常见的 key=value 模式。 - 错误消息保留 :仅从堆栈前的消息行中提取摘要,避免混入堆栈行中的冒号(如
Thread.java:750)。
5. 文档构建:层级化的 MaterializedDocument
TraceDocumentBuilder 将按 requestId 分组的日志转换为层级化文档:
java
public MaterializedDocument buildTraceDocument(String requestId, List<ErrorLogRecord> records) {
// 按服务分组
Map<String, List<ErrorLogRecord>> byService = records.stream()
.collect(Collectors.groupingBy(ErrorLogRecord::serviceCode));
// 为每个服务创建 LeafSection
List<NavigableSection> sections = new ArrayList<>();
for (var entry : byService.entrySet()) {
String semanticContent = logDistiller.buildSemanticContent(sorted);
String rawLogs = logDistiller.buildRawLogs(sorted);
LeafSection leaf = new LeafSection(
UUID.randomUUID().toString(),
null,
service,
semanticContent, // 蒸馏后的内容(用于 BM25 检索)
null,
Map.of(
"service", service,
"logCount", sorted.size(),
"hasError", hasError,
"rawLogs", rawLogs, // 原始日志(元数据)
"minTimestamp", sorted.getFirst().timestamp().toString(),
"maxTimestamp", sorted.getLast().timestamp().toString()
)
);
sections.add(leaf);
}
// 文档级元数据
String uri = "traces/" + datePrefix + "/" + requestId;
return new MaterializedDocument(requestId, uri, "Trace-" + requestId, ...);
}
设计亮点:
- 双层内容 :
content存蒸馏后的摘要(用于高效检索),metadata.rawLogs存完整原始日志(用于回溯)。 - URI 按日期分层 :
traces/2026-06-30/abc-123,方便按日期批量清理过期索引。 - 丰富的元数据 :
hasError、minTimestamp、maxTimestamp等,支持后置过滤。
6. Lucene 索引配置
RAG 配置在 RagConfiguration 中完成,使用纯文本检索(BM25),不依赖向量嵌入:
java
@Bean
public LuceneSearchOperations luceneSearchOperations() {
var chunkerConfig = new ContentChunker.Config(
1000, // chunk-size: 每个分块 1000 字符
200, // chunk-overlap: 块间重叠 200 字符
100 // min-chunk-size: 最小块大小
);
return new LuceneSearchOperations(
luceneName,
java.util.List.of(), // 无 enhancer
null, // 无 embedding(纯文本模式)
null, // 无 keyword extractor
0.0, // vector weight = 0(不使用向量)
chunkerConfig,
ChunkTransformer.NO_OP,
null // 内存索引(不持久化)
);
}
选择 BM25 而非向量检索的原因:
- 日志场景的特殊性:日志中的关键信息(异常类名、错误码)是精确的文本匹配,而非语义相似度。BM25 对这类"关键字检索"天然友好。
- 零依赖:不需要额外的 Embedding 模型和向量数据库,降低部署复杂度。
- 实时性:内存索引的写入和查询都是毫秒级,满足 Agent 的实时需求。
7. 检索服务:面向 Agent 的 API
LogSearchService 封装了 Lucene 搜索操作,提供多种检索方式:
java
// 基础语义搜索
public List<SimilarityResult<Chunk>> searchLogs(String query, int topK, double threshold) {
TextSimilaritySearchRequest request = TextSimilaritySearchRequest.create(query, threshold, topK);
return luceneSearchOperations.textSearch(request, Chunk.class);
}
// 带元数据过滤的搜索
public List<SimilarityResult<Chunk>> searchLogs(String query, int topK, double threshold,
String service, LocalDateTime from,
LocalDateTime to, Boolean hasError) {
// 先扩大候选集(3倍),再后置过滤
List<SimilarityResult<Chunk>> results = searchLogs(query, topK * 3, threshold);
return results.stream()
.filter(res -> {
Chunk chunk = getChunk(res);
Map<String, Object> meta = chunk.getMetadata();
// 服务名、错误级别、时间范围过滤...
})
.limit(topK)
.collect(Collectors.toList());
}
// 按 requestId 获取完整链路
public List<SimilarityResult<Chunk>> getTraceByRequestId(String requestId) {
return searchLogs(requestId, 100, 0.0).stream()
.filter(res -> requestId.equals(getChunk(res).getMetadata().get("requestId")))
.collect(Collectors.toList());
}
8. 日志摄入与清理
摄入流程 :LogIngestionService.ingestFromFile() 将下载的日志文件解析、分组、构建文档并写入索引:
java
public IngestionResult ingestFromFile(String serviceCode, String fileName) {
// 1. 解析日志文件
List<ErrorLogRecord> records = parseLogFile(serviceCode, fileName);
// 2. 按 requestId 分组
Map<String, List<ErrorLogRecord>> byRequestId = records.stream()
.filter(r -> !"EMPTY".equals(r.requestId()))
.collect(Collectors.groupingBy(ErrorLogRecord::requestId));
// 3. 逐个构建文档并索引
int tracesIndexed = 0, chunksCreated = 0;
for (var entry : byRequestId.entrySet()) {
MaterializedDocument doc = traceDocumentBuilder.buildTraceDocument(entry.getKey(), entry.getValue());
luceneSearchOperations.deleteRootAndDescendants(doc.getUri()); // 先删旧版本
List<String> chunkIds = luceneSearchOperations.writeAndChunkDocument(doc);
tracesIndexed++;
chunksCreated += chunkIds.size();
}
return new IngestionResult(List.of(fileName), tracesIndexed, chunksCreated);
}
定时清理 :LogCleanupScheduler 定期清理超过 30 天的旧索引数据,避免内存膨胀。同时 cleanTracesByDate() 方法支持按日期精准清理。
9. ToolishRag:让 LLM 直接使用 RAG
最关键的一步是将 RAG 能力暴露给 LLM。通过 ToolishRag 包装,Lucene 搜索工具变成了 LLM 可调用的"技能":
java
@Bean
public ToolishRag insuranceRag(LuceneSearchOperations luceneSearchOperations) {
return new ToolishRag(
"insurance_docs",
"Search log documents using BM25 full-text search...",
luceneSearchOperations,
"Always search the knowledge base before analyzing exceptions..."
);
}
在 Agent 的 Action 中,通过 withReference(insuranceRag) 注入:
java
FetchServiceLogInput result = ctx.ai()
.withDefaultLlm()
.withReference(insuranceRag) // LLM 现在可以调用 RAG 工具了
.createObject(ragSearchPrompt, FetchServiceLogInput.class);
此时 LLM 在执行推理时,可以自主决定:
- 调用
textSearch搜索异常关键字。 - 根据搜索结果调整查询词。
- 使用
broadenChunk扩展上下文。 - 最终输出精准的抓取参数。
这种"Agent + RAG"的组合,让 LLM 不仅能"思考",还能"查阅资料",大幅提升了诊断的准确性。
10. 性能数据
| 指标 | 数据 |
|---|---|
| 日志解析速度 | ~5000 行/秒 |
| 蒸馏压缩率 | 原始日志 → 蒸馏摘要约 10:1 |
| 索引构建时间 | 100MB 日志文件约 15-30 秒 |
| BM25 检索延迟 | < 50ms(内存索引) |
| 索引内存占用 | 约原始日志大小的 30% |
11. 小结
日志 RAG 引擎为系统提供了"深度分析"的能力。通过日志蒸馏去除噪音、层级化文档构建保留结构、BM25 索引实现毫秒检索、ToolishRag 让 LLM 直接调用------这四个环节环环相扣,共同构成了 Slow Path 的核心基础设施。在下一篇文章中,我们将看到如何利用 request_id 实现跨服务的全链路追踪。