Administrator
发布于 2023-03-15 / 6360 阅读
93

RAG 检索增强生成的 Java 实现方案

给内部文档做个能问答的机器人

公司几百份技术文档散在 Wiki,搜起来费劲——关键词搜只能匹配字面,问"怎么处理订单超时"搜不到"订单 30 分钟未支付自动关单"的那段。我想用 RAG(检索增强生成)做个问答,让模型"先查资料再回答"。2023 年初这套链路在 Java 侧得自己拼,没有现成框架,正好练手,也顺手沉淀一套可复用的检索服务。

完整链路拆解

RAG 不是调一个 API,而是一条流水线,每一步都影响最终答案质量:

1. 文档切分

按段落 + 滑动窗口切,避免一句话被截断。我们定 500 字一块、重叠 50 字:

List<String> chunks = slidingSplit(doc, 500, 50);

切太碎,上下文丢失,模型回答"知道有但说不清";切太大,一块塞太多无关内容,噪声大。500 字是我们测出来的甜点——既保语义完整,又不至于超 token。重叠是为了边界处的句子不被腰斩。

2. 向量化

每块送 embedding(当时用 OpenAI ada-002),存进向量库。Java 侧用 Redis 7 的向量检索或 ES 8 的 dense_vector:

// ES 8 写入
IndexRequest r = new IndexRequest("doc_chunk")
    .source("vec", embedding, "text", chunk);

// 查询用 knn 检索
SearchRequest req = new SearchRequest("doc_chunk");
req.source(new SearchSourceBuilder()
    .knn(new KnnSearchQuery("vec", queryVec, 5)));

我们选了 ES 8,因为它本来就在用,运维成本低;Redis 7 的向量检索也试过,小规模够用,但过滤(按文档权限)不如 ES 灵活。

3. 召回

用户问题同样向量化,近邻检索取 top 5。关键参数是距离阈值,太低召回空、太高塞噪声。我们设了余弦相似度 > 0.75 才入选,低于的直接返回"资料里没找到"。

4. 重排

ES 召回的 top 5 再用一个相关性打分精排,把最相关的提到前面,控制进 prompt 的 token 数。粗排保召回、精排保精准,两层配合比单次检索稳。精排我们用了个简单的 BM25 叠加向量分,效果比纯向量好一截。

5. 生成

把"问题 + 重排后的片段"拼成 prompt 送大模型:

String prompt = "基于以下资料回答,资料中没有就回答不知道:\n"
    + String.join("\n---\n", topChunks)
    + "\n问题:" + question;

务必加"资料里没有就回答不知道",否则模型会编。我们吃过亏:问一个文档没覆盖的新接口,模型一本正经编了段错误用法。

踩过的坑

  • 切分太碎导致上下文丢失,回答"知道有但说不清";
  • 召回不加阈值,无关片段污染答案,模型被带偏;
  • prompt 塞太多块,超 token 限制被截断,关键信息在尾部被砍;
  • 没做权限过滤,普通员工搜到了高管会议纪要的片段——检索层要带 ACL,按用户可见范围过滤。

效果评估

上线后我们抽了 100 个真实问题人工评测:纯关键词搜索能答对的 38 个,RAG 答对的 79 个,且答案带出处链接,员工能点进去核对。 hallucination(编造)从关键词搜索的 0(搜不到就不会编)变成 6 个,靠"不知道就明说"的约束压住。

权限过滤不能省

企业文档有保密级别,检索必须带 ACL。我们在写入时就给每个 chunk 打了文档 ID 和可见范围标签,查询时把用户可看的文档 ID 列表作为 filter 传给 ES:

boolQuery().must(knn).filter(
    TermsQuery("doc_id", userVisibleDocIds));

这样普通员工永远搜不到他没权限的会议纪要片段。这层过滤必须做在检索侧,不能等生成时再说——否则模型可能从"不可见但被召回"的片段里泄露信息。我们上线第一周就靠这个拦住了一个越权查询。

增量更新与一致性

