做企业级RAG问答系统时,Java后端团队最常被问到的一个问题就是:别人都用Python那套FastAPI + LangChain,你们Spring Boot行不行?这篇文章我想用一次完整落地记录来回答这个问题。项目技术栈是Spring Boot 3.5.4 + LangChain4j + Milvus向量数据库,模型侧接入阿里百炼API(DashScope,兼容OpenAI协议),最终实现的是一个开箱即用的企业知识库问答系统,支持文档导入、文本切分、向量化存储、语义检索、大模型流式生成,以及答案来源引用。这套方案适合已经在Java技术栈上沉淀了大量业务代码、不想为了一个AI功能单独维护一套Python服务的团队参考,也适合刚接触RAG、想用Java跑通全链路的新手照着实操。
1. 整体设计思路:为什么选这套技术栈
1.1 需求拆解:企业知识库问答到底在解决什么问题
很多团队一上来就聊大模型,但企业知识库RAG的核心根本不是"模型",而是知识管理和检索效率。企业内部的文档散落在Wiki、飞书、语雀、内部网盘、数据库字段里,员工问一个制度问题,往往要翻好几个系统。大模型本身不会知道你公司报销流程长什么样,它只会一本正经地胡说八道。RAG的方案是把企业文档先切块、向量化、存入向量数据库,用户提问时先把问题也向量化,在库里检索出最相关的几个片段,再把这些片段拼进Prompt交给大模型,让模型基于这些真实素材作答。
这个链路拆开来看,就是几个必须想清楚的问题:数据从哪来、文档怎么切、向量怎么算、检索怎么召回、模型怎么生成。把这五件事想明白了,任何技术栈都只是实现方式差异。而"为什么选Spring Boot + LangChain4j"这个问题的答案,恰恰就是我的核心结论:如果一个团队已经用Java写了多年业务系统,那么RAG服务不应该成为一座孤岛,让它直接生长在现有微服务体系里,权限、审计、配置中心、监控全部复用,这比多维护一个Python服务划算得多。
1.2 技术路线比较:为什么不用Python栈,而选LangChain4j
选型阶段我做了个简单的对比表,直接摆在评审会上讲,避免"Java不行"这种纯感受式争论。
| 维度 | Python FastAPI + LangChain | Spring Boot + LangChain4j |
|---|---|---|
| 团队上手成本 | 需要Python技能栈,独立部署链路 | Java团队零额外门槛 |
| 与现有系统集成 | Feign/RestTemplate调Python接口,多一跳 | 直接注入Bean,共享配置中心与监控 |
| 框架成熟度 | LangChain生态最丰富 | LangChain4j较年轻,但Java核心场景覆盖完整 |
| 向量库存量集成 | 官方SDK齐全 | LangChain4j内置Milvus/PGVector/RedisVector等适配 |
| 性能与并发控制 | GIL、多进程部署需要额外设计 | 虚拟线程、Spring的线程池模型天然优势 |
LangChain4j虽然在生态丰富度上确实比不上Python版LangChain,但我们要做的企业问答场景,本质上就是加载文档、切分、向量化、检索、调大模型这几件事,LangChain4j对这些场景的封装已经完全够用。更关键的是LangChain4j的各种EmbeddingStore适配器是直接可用的,我们这次选Milvus,在依赖里引入一个langchain4j-milvus就能获得完整的向量读写能力,不需要自己封装Milvus SDK。
1.3 向量数据库选型:Milvus为什么合适
市面上的向量数据库不少,Milvus、pgvector、Redis Vector、Elasticsearch的向量插件、Qdrant、Weaviate,每个都能跑RAG。我最终选Milvus,首先是因为它本身就是专为向量检索设计的分布式数据库,对亿级向量的检索延迟控制得比pgvector这类传统数据库加装向量功能的方案好。企业知识库刚开始可能只有几十万条向量,但一旦要接多个业务线的文档,量级会快速上涨,预留扩展能力比临时换库成本低得多。
其次,Milvus对LangChain4j有官方维护的MilvusEmbeddingStore实现,这意味CRUD逻辑框架已经帮你处理好了。而且Milvus支持标量字段过滤,我们可以在存储向量的同时存文档ID、来源URL、部门标签等元数据,检索时先做条件过滤再算相似度,这对企业场景非常友好。比如可以只检索"市场部"范围内的文档,而不是全库检索一遍。
2. 环境准备与基础设施搭建
2.1 Milvus单机版部署:Docker Compose一步步来
企业知识库体量在初期用单机版Milvus完全够,等数据量上来了再切分布式集群也不晚。我在本地和测试环境都用了Docker Compose方式部署Milvus standalone模式,整体包含三个组件:etcd(元数据存储)、MinIO(数据持久化)、milvus standalone(核心服务)。下面是完整可运行的docker-compose.yml,我在多个环境验证过。
version: '3.5' services: etcd: container_name: milvus-etcd image: quay.io/coreos/etcd:v3.5.18 environment: - ETCD_AUTO_COMPACTION_MODE=revision - ETCD_AUTO_COMPACTION_RETENTION=1000 - ETCD_QUOTA_BACKEND_BYTES=4294967296 - ETCD_SNAPSHOT_COUNT=50000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd healthcheck: test: ["CMD", "etcdctl", "endpoint", "health"] interval: 30s timeout: 20s retries: 3 minio: container_name: milvus-minio image: minio/minio:RELEASE.2024-12-21T18-27-00Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data command: minio server /minio_data --console-address ":9001" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 20s retries: 3 standalone: container_name: milvus-standalone image: milvusdb/milvus:v2.5.4 command: ["milvus", "run", "standalone"] security_opt: - seccomp:unconfined environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] interval: 30s start_period: 90s timeout: 20s retries: 3 ports: - "19530:19530" - "9091:9091" depends_on: etcd: condition: service_healthy minio: condition: service_healthy启动命令只有一个:
docker compose up -d然后在docker compose logs -f standalone里看到Milvus Proxy ... started之类的日志就说明起来了。注意,19530是gRPC服务端口,Java SDK连的就是这个;9091是健康检查端口。如果只是Spring Boot应用连Milvus,只需要暴露19530就行,仪表盘端口8090在新版本里已经挪到独立组件了。我第一次部署时按老教程找8090端口找半天,结果什么都没看到,原因就是新版Milvus把监控和UI组件拆分出去了。
2.2 Spring Boot 3.5.4项目初始化与依赖引入
项目直接用start.spring.io生成,Java版本选21,Spring Boot选3.5.4。我习惯在生成时勾选Spring Web、Spring Validation、Actuator这几个基础依赖,至于LangChain4j和Milvus相关依赖,我喜欢手工加到pom里,因为版本号需要和Spring Boot 3.5.x兼容,放build.gradle或pom里更好控制。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.4</version> <relativePath/> </parent> <properties> <java.version>21</java.version> <langchain4j.version>1.0.0-beta2</langchain4j.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <!-- LangChain4j 核心 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- OpenAI 兼容协议适配(阿里百炼 / DashScope) --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- Milvus 向量数据库适配 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-milvus</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>这里我重点说一下langchain4j-open-ai这个依赖。阿里百炼API的接口通过OpenAI兼容模式暴露,所以我们可以直接复用LangChain4j的OpenAI适配器,只需要把baseUrl指到百炼的/compatible-mode/v1就行。这是省事的关键一步,不需要去引入一个单独的DashScope SDK,也不需要在代码里单独维护一套鉴权逻辑。
2.3 阿里百炼API接入:模型配置与环境变量
去阿里云百炼控制台开通DashScope服务之后,拿到API Key,然后创建模型。我这边实际用的是qwen-plus当对话模型、text-embedding-v4当Embedding模型。把Key配置到环境变量DASHSCOPE_API_KEY,不要写死在application.yml里,否则代码一提交Key就泄露了。
application.yml里的LangChain4j配置大致长这样:
langchain4j: open-ai: chat-model: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: ${DASHSCOPE_API_KEY} model-name: qwen-plus temperature: 0.2 embedding-model: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: ${DASHSCOPE_API_KEY} model-name: text-embedding-v4 dimensions: 1024这里有个容易踩的坑:dimensions这个参数不是所有模型都支持,text-embedding-v4可以指定向量维度,默认1024。在LangChain4j的OpenAI适配器里,如果模型不支持自定义维度而你又传了dimensions,可能会被模型侧忽略甚至报错。所以我在配置里显式写了1024,后面创建Milvus集合时也用这个维度参数,两边保持一致,避免查询时报向量维度不匹配的错误。
如果不想依赖Spring Boot自动配置,也可以直接构造Bean,显式一点:
@Configuration public class LLMConfig { @Bean public ChatLanguageModel chatLanguageModel() { return OpenAiChatModel.builder() .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .apiKey(System.getenv("DASHSCOPE_API_KEY")) .modelName("qwen-plus") .temperature(0.2) .build(); } @Bean public EmbeddingModel embeddingModel() { return OpenAiEmbeddingModel.builder() .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .apiKey(System.getenv("DASHSCOPE_API_KEY")) .modelName("text-embedding-v4") .dimensions(1024) .build(); } @Bean public MilvusEmbeddingStore embeddingStore() { return MilvusEmbeddingStore.builder() .host("localhost") .port(19530) .collectionName("enterprise_rag") .dimension(1024) .build(); } }手动构造Bean的好处是减少了配置文件与Java字段名的匹配问题,而且MilvusEmbeddingStore的构建参数一目了然。我刚开始用Spring Boot自动配置时,经常分不清langchain4j.milvus.collection-name还是langchain4j.embedding-store.milvus.collection-name这种命名,索性改成手动Bean,省掉一半烦恼。
3. 核心链路实现:数据接入、向量化与存储
3.1 文档加载与文本切分策略
把文档喂给RAG系统之前,第一步是加载和切分。LangChain4j里Document就是一个带metadata的文本对象,TextSegment则是切分后的最小检索单元。我这边写了一个文件接收接口,支持上传Markdown、TXT和PDF,上传后统一转成纯文本再切分。
@Service public class DocumentIngestionService { private final EmbeddingModel embeddingModel; private final EmbeddingStore<TextSegment> embeddingStore; public void ingest(String content, Map<String, String> tags) { Document document = Document.from(content); DocumentSplitter splitter = DocumentSplitters.recursive(600, 100); List<TextSegment> segments = splitter.split(document); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments); } }切分参数需要解释一下:recursive(600, 100)表示每个segment最大600个字符,重叠部分100个字符。这里用recursive切分器是因为它是按段落、句子、子句的层级依次尝试分隔的,处理的文档类型范围最广。字符数到底设多少,取决于你的Embedding模型和业务场景。如果你是中文文档,600个字符大概能覆盖一个制度条目的完整上下文;如果是代码文档,可以适当调低到300到400,因为代码片段本身就结构化。
重叠区的设计是我最初忽略掉、后来才明白的关键细节。两个相邻文本块如果完全没有重叠,切分边界正好落在一个完整语义中间时,检索时就会漏掉关键信息。重叠100个字符,相当于给每个片段留了“跨上下文”的冗余,实测检索命中率比不重叠高出一截。重叠也不是越多越好,重叠多了等于同一个内容存两份,向量数量膨胀,存储成本和检索干扰都上升。
3.2 Embedding模型调用与向量维度匹配
Embedding模型负责把文本变成一串浮点数数组,也就是向量。text-embedding-v4输出的1024维向量,不同文本的语义越接近,向量余弦相似度越高。Java端调用非常简单:
Embedding embedding = embeddingModel.embed("报销流程是什么").content(); System.out.println(embedding.dimension()); // 1024这里有一个需要全链路对齐的约束:文档切片向量维度、问题向量维度、Milvus集合声明的向量维度,三者必须一致。我之前犯过一个很低级的错误:测试环境用text-embedding-v3生成向量,维数是1536,后来把模型换成text-embedding-v4,忘了同步改Milvus的collection定义,结果就是往集合里写向量时报维度校验失败。Milvus的collection schema一旦创建,dimension字段是改不了的,只能drop掉重建。所以要么一开始就用动态schema,要么提前把模型、维度方案固化清楚。
还有一个并发写入的问题。embeddingModel.embedAll批量处理时,阿里百炼API有单次调用条数上限,印象中默认是批量不超过10条或16条,具体以控制台配额为准。企业文档一多,千万不能把几千个切片一股脑embedAll,一定要分批。我在Service里加了一个简单的分批逻辑,每批50条,循环调用:
private static final int BATCH_SIZE = 50; for (int i = 0; i < segments.size(); i += BATCH_SIZE) { List<TextSegment> batch = segments.subList(i, Math.min(i + BATCH_SIZE, segments.size())); List<Embedding> batchEmbeddings = embeddingModel.embedAll(batch).content(); embeddingStore.addAll(batchEmbeddings, batch); }实测下来,50条一批并发压力比较可控,也不会触发百炼的限流。如果文档量到几十万条,建议上消息队列异步处理,HTTP接口只负责接收文档,写入向量可以放到消费者慢慢跑。
3.3 Milvus集合设计与数据写入
MilvusEmbeddingStore在第一次使用时如果发现collection不存在,会自动创建。但自动创建的schema是通用的,不会自动给你加业务字段。所以我更建议把集合设计显式做出来,比如定义好主键、向量字段,以及几个常用标量字段。
public void initCollection() { try (MilvusServiceClient client = new MilvusServiceClient( ConnectParam.newBuilder().withHost("localhost").withPort(19530).build())) { if (client.hasCollection(hasCollectionParam("enterprise_rag")).getData()) { return; } CreateCollectionParam createParam = CreateCollectionParam.newBuilder() .withCollectionName("enterprise_rag") .withDimension(1024) .withPrimaryField("id") .withVectorField("vector") .withAutoId(true) .build(); client.createCollection(createParam); } }不过这里要提醒一下:如果你直接用上面这段Milvus原生SDK代码,而LangChain4j的MilvusEmbeddingStore内部也有自己对collection schema的定义,两边字段名对不上就麻烦了。我实际生产环境里,绝大部分情况根本不需要手动调用Milvus原生SDK,直接让MilvusEmbeddingStore管理collection就行,它会自动创建id、vector、text、metadata等核心字段。需要加业务标量字段时,可以通过配置或自定义schema的方式扩展。图省事的话,先把元数据塞进TextSegment的metadata里,LangChain4j查找时会自动把metadata存进Milvus的metadata字段,检索结果里一样可以拿到来源。
我上线的版本没有用原生SDK初始化,而是直接靠MilvusEmbeddingStore的自动建表,只在配置里指定collectionName和dimension。原因很简单:一行代码的事,为什么非要再维护一套原生SDK的建表逻辑?等到需要定制索引参数,比如切换HNSW的M和efConstruction参数,再单独写原生SDK建表也不迟。
4. 问答链路实现:语义检索与大模型生成
4.1 语义检索:相似度匹配与阈值调优
检索环节是整个RAG系统里最影响答案质量的地方。检索不到相关文档,后面Prompt写得再好也白搭。LangChain4j封装了EmbeddingStoreContentRetriever,使用非常直接:
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build();这段代码里两个参数我解释一下。maxResults(5)表示最多召回5个文本片段,这个数量不能拍脑袋定,取决于你的模型上下文窗口和文档切分大小。如果每段600字符,5段大概3000字符,qwen-plus的上下文窗口完全能容纳。minScore(0.6)是相似度阈值,Embedding模型输出的是余弦相似度,百炼这个模型在标准中文数据集上,相关片段相似度普遍在0.7到0.85之间,不相关的基本在0.5以下。所以0.6作为阈值能挡住大多数低质量召回。但不同领域、不同Embedding模型对相似度分数的分布影响很大,上线前一定要抽样检查实际分数再定阈值。
我也尝试过更细的元数据过滤方式:当用户所属部门被识别出来后,只检索该部门文档:
Filter filter = new Filter( new Condition("department", Operator.EQUAL, "市场部")); EmbeddingMatch<TextSegment> matches = embeddingStore.findRelevant( queryEmbedding, 5, 0.6, filter);这就是前面选Milvus的一个重要原因:标量字段过滤能直接集成进向量检索流程,把检索范围缩小,避免相似度不相关的文本因为全局分数高而误召回。
4.2 Prompt构造与RAG问答流程
LangChain4j里组织RAG问答最常用的方式是定义AiServices接口。我会事先定义一个Assistant接口,方法上标注@SystemMessage和@UserMessage来指定Prompt模板:
public interface Assistant { @SystemMessage(""" 你是一个企业知识库助手。只能根据提供的资料回答问题。 如果资料中没有答案,直接说明“资料库中没有相关内容”,不要编造。 回答时尽量使用简体中文,并保持简洁。 """) String answer(@UserMessage String question); }然后组装服务和检索器:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(contentRetriever) .build(); String answer = assistant.answer("公司的报销流程是什么?");你可能会问,为什么这么简单的接口就能实现RAG?因为LangChain4j在AiServices内部做了大量装配:用户提问后自动调用ContentRetriever去向量库检索,把检索到的文本片段拼接到Prompt里,然后调用ChatLanguageModel,最后把模型回答返回。整个过程开发者只需要定义好接口和配置检索器。这种封装方式非常符合Spring Boot的编程习惯:面向接口编程,框架负责把细节串起来。
这里要提醒一个我最常看到的错误:系统提示词里写“只能根据提供的资料回答问题”,但并没有真正把资料传给模型。在AiServices模式下不会出现这个问题,因为ContentRetriever的检索结果会自动注入到Prompt的information或documents上下文里。但如果大家自己用ChatLanguageModel裸调API,记得手动把检索结果拼进Prompt,否则模型根本没有上下文可供参考,RAG就名存实亡了。
4.3 流式输出与前端交互体验
企业用户对问答系统的等待耐心很短,如果问一句要转圈五秒才出结果,体验就是灾难。所以接口一定做成流式输出。LangChain4j的StreamingChatLanguageModel接口可以逐字返回,配合Spring WebFlux的Flux或者Servlet异步响应都可以。我这边用的是Spring Web MVC的SseEmitter来实现,因为Spring Boot 3.5.x里SseEmitter用起来最直观,跟前端对接SSE也简单。
@RestController @RequestMapping("/api/rag") public class RagController { private final Assistant assistant; private final StreamingChatLanguageModel streamingChatModel; @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@RequestParam String question) { SseEmitter emitter = new SseEmitter(0L); streamingChatModel.chat(question) .onPartialResponse(token -> { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }) .onCompleteResponse(response -> emitter.complete()) .onError(emitter::completeWithError); return emitter; } }“流式返回”看起来只是体验优化,实际还解决了大模型接口超时问题。非流式情况下,qwen-plus生成一段长回答可能要十几秒,网关层经常会因为读超时断开连接。流式模式下,服务端持续有数据返回,网关层不会判定超时,前端也能实时看到生成过程。我强烈建议RAG问答接口全部走流式,哪怕你现在的前端只是内部工具,也值得为这个体验买单。
5. 生产化要点与性能优化
5.1 检索参数调优:topK、阈值与重排序
参数调优是最依赖数据的一条环节,我直接给一套多次验证过的初始参数,大家在此基础上基于自己的文档调整。
| 参数 | 初始推荐 | 说明 |
|---|---|---|
| 切分大小 | 600字符 | 中文场景下兼顾语义完整与检索粒度 |
| 切分重叠 | 100字符 | 缓解边界信息丢失 |
| topK(maxResults) | 5 | 上下文长度充裕时可调到8 |
| minScore | 0.6 | 依Embedding模型分数分布微调 |
| 流式输出 | 开启 | 优化体验,避免网关超时 |
| 温度 | 0.2 | 知识问答场景追求确定性,温度要低 |
我自己调参时踩过一次重组装的坑:以为topK越高答案越准,调到20之后发现大模型反而被无关片段干扰,回答开始东拉西扯。因为Milvus召回的是“相似但未必都相关”的片段,topK太高会把边缘内容也塞进上下文。更合理的做法是topK保持在5到8之间,如果业务上需要更高精度,可以引入重排(rerank)模型,先粗召回20条再用rerank模型精排取前5条,这在企业级知识库场景里是性价比很高的优化。不过百炼API的重排模型这边我们还没有接入,暂时不做展开。
5.2 缓存策略与连接池
RAG问答系统的性能瓶颈常常不在大模型,而在向量检索和网络往返。我给系统加了一层简单缓存:对用户问题做MD5,把“问题到答案”的映射缓存到Redis里。回答相同问题时直接命中缓存,省去向量检索和大模型调用。但缓存粒度要控制好,如果企业内部政策每周更新,缓存过期时间建议不超过1小时,否则用户会一直看到旧答案。
Milvus客户端连接也要讲究,不能在Service里每次new MilvusServiceClient,那是灾难。用LangChain4j的MilvusEmbeddingStore时,连接管理由框架处理,内部复用连接池。但如果你们像我一样在某些环节直接操作Milvus原生SDK,务必把MilvusServiceClient做成单例,并且合理设置连接超时。我自己遇到过一次性并发写入5000个向量时,默认客户端参数下出现grpc连接数过多的情况,后来通过调整线程池和批量大小解决了。
另外关于阿里百炼API的连接池,OpenAI兼容SDK底层是okhttp或类似HTTP客户端,Spring Boot默认的连接池配置不一定适用于高并发。我们给大模型调用的QPS不高,所以没有专门调优,但如果你们要承接更大流量,建议在API网关层做限流和重试,避免百炼侧429限流直接打到用户界面上。
6. 常见问题与避坑实录
6.1 高频报错与解决办法
我把这几个月被问得最多的几个问题整理成了一张速查表,遇到问题直接对照处理。
| 现象 | 根因 | 解决方法 |
|---|---|---|
| Milvus连接拒绝 | Docker容器未启动或端口未映射 | docker compose ps检查19530是否正常监听 |
| 向量维度不匹配 | Embedding模型维度与Milvus集合维度不一致 | 确认text-embedding-v4输出维度,重建集合 |
| 百炼API返回401 | API Key错误或未配置环境变量 | 检查DASHSCOPE_API_KEY是否已设置,百炼控制台核对Key |
| 流式接口返回乱码 | SSE格式不规范或响应编码问题 | 指定produces = text/event-stream,统一UTF-8编码 |
| 模型答案不着边际 | 检索结果相关性差或topK过大 | 调低topK,提高minScore,检查切分粒度 |
| 批量写入慢/超时 | 一次写入切片过多 | 按50条一批循环写入,必要时上异步消息队列 |
| Spring Boot启动失败但无端口显示 | 端口被占用或应用未完全启动 | 查看完整日志,检查server.port配置,确认没有其他进程占用 |
6.2 实操心得与避坑清单
最后分享几条我认为最值得写进团队文档里的经验。
第一,先把数据准备工作做好,再谈模型调优。我见过太多团队上来先折腾Prompt,结果检索库里导入的文档都是乱切的、元数据残缺的。数据质量决定RAG上限,这句话绝对不是空话。文本切分后的片段一定要人工抽样检查,看边界是不是断在了奇怪的地方,标题和正文有没有混在一起。
第二,LangChain4j版本升级要谨慎。这个框架目前迭代速度很快,API变动确实存在。我们从0.36.x升级到1.0.0-beta系列时,部分构造方法签名发生了变化。生产环境不建议每月追新版本,锁定一个稳定版本,重点验证检索和问答链路回归没问题再升。
第三,日志里不要记录完整用户问题和答案。企业知识库经常涉及内部敏感信息,一旦日志脱敏没做好,流经大模型的数据会暴露给运维甚至三方平台。我们在网关层做了脱敏,把所有包含用户问题、模型答案的日志都截断或打码,只保留长度、耗时、检索命中数量等无敏感信息的指标。
第四,成本控制要从第一天开始考虑。阿里百炼API按Token计费,RAG系统最烧钱的就是每次提问都要把几段召回文本拼进Prompt里重复计算Token。如果你发现一个月账单很高,优先考虑加Redis缓存层,把高频问题直接缓存答案,能省下一大笔费用。
这个项目后续如果要继续演进,我下一步会重点做两件事:一是接入重排模型,把粗召回和精排彻底分开,提升长尾问题的回答质量;二是做文档增量更新机制,避免每次全量重建向量库,用Milvus的按主键删除能力实现局部刷新。如果你也在用Java技术栈搭企业知识库,希望这篇文章能帮你少踩一些我踩过的坑,直接走通一条相对成熟的路。