Spring AI RAG - 07 AOP 日志记录与热词统计

文章目录

引言

知识库系统的运营离不开两类数据:用户在问什么(热词分析)、系统在做什么(操作日志)。前者帮助优化知识库内容,后者用于审计和排障。

本项目用一套巧妙的设计同时解决了这两个问题:通过 AOP 自动收集对话日志,再用定时任务对日志做中文分词统计,自动产出热词数据。本篇将完整拆解这条链路。

设计说明

整体架构

Java 复制代码
@Loggable 注解
    ↓
LoggingAspect 切面
    ↓
log_info 表(请求参数、方法名、类名、时间)
    ↓
TaskJobScheduled(每小时定时任务)
    ↓
IK Analyzer 中文分词
    ↓
word_frequency 表(热词频次)
    ↓
WordFrequencyController(前端展示)
    ↓
Redis 缓存(5分钟 TTL)

设计要点

为什么用 AOP 而不是手动埋点?

手动埋点意味着每个对话接口都要写一遍 logService.save(...),代码冗余且容易遗漏。AOP 通过注解 + 切面的组合,让日志记录与业务逻辑完全解耦。新增接口时只需要加一个 @Loggable 注解即可。

为什么用日志而不是直接监听对话?

直接监听对话需要订阅 ChatClient 的事件流,实现复杂且耦合。利用已有的日志数据做二次分析,是一种"零成本"的数据复用------日志本来就要存的,顺便拿来做热词分析。

为什么用 IK Analyzer 而不是简单 split?

中文不像英文有空格分词。split(" ") 对中文完全无效,需要专门的中文分词器。IK Analyzer 是 Java 生态最经典的中文分词工具,支持智能分词和最细粒度分词两种模式。

为什么定时任务而不是实时分词?

实时分词会拖慢对话响应。定时任务每小时跑一次,对线上业务零影响,且分词结果对热度分析的实时性要求并不高。

原理方案

自定义注解 + 切面

@Loggable 是一个自定义注解:

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
    String value() default "";  // 可选,指定要记录的参数名
}

LoggingAspect 是对应的切面:

java 复制代码
@Aspect
@Component
public class LoggingAspect {
    
    @Pointcut("@annotation(loggable)")
    public void loggableMethods(Loggable loggable) {}

    @Before(value = "loggableMethods(loggable)", argNames = "joinPoint,loggable")
    public void logBefore(JoinPoint joinPoint, Loggable loggable) {
        // 收集方法名、类名、时间、参数
        // 持久化到 log_info 表
    }
}

日志表结构

sql 复制代码
CREATE TABLE `log_info` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `method_name` VARCHAR(255) COMMENT '方法名',
    `class_name` VARCHAR(255) COMMENT '类目',
    `request_time` DATE COMMENT '请求时间戳',
    `request_params` TEXT COMMENT '请求参数',
    `response` TEXT COMMENT '响应结果',
    PRIMARY KEY (`id`)
);

request_params 存的是 JSON 序列化后的参数,热词统计就是基于这个字段。

定时分词流程

java 复制代码
@Scheduled(cron = "0 0 * * * ?")  // 每小时整点
public void taskJob() {
    // 1. 清缓存
    redisTemplate.delete("wordFrequencyList");
    
    // 2. 加载已有热词到 Map
    List<WordFrequency> existing = wordFrequencyService.list();
    Map<String, List<WordFrequency>> existingMap = existing.stream()
            .collect(Collectors.groupingBy(WordFrequency::getWord));
    
    // 3. 拼接所有日志的 request_params
    StringBuilder text = new StringBuilder();
    for (LogInfo log : logInfoService.list()) {
        text.append(log.getRequestParams());
    }
    
    // 4. IK 分词
    IKSegmenter segmenter = new IKSegmenter(new StringReader(text.toString()), true);
    
    // 5. 统计:新词新增,旧词频次+1
    while ((lexeme = segmenter.next()) != null) {
        if (existingMap.containsKey(word)) {
            // 频次+1
        } else {
            // 新增
        }
    }
    
    // 6. 批量写库
    wordFrequencyService.saveBatch(newWords);
    wordFrequencyService.saveOrUpdateBatch(updateWords);
}

源码解析

自定义注解 Loggable

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
    String value() default "";
}

注解的 value 参数支持指定要记录的参数名,如:

java 复制代码
@Loggable("message")
public Flux<String> chat(@RequestParam String message, @RequestParam String prompt) { ... }

切面会只记录 message 参数,不记录 prompt,避免日志冗余。

LoggingAspect 切面完整实现

