Java开发者转型AI工程化Week 5:让RAG学会思考、看见与自检------从固定管道到会决策的知识系统
作者: 一位正在转型的Java开发者
时间: 2026年5月
系列: Java开发者AI工程化转型记录(Week 5/9)
标签: Agentic RAG, RAGAS, 多模态RAG, CLIP, 评估体系, RRF
前言:Week 4留下的三个"没解决"
Week 1建立认知,Week 2补齐生产级能力,Week 3让AI学会自主行动,Week 4给这个会行动的智能体配了一座专业图书馆------PGVector存向量,TokenTextSplitter做切分,混合检索加RRF融合,跑通了"检索→增强→生成"的完整链路。
但复盘时我写下三个没解决的问题,它们指向同一个缺陷:这条管道是写死的。它不会判断"这个问题需不需要检索",它看不懂PDF里的财务报表和架构图,它也不知道自己答得好不好------唯一的反馈来自用户投诉。
如果说Week 4是"给AI配一座图书馆",Week 5就是"让图书管理员学会思考、看得懂图表,并且能给自己的服务打分"。
Day 1:Agentic RAG------当检索学会了决策
三代演进:从"检索完就生成"到"边检索边思考"
Agentic RAG这个词很热,剥掉包装它指的就是一件事:把LLM从"生成器"变成"控制器" 。
| 维度 | Naive RAG | Advanced RAG | Agentic RAG |
|---|---|---|---|
| 检索次数 | 固定 1 次 | 固定 1-2 次 | 按需 0-N 次 |
| 是否自我评估 | 无 | 无 | 有,且决定下一步 |
| 是否有纠错回路 | 无 | 无 | 有(改写 / 换源 / 降级) |
| 典型延迟 | 1-2s | 2-3s | 2-5s |
核心洞察 :三代演进的本质不是"检索得更多",而是谁来决定检索多少 。前两代检索次数是代码里的常量,到了Agentic RAG它变成一次LLM判断的输出。所以max_iterations=3不是随手写的数字,而是准确率收益与P99延迟之间的权衡线。
四种范式:Router、CRAG、Self-RAG、Adaptive RAG
| 范式 | 它解决什么 | 代价(额外LLM调用) |
|---|---|---|
| Router | 该走哪条检索路径 | 1 次(可用规则替代) |
| CRAG | 检索结果够不够好 | 1 次评估 + 可能的 N 次重检 |
| Self-RAG | 到底需不需要检索 | 1 次判断 |
| Adaptive RAG | 问题有多复杂,用哪档策略 | 1 次分级 |
关键收获 :四类里Router性价比最高------它能用关键词映射和正则兜住80%的case,只在未命中时才调LLM,这也是Day 6"两级路由"的由来。Self-RAG看起来最优雅,但每次查询都要先花一次调用判断"要不要检索",在FAQ这类高频简单场景下反而是亏的。
核心实现:route → retrieve → critique → correct

