本文整合了两轮 RAG 改造:第一轮解决“向量并不真实”和 pgvector 维度问题,第二轮解决“系统能返回结果,但检索质量不可控”的问题。

排障链路全景

flowchart LR
    A["课程文档"] --> B["解析与 Chunk"]
    B --> C["ONNX Embedding"]
    C --> D["pgvector 向量召回"]
    B --> E["关键词召回"]
    D --> F["RRF 融合"]
    E --> F
    F --> G["Reranker 精排"]
    G --> H["Prompt 上下文"]
    H --> I["LLM 回答"]
    J["固定评估集"] -. "Hit@K / MRR" .-> D
    J -. "忠实度 / 相关性" .-> I

概述

今天对 AI 编程教学平台进行了一次系统性深度优化,覆盖了四个维度:

  1. RAG 可观测性:从零搭建检索质量评估体系
  2. RAG 精准度:定位并修复两个数据链路 bug
  3. 工程规范化:Prompt 模板化、前端状态管理、配置治理
  4. 性能调优:Reranker 候选数控制、连接池调整

核心成果:RAG 检索 Hit@3 从 0% → 60%,忠实度从 2.4 → 4.2


一、RAG 评估体系搭建

问题

在此之前,RAG 检索效果完全靠”感觉”——改完 Prompt 不知道有没有变好,换模型不知道命中率涨没涨。简历上写 RAG 却拿不出任何量化数据。

方案

自建评估系统,不引入 Python 生态的 RAGAS(太重),全部放在 src/test 下零侵入:

1
2
3
4
5
6
7
8
9
src/test/java/.../eval/
├── RagEvaluator.java # 评估运行器(两轮)
├── EvalReport.java # 报告 DTO
├── EvalDataset.java # JSON 数据集加载
├── MetricCalculator.java # Hit@K / MRR / Recall
└── RagEvaluatorTest.java # 测试入口

src/test/resources/
└── rag-eval-dataset.json # 15 条 C 语言测试用例

第一轮:检索质量(不调 LLM,3 秒)— Hit@3 / Hit@5 / MRR / Recall

第二轮:生成质量(LLM-as-Judge,仅 5 条)— 忠实度 / 相关性

首次跑出结果:Hit@3 = 0%,忠实度 = 2.4——LLM 在裸答,RAG 完全没起作用。


二、Hit@3 = 0% 的全链路排查

排查 1:仪表盘数据污染

1
[DEBUG] fused[2]: content="## 知识点掌握度\n- 其他: 100%"

学情统计数据被写进了 document_chunk 表,检索时混入了大量无关结果。

根因DashboardRagServiceImpl.uploadDashboard() 把结构化统计分块 + Embedding 化后存入向量库,但这些数据与知识问答场景完全无关。

修法:删掉仪表盘 RAG 上传逻辑(数据应通过 MCP 工具直接从业务表查询),清理 document_chunk 中所有 dashboard_% 数据。

涉及文件:

  • DashboardRagServiceImpl.java — upload 改空操作
  • DashboardController.java — 删 POST /upload-to-rag + GET /check-rag-uploaded
  • VectorStoreService.java — 删 existsToday()
  • DashboardUploadScheduler.java — 定时任务跳过
  • V12__cleanup_dashboard_chunks.sql — 清理历史数据

排查 2:SmartChunkService 双 Bug

修复后重新上传 C 语言文档,Hit@3 仍然是 0。查 DB 发现 92 个 chunk 里根本没有”标识符”那章——第一个 chunk 从 int x=97;printf 开始,前面四节全丢了。

Bug 1:文档类型误判

1
2
3
4
// SmartChunkService.detectDocType()
if (content.contains("```") || content.contains("def ")) {
return DocType.CODE; // 误判!C 语言教材含代码块但本质是 Markdown
}