java 复制代码
@Aspect
@Component
public class LoggingAspect {

    @Autowired
    private LogInfoService logInfoService;

    @Pointcut("@annotation(loggable)")
    public void loggableMethods(com.xushu.rag.annotation.Loggable loggable) {}

    @Before(value = "loggableMethods(loggable)", argNames = "joinPoint,loggable")
    public void logBefore(JoinPoint joinPoint, Loggable loggable) {
        LogInfo logInfo = new LogInfo();
        logInfo.setMethodName(joinPoint.getSignature().getName());
        logInfo.setClassName(joinPoint.getTarget().getClass().getName());
        logInfo.setRequestTime(new Date());

        Object[] args = joinPoint.getArgs();

        // 是否指定了具体的参数名
        if (loggable.value() != null && loggable.value().length() > 0) {
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            String[] parameterNames = signature.getParameterNames();

            Map<String, Object> selectedParams = new HashMap<>();
            List<String> targetParams = Arrays.asList(loggable.value());

            if (parameterNames != null) {
                for (int i = 0; i < parameterNames.length; i++) {
                    if (targetParams.contains(parameterNames[i])) {
                        selectedParams.put(parameterNames[i], args[i]);
                    }
                }
            }
            logInfo.setRequestParams(selectedParams.toString());
        } else {
            // 没有指定参数名时,记录所有参数
            logInfo.setRequestParams(Arrays.toString(args));
        }

        logInfoService.save(logInfo);
    }
}

关键技术点:

  1. @Pointcut("@annotation(loggable)") ------ 通过注解参数化绑定的方式,让切面能接收到具体的注解实例
  2. MethodSignature.getParameterNames() ------ 获取方法参数名,需要编译时保留参数名信息(-parameters 标志)
  3. 筛选参数:通过参数名匹配,只记录注解中指定的参数

在对话接口上挂注解

java 复制代码
@GetMapping(value = "/stream")
@Loggable("message")  // 只记录 message 参数
public Flux<String> streamRagChat(@RequestParam String message, @RequestParam String prompt) {
    // ...
}

@PostMapping(value = "/rag")
@Loggable  // 记录所有参数
public Flux<String> generatePost(@RequestParam String message) {
    // ...
}

零侵入,业务代码完全感知不到日志的存在。

日志查询接口

java 复制代码
@RestController
@RequestMapping(ApplicationConstant.API_VERSION + "/log")
public class LogInfoController {

    @Autowired
    private LogInfoService logInfoService;

    @Operation(summary = "分页查询日志信息")
    @GetMapping("/page")
    public BaseResponse<IPage<LogInfo>> getLogInfoPage(
            @RequestParam int page, @RequestParam int size,
            @RequestParam(required = false) String methodName,
            @RequestParam(required = false) String className,
            @RequestParam(required = false) String requestParams) {
        Page<LogInfo> pageParam = new Page<>(page, size);
        QueryWrapper<LogInfo> queryWrapper = new QueryWrapper<>();
        if (methodName != null) queryWrapper.like("method_name", methodName);
        if (className != null) queryWrapper.like("class_name", className);
        if (requestParams != null) queryWrapper.like("request_params", requestParams);
        Page<LogInfo> result = logInfoService.page(pageParam, queryWrapper);
        result.setTotal(result.getRecords().size());
        return ResultUtils.success(result);
    }

    @Operation(summary = "批量删除日志")
    @PostMapping("/batch")
    public BaseResponse deleteLogInfos() {
        boolean result = logInfoService.remove(null);
        return result ? ResultUtils.success("删除成功") : ResultUtils.error("删除失败");
    }
}

支持按方法名、类名、参数关键字模糊查询,便于排障。

定时任务完整实现

java 复制代码
@Component("taskJob")
@Slf4j
public class TaskJobScheduled {

