ReAct Agent 有了脑子,但记忆是糊涂的:RAG 准确率从 30% 到 87% 的三级跳
📖 系列导读:上篇我们聊了怎么让 Java 后端"长脑子"——ReAct Agent 从零实现:我们如何用 Java 构建企业级 AI 应用。但 Agent 光有脑子不够,还得有记忆。这篇聊聊如何解决记忆系统的检索不准问题。
📊 核心数据:47 份杂乱文档 → 412 条结构化知识,准确率 30% → 87%
阶段 准确率 关键改进 提升
─────────────────────────────────────────────
纯向量检索 30% 基线 -
规则增强 50% Query改写 +20%
混合检索 70% +BM25关键词 +20%
当前版本 87% +意图识别+审核 +17%
🎯 问题:能思考,但记不住
上篇的 ReAct Agent 跑起来后,我们让它回答一个简单问题:
💬 "设备溢价率怎么算?"
它启动了推理循环,然后……调错了 Tool。它去查设备状态,而不是查知识库。
🔍 根本原因:知识库里的文档是"糊涂"的
我们一开始的做法很 naive:
// ❌ naive 版本:直接把整篇文档塞进 prompt
String context = readFiles("操作手册.pdf", "FAQ.docx", "术语表.xlsx");
String prompt = "参考以下文档回答问题:\n" + context + "\n问题:" + question;
问题很快暴露:
| 问题 | 影响 |
|---|---|
| 文档太长 | 根本塞不进 prompt |
| 无关信息干扰 | LLM 被大段无关文字带偏 |
| 检索不准 | 问"溢价率",召回"溢货处理" |
💡 核心洞察:Agent 有了推理能力,但记忆系统不给力——这就是 RAG 要解决的问题。
📁 原始数据的混乱现状
我们收到运营部门发来的 47 份文档:
📂 原始文档(47个)
├── 📄 PDF 操作手册(23个)
│ ├── 扫描件、水印、表格横跨3页
│ └── 50页里只有20页是有效内容
├── 📝 Word 业务规范(15个)
│ ├── 格式混乱:"1.2.3." / "Q:A:" / 纯段落
│ └── 同一概念不同写法:"溢价率/溢价/上浮比例"
└── 📊 Excel 台账(9个)
├── 多 sheet,表头位置不确定
└── 第0行是标题"XX部门手册",第1行才是真表头
🔥 真实案例
案例 1:语义残缺
💬 "点击那个按钮完成设置"
哪个按钮?在哪?完全没有上下文。
案例 2:表头探测失败
某 Excel 第 0 行是标题,第 1 行才是表头。固定读第 0 行会产生几千个 col1, col2, col3...
🏭 文档清洗:四层流水线
Layer 1️⃣:分类解析
parsers = {
'.pdf': PdfParser(extract_tables=True, ocr_enabled=True),
'.docx': DocxParser(heading_levels=[1,2,3]),
'.xlsx': ExcelParser(auto_detect_header=True) # 智能探测表头行
}
📌 Excel 表头自动探测:
public int detectHeaderRow(Sheet sheet) {
for (int i = 0; i < Math.min(5, sheet.getLastRowNum()); i++) {
Row row = sheet.getRow(i);
long nonEmptyCells = countNonEmptyCells(row);
long totalCells = row.getLastCellNum();
// 有效列超过 50%,认为是数据行
if (nonEmptyCells > totalCells * 0.5) return i;
}
return 0;
}
Layer 2️⃣:内容结构化(规则+模型结合)
📋 FAQ 型文档(有明确 Q/A 格式):
patterns = [
r'(?:Q|问题|【问】)[::]\s*(.+?)\n+(?:A|答案|【答】)[::]\s*(.+)',
r'(\d+).\s*(.+?)\n\s*答[::]\s*(.+)'
]
qa_pairs = regex_extract(text, patterns)
📖 手册型文档(大段连续文本)——这是最头疼的
我们用反向问题生成把叙述文转成问答对:
# 步骤型段落识别:含"首先/然后/点击"等动词
steps = extract_steps(text)
# 用轻量模型反向生成问题
prompt = f"根据以下操作步骤,生成用户可能问的问题:\n{steps}"
question = chatglm.generate(prompt)
# 🎯 输出:"如何批量修改设备售价?"
🔒 数据安全提示:上述代码涉及调用外部 LLM,生产环境务必注意数据脱敏。我们的实践:
- 敏感字段(手机号、设备 ID、具体金额)在送 LLM 前通过
DataMaskingService脱敏 - 优先使用本地部署模型(ChatGLM-6B/9B),数据不出内网
- 必须调用云 API 时,确认服务商隐私政策,并做好日志审计
Layer 3️⃣:标准化与去重
validators = [
lambda row: len(row['question']) >= 10, # 问题不能太短
lambda row: len(row['answer']) >= 20, # 答案不能太短
lambda row: row['question'] not in existing, # 去重
lambda row: similarity(row['q'], row['a']) < 0.8 # QA不能完全一样
]
📎 实战代码:核心片段分享
1. Excel 表头智能探测(解决"第 0 行是标题"的坑)
def detect_header_row(sheet, max_check_rows=5):
"""
自动探测表头行:有效列数 > 50% 的行被认为是数据行
"""
for i in range(min(max_check_rows, sheet.max_row)):
row = sheet[i + 1] # openpyxl 是 1-based
non_empty = sum(1 for cell in row if cell.value)
total = len(row)
if total > 0 and non_empty / total > 0.5:
return max(0, i - 1) # 数据行的上一行是表头
return 0 # 默认第一行
2. Word 按标题层级切分(保留文档结构)
def parse_docx_by_heading(file_path):
"""
按 Heading 1/2/3 切分,把大文档拆成有层级的 chunk
"""
doc = Document(file_path)
chunks, current = [], {'title': '引言', 'content': []}
for para in doc.paragraphs:
if para.style.name.startswith('Heading'):
# 遇到标题,保存当前章节,开启新章节
if current['content']:
chunks.append(current)
current = {'title': para.text.strip(), 'content': []}
else:
if len(para.text.strip()) > 5:
current['content'].append(para.text)
return chunks
思路说明:
- Excel:不要信业务说的"表头在第 X 行",用数据密度自动探测
- Word:不要按固定字数切,按标题层级切才能保留语义完整性
- 通用原则:清洗阶段宁可 conservative(宁可丢也别错),后面召回率才好调
Layer 4️⃣:人工审核闭环
⚠️ 关键认知:自动化只能解决 73% 的问题,剩下 27% 必须人工校验
人工审核 checklist:
- 步骤描述是否准确?
- 生成的反问是否符合业务习惯?
- 分类标签是否正确?
📊 清洗效果数据
| 阶段 | 数量 | 说明 |
|---|---|---|
| 📁 原始文档 | 47 个 | PDF 23, Word 15, Excel 9 |
| 🤖 自动提取 | 312 条 | 规则+模型自动识别 |
| 👤 人工审核 | 156 条 | 置信度低或结构复杂 |
| ✅ 最终入库 | 412 条 | 剔除重复和低质量 |
| 📝 人工修正率 | 27% | 持续优化中 |
💾 向量存储:Milvus 设计
🎯 为什么选 BGE-M3?
| 模型 | 维度 | 长文本 | 部署方式 |
|---|---|---|---|
| text-embedding-ada-002 | 1536 | 8K | 调用 API(贵+外泄风险)❌ |
| BGE-large-zh | 1024 | 512 | 本地部署 |
| BGE-M3 ✅ | 1024 | 8192 | 本地部署 |
🗄️ Milvus 表结构
Collection: knowledge_base
Fields:
- id: UUID # 主键
- embedding: FLOAT_VECTOR(1024) # 向量
- content: VARCHAR(4096) # 原始文本
- question: VARCHAR(1024) # FAQ专用问题
- answer: VARCHAR(4096) # 答案
- category: VARCHAR(64) # 分类:售卖规划/补货管理/设备运维
- source: VARCHAR(256) # 来源文件
- chunk_index: INT # 分块序号
- tenant_id: INT # 多租户隔离
Index:
type: IVF_FLAT
metric_type: COSINE
nlist: 1024
🔍 检索优化:三级跳的全过程
❌ 初始版本:纯向量检索(准确率 30%)
float[] vector = embedding.embed(question);
List<Chunk> results = milvus.search(vector, topK=5);
问题:"溢价率"和"价格"的向量太接近,经常召回错误内容。
⚠️ 改进版:混合检索(准确率 70%)
// 1️⃣ 向量召回 Top 20
List<Chunk> vectorResults = milvus.search(vector, topK=20);
// 2️⃣ BM25 关键词召回 Top 20
List<Chunk> bm25Results = es.search(question, topK=20);
// 3️⃣ 融合去重
List<Chunk> merged = mergeAndDeduplicate(vectorResults, bm25Results);
// 4️⃣ 按综合分数排序,取 Top 5
return merged.stream()
.sorted(Comparator.comparing(Chunk::getScore).reversed())
.limit(5)
.collect(toList());
✅ 当前版:Query 改写 + 混合检索(准确率 87%)
public String rewriteQuery(String raw) {
// 1️⃣ 同义词扩展(业务词表)
String expanded = synonymExpand(raw, businessTerms);
// "溢价咋算" -> "溢价率计算方法"
// 2️⃣ 意图分类
Intent intent = classifier.classify(expanded);
// QUERY_OPERATION / FAULT_DIAGNOSIS / DATA_ANALYSIS
// 3️⃣ 分类增强
return String.format("[%s] %s", intent.getCategory(), expanded);
}
📈 效果对比
准确率提升曲线(三级跳)
│
90% ┤ 🎯 目标
│
87% ┤────────────────── ✅ 当前版本
│ (+意图识别+人工审核)
70% ┤────────── ⚠️ 混合检索
│ (+BM25关键词)
50% ┤──── 🔧 规则增强
│ (+Query改写)
30% ┤ ❌ 纯向量检索
│ (基线)
└────────────────────────
| 优化阶段 | 准确率 | 主要改进 | 提升幅度 |
|---|---|---|---|
| 纯向量检索 | 30% | 基线 | - |
| 规则增强 | 50% | Query改写+同义词扩展 | +20% |
| 混合检索 | 70% | +BM25关键词召回 | +20% |
| 当前版本 | 87% | +意图分类+人工审核 | +17% |
💣 踩过的坑
坑 1:向量检索的"语义漂移"——相似≠相关
现象: 用户问"怎么改价",召回的是"价格改了之后怎么同步"。向量相似度 0.92,但完全不是用户想要的。
根因: Embedding 模型把"改价"和"价格"的向量算得很近,但业务意图完全不同(一个是操作、一个是同步逻辑)。
** naive 修复(无效):**
// 单纯提高相似度阈值,反而漏掉真正相关的
if (score > 0.95) // 太高会漏,太低会错
正确解法:重排序(Rerank)
// 两阶段检索:先召回 20 条,再用轻量模型精排
List<Chunk> candidates = milvus.search(vector, topK=20);
// 使用 Cross-Encoder 精排(双塔模型,查询和文档交叉注意力)
List<ScoredChunk> reranked = candidates.stream()
.map(c -> new ScoredChunk(c, crossEncoder.score(query, c.getContent())))
.sorted(Comparator.comparing(ScoredChunk::getScore).reversed())
.limit(5)
.collect(toList());
效果: "改价"类查询错误减少 60%。
坑 2:多租户上下文穿透——ThreadLocal 的陷阱
现象: 经反馈,A 租户偶尔能看到 B 租户的设备文档。排查发现:异步线程里的查询没带 tenantId。
根因: Milvus 查询的 tenantId 从 ThreadLocal 取,但异步线程不继承父线程的 ThreadLocal。
代码陷阱:
// ❌ 错误:异步线程丢失租户上下文
CompletableFuture.runAsync(() -> {
// 这里 TenantContextHolder.get() 返回 null
milvus.search(vector);
});
正确解法:显式传递 + 强制校验
// 1. 异步任务必须显式传递 tenantId
public CompletableFuture<SearchResult> asyncSearch(float[] vector, Long tenantId) {
return CompletableFuture.supplyAsync(() -> {
// 强制设置,不能依赖 ThreadLocal
TenantContextHolder.setTenantId(tenantId);
try {
String expr = String.format("tenant_id == %d", tenantId);
return milvus.search(vector, expr);
} finally {
TenantContextHolder.clear(); // 必须清理,防止线程复用污染
}
});
}
// 2. Milvus 查询层强制校验
public List<Chunk> search(float[] vector, String expr) {
if (expr == null || !expr.contains("tenant_id")) {
throw new IllegalArgumentException("查询必须包含租户过滤");
}
return doSearch(vector, expr);
}
坑 3:增量更新时的索引碎片——越更新越慢
现象: 知识库上线后,每周增量上传新文档。长时间后,同样的查询从 200ms 变成 2s。
根因: Milvus 的 IVF_FLAT 索引在频繁增量插入后,产生大量小 Segment,查询时需遍历所有 Segment。
排查过程:
# 查看 Milvus 集合统计
curl http://localhost:9091/api/v1/collections/statistics
# 发现 Segment 数量 > 100,而健康值应 < 20
正确解法:段合并策略
// 1. 定期手动合并(夜间任务)
@Component
public class MilvusCompactionJob {
@Scheduled(cron = "0 0 2 * * SUN") // 每周日凌晨2点
public void compact() {
// 合并小 Segment
milvusClient.compact("knowledge_base");
// 重建索引(如果需要)
milvusClient.loadCollection("knowledge_base");
}
}
// 2. 批量插入替代单条(减少 Segment 数量)
public void batchInsert(List<Chunk> chunks) {
// 每 100 条一批,而不是逐条插入
List<List<Chunk>> batches = Lists.partition(chunks, 100);
for (List<Chunk> batch : batches) {
milvusClient.insert(batch);
}
}
效果: 查询耗时稳定在 150-250ms。
🚀 87% 不是终点
本文构建的方案还有 3 个可优化的方向,也是下一系列的预告:
1️⃣ 13% 的错误率怎么消灭?
测试发现,错误主要来源于:
- 相似语义但不同意图("改价" vs "价格同步")
- 需要多步推理的复杂问题
🔜 下一篇预告:《90% 准确率攻坚战:重排序模型与多路召回实战》
2️⃣ 扫描件和图文混排
目前只处理了文本型 PDF,扫描件和图文混排还没支持。
🔜 下一篇预告:《从 OCR 到多模态:图文混排文档的解析实战》
3️⃣ 多知识库路由错误
我们有 3 个知识库(业务指标/操作指南/产品信息),意图识别路由错误率还有 20%。
🔜 下一篇预告:《3 个知识库、20% 路由错误:意图识别与路由纠错实战》
📝 总结
RAG 不是"向量化+检索"的简单组合,而是一个从 文档清洗 → 分块策略 → 向量存储 → 检索优化 的完整工程。
最深刻的体会:"整理文档比调 prompt 更重要",比技术实现更难。
从 30% 到 87%,不是调了某个参数,而是搭了一套四层清洗流水线。近 3 倍的提升,换来的是业务侧从质疑到认可的转变。
从 87% 到 90%,需要更精细的重排序和路由策略——这就是下一篇的故事。
🔗 相关阅读
- ReAct Agent 从零实现:我们如何用 Java 构建企业级 AI 助手 👈 系列上篇
- 🔔 关注作者,第一时间获取《90% 准确率攻坚战》
💻 代码说明
文中代码片段可直接使用,如需完整实现方案欢迎评论区交流。
📌 关于作者:一个喜欢解决实际问题的后端工程师。近期沉迷 LLM 应用开发,这个系列分享我在真实项目里踩过的坑和爬出来的经验。
如果觉得有用,请给个 👍 点赞 | 🔖 收藏 | 💬 评论 支持一下!
#Java #AI #RAG #LLM #Agent #后端开发 #向量检索 #技术分享