前言

这篇文章源自我在维护 EduMind(AI 教学平台)时的一次逐文件代码审查。一个下午,从 SecurityContextHolder 问到 Self-Attention,从 EmbeddingService 追问到 RerankerService。现在把沿途学到的知识点整理成一篇结构化复盘,方便自己复习,也方便其他接手这个项目的开发者快速建立全局理解。

本文按架构分层组织,而不是按文件顺序。

一、RAG 管线:从文本到检索结果的全过程

这是整个项目最核心的模块,位于 edumind/.../rag/ 下。

1.1 管线全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
用户提问 → RagService.search()

├─ ① EmbeddingService.embedQuery()
│ 文本 → Tokenizer 分词 → ONNX → Mean Pooling → L2 归一化 → float[512]

├─ ② VectorStoreService.similaritySearch()
│ pgvector <=> 余弦距离排序

├─ ③ VectorStoreService.keywordSearch()
│ 中文 2-gram 切词 → ILIKE 模糊匹配
│ (模糊 query → QueryRewriter LLM 改写后再搜)

├─ ④ RrfFusionService.fuse()
│ 向量结果 + 关键词结果 → 统一排序

├─ ⑤ RerankerService.rerank()
│ Cross-Encoder 逐篇精排 → 取 topK
│ (低置信度兜底:top-1 < 0.3 → LLM 改写 → 追加一轮)

└─ ⑥ 格式化返回 (QQ/MCP/RAW)

1.2 Embedding:文本变向量的全流程

EmbeddingService 的职责:输入一句中文,输出 512 个浮点数。底层使用 ONNX Runtime 本地 CPU 推理 bge-small-zh-v1.5 模型(~120MB)。

关键机制:

  • BGE Query 指令前缀:Query 侧加 "为这个句子生成表示以用于检索相关文章:",Doc 侧不加。BGE 模型训练时就区分了这两种场景。
  • Tokenizertk.encode() 一行调用了四个子步骤——分词 → 查词表 → 加 [CLS]/[SEP] 标记 → 对齐到 512 长度 → 生成 attention_mask。