文档天天改,全量重算不现实。我们用消息队列:Wiki 文档变更事件触发消费者,重新切块 embedding 写回向量库,旧版本 chunk 标记失效。关键要保证"更新中"的文档检索不返回半新半旧的内容,我们用版本号,查询只取最新版本。首次全量建索引 500 万段跑了 6 小时,日常增量基本秒级生效。

把评测做成闭环

RAG 效果会随文档变化漂移,不能上线就不管。我们每周抽 50 个真实问题人工评分(相关、答错、编造三类),画趋势线。有次发现"编造"类从 3 个涨到 9 个,追查是某批新文档切块太碎、召回噪声大,调了切分策略(500→800 字、重叠 50→100)后回落。评测闭环让我们知道系统是在变好还是变坏,而不是凭感觉。

向量库选型:Redis 还是 ES

我们两个都试了。Redis 7 的向量检索(HNSW)好处是快、运维简单,我们已经在用它做缓存,加个向量索引零额外组件;缺点是过滤能力弱,按文档权限过滤得在应用层做,量大的时候召回后再过滤会丢精度。ES 8 的 dense_vector 配合 bool filter 能在检索期就带 ACL,过滤精准,但 ES 本身重,多养一个集群。最终选 ES,因为企业文档权限复杂,检索期过滤比性能更重要;小场景用 Redis 足够。

延迟与成本的账

整条链路端到端延迟我们测过:切分忽略(离线)、向量化 30ms(调用 API)、ES 检索 8ms、重排 5ms、大模型生成 800ms~1.5s(看回答长度)。大头在大模型生成,检索侧几十毫秒可忽略。成本上,向量化按 token 计费,500 万段首次建库花了约 200 元;每次问答向量化问题几十 token 几乎免费。所以 RAG 的成本主因是生成,不在检索,优化重点该放提示词精简和缓存高频问答。

高频问答缓存

"怎么申请权限""报销流程是什么"这类问题每天被问几十次,每次都跑全链路浪费。我们加了一层语义缓存:问题向量化后先查最近邻,如果和历史某问题余弦相似度 > 0.95 且答案已缓存,直接返回,跳过生成。命中率约 30%,大模型调用量降三成,延时从 1 秒降到 50ms。这是性价比极高的一步,上线当天就看到 QPS 和大模型费用双降。

badcase 分析与回归

上线后我们收集了回答错的 case,归类发现三类:一是召回错了(相关文档没进 top 5),靠调切分和阈值解决;二是召回对但模型没抓住重点,靠改提示词"只依据下列资料、不要发挥"压住;三是资料本身过时,靠增量更新解决。每类对应一个可执行的改进,而不是笼统"模型不行"。我们还建了 badcase 库,每次模型或参数变更都回归这批 case,防止"修一个坏一个"。RAG 的优化是数据驱动、可回归的,这点比纯调模型靠谱。

这套链路的成本总账

一学期跑下来:GPU 机器(自建 embedding)月成本约 3000 元,大模型 API 按问答量月约 2000 元,ES 集群多养一个节点约 1500 元,合计月 6500 元。替代的是原来 2 个运营同学每天 3 小时答重复问题(约 1.2 人日/月),人力折算远超 6500。更别说员工查资料的体验提升。所以从 TCO 看 RAG 在内部知识场景是划算的,前提是文档量够大、重复问题够多。量小的团队上 RAG 可能养不起这套链路,直接关键词搜索更实际。

给想自建 RAG 的提醒

别一上来就追最复杂的链路。我们建议从最小可用开始:切块 + 向量化 + 召回 top 5 + 拼提示词给大模型,先跑通,再逐步加重排、加权限、加缓存。每一步都能量化收益,避免"为了高级而高级"。RAG 的复杂度在检索质量,不在链长度。我们见过有人堆了六层处理,召回还是烂,因为最基础的切分就没调好。地基稳了再砌墙。

小结

RAG 的难点不在"调模型",而在检索质量。切分策略、向量库选型、召回阈值、重排,每一步都影响最终答案。2023 年初 Java 侧尚无成熟 RAG 框架,链路得自己搭,但每块都清晰可控,也方便按业务定制(权限、评测)。等 Spring AI 这类生态成熟,这套链路能平滑替换底层,上层逻辑不动。

参考