微服务日志智能诊断系统(七) 日志 RAG 引擎——蒸馏、索引与精准检索


项目地址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=xxxuserId=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,方便按日期批量清理过期索引。
  • 丰富的元数据hasErrorminTimestampmaxTimestamp 等,支持后置过滤。

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 在执行推理时,可以自主决定:

  1. 调用 textSearch 搜索异常关键字。
  2. 根据搜索结果调整查询词。
  3. 使用 broadenChunk 扩展上下文。
  4. 最终输出精准的抓取参数。

这种"Agent + RAG"的组合,让 LLM 不仅能"思考",还能"查阅资料",大幅提升了诊断的准确性。

10. 性能数据

指标 数据
日志解析速度 ~5000 行/秒
蒸馏压缩率 原始日志 → 蒸馏摘要约 10:1
索引构建时间 100MB 日志文件约 15-30 秒
BM25 检索延迟 < 50ms(内存索引)
索引内存占用 约原始日志大小的 30%

11. 小结

日志 RAG 引擎为系统提供了"深度分析"的能力。通过日志蒸馏去除噪音、层级化文档构建保留结构、BM25 索引实现毫秒检索、ToolishRag 让 LLM 直接调用------这四个环节环环相扣,共同构成了 Slow Path 的核心基础设施。在下一篇文章中,我们将看到如何利用 request_id 实现跨服务的全链路追踪。


相关推荐
掉鱼的猫1 小时前
代码审查 Agent Harness 实战:AI 自动 Code Review
java·llm·agent
学渣超1 小时前
微服务日志智能诊断系统(六) Fast Path vs Slow Path——双路径日志诊断策略
java·后端·agent
添砖java_8571 小时前
网页聊天室测试报告
java·spring boot·spring·maven
apprentice1 小时前
跳出余弦相似度陷阱:RAG检索本质是筛选
agent·ai编程
ZOnePieceC1 小时前
Go之context
后端
GrowthGPT1 小时前
平台自报的 ROAS 在骗你
agent
先吃饱再说1 小时前
手写一个 mini-cursor:LangChain Agent 开发完全实战
langchain·llm·agent
chanalbert1 小时前
Agent Skill 工程化指南(三):运行与生效 — 运行时的全链路机制
llm·agent