    @Autowired
    private LogInfoService logInfoService;
    @Autowired
    private WordFrequencyService wordFrequencyService;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * cron 表达式: 0 0 * * * ?
     * 每小时的0分0秒执行
     */
    @Scheduled(cron = "0 0 * * * ?")
    public void taskJob() {
        log.info("分词器定时任务开始执行");
        
        // 1. 清空 Redis 缓存
        redisTemplate.delete("wordFrequencyList");
        
        // 2. 加载已有热词
        List<WordFrequency> wordFrequencies = wordFrequencyService.list();
        Map<String, List<WordFrequency>> collectMap = wordFrequencies.stream()
                .collect(Collectors.groupingBy(WordFrequency::getWord));
        
        // 3. 拼接所有日志参数
        StringBuilder text = new StringBuilder();
        List<LogInfo> list = logInfoService.list((Wrapper<LogInfo>) null);
        for (LogInfo logInfo : list) {
            text.append(logInfo.getRequestParams());
        }
        String result = text.toString();
        
        // 4. IK 分词与统计
        Map<String, WordFrequency> newMap = new HashMap<>();
        try (StringReader reader = new StringReader(result)) {
            IKSegmenter segment = new IKSegmenter(reader, true);  // true: 智能分词
            Lexeme lexeme;
            List<WordFrequency> wordFrequencyList = new ArrayList<>();
            List<WordFrequency> updateList = new ArrayList<>();
            
            while ((lexeme = segment.next()) != null) {
                // 过滤单字
                if (lexeme.getLength() <= 1) continue;
                // 过滤超长字符(可能是 URL、UUID 等噪音)
                if (lexeme.getLength() >= 10) continue;
                
                String word = lexeme.getLexemeText();
                
                if (!collectMap.containsKey(word)) {
                    // 新词
                    if (newMap.containsKey(word)) {
                        // 已经统计过的新词,频次+1
                        WordFrequency wf = newMap.get(word);
                        wf.setCountNum(wf.getCountNum() + 1);
                    } else {
                        // 第一次出现的新词
                        WordFrequency wf = new WordFrequency();
                        wf.setWord(word);
                        wf.setCountNum(1);
                        wf.setBusinessType("log");
                        wf.setCreateTime(new Date());
                        wf.setUpdateTime(new Date());
                        wordFrequencyList.add(wf);
                        newMap.put(word, wf);
                    }
                } else {
                    // 已存在的旧词,频次+1
                    WordFrequency wf = collectMap.get(word).get(0);
                    wf.setCountNum(wf.getCountNum() + 1);
                    wf.setUpdateTime(new Date());
                    updateList.add(wf);
                }
            }
            
            // 5. 批量持久化
            wordFrequencyService.saveBatch(wordFrequencyList);
            wordFrequencyService.saveOrUpdateBatch(updateList);
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

几个值得注意的细节:

  1. new IKSegmenter(reader, true) ------ 第二个参数 useSmart,true 表示智能分词(粒度较粗),false 表示最细粒度分词
  2. 过滤策略
    • 长度 ≤ 1 的字("的"、"是"等)没有统计意义
    • 长度 ≥ 10 的字符串通常是 URL、UUID、英文长串,过滤掉
  3. 新词去重newMap 用于在本轮分词中临时记录新词,避免同一新词被重复 INSERT
  4. 批量操作saveBatchsaveOrUpdateBatch 比逐条操作快几十倍

启用定时任务

需要在主类上加 @EnableScheduling

java 复制代码
@SpringBootApplication
@EnableScheduling
public class XushuRagAiApplication {
    public static void main(String[] args) {
        SpringApplication.run(XushuRagAiApplication.class, args);
    }
}

热词查询接口(含 Redis 缓存)

java 复制代码
@GetMapping("/getList")
public BaseResponse<Object> getList() throws JsonProcessingException {
    String cacheKey = "wordFrequencyList";
    String cachedListStr = (String) redisTemplate.opsForValue().get(cacheKey);
    
    if (cachedListStr != null) {
        try {
            Object cachedList = objectMapper.readValue(cachedListStr, List.class);
            log.info("从 Redis 缓存中获取数据");
            return ResultUtils.success(cachedList);
        } catch (Exception e) {
            log.error("Redis 缓存解析失败", e);
        }
    }

    Object dataList = wordFrequencyService.list();
    String dataListJson = objectMapper.writeValueAsString(dataList);
    redisTemplate.opsForValue().set(cacheKey, dataListJson);
    redisTemplate.expire(cacheKey, 5, TimeUnit.MINUTES);

    return ResultUtils.success(dataList);
}

缓存策略:

  • 5 分钟 TTL,避免数据库被频繁查询
  • 定时任务执行时主动清除缓存,保证数据新鲜
  • 缓存解析失败时降级到数据库,不影响业务

验证结果

触发日志记录

调用任意带 @Loggable 注解的接口:

复制代码
GET /api/v1/chat/stream?message=Spring AI怎么用

查询日志表:

sql 复制代码
SELECT * FROM log_info ORDER BY id DESC LIMIT 1;
复制代码
id | method_name    | class_name              | request_time | request_params
---|----------------|-------------------------|--------------|----------------
42 | streamRagChat  | com...ChatController    | 2026-05-14   | {message=Spring AI怎么用}

手动触发定时任务

为方便测试,可以在 Controller 中暴露手动触发接口:

java 复制代码
@Autowired
private TaskJobScheduled taskJobScheduled;

@PostMapping("/triggerTask")
public BaseResponse trigger() {
    taskJobScheduled.taskJob();
    return ResultUtils.success("已触发");
}

执行后查询热词表:

sql 复制代码
SELECT * FROM word_frequency ORDER BY count_num DESC LIMIT 10;
复制代码
id | word       | count_num | business_type
---|------------|-----------|---------------
1  | spring     | 23        | log
2  | 知识库     | 18        | log
3  | rag        | 15        | log
4  | 文档       | 12        | log
...

前端展示

热词数据可以用来生成词云:

javascript 复制代码
// 前端拉取数据
const data = await fetch('/api/v1/frequency/getList');
// 渲染词云
new WordCloud(canvas, { list: data.map(w => [w.word, w.countNum]) });

优化建议

异步日志写入

logInfoService.save(logInfo) 是同步操作,会阻塞请求。可以改成异步:

java 复制代码
@Async
public void saveAsync(LogInfo logInfo) {
    logInfoService.save(logInfo);
}

或者用消息队列(RabbitMQ、Kafka)做缓冲。

分批处理大日志表

定时任务每次都 list() 全表,日志多了之后会 OOM。改进方案:

java 复制代码
// 只统计最近 1 小时的日志
QueryWrapper<LogInfo> qw = new QueryWrapper<>();
qw.gt("request_time", DateUtils.addHours(new Date(), -1));
List<LogInfo> list = logInfoService.list(qw);

或者分页处理:

java 复制代码
int page = 1;
while (true) {
    Page<LogInfo> pageData = logInfoService.page(new Page<>(page, 1000));
    if (pageData.getRecords().isEmpty()) break;
    process(pageData.getRecords());
    page++;
}

增量统计

目前每次都全量重算。可以引入"上次处理时间戳":

sql 复制代码
-- 增加字段
ALTER TABLE log_info ADD COLUMN processed BOOLEAN DEFAULT FALSE;

-- 只处理未处理的
SELECT * FROM log_info WHERE processed = FALSE;

-- 处理完更新
UPDATE log_info SET processed = TRUE WHERE id IN (...);

自定义停用词表

IK 默认会过滤一些停用词,但中文场景往往需要扩展。可以在 classpath 下添加 stopword.dic

复制代码
的
是
在
我们
然后

IK 会自动加载并过滤。

日志归档

log_info 表会快速膨胀。建议:

  • 按月分表
  • 历史数据归档到 ClickHouse 或 ES
  • MySQL 只保留近 7 天数据

小结

本篇用一个巧妙的"日志 → 分词 → 统计"链路实现了热词分析:

  • @Loggable + AOP 切面,零侵入收集对话日志
  • IK Analyzer 处理中文分词
  • @Scheduled 定时任务每小时统计一次
  • Redis 缓存提升查询性能
  • 配合前端可以做词云、热度排行等可视化

下一篇将进入身份认证领域,看看 JWT 是如何在这个系统中保障接口安全的。

相关推荐
小小工匠2 小时前
Spring AI RAG - 14 网络检索增强:Web Search 集成
rag·spring ai·websearch
小小工匠3 小时前
Spring AI RAG - 10 来源追溯:自定义 Advisor 实现
spring ai·来源追溯
wuxinyan12317 小时前
工业级大模型学习之路012:RAG 零基础入门教程(第七篇):高级检索架构(解决分块不合理问题)
人工智能·学习·rag
CSharp精选营18 小时前
AI 开发狂飙!.NET 11 Preview 4 原生集成向量搜索 + MCP 模板,EF Core 直接对标 RAG 应用
rag·向量搜索·ef core·mcp·.net 11
千桐科技1 天前
qKnow 智能体构建平台知识图谱能力优化:围绕图谱探索、知识库、数据源、知识推理、知识融合与概念属性的完善升级
人工智能·大模型·知识图谱·agent·rag·qknow·智能体构建平台
养肥胖虎1 天前
RAG学习笔记:让大模型先查资料再回答问题
ai·知识库·rag
进击切图仔2 天前
从零手写 RAG
python·huggingface·rag
恼书:-(空寄2 天前
Spring AI实战|ChatMemory Advisor记忆优化:Redis + Kryo持久化方案
spring ai·会话记忆
爱跑步的程序员~2 天前
RAG 技术全面解析:从原理到实践
python·ai·langchain·rag