2026/10/21 5:36:52

LangChain4j + SpringBoot:构建生产级RAG知识库问答全流程

LangChain4j + SpringBoot:构建生产级RAG知识库问答全流程 1. 先想清楚一件事模型答不上来不是模型不行大概每个接过LLM应用开发的人都有过这种经历把几十页内部资料丢给模型问它其中一个细节它要么客客气气说我无法访问该信息要么一本正经开始编编得还挺像那么回事。之前我在一个内部知识问答项目里就被这个问题搞得很狼狈后来才发现问题根本不在模型参数多大、提示词写得多花哨而在于我走错了路。1.1 大模型的知识边界到底长什么样大模型的通用知识在训练完成那一刻就固化了。之后世界上发生了什么新事它不知道你公司内部的手册、合同、工单、私有Wiki训练语料里压根没有。日常闲聊感觉不出来一旦落到企业内部场景立刻暴露三个短板知识截止。新版本功能、刚发布的接口规范、最新的政策条款模型一律答不上来。幻觉。它不会说我不知道而是用最合理的措辞把空白填上专业说法叫hallucination。私有数据不可见。你文档里写了什么对它是一堵墙。RAGRetrieval-Augmented Generation检索增强生成的思路说穿了一文不值别让模型硬背资料在它回答之前先从一个外部知识库里检索出与问题相关的片段把这些片段连同问题一起塞给它让它看着资料回答。相当于给模型配了一个随叫随到的资料员资料员负责翻书模型负责照着念和归纳。1.2 RAG和微调根本不是二选一的对立面很多人一听要让模型懂我的文档第一反应是微调。我的回答通常是先搞清楚你要解决的是知识缺失还是行为风格问题。对比维度RAG微调知识更新速度换文档即可分钟级生效需要重新准备语料并训练小时甚至天级回答可溯源能引用原文片段便于审计不可解释答错了你都不知道依据在哪私有文档支持天然支持不用标注需要整理成问答对成本高成本主要是向量化接口费用和存储训练算力通常贵一个数量级擅长场景知识问答、资料查询输出风格、语气、固定格式的调整结论很直白如果你的诉求是让模型知道我司文档里写了什么RAG是成本最低、见效最快的路径。微调更适合让模型学会某种表达习惯或者把专业术语固化到生成风格里而不是用来背资料。1.3 LangChain4j在这个链路里的位置Java生态里做RAGLangChain4j是绕不开的一套东西。它把LLM应用开发里的通用环节抽象成了组件ChatLanguageModel对话模型、EmbeddingModel向量化模型、EmbeddingStore向量存储、DocumentSplitter文档切分器、ContentRetriever内容检索器再用AiServices把这些组件编排成完整的问答服务。配合SpringBoot的starter自动装配你不需要手动new一堆对象配置写在application.yml里注入即用。这一篇我按一条完整的RAG流水线往下拆从工程配置讲到文档切分、向量化、检索再到排查链路和进阶调优。每一步都会讲清楚为什么这么做和这里容易踩什么坑。2. 地基SpringBoot工程里怎么引入、怎么配置2.1 starter到底替我们做了多少事先看最简单的Maven引入以当前稳定版本为准dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai-spring-boot-starter/artifactId version0.36.2/version /dependency版本号请以你拉取时的最新稳定版为准我写这篇时用的还是0.36.x。这个starter一进来Spring容器里就自动出现了两样关键东西ChatLanguageModel和EmbeddingModel。你没写任何一行创建对象的代码它们就已经能注入Service了。这就是starter的价值把读配置、构建客户端、设置超时重试这类模板代码全部吃掉了。你只需要关心业务代码。不过自动配置有个副作用——如果你在配置里漏了api-key应用启动时不一定报错但一调用就抛异常而且异常信息往往不够直观。所以我建议一上来就打开Spring Boot的debug日志或者直接看一眼自动配置报告确认这两个Bean真的创建成功了。2.2 配置项里最容易翻车的几个点application.yml里最基础的配置长这样langchain4j: open-ai: chat-model: api-key: ${LLM_API_KEY} model-name: gpt-4o-mini temperature: 0.2 timeout: 60s max-retries: 2 embedding-model: api-key: ${LLM_API_KEY} model-name: text-embedding-3-small三个地方特别容易翻车第一对话模型和Embedding模型是两套独立配置。很多人只配了chat-model结果注入EmbeddingModel时拿到一个直接用默认值的对象或者干脆启动失败。你在RAG里既要对话又要向量化两段都要写。第二temperature建议压低。RAG场景我们希望模型忠实于检索到的资料temperature设成0.2左右比较合适。设太高模型会放飞自我把资料扔一边自己发挥。这类问题特别隐蔽因为它不是报错只是回答质量飘忽。第三超时和重试别用默认值裸奔。Embedding模型处理长文本时比较慢默认超时可能不够而LLM接口的偶发超时也很常见。timeout设成60秒、max-retries设成2是我试下来比较稳的组合。2.3 没有大模型Key的时候怎么在本地先跑通很多人卡在第一步没有可用的API Key怎么验证整条链路两个思路。一是用本地模型服务。LangChain4j有对应的starter把大模型服务跑在本机配置里指向localhost即可。Embedding也有本地方案专门有基于ONNX的本地嵌入模型模块加载即用、完全离线。缺点是对机器性能有要求嵌入模型倒是还好对话模型如果比较大会吃内存。二是先用假配置把链路跑通验证检索这一步。RAG里真正核心的是检索质量Embedding可以用本地小模型顶上对话部分临时用简单模型等拿到Key再切换。这条路径我建议所有人都走一遍因为后面调优时你会频繁需要只看检索结果、不看最终回答的调试手段。3. RAG流水线的四个环节逐一拆解RAG不是一个大而全的魔法而是四个环节串起来的一条流水线文档加载与切分、向量化、存储、检索。任何一个环节出了问题最终回答都会不对劲。3.1 文档加载与切分所有下游问题的源头加载这一步很简单LangChain4j的DocumentLoader给了现成的方法// 加载本地文件 Document document FileSystemDocumentLoader.loadDocument(Paths.get(docs/产品手册.txt)); // 也支持加载URL文档 Document urlDoc UrlDocumentLoader.load(URL.create(https://example.com/help.html));txt、md、html都好说PDF需要额外的解析器依赖解析效果取决于文档本身的质量。图片类PDF基本别指望纯文本加载能靠谱。真正决定成败的是切分。你不可能把整本手册一次性塞给模型上下文窗口放不下而且检索粒度太粗命中率极低。所以要切成一段一段的文本片段专业叫TextSegment。DocumentSplitter splitter DocumentSplitters.recursive(300, 50); ListTextSegment segments splitter.split(document);第一个参数是每段的目标长度按token数算第二个参数是相邻段落之间的重叠长度。为什么要重叠因为文档在切分处可能把一个完整语义拦腰截断。上一段的结尾和下一段的开头本来是一句连贯的话或者一个表格的上下文重叠部分能让两边都保留足够的线索。切分参数直接决定后面所有环节的上限。段太长一段里揉进去太多主题向量化之后语义被平均掉检索时哪哪都沾一点、哪哪都不精准段太短单段上下文不完整模型拿到片段也读不懂。我见过太多人把时间花在调Prompt上最后发现问题出在切分参数上。3.2 向量化Embedding模型的选型逻辑切好的文本片段要变成计算机能算相似度的东西这就是Embedding模型干的活。它把一段文本映射成一个高维向量语义相近的文本向量在空间里也靠近。EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(System.getenv(LLM_API_KEY)) .modelName(text-embedding-3-small) .build(); ListEmbedding embeddings embeddingModel.embedAll(segments).content();选Embedding模型有三条经验一是中文场景优先选多语言或中文专项模型。纯英文优化的模型处理中文向量质量会打折扣检索召回率肉眼可见地下降。二是入库和检索必须用同一个Embedding模型。这不是建议是硬性要求。不同模型生成的向量在不同的向量空间里就像两套坐标系算出来的相似度没有任何意义。很多人入库时用一个模型检索时换了另一个然后百思不得其解为什么召回结果一塌糊涂。三是维度不是越高越好。高维度向量确实能表达更细的语义但存储和计算成本也上去了。业务场景里中小规模知识库几百维的模型完全够用。3.3 存储从进程内Store到专业向量库向量存哪取决于你的知识库规模。LangChain4j最基础的是InMemoryEmbeddingStore全部存在内存里适合验证链路、做Demo。EmbeddingStoreTextSegment embeddingStore new InMemoryEmbeddingStore(); embeddingStore.addAll(embeddings, segments);它有个致命短板不持久化。应用一重启向量全部消失你得重新加载文档、重新切分、重新向量化。我见过有人部署上线第二天发现问答突然全部失效就是因为它以为数据存住了。InMemoryStore提供了序列化方法可以手动持久化到文件再加载。但对稍微正式一点的项目我更推荐直接上专业向量库Milvus、Qdrant、Chroma、pgvector这些都是LangChain4j官方支持过的方向各有对应集成包。存储方案部署方式持久化适合规模备注InMemoryEmbeddingStore进程内手动序列化几千段以内适合Demo和调试Chroma独立服务或嵌入式自动中小规模上手快pgvector借助PostgreSQL自动中小规模复用现有数据库Milvus / Qdrant独立服务自动大规模支持混合检索、生产首选3.4 检索与组装ContentRetriever到底怎么工作检索环节LangChain4j封装成了ContentRetriever最常用的是EmbeddingStoreContentRetrieverContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.7) .build();它的工作流程是用户问题先被Embedding模型向量化然后在向量库里做相似度搜索取相似度最高的前N个候选再按minScore过滤掉相似度太低的噪声最后把留下的文本片段注入Prompt交给对话模型。maxResults控制最多取几段。取太少可能漏掉关键信息取太多会把不相关的东西也塞进上下文既稀释注意力又浪费token。minScore是相似度阈值这个值需要根据你实际数据的分数分布来定后面我会专门讲怎么调。到这里RAG的四个环节就串起来了。下面我把它们拼成一个能跑的SpringBoot服务。4. 跑通最小闭环一个SpringBoot Demo的全过程4.1 代码走读三段代码一条链路第一段文档入库。用一个Spring组件启动时自动加载文档、切分、向量化、写入存储Component public class DocumentIngestor { private final EmbeddingModel embeddingModel; private final EmbeddingStoreTextSegment embeddingStore; public DocumentIngestor(EmbeddingModel embeddingModel, EmbeddingStoreTextSegment embeddingStore) { this.embeddingModel embeddingModel; this.embeddingStore embeddingStore; } PostConstruct public void load() { Document document FileSystemDocumentLoader .loadDocument(Paths.get(docs/产品手册.txt)); DocumentSplitter splitter DocumentSplitters.recursive(300, 50); ListTextSegment segments splitter.split(document); ListEmbedding embeddings embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments); } }注意这里直接注入EmbeddingStore 靠starter的自动配置就能拿到。如果没配默认的store你得自己定义一个Bean。第二段定义问答助手接口并组装。LangChain4j的AiServices是核心编排器interface RagAssistant { String chat(String userMessage); } Component public class RagSearcher { private final RagAssistant assistant; public RagSearcher(ChatLanguageModel chatModel, EmbeddingModel embeddingModel, EmbeddingStoreTextSegment embeddingStore) { ContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.6) .build(); this.assistant AiServices.builder(RagAssistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build(); } public String ask(String question) { return assistant.chat(question); } }第三段暴露一个REST接口RestController public class RagController { private final RagSearcher ragSearcher; public RagController(RagSearcher ragSearcher) { this.ragSearcher ragSearcher; } GetMapping(/ask) public String ask(RequestParam String question) { return ragSearcher.ask(question); } }4.2 实测效果和参数微调跑起来之后随便问几个问题。我当时拿的是一份产品手册问产品支持导出哪些格式回答能把格式列表和适用条件说明白而且跟原文对得上问售后响应时间是多久也能给出准确数字。这就是RAG该有的效果——模型没有在编它在复述资料。但参数一定得微调。第一次跑通时我把minScore设成0.6回答质量时好时坏。后来我在调试模式里把每次检索的相似度分数打出来看发现命中的片段分数通常在0.65到0.78之间而真正不相关的片段一般低于0.55。所以阈值卡在0.6到0.65之间最合适。这个分数分布跟你的文档类型、Embedding模型强相关别照抄别人的值一定要基于自己数据的实测。4.3 跑通之后立刻要处理的三个隐患Demo能跑和能上线是两码事。我列出三个最常见的隐患一是重启丢数据。前面说过InMemoryEmbeddingStore不持久化。解决方案要么每次启动重新入库要么序列化到文件。更稳妥的做法是直接把向量库换成独立服务。二是文档一更新旧向量和新向量混在一起。如果产品手册改了版本旧内容没有清理检索就可能命中过时信息。生产环境要考虑版本管理起码在元数据里带上文档版本号检索时过滤掉旧版本。三是上下文超窗。maxResults设成5、每段又长加上对话历史一不小心就超出模型的上下文窗口。报错看起来像请求过大实际是检索结果历史消息撑爆了。控制maxResults和segmentSize要从一开始就想好。5. 检索不精准的完整排查链路如果说前面是怎么搭这一节是搭好之后出了问题怎么查。RAG效果不好的时候90%的人都直接调Prompt这是最低效的路径。正确顺序是先查检索、再查切分、最后才查生成。5.1 召回了一堆不相关内容先别急着骂模型症状模型回答里出现了资料里根本没有的信息或者答非所问。很多人第一反应是模型在幻觉其实大概率是检索环节把不相关的片段塞进了Prompt。模型只是忠实地基于你给的垃圾资料在回答。排查第一步绕过对话模型直接调ContentRetriever看结果// 调试用直接检索看看返回了什么 ContentRetriever retriever ...; ListContent contents retriever.retrieve(Query.from(产品支持导出哪些格式)); contents.forEach(c - System.out.println(c.textSegment().text())); System.out.println(c.textSegment().metadata());这样能看到模型看到了什么。如果检索结果本身就文不对题那问题一定在检索链路里。按这个顺序查Embedding模型是否一致。入库和检索用的是不是同一个这是头号原因。切分质量。打印几个片段看看有没有被拦腰截断、语义不完整的段落。如果切分乱了检索再怎么调都白搭。minScore阈值。把阈值降下来看返回的候选分数分布再决定卡多少合适。文档加载是否乱码。特别是中文文件编码不对会导致整个向量全是噪声。5.2 中文场景特有的切分与编码问题中文跟英文在切分上的表现差异很大。英文按空格和标点切就很自然中文的递归切分主要靠标点符号如果文档里长句很多、逗号少切出来的片段就可能超出目标长度或者语义被硬切。另外一个更隐蔽的坑是编码。用FileSystemDocumentLoader加载文档时默认编码可能不是UTF-8。Windows下常见的GBK编码文件加载进来就是一堆乱码向量化之后每个片段都变成了无意义的向量。检查方法很简单打印前100个字符看看。这个问题我们当时排查了很久最后发现是某份旧文档用了GBK编码其他文档都是UTF-8导致只有那一份文档的召回结果惨不忍睹。处理办法是在加载时显式指定编码Document document FileSystemDocumentLoader.loadDocument( Paths.get(docs/产品手册.txt), DocumentSource.from(path - { try { return Files.readString(path, StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException(e); } }));实际项目中我建议把所有源文档统一转成UTF-8存储不要指望代码兼容各种编码那会让问题排查复杂十倍。5.3 持久化、并发与扩展性隐患InMemoryEmbeddingStore在内存里做暴力搜索几千个片段没问题但文档一多每次检索都要全量扫描计算相似度响应时间会明显上涨。更麻烦的是并发写——如果一边在服务查询一边又在入库数据的一致性没人给你保证。我的建议是入库和查询分离。入库做成离线的独立任务生成好的向量存储或序列化文件在应用启动时加载。查询服务只读不写入。这是最稳的架构也方便后续把存储换成专业向量库。6. 把RAG从能跑调到好用的进阶操作6.1 混合检索与重排序纯向量检索有一个知名短板精确关键词匹配能力弱。比如用户问型号ABC-123的保修期如果这个型号在文档里以特殊格式出现向量相似度可能抓不住它而普通关键字搜索一抓一个准。所以生产级RAG普遍采用混合检索向量检索抓语义关键词检索抓精确匹配两者结果合并去重再排序。LangChain4j的自定义Retriever就是干这个的public class HybridRetriever implements ContentRetriever { private final ContentRetriever vectorRetriever; private final ContentRetriever keywordRetriever; Override public ListContent retrieve(Query query) { ListContent results new ArrayList(); results.addAll(vectorRetriever.retrieve(query)); results.addAll(keywordRetriever.retrieve(query)); // 去重、合并排序 return deduplicateAndSort(results, query.text()); } }在此基础上还可以加重排序Rerank。先宽松地取回10个候选用一个跨编码器的重排序模型重新打分保留最相关的3个。重排序模型比Embedding模型更精细代价是慢所以只对候选集跑。这一步对最终回答质量的提升非常明显也是很多看起来差不多但回答总差口气问题的解药。6.2 元数据过滤让检索范围可控知识库一复杂你会希望检索不是全局搜而是限定在某个范围内。LangChain4j的TextSegment支持Metadata入库的时候带上来源、分类、版本、部门等字段TextSegment segment TextSegment.from(text, Metadata.from(mapOf( source, 产品手册-2024版, category, 售后政策, tenantId, org_001 )));检索时按元数据过滤可以实现两类非常有用的能力一是多租户隔离。每个租户只能检索到自己的文档片段这是企业级应用的基本要求否则跨租户数据泄漏就是大事故。二是定向检索。用户问售后政策怎么样可以先识别意图再限定category等于售后政策的片段去检索精度提升立竿见影。6.3 上下文压缩与引用溯源最后说两个关于Prompt侧的经验。一是别把检索结果一股脑全塞进去。研究发现大模型对长Prompt中间部分的内容注意力会下降被称作lost in the middle。检索回来的5个片段中间那两个常常被模型忽略。解法是控制maxResults在3到5之间配合重排序把最重要的放最前。二是引用溯源。企业问答里这话是哪份文档说的比回答本身还重要。做法是把来源写进Metadata并让模型在回答时引用来源编号。LangChain4j的AiServices可以通过配置让模型在回答里带上引用。这样出了问题可以回溯老板和合规部门都放心。我在实际项目里的体会是RAG调优没有银弹任何参数都要基于你自己的数据实测。但有一条方法论是通用的——每次改动只动一个变量并且用同一组黄金测试题去回归。我维护了一组50题左右的标准问题集每次调整切分参数、阈值或者检索策略都拿这组题跑一遍对比回答质量。这比任何别人说好的参数都可靠得多。如果你顺着这条链路搭完大概率会发现真正影响问答质量的因素排序是文档切分质量大于Embedding模型选型大于检索阈值大于Prompt设计大于模型本身。很多刚接触RAG的人把精力都花在最后两项上方向反了。先把前面三项打好底子你会发现那些模型不聪明的问题其实有大半根本轮不到模型来背锅。