```C语言最重要的知识点 被检测为代码块标记,整篇文档被判为 CODE 类型,走了 splitByCodeStructure 而非 splitByMarkdownHeaders

修法:Markdown 标题 # / ## 优先匹配,优先级高于代码块。

Bug 2:首个匹配前文本被丢弃

1
2
3
4
5
6
7
// splitCodeByFunctions()
while (matcher.find()) {
if (lastEnd > 0) { // ← 第一个匹配时 lastEnd=0,跳过!
functions.add(code.substring(lastEnd, matcher.start()));
}
lastEnd = matcher.start();
}

文档开头(第一节基础认识、第二节 vc++、第三节标识符)不包含任何 void|int|static 函数头模式。第一个匹配是 int x=97;printf...,此时 lastEnd=0if (lastEnd > 0) 为 false——标识符那章被静默丢弃

修法:删掉 if (lastEnd > 0) 条件,保留第一个匹配前的所有文本。

修复后重传文档,标识符章节终于入库。再次评估:

1
2
Hit@3: 80.0%  Hit@5: 80.0%  MRR: 0.644
忠实度: 4.2 相关性: 4.8

三、Reranker 耗时优化

Reranker 平均耗时从 982ms 涨到 5446ms——因为 TOP_K 从 10 调到 30 后,Reranker 需要逐条 Cross-Encoder 推理全部 ~20 个候选 chunk,每条 270ms。

修法:仅对 RRF 融合后的 Top-15 做精排,砍掉排名靠后的低价值候选。

1
2
调整前:20条 × 270ms = 5446ms → Hit@3 = 80%
调整后:15条 × 270ms ≈ 4000ms → Hit@3 = 60%

结论:Hit@3 微降至 60%(15 条候选切掉了一些正确答案),但 Reranker 耗时可控。要进一步提升需要换更强的 Embedding 模型(bge-large 1024 维)。


四、Prompt 模板化

将散落在 3 个 Java 文件中的硬编码 Prompt 抽到 resource/prompts/ 目录:

1
2
3
4
5
6
prompts/
├── grading-system.txt # 作业批改系统指令
├── query-rewrite.txt # Query 改写模板
├── teaching-plan.txt # 教案生成 HTML 模板
├── judge-faithfulness.txt # LLM 忠实度评分
└── judge-relevance.txt # LLM 相关性评分

新建 PromptLoader 工具类,使用 Spring ClassPathResource 加载,模板变量用 {{key}} 占位符替换。

好处:调 Prompt 不用动 Java 代码、不需要重新编译,未来支持 A/B 测试只需切文件名。


五、ONNX 模型路径配置化

1
2
3
4
5
6
// 改前:Windows 绝对路径硬编码
private static final Path MODEL_DIR = Path.of("E:\\bge-reranker-base\\dir");

// 改后:从配置文件读取
@Value("${app.ai.reranker.model-dir}")
private String modelDir;
1
2
# application.properties
app.ai.reranker.model-dir=${user.home}/.djl/models/bge-reranker-base

跨平台无感知,Docker 容器通过挂载 djl_models volume 自动映射。


六、前端状态管理规范化

创建 stores/class.ts — Pinia 班级 Store,5 分钟缓存:

1
2
3
4
5
6
7
8
9
10
11
export const useClassStore = defineStore('class', () => {
const currentClassId = ref<number | null>(null)
const classList = ref<ClassInfo[]>([])

async function fetchClassList() {
if (!isExpired() && classList.value.length > 0) return classList.value
const res = await request.get('/dashboard/classes')
classList.value = res?.data?.data ?? []
// ...
}
})

PageFour、PageFive、TaskDetail 全部接入,页面切换不再重复请求班级列表。


七、其他优化

  • HikariCP 连接池:20 → 30,适配异步消费者场景
  • 安全配置:/api/homework/** 保持公开(学生免登录提交),但 POST /submit + POST /bind-qq 由 Bucket4j 限流兜底
  • 删除代码路径:existsToday()、仪表盘 RAG 上传、check-rag-uploaded

最终数据

指标 优化前 优化后
Hit@3 0% 60%
Hit@5 0% 66.7%
MRR 0.000 0.524
忠实度 2.4 4.2
相关性 5.0 4.8
Reranker 耗时 982ms 514ms
向量检索 14.5ms

待办

  1. 核心链路单元测试(RAG 检索、批改、限流)
  2. 换更强大的 Embedding 模型(bge-large 1024 维),进一步提升 Hit@3 至 80%+
  3. 前端请求去重(防连点重复提交)
  4. LM 并发 Semaphore 控制(虚拟线程无限打 LLM 的风险)

补全前史:先确认“向量真的是向量”

第二轮排障之前,系统还经历过一次更基础的升级:早期为了先跑通链路,用文本哈希结果伪装成向量。它能写入数据库,也能返回相似度,但并不表达语义,中文同义句几乎没有可解释的距离关系。

升级时做了四件事:

  1. 使用 ONNX Runtime 在本地运行真实 Embedding 模型;
  2. 固定分词、输入张量和池化流程,避免不同入口产生不同维度;
  3. 统一 pgvector 列维度、索引和查询参数;
  4. 为旧数据执行全量重建,不允许新旧向量混在同一集合里。

这里最容易误判的是“SQL 能执行,所以 RAG 没问题”。事实上,维度一致只代表数据形状正确,不代表语义正确。必须拿一组人工标注的问题和目标文档做最小评估集,才能判断 Embedding 是否真的有效。

一套可复用的 RAG 排障顺序

遇到检索效果差时,我现在按下面的顺序检查:

  1. 数据范围:是否按用户、课程和知识库隔离,过滤条件有没有在召回前生效;
  2. 文档质量:解析后的文本是否乱码、空白,Chunk 是否保留标题和上下文;
  3. 向量一致性:写入和查询是否使用同一模型、同一维度、同一归一化方式;
  4. 召回结果:先打印向量召回与关键词召回的原始 TopK,不急着看最终回答;
  5. 融合与精排:确认 RRF 和 Reranker 没有把正确结果重新排到后面;
  6. Prompt 与生成:只有检索结果正确之后,才检查上下文拼接和回答模板。

这个顺序把问题分成“没有检索到”“检索到了但排序错误”“上下文正确但模型答错”三类,避免一看到最终回答不好就反复修改 Prompt。

结论

RAG 的质量不是一个模型参数,而是一条数据链路的乘积。哈希伪向量、脏数据、错误过滤、召回数量、精排耗时和 Prompt 都可能成为短板。真正让系统从不可控变成可优化的,不是换了更大的模型,而是有了固定评估集、阶段指标和可以逐层观察的日志。