这条闭环分四步:路由选策略、按策略检索、评判检索质量、不达标则纠正后重来。其中DIRECT_ANSWER那条虚线旁路是我特别加的------遇到"你好"这类问题直接跳过整条检索链路,省下的不只是延迟,还有真金白银。
路由策略定义成枚举,fromString做兜底------LLM返回的字符串永远不能直接信任:
scss
public enum RetrievalStrategy {
VECTOR_SEARCH,
KNOWLEDGE_GRAPH,
WEB_SEARCH,
DIRECT_ANSWER, // 无需检索,直接回答
HYBRID,
RE_QUERY, // 改写查询后重新检索
CLARIFICATION; // 信息不足,需要向用户澄清
public static RetrievalStrategy fromString(String s) {
return Arrays.stream(values())
.filter(v -> v.name().equalsIgnoreCase(s))
.findFirst()
.orElse(VECTOR_SEARCH); // 兜底,绝不抛异常
}
}
主循环是"路由 → 检索 → 评判 → 纠正"的闭环:
ini
public RAGResponse process(String question) {
RouteDecision route = routingAgent.route(question);
if (route.strategy() == RetrievalStrategy.DIRECT_ANSWER) {
return generate(question, List.of()); // 省掉整条检索链路
}
List<Document> docs = retrieve(question, route.selectedTools());
Critique critique = critiqueAgent.critique(question, docs);
int iteration = 0;
while (critique.requiresCorrection() && iteration++ < maxIterations) {
docs = retrieve(critique.correctedQuery(), route.selectedTools());
critique = critiqueAgent.critique(question, docs);
}
return generate(question, docs);
}
关键设计 :这是一版骨架实现 ------critiqueAgent依赖写死的阈值,纠正手段也只有改写查询一种(真实场景还应包括切换数据源、放宽阈值、降级直答)。但骨架的价值在于定下了控制流:检索不再是管道里的一段,而是一个可被评判、可被重试的"动作"。
Day 2:RAG评估体系------给"感觉不错"定一个刻度
RAGAS四指标:忠实度、答案相关性、上下文精确率、上下文召回率
这一天的素材有99KB,核心却只有四个指标:两个管生成,两个管检索。
| 指标 | 它到底在问什么 | 怎么算 | 我的阈值 |
|---|---|---|---|
| Faithfulness | 答案有没有编造 | 有支撑的事实数 ÷ 总事实数 | ≥ 0.85 |
| Answer Relevancy | 答没答到点上 | 反推N个问句与原问题的相似度均值 | ≥ 0.80 |
| Context Precision | 有用的排得靠前吗 | 位置加权的精确率 | ≥ 0.70 |
| Context Recall | 该找的都找到了吗 | 被覆盖的关键点 ÷ 总关键点 | ≥ 0.80 |
Faithfulness的算法值得说清楚------它不是相似度分数,而是计数比值:
scss
// 1. 把答案拆成一条条可验证的"陈述"
List<String> claims = extractClaims(answer);
// 2. 逐条回原文比对,能被上下文支撑的才算数
long supported = claims.stream()
.filter(c -> isSupportedBy(c, retrievedContext))
.count();
// 3. 忠实度 = 有支撑的 / 全部
return claims.isEmpty() ? 1.0 : (double) supported / claims.size();
深层感悟 :这里藏着两个坑。一是claims.isEmpty()返回1.0------空答案等于"完全忠实" ,所以Faithfulness必须和Answer Relevancy配对,否则模型学会说"我不知道"就能拿满分。二是extractClaims和isSupportedBy各要一次LLM调用,评估成本会吃掉优化收益。
检索侧四指标与综合评分等级
RAGAS管生成,检索侧另有四个指标:
| 指标 | 公式要点 | 关心什么 |
|---|---|---|
| Recall@k | 召回相关文档 ÷ 全部相关文档 | 漏没漏 |
| Precision@k | 前k个里相关的 ÷ k | 噪声多不多 |
| MRR | 1/rank 的均值 |
第一个正确答案排第几 |
| nDCG | DCG ÷ IDCG,DCG = Σ rel_i / log2(i+1) |
排序整体好不好 |
nDCG里那个log2(i+1)是位置衰减------排第1位的相关文档,价值远高于排第10位的。综合分我按等权处理:
ini
double overall = 0.25 * contextRecall
+ 0.25 * contextPrecision
+ 0.25 * faithfulness
+ 0.25 * answerRelevancy;
| 等级 | 分数 | 含义 |
|---|---|---|
| A+ / A | ≥0.95 / ≥0.90 | 可放心上线 |
| B+ / B | ≥0.85 / ≥0.80 | 可用,有优化空间 |
| C / D | ≥0.70 / ≥0.60 | 需要针对性优化 |
核心洞察 :等权不是因为四个指标一样重要,而是因为项目早期我并不知道哪个更重要。等业务跑起来、看到真实分布之后,权重才该被重新分配------这正是Day 6"双Grader"要解决的问题。
基线 + 漂移检测:评估不是一次性动作
上线前跑一次得到基线,之后按天跑,用JS散度比较"今天的查询分布"与"基线分布",超过阈值(我设的0.15)就告警。
关键收获 :没有基线的评估分数毫无意义。0.82分是好是坏,取决于昨天是0.85还是0.75。漂移检测解决的是另一个问题------提问方式整体变了(比如新功能上线后问题类型迁移)时,旧基线和Golden Dataset会一起失效,这时分数下降不是系统变坏了,是尺子不准了。
Day 3:多模态RAG------让知识库看得懂图表
CLIP双塔:把图像和文本压进同一个坐标系
多模态检索的地基是CLIP,结构非常"Java友好"------两个Encoder,一个共享向量空间:
ini
// 文本塔与图像塔,输出同维度向量,可直接算相似度
float[] textVec = textEncoder.encode("一只在晒太阳的猫");
float[] imageVec = imageEncoder.encode(imageFile);
double score = cosineSimilarity(textVec, imageVec);
训练用对比学习:一个batch里N个图文对,算出N×N相似度矩阵,对角线是正样本,其余全是负样本,损失目标就是把对角线拉近、非对角线推远。
深层类比 :这套结构和推荐系统里的双塔召回模型是同一个东西------用户塔、物料塔,各自编码到同一空间,向量检索做粗排。想明白这点,CLIP就不再神秘:它只是把"用户-物料"换成了"文本-图像",工程实现上几乎没有新东西。
版面分析与"表格必须转Markdown"

