ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Spring Boot + LangChain4j + Milvus的企业级RAG系统实战

基于Spring Boot + LangChain4j + Milvus的企业级RAG系统实战 去年我在做企业知识库项目时最大的痛点就是文档检索准确率上不去。后来把方案从 Elasticsearch 关键词检索换成了 RAG 架构用 Spring Boot 3.5.4 做服务端LangChain4j 做 AI 编排Milvus 存向量再配上阿里百炼的 API总算把整个链路跑通了。这篇博客就是把当时踩过的坑、调试过的代码、验证过的参数完整记录下来给团队内部分享时也整理过一遍现在扩展成文发出来。文章覆盖 Milvus 部署、Spring Boot 集成、文档处理、检索问答到问题排查的全过程适合已经会用 Spring Boot 但是刚接触 RAG 的 Java 工程师也适合想从 Python 生态切换到 Java 方案做 AI 应用的同学参考。整套代码我已经在内部项目里跑了一个多月稳定性没问题可以直接当脚手架用。1. 方案设计与技术选型为什么是 Spring Boot LangChain4j Milvus1.1 技术栈需求拆解先聊一个问题企业级 RAG 系统到底需要什么如果你只是自己玩那用 Python 写个 FastAPI LangChain Chroma 的脚本就够了一天能跑通。但一旦要落到企业环境事情就没那么简单了——你需要接入现有的权限体系、需要把问答能力嵌进已有的业务系统、需要考虑多人并发访问时的稳定性、还需要面对运维同学对技术栈的质疑。这时候一个基于 Java 生态的方案就变得非常有吸引力。我当时的选型逻辑很直接Spring Boot 3.5.4 作为应用框架企业里最不缺的就是会 Spring 的工程师招人成本低LangChain4j 作为 AI 编排层它把 LLM、Embedding、向量存储、文档解析这些组件都抽象好了Java 开发者不用从零写 HTTP 调用和 Prompt 拼装Milvus 作为向量数据库它是目前开源社区里最成熟的向量检索组件支持千万级向量毫秒级检索而且有 Java SDK不需要另起一套服务阿里百炼 API 提供 LLM 和 Embedding 能力国内厂商的接口调用稳定性和合规性天然占优势。1.2 LangChain4j 在 Java 生态中的定位LangChain4j 这个名字很容易让人误会以为它是 LangChain 的 Java 移植版。实际上它跟 LangChain 只是设计思路相似代码完全是独立维护的。它在 Java 生态里做的事情就是把 RAG 的整个链路给编排起来文档加载、文本切分、向量化、向量存储、检索、Prompt 拼接、LLM 调用、流式输出这些环节都有对应的抽象接口和默认实现。我用下来的感受是LangChain4j 的抽象层级设计得比较合理。先说 EmbeddingModel 和 ChatLanguageModel 这两个核心接口前者负责把我这边的文档转成向量后者负责把用户的提问和大模型连接起来。再往下LangChain4j 还提供了 ContentRetriever用来做检索增强时的召回逻辑。跟直接用 Spring AI 相比LangChain4j 的优势在于它支持更多向量数据库和模型供应商而且文档示例更丰富社区也相对活跃。当然 Spring AI 也不错但我在选型时更看重 LangChain4j 在 RAG 场景下的成熟度。1.3 向量检索 vs 关键词检索业务场景决定技术方向很多读者可能会问我们公司原来用 Elasticsearch 做文档搜索为什么一定要换 RAG这里我需要把问题拆开看。ES 解决的是“关键词匹配”问题它能快速找出包含某个词语的文档但对“语义相似”无能为力。比如用户问“这个季度的营收情况怎么样”文档里写的是“Q3 财务数据”关键词几乎对不上ES 就漏掉了这条关键信息。RAG 的核心是语义检索用户在提问时我先把问题转成向量然后在 Milvus 里找到与这个向量距离最近的一批文档片段把这些片段作为上下文塞给大模型让大模型基于这些信息生成答案。这样即使提问和文档措辞完全不同也能通过语义相关性把内容召回。但是说实话在实际项目里我用的方案是“语义检索为主关键词检索兜底”的双路召回策略。这个我会在第 5 节详细展开。2. Milvus 向量数据库Docker 部署与集合初始化2.1 Windows / Linux 下的 Milvus 部署方式对比Milvus 的官方推荐部署方式是 Docker Compose这套方案在 Linux 上基本无痛。我先说 Linux 上的标准流程再补充 Windows 上的一些特殊处理。最简配置只需要三个组件etcd元数据存储、MinIO数据持久化、Milvus Standalone主服务。用 Docker Compose 启动时关键是版本号要统一。我在 3.5.4 这个项目里用的是 Milvus 2.4.x 版本系列这里我贴一个生产可用的 docker-compose 片段version: 3.5 services: etcd: container_name: milvus-etcd image: quay.io/coreos/etcd:v3.5.5 environment: - ETCD_AUTO_COMPACTION_MODErevision - ETCD_AUTO_COMPACTION_RETENTION1000 - ETCD_QUOTA_BACKEND_BYTES4294967296 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd command: etcd -advertise-client-urlshttp://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd minio: container_name: milvus-minio image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data command: minio server /minio_data healthcheck: test: [CMD, curl, -f, http://localhost:9000/minio/health/live] interval: 30s timeout: 20s retries: 3 milvus: container_name: milvus-standalone image: milvusdb/milvus:v2.4.1 command: [milvus, run, standalone] ports: - 19530:19530 - 9091:9091 environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus depends_on: - etcd - minio注意这里有个容易踩的坑。很多人直接从 Docker Hub 拉milvusdb/milvus:latest结果跟本地的客户端 SDK 版本对不上导致连接报错。我的建议是服务端版本和客户端 SDK 版本永远要保持一致要么都用 2.4.x要么都用 2.3.x别混搭。然后是启动命令docker compose -f docker-compose.yml up -d启动完成后验证连通性可以用 Docker 日志看启动状态也可以直接用一个 Java SDK 的小测试连一下 19530 端口。针对 Windows 用户我实测过 Docker Desktop WSL2 环境下运行上面的 Compose 文件完全没问题。唯一要提醒的是确保 Docker Desktop 的 Resources 里至少分配 4GB 内存因为 Milvus Standalone 启动时会占 2~3GB 内存资源不够会频繁 OOM。2.2 集合设计字段、索引与度量方式的选择Milvus 的数据结构跟 MySQL 很像一张表叫 Collection集合每一行叫 Entity实体字段分为标量字段和向量字段。在设计知识库的集合时我一开始走了不少弯路后来才定型为下面这个结构字段名类型说明idInt64主键自增doc_nameVarChar原始文档名称用于追踪知识来源chunk_indexInt64分块序号contentVarChar文本内容限制长度 65535embeddingFloatVector(1024)文本向量维度需要跟 Embedding 模型对齐我用的阿里百炼text-embedding-v3输出维度是 1024。设置维度时千万不要图省事随意填如果你填了 512到时候把 1024 维向量插进去会直接报维度错误这个非常坑。创建集合的 Java 代码我用的是 Milvus Java SDK2.4.x 版本核心逻辑如下FieldType idField FieldType.newBuilder() .withName(id) .withDataType(DataType.Int64) .withPrimaryKey(true) .withAutoID(true) .build(); FieldType contentField FieldType.newBuilder() .withName(content) .withDataType(DataType.VarChar) .withMaxLength(65535) .build(); FieldType embeddingField FieldType.newBuilder() .withName(embedding) .withDataType(DataType.FloatVector) .withDimension(1024) .build(); CreateCollectionParam createCollectionParam CreateCollectionParam.newBuilder() .withCollectionName(enterprise_kb) .withDescription(企业知识库向量集合) .withFieldTypes(List.of(idField, contentField, embeddingField)) .build(); milvusClient.createCollection(createCollectionParam);这里还有个关键的步骤创建完集合之后必须手动创建索引否则检索阶段会直接报“index not found”。我用的是 HNSW 索引它适合高维向量的近似最近邻搜索查询速度快且召回率较高。索引参数如下IndexParam indexParam IndexParam.newBuilder() .withFieldName(embedding) .withIndexType(IndexType.HNSW) .withMetricType(MetricType.COSINE) .withExtraParam({\M\: 16, \efConstruction\: 200}) .build(); milvusClient.createIndex(enterprise_kb, indexParam);关于度量方式的选择我在 COSINE余弦相似度和 IP内积之间纠结过。用阿里百炼的 Embedding 模型时它返回的向量经过了归一化处理此时用 IP 和 COSINE 的效果几乎一样。最终我选了 COSINE语义上的解释更直观两个向量的夹角越小相似度越高。2.3 连接池与数据插入的工程化细节直接裸写 Milvus Java SDK 能跑通但工程上必须考虑连接复用。我封装了一个MilvusConfig用MilvusServiceClient的单例管理连接配置好连接超时和闲置超时Configuration public class MilvusConfig { Bean public MilvusServiceClient milvusClient( Value(${milvus.host}) String host, Value(${milvus.port}) int port) { ConnectParam connectParam ConnectParam.newBuilder() .withHost(host) .withPort(port) .withConnectionTimeout(10000) .withKeepAliveTime(60000) .withKeepAliveTimeout(5000) .build(); return new MilvusServiceClient(connectParam); } }这里有个细节很容易被人忽略Milvus 的MilvusServiceClient在长时间空闲之后底层 gRPC 连接可能已经断开如果你不设置 keep-alive下一次查询就会偶发超时。我在这个问题上面花了整整一天排查最后发现是连接池配置的问题所以这个参数一定要加。插入数据也比较有讲究。我用的批量插入每批 100~200 条用InsertParam把字段封装成列表ListInsertParam.Field fields List.of( new InsertParam.Field(content, contentList), new InsertParam.Field(embedding, embeddingList) ); InsertParam insertParam InsertParam.newBuilder() .withCollectionName(enterprise_kb) .withFields(fields) .build(); milvusClient.insert(insertParam);提示批量插入前记得先调用flush或者直接依赖 Milvus 的自动 flush 机制。如果不 flush刚插入的数据不一定能被立即检索到这在联调时会让你怀疑人生。3. Spring Boot 集成 LangChain4j配置与核心代码3.1 阿里百炼 API从开通到拿到 Key先花点时间讲讲阿里百炼 API因为这是整个链路里沟通成本最高、也最容易踩坑的部分。百炼平台是阿里云的大模型服务平台注册并开通后在控制台可以创建 API Key。拿到 Key 之后关键是配置 Base URL。LangChain4j 的 OpenAI 兼容模块默认的 Base URL 是https://api.openai.com我们需要把它替换成百炼的兼容地址https://dashscope.aliyuncs.com/compatible-mode/v1。这一点非常重要——百炼平台提供了 OpenAI 兼容的 HTTP 接口LangChain4j 不用额外写一个自定义 Provider直接复用OpenAiChatModel和OpenAiEmbeddingModel只需换掉 Base URL 和 API Key 就能对接。我使用的模型版本是对话模型qwen-plus中文能力不错成本适中Embedding 模型text-embedding-v31024 维百炼的模型名有个版本概念我当时用的时候是qwen-plus指向最新的稳定版本。如果你在调用时报模型不存在去控制台看一下模型列表确认可用的模型 ID别看文档直接复制错过期的 ID。3.2 Maven 依赖与核心配置项接下来是项目里的 Maven 依赖。我用的是 LangChain4j 1.0 系列的 Spring Boot Starter这个是官方专门为 Spring Boot 生态做的集成包比直接引 Core 包省心很多dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-spring-boot-starter/artifactId version1.4.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai-spring-boot-starter/artifactId version1.4.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-milvus/artifactId version1.4.0/version /dependency dependency groupIdio.milvus/groupId artifactIdmilvus-sdk-java/artifactId version2.4.1/version /dependency注意如果只引langchain4j-milvus而不引milvus-sdk-java运行时大概率会遇到ClassNotFoundException因为 LangChain4j 对 Milvus 的封装依赖底层 SDK。我当时就是漏了这一点好在编译期没报错运行期才发现。然后是application.yml配置。LangChain4j Spring Boot Starter 的规范是langchain4j.*开头langchain4j: open-ai: chat-model: api-key: ${DASHSCOPE_API_KEY} base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 model-name: qwen-plus temperature: 0.2 max-tokens: 2048 embedding-model: api-key: ${DASHSCOPE_API_KEY} base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 model-name: text-embedding-v3这里temperature我刻意调低到 0.2因为 RAG 场景下我们希望模型严格依据检索到的知识回答而不是自由发挥。温度越低输出越稳定幻觉越少。如果是在做创意写作类应用可以把温度调到 0.7~0.9但知识问答场景要低。3.3 手动配置 EmbeddingModel 和 ChatLanguageModel虽然 Spring Boot Starter 能自动装配但我在实际项目中还是选择了手动创建 Bean。原因有两个第一自动装配时有些参数不透明出了问题不好调第二我需要在 EmbeddingModel 里做自定义的请求维度配置。看下面的配置类代码Configuration public class LangChain4jConfig { Value(${langchain4j.open-ai.embedding-model.api-key}) private String embeddingApiKey; Value(${langchain4j.open-ai.embedding-model.base-url}) private String embeddingBaseUrl; Value(${langchain4j.open-ai.embedding-model.model-name}) private String embeddingModelName; Value(${langchain4j.open-ai.chat-model.api-key}) private String chatApiKey; Value(${langchain4j.open-ai.chat-model.base-url}) private String chatBaseUrl; Value(${langchain4j.open-ai.chat-model.model-name}) private String chatModelName; Bean public EmbeddingModel embeddingModel() { return OpenAiEmbeddingModel.builder() .apiKey(embeddingApiKey) .baseUrl(embeddingBaseUrl) .modelName(embeddingModelName) .dimensions(1024) .build(); } Bean public ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .apiKey(chatApiKey) .baseUrl(chatBaseUrl) .modelName(chatModelName) .temperature(0.2) .maxTokens(2048) .build(); } }这里我单独给OpenAiEmbeddingModel加了一个.dimensions(1024)参数。这个参数的作用是显式指定嵌入向量的维度在对接百炼text-embedding-v3时能保证向量维度和 Milvus 集合定义一致。如果不显式指定默认维度可能不匹配插入数据时就会报错。3.4 关联配置 LangChain4j 与 MilvusLangChain4j 给 Milvus 提供了现成的MilvusEmbeddingStore类我们只需要在这个类里配置集合名和维度Configuration public class MilvusEmbeddingStoreConfig { Value(${milvus.host}) private String milvusHost; Value(${milvus.port}) private int milvusPort; Bean public EmbeddingStoreTextSegment embeddingStore() { return MilvusEmbeddingStore.builder() .host(milvusHost) .port(milvusPort) .collectionName(enterprise_kb) .dimension(1024) .build(); } }这里有个非常重要的细节MilvusEmbeddingStore在首次访问时如果发现集合不存在会根据你的参数自动创建集合。如果你在代码里已经手动创建过集合那么这里的dimension必须跟已创建的集合一致否则 LangChain4j 内部会尝试用新的维度去重建集合导致数据丢失。所以我的建议是集合的创建和字段定义必须在项目启动时就明确好不要依赖 LangChain4j 的自动创建逻辑而是用MilvusServiceClient显式创建。这样才能确保字段设计完全可控不会被框架的默认行为干扰。4. 文档解析、分块与向量化入库4.1 文档类型的统一处理企业知识库里的文档类型五花八门Word、PDF、Markdown、Excel、TXT。工程化处理时最省心的方案是先把所有文档转成纯文本然后再统一分块。我在项目里用了一个中间工具层Apache Tika。Tika 能自动识别文档类型并抽取文本不用为每种格式单独写解析器。Maven 依赖如下dependency groupIdorg.apache.tika/groupId artifactIdtika-core/artifactId version2.9.0/version /dependency dependency groupIdorg.apache.tika/groupId artifactIdtika-parsers-standard-package/artifactId version2.9.0/version /dependency解析代码大致是这样public String parseDocument(MultipartFile file) { try (InputStream inputStream file.getInputStream()) { BodyContentHandler handler new BodyContentHandler(-1); Metadata metadata new Metadata(); ParseContext parseContext new ParseContext(); AutoDetectParser parser new AutoDetectParser(); parser.parse(inputStream, handler, metadata, parseContext); return handler.toString(); } catch (Exception e) { throw new RuntimeException(文档解析失败: file.getOriginalFilename(), e); } }注意BodyContentHandler(-1)这个构造参数-1表示不限制内容大小。默认的构造函数只保留前 100KB 的内容如果知识库文档超过这个量级解析出来的正文会被截断这是我踩过的真实的坑。4.2 分块策略重叠窗口对检索准确率的影响分块是整个 RAG 链路中最容易被忽视但又最关键的一环。分得太细每个片段缺少上下文检索到了也答不对分得太粗片段里夹杂大量无关内容向量表示会被稀释精确度下降。另外如果片段超过模型上下文窗口塞进去后大模型只能截断处理。我最终选用的分块规则是按段落优先段落过长时按句子切分单块固定 500 到 800 个字符重叠 100 个字符。重叠的目的是避免一个问题刚好被切分边界截断导致语义不完整。以下是用 LangChain4j 的DocumentSplitter实现分块的代码DocumentSplitter splitter new DocumentByParagraphSplitter(600, 100); ListTextSegment segments splitter.split(document); ListEmbedding embeddings embeddingModel.embedAll(segments).content(); from 这里 DocumentByParagraphSplitter(600, 100) 的两个参数分别是最大块长和重叠长度。你也可以按句子分用 DocumentBySentenceSplitter这个更细粒度一些。我做了对比测试段落分割在中文文档上的效果普遍好于句子分割因为中文的语义往往跨句子依存单独一句话很难承载完整信息。如果你使用的 langchain4j 版本里没有现成的DocumentByParagraphSplitter可以自己写一个简单实现核心逻辑就是按\n\n切分段落再按字符长度合并保证相邻块之间有一段重叠。4.3 Embedding 批量请求的限流与重试把分好的文本段调用embeddingModel.embedAll()转成向量时有一个非常现实的问题批量请求可能被百炼 API 限流RateLimit。百炼 API 对 QPS 有约束如果你一次性提交大量文本可能会收到 HTTP 429。解决思路是加一个简单的重试机制。我写了一个工具类用 Spring 的Retryable注解来实现Component public class EmbeddingService { private final EmbeddingModel embeddingModel; public EmbeddingService(EmbeddingModel embeddingModel) { this.embeddingModel embeddingModel; } Retryable( value {RateLimitException.class}, maxAttempts 3, backoff Backoff(delay 1000, multiplier 2) ) public Embedding embed(String text) { return embeddingModel.embed(text).content(); } }另外我在做知识库初始化时会严格控制批量大小。比如一次处理 20 个文本段然后休眠 200 毫秒再处理下一批。虽然慢一点但胜在稳定。这个策略在初始化 1 万条以内的知识库时是完全可以接受的。4.4 入库流程的完整闭环入库流程我整理成了一个服务类逻辑比较清晰Service public class KnowledgeBaseService { private final EmbeddingModel embeddingModel; private final EmbeddingStoreTextSegment embeddingStore; public void addDocument(MultipartFile file) { // 1. 解析文档 String text parseDocument(file); // 2. 分块 Document document Document.from(text, Metadata.from(fileName, file.getOriginalFilename())); ListTextSegment segments splitText(document); // 3. 向量化 ListEmbedding embeddings embeddingModel.embedAll(segments).content(); // 4. 存入 Milvus embeddingStore.addAll(embeddings, segments); } }这里还要补充一个痛点问题已经入库的文档如果更新了怎么办总不能无脑再插入一遍。我当时的做法是引入文档级元数据管理在 Milvus 集合里增加doc_id字段入库前先按doc_id删除旧数据再插入新数据。这样整个知识库就可以增量更新不用每次都全量重建。Milvus 支持按标量字段做过滤删除用QueryParam指定doc_id xxx取出目标数据再用DeleteParam删除代码不难工程上却很实用。5. 问答接口实现检索增强生成5.1 AiServices用声明式方式编排 RAG 链路LangChain4j 提供了一个非常有用的工具类AiServices它可以让我们用接口声明的方式把大模型、检索器、提示词模板组装在一起。我先定义一个问答接口Assistant public interface KnowledgeAssistant { String answer(UserMessage({{question}}) String question); }这里Assistant注解是告诉 LangChain4j这是一个 AI 服务接口UserMessage是提示词模板{{question}}占位符会被实际提问内容替换。上了AiServices之后创建接口实现的逻辑就简化了KnowledgeAssistant assistant AiServices.builder(KnowledgeAssistant.class) .chatLanguageModel(chatLanguageModel) .contentRetriever(contentRetriever) .build();这种声明式写法对于 Java 工程师非常友好代码可读性和可维护性比手写 Prompt 拼接要强很多。5.2 ContentRetriever 配置与 Top K 调优ContentRetriever是检索器的核心抽象。LangChain4j 给我们提供了EmbeddingStoreContentRetriever它负责从EmbeddingStore里按相似度检索 Top K 条内容。Bean ContentRetriever contentRetriever(EmbeddingStoreTextSegment embeddingStore, EmbeddingModel embeddingModel) { return EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.75) .build(); }maxResults是召回数量minScore是最低相似度阈值。这两个参数直接影响回答质量。我测试下来Top 5 是一个比较合理的值——太少上下文不足太多塞进 Prompt 不但增加 token 消耗还可能分散大模型的注意力。minScore我设为 0.75低于这个分数说明检索到的内容跟问题相关性不足宁可让模型承认不知道也不要硬答这样能显著降低幻觉。调优的过程顺手分享一下我用了一个 20 条问题的测试集逐个调整maxResults和minScore两个参数人工给答案打分0-5 分。最后确定maxResults5、minScore0.75时综合得分最高。不同知识库的数据分布不一样这个参数不是通用的需要实际测试。5.3 流式输出与 SSE 协议对接前端企业应用里用户点了提问按钮之后如果等了五六秒才一次性收到完整回答体验非常糟糕。解决方法是把大模型的输出改成流式Streaming前端通过 SSEServer-Sent Events逐字接收。LangChain4j 支持流式接口StreamingChatLanguageModelStreamingChatLanguageModel streamModel OpenAiStreamingChatModel.builder() .apiKey(chatApiKey) .baseUrl(chatBaseUrl) .modelName(chatModelName) .temperature(0.2) .build();然后在 Controller 里用SseEmitter向前端推送GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String question) { SseEmitter emitter new SseEmitter(0L); TokenStream tokenStream assistant.stream(question); tokenStream .onPartialResponse(token - { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }) .onComplete(emitter::complete) .onError(emitter::completeWithError) .start(); return emitter; }这样前端页面就能实现类似 ChatG 打字机式的效果。注意Spring 的SseEmitter一旦超时就会自动断开连接。我在构造时传入0L表示不超时但实际生产环境需要在前端配置心跳包比如每 15 秒发一个注释行否则中间经过 Nginx 时连接会被闲置超时掐断。这个坑我在联调时踩过务必留意。5.4 双路召回当语义检索遇到关键词检索前文提到我用了双路召回策略这里展开讲一下。语义检索在“意思相同但说法不同”的场景下表现很好但在“精确匹配产品型号、订单编号”的场景下反而会翻车。举个例子用户问“PO-20240301 这个订单的状态”文档里恰好有一模一样的编号但如果语义检索只按向量距离召回这个编号完全可能被忽略因为单个编号对整体向量的相似度贡献太小了。我的方案是在语义检索之外加一个基于关键词的召回逻辑用 ES 或 MySQL 的 LIKE 查询做兜底两路结果合并去重后一起喂给大模型。具体实现在ContentRetriever里可以自定义public class HybridContentRetriever implements ContentRetriever { private final EmbeddingStoreContentRetriever semanticRetriever; private final JdbcTemplate jdbcTemplate; Override public ListContent retrieve(Query query) { // 路1语义检索 ListContent semanticResults semanticRetriever.retrieve(query); // 路2关键词检索 ListContent keywordResults jdbcTemplate.query( SELECT content FROM enterprise_kb_text WHERE content LIKE ? LIMIT 5, new Object[]{% query.text() %}, (rs, rowNum) - Content.from(rs.getString(content)) ); // 合并去重 MapString, Content merged new LinkedHashMap(); for (Content c : semanticResults) { merged.putIfAbsent(c.text(), c); } for (Content c : keywordResults) { merged.putIfAbsent(c.text(), c); } return new ArrayList(merged.values()); } }需要说明的是这个HybridContentRetriever中的enterprise_kb_text表是另外建的“文本镜像表”。我每次向 Milvus 插入向量数据时会同时在 MySQL 里保存一份原始文本和文档元信息目的就是为了给关键词召回留一条后路。这个方案在工程上是划算的MySQL 对文本存储和 LIKE 查询的成熟度比 Milvus 的标量过滤要可靠得多。6. 常见问题与排查技巧实录6.1 问题速查表问题现象可能原因解决方案Milvus 连接超时host/port 配置错误或容器未启动检查docker ps确认容器状态在宿主机用telnet 127.0.0.1 19530测试端口连通性插入数据报维度错误Embedding 模型输出维度与集合定义不一致统一dimensions配置检查阿里百炼模型文档确认输出维度检索时报 index not found创建集合后未创建索引在MilvusServiceClient中显式设置createIndexAPI 返回 401 错误API Key 错误或未配置环境变量检查百炼控制台的 API Key确认DASHSCOPE_API_KEY环境变量已生效回答内容与知识库无关minScore阈值太低召回了无关片段调高minScore比如从 0.5 调到 0.75检查分块长度是否过大中文乱码文档解析时编码不匹配解析前探测文档编码UTF-8 优先PDF 抽取时确认字体数据和 ToUnicode 映射正常启动时 Bean 创建失败LangChain4j 版本与 Milvus SDK 版本冲突统一版本LangChain4j 1.x 系列搭配 Milvus SDK 2.4.x6.2 Milvus 连接超时排查实例有一次我把服务部署到测试环境后接口一直报“Milvus connection timeout”。第一反应是网络不通但开发环境是通的。后来排查发现测试环境的 Docker Compose 文件是从旧仓库复制过来的etcd 容器一直处于重启循环导致 Milvus 服务启动不完整。这里有个经验Milvus 对 etcd 的依赖非常强etcd 挂掉Milvus 表面看是运行的实际上查询和插入都会卡住。遇到连接超时先看 etcd 容器的日志。第二个连接超时的原因是我们用的连接参数withConnectionTimeout(10000)太短。知识库初始化时Milvus 服务端负载高响应变慢10 秒不够用后来提速到 30 秒并加上连接池预热机制问题才彻底解决。6.3 阿里百炼 API 限流批量导入时的高频炸弹批量导入知识库时我遇到过一段高频 429 错误日志。一开始以为是网络抖动重试几次后依然复现然后发现百炼 API 的 QPS 限制是 10不同账号可能有差异而embedAll内部是并行调用模型接口的100 条文本同时发出请求直接触发限流。解决思路是加令牌桶限制。我用 Guava 的RateLimiter做一个简单限速private final RateLimiter rateLimiter RateLimiter.create(8.0); // 每秒最多 8 个请求 public Embedding embedWithLimit(String text) { rateLimiter.acquire(); return embeddingModel.embed(text).content(); }配合前面提到的Retryable毛刺和长尾都能吃掉导入上万条文档不再中断。6.4 Long Token 截断问题被忽略的 maxTokens有次我给用户演示时模型输出到一半突然停了控制台日志也没有明显报错。排查之后发现是maxTokens2048限制了输出长度。当检索到的上下文很长模型需要生成的长篇回答超过了 2048 token它就直接截断了。这类问题通常不是“模型坏了”而是「Prompt 太长 maxTokens 太小」的资源博弈。解决办法是我在OpenAiChatModel构造时把maxTokens提升到 4096同时把maxResults从 5 降到 4保证输入侧的 token 占比合理。Prompt 输入和输出共享模型上下文窗口上下文的长度限制是 128K如果不控制总会遇到突发情况。6.5 流式接口的并发安全SseEmitter 的可用性坑流式接口上线后前端反馈偶发连接直接关闭而且集中出现在中午高峰时段。抓了线程栈之后发现同一个SseEmitter被多个线程调用send内部状态被搞乱了异常提前触发了 complete。原因是我在 Controller 里直接把SseEmitter和TokenStream绑在一起但 Spring 的异步线程池和 LangChain4j 的回调线程池不是同一个线程。修复办法是给流式回调加一个串行化保护ExecutorService executor Executors.newSingleThreadExecutor(); tokenStream .onPartialResponse(token - executor.submit(() - { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } })) .onComplete(() - { executor.submit(emitter::complete); }) .start();通过指定一个独立的单线程执行器来串行化所有发送操作保证了send的线程安全。后续我再优化为每个会话维护独立的SseEmitter实例和独立的单线程池彻底解决了并发冲突。7. 从单个 Demo 到可落地的系统我对这套架构的最终体会说句实在话把一套 RAG 从能跑通到生产可用中间的距离比我想象的大得多。最大的感悟有两点第一不要神化语义检索也不要完全抛弃关键词检索。语义检索在小规模演示集上表现很好但到了真实企业知识库用户的提问方式千奇百怪产品编号、人名、地名这类高频实体反而需要用传统检索去稳住下限。双路召回方案两边的权重可以按业务调但思路大方向是对的。第二企业级系统的关键是“可运维”而不是“炫技术”。Milvus 的容器编排要打通监控报警阿里百炼的 API 调用要有熔断降级知识库文档更新要有管理后台用户提问要有日志审计。这些东西单独拎出来每一个都不复杂但整合到一起才是真正的工程能力。LangChain4j 帮我解决的是“AI 链路的可编排”问题剩下的工程细节还是得靠 Spring Boot 生态这套成熟的东西来兜底。最后分享一个小技巧上线之后我在问答接口里做了一个“是否命中知识库”的标记位检索分数低于阈值时前端会提示“当前回答基于模型通用能力未命中知识库文档”。这个小功能上线之后好评度很高因为用户能直观感知到 RAG 系统“什么时候在检索、什么时候在自由发挥”对系统的信任度提升了一个台阶。如果你也在做类似的企业级 RAG 项目这个设计可以直接抄作业。
返回列表