ReAct Agent 有了脑子,但记忆是糊涂的:RAG 准确率从 30% 到 87% 的三级跳

0 阅读10分钟

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-00215368K调用 API(贵+外泄风险)❌
BGE-large-zh1024512本地部署
BGE-M310248192本地部署

🗄️ 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%,需要更精细的重排序和路由策略——这就是下一篇的故事


🔗 相关阅读

💻 代码说明

文中代码片段可直接使用,如需完整实现方案欢迎评论区交流。


📌 关于作者:一个喜欢解决实际问题的后端工程师。近期沉迷 LLM 应用开发,这个系列分享我在真实项目里踩过的坑和爬出来的经验。

如果觉得有用,请给个 👍 点赞 | 🔖 收藏 | 💬 评论 支持一下!


#Java #AI #RAG #LLM #Agent #后端开发 #向量检索 #技术分享