这张图是整条流水线:先做版面分析,切成文本块、表格、图片三类再分头处理 ,最后汇入同一个检索空间。表格是隐藏BOSS,我在这里踩了最实在的坑。直接抽成文本是这样一串字符:产品 单价 库存 iPhone15Pro 8999 150 MacBookAir 7999 80;转Markdown后则保留了结构:
yaml
| 产品 | 单价 | 库存 |
|------|------|------|
| iPhone 15 Pro | 8999 | 150 |
| MacBook Air | 7999 | 80 |
工程挑战 :第一种形式在向量空间里几乎无法被召回------"iPhone的价格是多少"和"iPhone15Pro 8999 150"没有任何语义对齐关系。而Markdown保留了列名与值的对应关系,LLM能读懂,Embedding也能捕捉。这条经验在本系列还会遇到两次。
图片必须有Caption,否则等于没入库
图片处理分三步:视觉模型生成描述 → 把描述当文本向量化 → 原图另存对象存储,元数据里只留引用。
arduino
// 图片入库前必须先"翻译"成文本
String caption = visionModel.describe(image); // "架构图:API网关下挂三个微服务..."
Document doc = new Document(caption, Map.of(
"modality", "image",
"objectKey", objectStore.put(image), // 原图另存
"pageNo", pageNo
));
vectorStore.add(List.of(doc));
关键设计 :为什么不让图像向量直接进检索?存储成本高数倍 、检索到了也说不清为什么命中 、专业图表上几乎失效(CLIP在自然图片上很好,在架构图和财务报表上语义损失严重)。先生成描述再向量化是个又土又有效的折中,Week 8 Day 3会把它升级。
Day 4:四个Agent怎么配合------可用的Agentic RAG实现
四Agent分工与Grader的双判据
Day 4把骨架落成了可运行的实现,四个Agent各司其职:
| Agent | 职责 | 输入 | 输出 |
|---|---|---|---|
| Router | 选检索策略 | 用户问题 | RouteDecision |
| Retriever | 按策略取回文档 | 问题 + 策略 | List<Document> |
| Grader | 评判检索质量 | 问题 + 文档 | GradingResult |
| Generator | 生成最终答案 | 问题 + 文档 | RAGResponse |
Grader用了双判据,这是我在这一天最认可的设计:
ini
double avgScore = gradings.stream()
.mapToDouble(DocumentGrading::getRelevanceScore)
.average().orElse(0.0);
long highQualityCount = gradings.stream()
.filter(g -> g.getRelevanceScore() >= qualityThreshold)
.count();
boolean isSufficient = avgScore >= qualityThreshold && highQualityCount >= 2;
感悟 :为什么必须两个条件?因为只看均分会被"一篇高分+三篇垃圾"骗过 ------一篇0.95加三篇0.2,均值0.39看着还行,实际只有一条能用。highQualityCount >= 2保证了"至少两个独立来源能支撑答案",这正是RAG避免幻觉的底气。
调试洞察:LLM返回的JSON一定要有正则兜底
这一天的调试时间,大半花在解析LLM返回的JSON上。三类故障反复出现:被 ```json 围栏包住、前后加了客套话、字符串值里漏转义引号。于是有了三段式兜底:
arduino
private String extractJson(String raw) {
String s = raw.replaceAll("```json|```", "").trim(); // 1. 去围栏
int start = s.indexOf('{'), end = s.lastIndexOf('}');
if (start >= 0 && end > start) return s.substring(start, end + 1); // 2. 截取
return fallbackByRegex(s); // 3. 正则兜底
}
调试洞察 :永远不要相信LLM会返回干净的JSON。哪怕Prompt里写了"只返回JSON",temperature稍微一高它就开始加解释。兜底必须是"三层递减"而非"一次尝试",而且第三层的正则要针对具体字段各写一个,别指望一个通用正则解决所有情况。
ExecutionTrace:让每一次决策可回放
Agent系统最怕的不是出错,是出错后无法归因。办法是把每次决策都记进Trace:
| 字段 | 记什么 | 用来回答什么问题 |
|---|---|---|
routeDecision |
选了哪个策略、为什么 | 是路由选错还是检索没找到 |
retrievalSteps |
每次检索的策略与得分 | 第几次才找到的 |
gradingResult |
均分、高质量数、是否达标 | 纠正因何触发 |
correctionApplied |
改写前后的query | 改写有没有把意思改偏 |
关键感悟 :Agent系统没有Trace就是黑盒。我在改写逻辑上吃过亏------"如何配置VPN"被改写成"VPN设置步骤",检索结果反而变差。有了Trace,对比改写前后的query和得分分布,问题一眼可见;没有Trace只能靠猜。
Day 5:把指标写成可插拔的Java代码
RAGSystemInterface:让评估器跨项目复用
评估框架最容易犯的错,是和某个具体RAG实现绑死。素材里用一个函数式接口解耦:
arduino
public interface RAGSystemInterface {
/** 被测系统只需实现这一个方法 */
RAGResult query(String question);
class RAGResult {
public String generatedAnswer;
public List<String> retrievedDocuments;
public TokenConsumption tokenConsumption;
}
}
// 调用方:不管底层是 LangChain4j 还是 Spring AI
EvaluationReport report = evaluator.runFullEvaluation(myRagSystem::query);
设计哲学 :这是我本周最满意的设计。评估框架不该依赖任何具体RAG实现,否则每换项目就要重写适配器。接口只有三个字段------答案、检索文档、Token消耗------恰好是RAGAS四指标所需的全部输入,多一个都多余。
Context Precision:语义相似 ×0.7 + 关键词命中 ×0.3
上下文精确率是两条路径的加权:语义相似度 × 0.7 + 关键词重叠率 × 0.3。
深层感悟 :这个0.7/0.3不是拍脑袋。纯语义会漏掉专有名词的精确匹配 ------PGVector和Milvus在向量空间里可能很近,但用户问的就是前者;纯关键词扛不住同义改写------"怎么装"和"安装步骤"一个字不重叠。权重应该跟着业务词汇特点调:技术文档专有名词多,关键词权重就该更高。
并行评估:CompletableFuture + 独立线程池
评估一个测试集要跑上百次查询,串行要几十分钟:
scss
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (TestCase testCase : dataset.getTestCases()) {
futures.add(CompletableFuture.runAsync(
() -> evaluateTestCase(testCase, ragSystem, metricScores, ...),
evaluationExecutor // 专用线程池,不是业务线程池
));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
工程挑战 :评估任务绝不能复用业务线程池。评估是CPU加IO混合的长任务,一旦和在线请求抢线程,延迟立刻劣化。线程池要单独配置,评估接口还要限流,否则一次全量评估就能把API配额打光。
Day 6:企业级智能知识库------把五天的零件装成系统
两级路由:正则快匹配优先,未命中才唤醒LLM
Day 6是综合实战,素材152KB,我只挑三个最值得写的点。第一个是两级路由------用规则挡住大部分请求,把LLM留给真正需要的case:
kotlin
// 第一级:正则快匹配,零成本、零延迟
if (query.matches(".*(比较|对比|区别|联系).*")) {
return new RoutingDecision(MULTI_HOP, 0.9);
}
if (query.matches(".*(多少|几个|统计|百分比).*")) {
return new RoutingDecision(KEYWORD_SEARCH, 0.8);
}
// 第二级:规则未命中,才调用LLM做意图分类
return llmIntentClassifier.classify(query);
| 路由方式 | 覆盖请求比例 | 单次成本 | 延迟 |
|---|---|---|---|
| 正则快匹配 | 约 90% | 0 | < 1ms |
| LLM意图分类 | 约 10% | 1次调用 | 300-800ms |
设计哲学 :背后是一个反复出现的模式------规则能解决的,绝不要花钱调LLM(Week 9的图表选型和异常检测前置还会再遇到两次)。90%的请求用规则兜住,整体LLM调用量直接降到十分之一,这是所有成本优化里投入产出比最高的一招。
双Grader:相关性守前门,幻觉守后门
| Grader | 时机 | 判什么 | 不达标怎么办 |
|---|---|---|---|
| RelevanceGrader | 检索后、生成前 | 这段上下文和问题相关吗 | 过滤掉,阈值0.6 |
| HallucinationGrader | 生成后、返回前 | 答案的说法都在上下文里吗 | >0.5 触发重查 |
感悟 :两道都要,因为它们防的是两类完全不同的错误。相关性Grader防"垃圾进"------检索到一堆无关文档,LLM再强也只能胡编;幻觉Grader防"垃圾出"------上下文明明是对的,模型自己加了料。只装前者,你会得到一个"检索很准但答案仍在编"的系统。
我改的坑:BM25的IDF是个占位符
混合检索里有BM25,顺着代码读下去我发现了这个:
arduino
/**
* 计算 IDF(逆文档频率)
*/
private double calculateIDF(String term) {
// 简化实现
return Math.log(1 + 1); // 假设 N=1, n_qt=0
}
调试洞察 :Math.log(1 + 1)恒等于ln2 ≈ 0.693,每个词的IDF一模一样,BM25退化成了纯词频统计。后果很具体:出现十次的"的"和出现一次的"PGVector"被同等对待。这行代码本身没错------它是个诚实的占位符,注释也写清了"简化实现"------但它藏在一个看起来能跑的系统里。修法不难,维护一份词项到文档频率的映射:
arduino
private final Map<String, Integer> docFrequency = new ConcurrentHashMap<>();
private volatile int totalDocs = 0;
private double calculateIDF(String term) {
int nqt = docFrequency.getOrDefault(term, 0);
return Math.log(1 + (double)(totalDocs - nqt + 0.5) / (nqt + 0.5));
}
补完再用JMH压一遍,顺带把HNSW三个参数也调了。这提醒我一件事:骨架代码里最危险的不是报错的部分,而是"能跑但结果不对"的部分------前者编译期就被抓出来,后者要等上线三个月才发现检索一直是个摆设。
Week 5复盘:三个核心突破
1. 从"固定管道"到"LLM当大脑"
Week 4的RAG是写死的管道,Week 5把LLM放到了控制器的位置:判断要不要检索、选哪种策略、评估质量、决定纠错。代价是延迟和成本,所以max_iterations=3是权衡的结果。管道是确定的,Agent是决策出来的------中间隔着一次范式转换。
2. 从"感觉还行"到"RAG质量可量化"
八个指标加一个综合分,让"这个RAG好不好"从主观判断变成可讨论的数字。但门槛不在算分而在建基线------0.82本身毫无意义,有意义的是"相比上周的0.75提升了0.07"。更根本的认识是:评估本身也花钱,所以采样、并行、独立线程池是必备组件而非优化项。
3. 从"只读文本"到"看得见非文本内容"
多模态真正的难点不是"加一个视觉模型",而是把非文本翻译成文本:表格转Markdown才有语义结构,图片生成Caption才能被检索,视频拆关键帧加音频转录才能按时间戳定位。CLIP的统一向量空间提供了理论优雅,但工程上最有效的往往是最朴素的做法。
待解决的深层问题
1. 评估器本身的可靠性由谁评估
Faithfulness和Answer Relevancy都依赖LLM当裁判,而LLM-as-Judge已知会偏向更长的答案、偏向与自己风格相近的表述。这个偏差怎么量化?用另一个模型来评,那又是谁来评那个模型?我目前只做到"人工抽检 + 一致性系数",离自动化还差得远。
2. 多模态统一向量空间在专业内容上的语义损失
CLIP在自然图片上表现优秀,但知识库里塞满了架构图、流程图和财务报表。这类图的语义恰恰藏在细节里------箭头的方向、数字的对应关系------而这些是对比学习损失不关心的。目前只能靠详尽Caption弥补,但Caption本身就是一次有损压缩。
3. 迭代次数与延迟预算的天然矛盾
Agentic RAG要求迭代最多3次,同时要求P99 < 5s。按单次检索加评判约1.5s算,三次就到4.5s,留给生成的余量几乎没有。出路只有并行化(多策略同时检索)和缓存(相似查询复用上轮结果),但两者都会引入新的复杂度。
给同行者的建议
如果你也在做RAG,我最想分享的是:先建评估,再谈优化。
没有刻度的时候,所有优化都是自嗨------你改了分块大小,觉得"好像好一点了",然后就上线了。有了八指标和一条基线,你才知道那次改动是提升0.03还是劣化0.05。更微妙的是,评估会反过来指出方向:Context Recall低而Faithfulness高,问题在检索;两个都低,问题在文档处理;Context Precision低,问题在排序。指标会告诉你该改哪里,不用猜。
先建评估,再谈优化------没有刻度的优化都是自嗨。
Week 5解决的是"怎么让RAG变好",Week 6要面对一个更难的问题------这些"会查资料的Agent",该怎么组织、怎么度量、怎么接入。