
你拿到一个Java为主的遗留系统老板突然说“给它加个AI问答助手”然后你打开搜索引擎翻到的全是Python的LangChain教程那一刻的心情我太懂了。我这次做的东西就是完全站在Java技术栈内用LangChain4j Milvus从零搭了一套带会话记忆和RAG检索的AIChat接口整个过程踩了不少坑但跑通之后效果是真的能打。这篇就当是你的避坑地图照着做至少能少走我两天的弯路。先交代一下目标项目最终产出一个支持多会话隔离、知识库检索增强、能连续多轮对话的AI聊天接口。技术栈锁定Java 17 Spring Boot 3.2 LangChain4j 0.31.0 Milvus 2.4单机版。文章不会用Python也不会让你装一堆重量级中间件只有Milvus是必须用Docker拉起来的。1. 项目背景与整体思路1.1 为什么是LangChain4jJava生态里做LLM应用可选框架本来就不多。LangChain4j是目前最贴近LangChain设计思路的JVM实现但它跟Python版不是简单翻译的关系而是针对Java开发者的习惯重写了一套API。这个项目里我选它主要看中三点第一它把ChatLanguageModel、EmbeddingModel、EmbeddingStore这些核心接口抽象得足够稳定换模型供应商时不用改业务代码第二它原生支持Spring Boot写点自动配置就能注入使用第三低层API边界清晰方便我控制会话消息的历史长度和RAG召回的细节。你可能听说过LangChain4j还有一套高层API叫AiServices用起来确实省事但这套API在0.31.0版本里对会话管理、消息裁剪、检索器和LLM的联动方式做了一些既定假设如果你想自己掌控“什么时候检索、检索几条、怎么跟历史消息拼prompt”用低层API反而更顺手。这篇教程全程走低层API因为我想让你看得见每一次请求的组装过程后面换成高级封装时心里也有底。1.2 为什么选择Milvus做RAG之前我也比较过FAISS、PgVector和Milvus。FAISS在本地几十万条向量时很有优势但它是嵌入式服务对于Java团队来说需要自己处理索引生命周期、持久化、多副本工程成本不低。PgVector则是寄生在PostgreSQL里适合想顺便用SQL语义做过滤的项目但高并发向量检索下性能天花板比较明显。Milvus的优势在于它就是专门为向量检索而生的分布式数据库。当前版本Milvus 2.4支持了稀疏向量和BM25可以跟稠密向量做混合检索这一点对RAG很关键。因为在实际知识库问答里关键词匹配和语义匹配往往是互补的比如产品型号“AIC-2000”这种词纯语义向量完全可能把它跟“AI程序”混在一起。混合检索能在不拆成两套系统的情况下同时拿到精确匹配和语义相关的候选集后面我也会讲怎么预留这个扩展口子。另外Milvus有完整的Java SDKLangChain4j也提供了对应的集成模块接入成本很低。1.3 整体架构拆解整个系统可以拆成两条链路。离线知识库构建链路负责把一堆文档变成Milvus里的向量文档加载、文本切分、Embedding向量化、写入向量集合。在线对话链路则是用户输入进来先向量化用户问题再从Milvus召回相关片段拼上系统提示词与历史会话最后交给大模型生成回复再把这一轮问答写回会话记忆。这里面的关键点在于会话记忆和RAG检索不是互相独立的。多轮对话中用户可能会追问“那它的性能呢”如果只看当前问题没有上下文根本不知道“它”指什么。所以设计上需要先拿历史消息做一次会话摘要或者原样拼接再结合当前问题去检索或者把历史消息拼到prompt里让模型自己理解上下文。我选的是后一种实现简单且对上下文窗口较小的模型更友好。数据流归纳如下离线入库Document - TextSegment - Embedding - Milvus Collection在线问答UserMessage - Embedding - Milvus近似检索 - 拼接System Messages - ChatModel - AiMessage - 写回ChatMemory2. 环境准备与基础配置2.1 Milvus的Docker部署我使用的是Milvus Standalone单机版对应Docker Compose方式安装。官方已经提供了现成的编排文件核心依赖是etcd和MinIOetcd负责元数据存储MinIO负责对象存储。你在服务器上执行下面几步就行wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml docker-compose -f milvus-standalone-docker-compose.yml up -d第一次启动会拉取几个镜像等一两分钟。验证状态docker-compose -f milvus-standalone-docker-compose.yml ps正常情况下能看到milvus-standalone、etcd、minio三个容器都在运行。Milvus对外暴露两个端口19530是gRPC接口端口9091是健康检查与监控端口。接着可以用curl验证服务健康状态curl http://localhost:9091/healthz返回OK就算通了。这里有个小坑如果你本机已经装了etcd或者MinIO尤其常见的是在开发机上跑着其他中间件容易跟docker-compose里映射的端口冲突。稳妥做法是装之前先检查一下端口占用或者直接改编排文件里的端口映射。2.2 项目依赖与Spring Boot集成我的项目用的是Spring Boot 3.2.5JDK 17打包成普通的Web应用。LangChain4j当时最高的release版本就是0.31.0也正好是我这篇教程锁定的版本再往后API有变化需要另外适配。先看Maven依赖这里列的是核心部分。dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-milvus/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings-all-minilm-l6-v2/artifactId version0.31.0/version /dependencySpring Boot本身并不强制但用它来管理Bean和配置文件很舒服。我在application.yml里放了OpenAI的API Key、模型名以及Milvus的连接参数。注意别把API Key硬编码在代码里用环境变量或者配置中心是最基本的习惯。2.3 Embedding模型选型Embedding模型这次用了AllMiniLmL6V2它在LangChain4j的embeddings模块里直接内置了无需额外申请API离线就能跑很适合本地开发和测试。它的输出向量维度是384这也决定了后面Milvus集合里的dimension参数必须写成384否则插入向量时会报维度不匹配。对中文知识库来说AllMiniLmL6V2的实际语义理解能力只能说勉强够用尤其是遇到一些口语化表达或专业术语比较多的场景召回质量会下降。如果你想上线中文业务建议换成BGE-small-zh或者M3E这类中文优化模型它们的维度通常是768或者1024。换模型时记得把Milvus集合的dimension参数同步调整并且重新跑一遍离线入库脚本。这个问题我在公司内部试过一次只改了模型没删旧集合结果检索接口直接抛异常排查了半天才发现是新旧向量维度不一致。3. 核心模块实现从检索到对话3.1 知识库接入与切分策略知识库的文件格式一般就是Markdown、TXT、PDF这几类。LangChain4j提供了一堆DocumentLoader最简单的就是FileSystemDocumentLoader可以按递归方式加载目录下所有文件然后统一转成Document对象。切分是整个RAG链路里最容易被低估的环节。切太大单个片段语义完整但向量化之后主题容易发散召回时相关性被稀释切太小语义容易碎模型拿不到完整上下文。我这次对普通说明文档用的是200字符一块、20字符重叠的配置既能保证片段在语义上相对独立又不会让向量字段长度过于夸张。数字200和20不是拍脑袋定的我是拿手头的QA数据试了几组发现偏大片段在几个具体问题上召回率下降明显偏小片段则经常出现“上下文突然断掉”的现象。建议你也针对自己的语料做几组对比测试这比网上找个万能参数有意义得多。加载和切分的关键代码大致是这样的ListDocument documents FileSystemDocumentLoader .loadDocumentsRecursively(Path.of(data/knowledge)); DocumentSplitter splitter DocumentSplitters.recursive(200, 20); ListTextSegment segments splitter.splitAll(documents);这里的recursive(200, 20)意思是每个片段长度上限200两个相邻片段之间有20个字符的重叠重叠部分能让一个完整句子不太容易被拦腰切断。3.2 向量化与Milvus存储拿到TextSegment列表之后下一步就是逐个做Embedding然后写入Milvus。这里的实现思路很直接TextSegment本身会保存文本内容我们在向量库里只用它存id和向量文本内容可以自己拼一个metadata存进去也可以直接使用TextSegment对象因为LangChain4j的Milvus模块已经把TextSegment和向量的映射封装好了。先创建EmbeddingStore客户端MilvusEmbeddingStore embeddingStore MilvusEmbeddingStore.builder() .host(localhost) .port(19530) .collectionName(aichat_kb) .dimension(384) .build();接着用内置的EmbeddingModel把每个片段变成向量for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment.text()).content(); embeddingStore.add(embedding, segment); }这一步如果在数据量大时跑得很慢建议批量处理或者把排序和写入放到一个离线JOB里去执行不要占用在线接口的时间。我第一次就是把入库逻辑放在了Web接口里知识库文件一大请求直接超时后来拆成了启动时自动执行一次的任务才算解决。3.3 检索流程的实现在线问答时用户问题先进Embedding模型得到向量然后用这个向量去Milvus里做近似检索。在LangChain4j里对应的是EmbeddingSearchRequest需要指定召回条数和最低分数阈值。最低分数阈值特别重要不加的话会把一些语义上完全无关的片段也捞出来模型就容易被带偏。EmbeddingSearchRequest searchRequest EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .maxResults(3) .minScore(0.6) .build(); EmbeddingSearchResultTextSegment searchResult embeddingStore.search(searchRequest);maxResults我设成3是对上下文窗口和回答质量做了权衡。太少容易漏信息太多则会把prompt撑得很长而且增加模型生成时的干扰项。尤其在多轮对话场景除了RAG片段还要塞历史消息上下文窗口会被明显压缩。0.6的阈值在AllMiniLmL6V2上是一个保守但有效的起点具体值你需要根据自己知识库的内容来调建议用一批已知问题跑一下看看不同阈值下的召回率。3.4 会话管理的设计与实现会话管理在LangChain4j里叫ChatMemory。它负责维护一段会话的消息列表供LLM生成上下文时使用。我这次用了MessageWindowChatMemory意思是只保留最近N条消息避免无限增长把上下文窗口占满。多用户场景下必须做会话隔离不能所有人共享一份记忆。我用一个ConcurrentHashMap按sessionId来隔离每个会话创建独立的记忆实例private final MapString, MessageWindowChatMemory sessionMemories new ConcurrentHashMap(); private MessageWindowChatMemory getOrCreateMemory(String sessionId) { return sessionMemories.computeIfAbsent(sessionId, k - MessageWindowChatMemory.withMaxMessages(20)); }20条历史消息基本能覆盖大多数多轮对话场景。如果你的问题通常需要更长上下文建议把这个值调到30到40但同时留意模型上下文窗口是否够用。4. 完整代码走读与实操记录4.1 引入依赖的完整清单前面已经列了Maven依赖的基础部分这里再说两个容易被忽略的点一是langchain4j的核心包必须和各个扩展包版本保持一致我用的是0.31.0那open-ai、milvus、embeddings都要用同一个版本否则会出现接口签名不匹配的问题。二是Spring Boot如果启用了spring-boot-starter-web要确认Jackson相关依赖版本没有冲突它们在很多Java项目里是老冤家。4.2 核心Service代码拆解下面这段代码是项目里AIChatService的核心逻辑去掉了一部分异常处理和日志保留了最关键的流程。我把它贴出来不是为了让你直接抄而是借着代码把整个调用链讲清楚。Service public class AiChatService { private final ChatLanguageModel chatModel; private final EmbeddingModel embeddingModel; private final MilvusEmbeddingStore embeddingStore; private final MapString, MessageWindowChatMemory sessionMemories new ConcurrentHashMap(); public AiChatService() { this.chatModel OpenAiChatModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(gpt-3.5-turbo) .build(); this.embeddingModel new AllMiniLmL6V2EmbeddingModel(); this.embeddingStore MilvusEmbeddingStore.builder() .host(localhost) .port(19530) .collectionName(aichat_kb) .dimension(384) .build(); } public String chat(String sessionId, String userInput) { // 1. 向量化用户问题 Embedding questionEmbedding embeddingModel.embed(userInput).content(); // 2. 从Milvus召回相关片段 EmbeddingSearchRequest searchRequest EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .maxResults(3) .minScore(0.6) .build(); EmbeddingSearchResultTextSegment searchResult embeddingStore.search(searchRequest); ListString contexts searchResult.matches().stream() .map(EmbeddingMatch::embedded) .map(TextSegment::text) .toList(); // 3. 拼接系统提示词 StringBuilder systemPrompt new StringBuilder(); systemPrompt.append(你是知识库问答助手。请严格根据以下资料回答问题如果资料不足以回答请明确拒绝回答。\n\n); for (int i 0; i contexts.size(); i) { systemPrompt.append(【资料).append(i 1).append(】\n) .append(contexts.get(i)).append(\n\n); } // 4. 组装消息列表系统消息 历史会话 当前问题 MessageWindowChatMemory chatMemory getOrCreateMemory(sessionId); ListChatMessage messages new ArrayList(); messages.add(SystemMessage.from(systemPrompt.toString())); messages.addAll(chatMemory.messages()); messages.add(UserMessage.from(userInput)); // 5. 调用大模型 ResponseAiMessage response chatModel.generate(messages); String answer response.content().text(); // 6. 写回会话记忆 chatMemory.add(UserMessage.from(userInput)); chatMemory.add(AiMessage.from(answer)); return answer; } }注意第4步的顺序SystemMessage在最前接着是历史会话最后一条必须是当前用户输入。这符合主流对话模型的输入约定。如果系统提示词被后续历史消息“冲淡”模型会更容易把上下文理解偏。第6步里我把当前问题和大模型回答都写回了记忆这样下一轮对话才能看到已经问过什么。这里有一个容易踩的坑如果你用ChatLanguageModel.generate(messages)时messages里已经包含了历史消息同时又把当前输入和回答写回记忆一定不要把memory里的历史再加到下一次的messages里两次否则对话会越来越膨胀。正确做法就是上面这样下次调用时从getOrCreateMemory重新拿一次完整的会话列表。4.3 接口层与Web交互由于要支持多会话接口层需要接收一个sessionId参数。前端可以自己生成UUID也可以后端在创建会话时下发整体设计看你的业务需要。我习惯用POST请求Body里带JSON这样扩展字段容易。RestController RequestMapping(/api/chat) public class ChatController { private final AiChatService aiChatService; public ChatController(AiChatService aiChatService) { this.aiChatService aiChatService; } PostMapping public ResponseEntityMapString, String chat(RequestBody ChatRequest request) { String answer aiChatService.chat(request.sessionId(), request.message()); return ResponseEntity.ok(Map.of(answer, answer)); } public record ChatRequest(String sessionId, String message) { } }这个接口看着简单但已经能支撑一个最小可用的AIChat应用了。前端只需要把用户的输入发过来拿到answer展示到聊天框里。sessionId由前端存到localStorage或URL参数里每次请求带回来即可。4.4 项目启动与验证把知识库文件放到项目根目录的data/knowledge文件夹下然后启动应用。我建议给知识库导入单独做一个初始化入口比如ApplicationRunner或者一个Controller方便手动触发。启动之后先用curl做一次快速验证curl -X POST http://localhost:8080/api/chat \ -H Content-Type: application/json \ -d {sessionId:test-001,message:这个产品支持哪些协议}如果知识库里确有相关描述返回的answer应该能引用到对应内容。接着再用同一个sessionId追问一句“那它的延迟表现怎么样”如果模型能明白这是在问同一个产品说明会话记忆已经生效。5. 常见问题与避坑指南5.1 Milvus安装与连接问题Milvus安装最常见的坑是健康检查显示正常但SDK连接超时。这时候先确认连接的是不是19530端口而不是9091。9091是HTTP健康检查端口不能用于gRPC连接。第二个常见问题是容器启动后因为本机内存不足被自动killMilvus Standalone对内存要求不算低默认配置下建议给Docker至少4GB内存。如果你在Windows的Docker Desktop上跑记得去Resources里把内存调大不然遇到过一会儿容器突然消失的情况排查半天才发现是OOM。还有一点Milvus的集合创建和索引构建默认是异步的。第一次插入大量向量时如果还没建好索引就执行检索部分数据会查不到或者耗时飙高。我建议在离线入库完成之后显式调用一下集合的flush接口或者用代码等待索引构建完成再做检索验证。5.2 LangChain4j版本兼容LangChain4j的API变更速度很快网上很多教程用的是0.2x甚至更早的版本代码拿过来基本都要改。比较典型的是老的generate方法签名在0.31.0里变成了通过Response 返回而早期版本可能是ChatCompletionResponse或别的包装类型。如果你在编译时遇到找不到方法、类型不匹配的问题先检查自己引用的扩展包版本是否和核心包一致同时确认代码里的包名是不是dev.langchain4j。另一个容易踩的版本坑是Spring Boot 3.2与Spring Boot 3.3之间的隐式组件扫描变化。如果项目用的是Spring Boot 3.3部分LangChain4j自动配置类里的ConditionalOnClass判断可能不生效导致Bean没被创建。我这次用的Spring Boot 3.2.5相对稳妥。5.3 内存与性能调优AllMiniLmL6V2在CPU上运行很轻量但如果同时有多路并发请求每个请求都会执行一次Embedding计算内存占用会明显上升。我在本地压测时发现线程池默认10个并发时内存用量能到1.5GB以上。为此我做了两件事一是把EmbeddingModel实例在整个Spring容器里作为单例不要每次请求都new一个二是利用Spring的异步线程池限制Embedding调用的并发数把默认的无限调度改成了固定线程数。Milvus侧的检索性能通常不是瓶颈但如果你导入的知识库特别大且检索时发现响应越来越慢需要检查集合的索引类型。默认的FLAT索引在数据量大时是全量扫描建议改成IVF_FLAT或HNSW具体参数可以按数据量来调。对百万级以下的数据HNSW的召回效果和速度都很不错。5.4 文本切分与召回质量我在调试过程中遇到过一个有意思的情况知识库里有一段关于“API错误码”的表格切分脚本把表格内容直接拆成了两半一部分在片段A一部分在片段B。用户问“错误码4001什么意思”跟片段A能匹配上但片段A里正好没有4001的说明导致模型回答“资料中未提及”。后来我把切分策略调整为对表格型内容优先按行切分而不是按固定字符长度切分问题才解决。这提醒我一个重要原则文本切分不是一劳永逸的需要根据不同文档类型设计不同规则。Markdown文档可以先按标题层级切分再对长段落做二次切分TXT文档用递归切分即可PDF文档如果版式复杂最好转成Markdown再处理。召回质量差不一定是Embedding模型不行更多时候是切分策略没跟上。5.5 其他小坑AllMiniLmL6V2的输入长度有上限长文本会截断所以切分长度控制在200是合理的切到800以上反而会损失开头语义。不同大模型对SystemMessage的支持程度不一样部分国产模型对系统提示词不敏感必要时需要把系统提示词拼在用户消息里。每次请求都重新计算历史消息里所有向量的话延迟会很高其实历史消息不需要向量化只有新来的问题才需要去知识库检索。对话接口要注意超时设置。大模型生成通常比普通接口慢Spring MVC默认的几十秒超时可能会不够建议在网关层和HTTP Client层都预留60秒以上。收尾一点个人体会整个项目跑通之后我最深的感受是RAG链路里模型本身的可控性往往比想象的强真正的瓶颈在数据处理和召回策略上。文本切分的参数、最低相关性阈值、历史消息条数这几个变量直接影响回答质量而且它们之间还互相牵制。你不去试一遍永远不知道自己手头这批知识库在哪个参数组合下效果最好。另外说一句你完全可以把这套实现改造成一个“AI Skill”或者工具包去沉淀。热词里有“langchain4j 怎么写skill博客”我的建议是先跑通一个最小闭环然后围绕“怎么把知识库接入做成可配置”“怎么把会话记忆的过期策略暴露成参数”“怎么把混合检索加进来”这几个方向做扩展每个方向单开一篇博客。这样分享出来的内容比单纯的API介绍要有价值得多。最后再补一个实用技巧在开发调试阶段把每次检索召回的片段文本和对应分数打印出来打上完整日志。别小看这几行日志它能帮你迅速定位是“没召回到”还是“召回错了”否则你只会看到模型答得不好至于为什么不好两眼一抹黑。有了日志调参数就有了依据效率会快很多。