OnnxEmbeddingTranslator 是 DJL 框架和 ONNX 模型之间的”翻译官”:

  • processInput:把 Tokenizer 的输出包装成 ONNX 吃得了的张量格式(加 batch 维度、贴名字标签、补全 token_type_ids
  • processOutput:Mean Pooling(用 attention_mask 排除 padding)+ L2 归一化(缩放成长度为 1 的单位向量,这样点积 = 余弦相似度)

理解关键processInputprocessOutput 你永远不会显式调用——DJL 框架在 predictor.predict() 内部按约定好的顺序调它们。跟 Spring 调你的 preHandle()、Tomcat 调你的 doGet() 是一回事。

1.3 Reranker:为什么需要第二个模型

Bi-Encoder(Embedding):Query 和 Doc 分别编码成向量,最后做一次点积比较。编码时模型不知道对方长什么样——快但粗糙。

Cross-Encoder(Reranker):Query 和 Doc 拼成一句话喂给模型,Transformer 的 Self-Attention 让每个 query 词都能看到每个 doc 词,逐词交互后打分——慢但精准。

关键区别:Bi-Encoder 的 doc 向量可以离线算好存 pgvector,Cross-Encoder 每次换 query 都得对每篇 doc 重跑一次模型。所以两阶段配合:Embedding 从一万篇捞 20 篇(毫秒级),Reranker 对 20 篇精排(百毫秒级)。

Self-Attention 怎么知道边界? 三层机制:[SEP] 分隔符的 embedding + 位置编码拉开距离 + 注意力矩阵自然聚类。额外去掉 token_type_ids 后模型反而泛化更好——被逼着靠语义判断边界。

OnnxRerankerTranslator 与 Embedding 版本的关键差异:

  • 输入是 QueryDocPair(两条字符串),不是单句
  • Tokenizer 用 encode(query, document) 双参数版本,自动插入 [SEP]
  • processOutput 不需要 Mean Pooling 也不需要 L2 归一化——直接从 [CLS] 位置取 logit,sigmoid 压缩到 0~1

1.4 向量检索 vs 关键词检索

向量检索 关键词检索
原理 pgvector <=> 余弦距离 ILIKE %keyword%
搜什么 语义(意思相近) 字面(字符相同)
优势 模糊口语、同义词 精确术语、代码片段
盲区 缩写、编号、API 名 同义词、口语改写

中文关键词提取用 2-gram 滑动窗口("指针数组"["指针","针数","数组"]),比 pg_trgm/tsvector 更务实。

1.5 SmartChunkService:文档切割策略

四种策略按文档类型选择:Markdown 按标题层级、代码按函数定义正则、对话按轮次、通用按段落。

做对了的:表格保护(连续 |...| 行原子化)、滑动窗口 + overlap(保证 chunk 边界语义连续性)、列表项原子化。

可以优化的(已记录待办):Token 计数用 length/3 是瞎猜(应用真 Tokenizer)、小块不合并(”好的” 1 token 无检索价值)、元数据未拼入 content(文档名和章节标题有存储但检索时没用到)。

二、MCP 工具系统:让 AI 能调用后端能力

2.1 与传统 Java 的对比

MCP 工具 = 给 AI 调用的 Service。传统 Controller 是前端写死了调哪个接口,MCP 是 AI 自己看工具列表、自己决定调哪个、自己填参数。

1
2
传统:  前端 → Controller → Service → DB
MCP: AI → OpenClaw → POST /mcp → McpController → ToolDefinition.execute() → Service → DB

2.2 四个核心组件

  • ToolDefinition 接口name() + description() + inputSchema() + execute()。本质就是策略模式/命令模式。
  • McpController:协议翻译层。单入口 POST /mcp,用 method 字段区分 tools/listtools/call。Spring 自动注入所有 ToolDefinition 实现类。
  • ToolContextHolder:ThreadLocal 传递用户上下文。McpController 在调工具前 set(),调完后 clear()——和 SecurityContextHolder 一样的思路。
  • KnowledgeSearchTool:唯一用到 ToolContextHolder 的工具。需要知道”谁在搜”来做知识库权限过滤。三级降级策略:ThreadLocal → sessionId 反查 → QQ号/群号反查。

三、AI 基础设施

3.1 StructuredOutputInvoker:让 LLM 输出可靠的 JSON

LLM 是文字接龙,让它输出 JSON 时可能给你:

1
2
3
好的,以下是结果:
```json
{"totalScore": 85}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

`StructuredOutputInvoker` 专门解决这个问题:`extractJson()` 扒掉 markdown 代码块和废话 → `objectMapper.readValue()` 反序列化 → 失败就把错误原因注入 prompt 再试一次 → 最多 N 次重试。

### 3.2 DAG 工作流引擎

`GradingWorkflow` 把批改拆成 3 个 DAG 节点:`[GRADE] → [ERROR_ANALYSIS] → [SUGGESTION]`。每个节点用 `StructuredOutputInvoker` 自带 JSON 重试,节点异常不影响后续步骤。已嵌入 `GradingStreamConsumer.doGrade()` 替代了原来的单次 LLM 调用。

`WorkflowEngine` 负责拓扑执行、失败跳转 fallback、执行轨迹追踪(Caffeine 缓存)。

### 3.3 OpenClawServiceImpl:所有 AI 调用的唯一出口

四个核心设计:
- **双通道**:非流式用 `RestClient`(批改),流式 SSE 用 `WebClient`(聊天打字机效果),手动解析 `data: {...}` 行
- **熔断降级**:`@CircuitBreaker`,OpenClaw 挂了不傻等
- **动态 System Prompt**:按课程从 DB 加载自定义 Prompt,不同课程不同 AI 人格
- **会话上下文注册**:打通"用户→会话→MCP 工具回调"的权限链

## 四、分布式基础设施

### 4.1 双层限流

请求 → TokenBucketInterceptor(网关层,Bucket4j 令牌桶,按 IP+URI 限流)
→ RateLimitAspect(方法层,Redis Lua 滑动窗口,按 全局/IP/用户 维度限流)
→ Controller


`DistributedRateLimiter`:桶状态存 Redis(多实例共享),桶引用缓存在本地 Caffeine(减少 Redis 交互),tryConsume 底层是 Redis Lua 脚本原子操作。

### 4.2 布隆过滤器:防缓存穿透

`BloomFilterInitializer` 启动时把所有 classId 加载进 `RBloomFilter`,`DashboardController` 的 6 个查询方法全部先过布隆——不存在的 classId 直接 401,DB 完全不碰。新建班级时 `ClassServiceImpl` 同步 `add`。

### 4.3 缓存策略

- **Cache-Aside**:`CacheThroughService` + `CacheConsistencyService`,写 DB 后删缓存(双删策略)
- **ChatHistoryServiceImpl.evictHistoryCache()**:写 DB → 清 Spring Cache,下次读强制走 DB
- **批改结果缓存**:Redis 7 天 TTL,key = SHA-256(submissionId + 题目 + 知识点 + prompt 模板哈希)

## 五、业务服务亮点

### 5.1 TaskReminderService:分布式定时提醒

用 Redisson 的 `RScheduledExecutorService` 注册定时任务(重启不丢),作业截止前 24h 和 1h 各提醒一次。群 @全员 + 私聊双推送。全员提交时发送祝贺消息。

### 5.2 TokenService:JWT 黑名单

`JwtAuthenticationFilter` 从请求提取 token → 查黑名单 → 验证 → 把 userId 塞进 `authentication.setDetails()` → `SecurityContextHolder`。Controller 里用 `getCurrentUserId()` 从 ThreadLocal 取——整个请求生命周期内可用。

## 六、关键设计模式速查

| 模式 | 项目中的体现 |
|------|------------|
| 策略模式 | `ToolDefinition` 接口 + 5 个实现类 |
| 命令模式 | `ToolDefinition.execute()` |
| 模板方法 | `AbstractStreamConsumer`(消费循环框架) |
| 适配器模式 | `McpController`(JSON-RPC → Java 方法调用) |
| 拦截器链 | `TokenBucketInterceptor` + `RateLimitAspect` 双层 |
| Cache-Aside | `CacheThroughService` + `evictHistoryCache` |
| ThreadLocal | `ToolContextHolder`、`SecurityContextHolder` |
| 工厂方法 | `WorkflowNode.of()`、`GradingState.create()` |
| 降级策略 | 熔断 fallback、pgvector 回退、RAG 低置信度兜底 |

## 后记

这个项目的源码有几个让我印象深刻的设计决策:

1. **RAG 是单一真相来源**:所有检索路径强制走 `RagService.search()`,不存在多个检索实现。
2. **所有 LLM 调用经过 OpenClaw**:不直接调模型 API,统一走网关层——API Key 管理、模型路由、工具调用全部收敛。
3. **降级无处不在**:pgvector 不可用有回退、Reranker 不存在就跳过、LLM 熔断有 fallback、检索低置信度自动追加。系统在各种环境都能跑起来,不依赖任何"必须有"的外部服务。
4. **传统 Java 模式和 AI 代码的融合**:ThreadLocal、策略模式、AOP 切面的用法和传统 Spring 项目一样,但服务的是 ONNX 推理、向量检索、MCP 协议这些新场景。