news 2026/9/19 8:04:20

Spring Boot 3.5.4 + LangChain4j + Milvus 打造企业级 RAG 知识库问答系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3.5.4 + LangChain4j + Milvus 打造企业级 RAG 知识库问答系统

1. 为什么是 Java 阵营的 RAG:Spring Boot 3.5.4 + LangChain4j 的选型逻辑

我一直有一个观点:RAG 技术栈不应该被 Python 垄断。过去小半年,我们团队把一套基于 Spring Boot 3.5.4、LangChain4j、Milvus 和阿里百炼 API 的企业级 RAG 问答系统推上了生产环境,解决的是公司内部知识库的检索问答问题。踩了不少坑,也沉淀了一套可以完整复用的搭建路径。这篇不聊概念,直接讲怎么落地。

先说场景。我们公司有大量内部文档:产品手册、故障处理记录、项目复盘、接口文档,散落在 Confluence、GitLab Wiki 和本地目录里。员工找一份资料,经常要在几个系统里来回切换,效率很低。老板拍板要做一个统一的知识库问答入口。最直接的技术方案就是 RAG:把文档灌进去,向量化,用户提问时先检索相关片段,再交给大模型组织答案。

为什么选 Java 而不是 Python?原因很现实。我们核心业务系统是 Spring Boot,用户体系、权限、审批流程、工单系统全在里面。如果用 Python 单独搭一个 RAG 服务,就要多做一套服务治理、监控、部署和权限对接。既然 Java 生态已经有 LangChain4j 这种相对成熟的 LLM 编排框架,为什么不直接在现有 Spring Boot 工程里扩展?

LangChain4j 对标的是 Python 的 LangChain,核心思路一致:把大模型、向量库、文档处理、提示词编排抽象成统一 API。它解决的最痛问题不是“能不能调通大模型”,而是“怎么把 RAG 链路里的每个环节变成 Java 对象”。EmbeddingModel、ChatLanguageModel、EmbeddingStore、ContentRetriever 这些接口设计得比较干净,和 Spring Boot 的依赖注入、配置体系配合得很好。

版本选型上,我们最终用了 Spring Boot 3.5.4 + JDK 21 + LangChain4j 0.36.2。Spring Boot 3.5.x 对 JDK 21 的虚拟线程支持很成熟,适合后面做并发问答。LangChain4j 选 0.36.2 是因为langchain4j-milvus模块已经稳定,MilvusEmbeddingStore直接可用。阿里百炼的 DashScope 提供了 OpenAI 兼容接口,所以不需要额外引入 DashScope SDK,LangChain4j 的OpenAiChatModel改一下 baseUrl 就能用。

适合这篇文章的读者,我默认你是 Java 后端开发,懂 Spring Boot,用过或听说过 RAG,但还没在 Java 工程里完整落地过。如果你是完全没接触过向量数据库的小白,也没关系,后面 Milvus 的部署部分我会按步骤拆开讲清楚。

选型时我也对比过其他方案,列个表供参考:

方案优点缺点适合场景
LangChain4j + Milvus与 Spring Boot 集成顺滑,事务/配置/依赖注入复用,Milvus 支持高并发生态比 Python 略薄,新功能迭代稍慢Java 团队做企业级知识库、问答机器人
Spring AI官方背书,抽象思路类似当时 RAG 相关组件还不够丰富,向量库集成偏少想用 Spring 官方生态,且需求简单
Python LangChain + FastAPI生态最大,示例多,新模型适配快需要额外维护 Python 服务,跨语言联调成本高团队以 Python 为主,或纯算法团队
直接 HTTP 调大模型 + 自写检索无框架依赖,最灵活链条上每一步都要自己写,分块、提示词、上下文拼装、元数据过滤全要造轮子高度定制化的小项目

对大多数 Java 团队来说,LangChain4j 是“性价比”最高的选择。它不是最全能的,但足够顺手。

2. Milvus 环境准备:Docker 单机部署到集合设计

Milvus 是整个系统的记忆体。文档向量化之后全部存在这里,检索速度直接决定问答延迟。我们生产环境用 Kubernetes 部署 Milvus 集群,但开发环境和个人学习阶段,Docker 单机版完全够用。下面这套部署步骤,是我在实际项目中验证过的。

2.1 Docker Compose 部署 Milvus 单机版

Milvus 单机版依赖 etcd 和 MinIO,etcd 负责元数据存储,MinIO 负责对象存储。用 Docker Compose 一把拉起最省事。新建docker-compose.yml

version: "3.5" services: etcd: container_name: milvus-etcd image: quay.io/coreos/etcd:v3.5.5 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 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 standalone: container_name: milvus-standalone image: milvusdb/milvus:v2.4.1 command: ["milvus", "run", "standalone"] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus ports: - "19530:19530" - "9091:9091" depends_on: - etcd - minio

执行docker compose up -d,等服务起来后,可以用 Docker Desktop 的容器列表看状态。Windows 用户要注意一点:Milvus 在 Windows 上不能直接裸装,但 Docker Desktop 跑这套 Compose 文件没问题,文件路径挂载用./volumes时会自动映射,不用额外处理。

提示:如果是生产环境,不要把 MinIO 的默认账号密码留在配置里。Milvus 通过环境变量MINIO_ACCESS_KEYMINIO_SECRET_KEY对接,实际部署时必须改成强密码,并限制端口暴露范围。

2.2 连接参数与 Java 客户端

Milvus 默认 gRPC 端口是 19530。Java 客户端用官方 SDKmilvus-sdk-java。在pom.xml中引入:

<dependency> <groupId>io.milvus</groupId> <artifactId>milvus-sdk-java</artifactId> <version>2.4.3</version> </dependency>

连接方式有两种:直接用MilvusServiceClient,或者用 LangChain4j 的MilvusEmbeddingStore封装。前者适合做集合管理和精细控制,后者适合在 RAG 链路中直接当成EmbeddingStore用。实际项目里我两个都用了:启动时用MilvusServiceClient初始化集合和索引,运行时用MilvusEmbeddingStore做检索写入。

MilvusServiceClient milvusClient = new MilvusServiceClient( ConnectParam.newBuilder() .withHost("localhost") .withPort(19530) .withAuthorization("root", "milvus-root-password") .build() );

注意:Milvus 从 2.x 开始默认启用 root 账号鉴权,连接时如果不带用户名密码,会直接抛MilvusException。开发环境图省事可能有人关掉鉴权,千万别这么干。后面接权限系统的时候,你一定会需要区分谁在写入、谁在检索。

2.3 集合设计:字段、类型、索引

Milvus 的集合(Collection)可以类比关系型数据库的表。设计集合时,我这边用四个字段:

字段名类型说明
idInt64主键,自增
contentVarChar文档分块后的原始文本
metadataJSON文档名、页码、章节、更新时间等元数据
embeddingFloatVector向量字段,维度要和 Embedding 模型一致

创建集合的代码:

FieldType idField = FieldType.newBuilder() .withName("id") .withDataType(DataType.Int64) .withPrimaryKey(true) .withAutoID(true) .build(); FieldType contentField = FieldType.newBuilder() .withName("content") .withDataType(DataType.VarChar) .withMaxLength(8192) .build(); FieldType metadataField = FieldType.newBuilder() .withName("metadata") .withDataType(DataType.JSON) .build(); FieldType embeddingField = FieldType.newBuilder() .withName("embedding") .withDataType(DataType.FloatVector) .withDimension(1024) .build(); CreateCollectionParam createParam = CreateCollectionParam.newBuilder() .withCollectionName("java_rag_demo") .withDescription("企业内部知识库向量集合") .withFieldTypes(List.of(idField, contentField, metadataField, embeddingField)) .build(); milvusClient.createCollection(createParam);

维度这里需要特别说明。阿里百炼的text-embedding-v3模型默认输出 1024 维向量,所以withDimension(1024)必须和模型对齐。如果换了模型,维度不匹配,写入时 Milvus 不会报错,但检索时返回的结果全是乱的,而且这种问题特别难排查。

索引方面,实测 HNSW 在百万级向量以下的检索性能最好。参数我一般设置M=16efConstruction=200,召回效果和写入速度比较均衡。度量方式用COSINE,因为 OpenAI 兼容接口的 Embedding 模型返回的向量没有归一化,用余弦相似度比内积更稳定。

CreateIndexParam indexParam = CreateIndexParam.newBuilder() .withCollectionName("java_rag_demo") .withFieldName("embedding") .withIndexType(IndexType.HNSW) .withMetricType(MetricType.COSINE) .withExtraParam("{\"M\": 16, \"efConstruction\": 200}") .build(); milvusClient.createIndex(indexParam);

3. 阿里百炼 API 配置与 Spring Boot 接入

大模型这块我们用了阿里百炼,主要原因是国内访问稳定、中文效果好,而且 DashScope 提供了 OpenAI 兼容模式,Java 代码不需要引入额外 SDK。LangChain4j 只认模型类型,不认厂商,所以配置起来很干净。

3.1 获取 API Key 与兼容地址

百炼控制台开通模型服务后,在 API-KEY 管理页面创建一个 Key。LangChain4j 对接时,baseUrl 填:

https://dashscope.aliyuncs.com/compatible-mode/v1

这个地址兼容 OpenAI 的/chat/completions/embeddings等接口路径。模型名称填qwen-plus,这是通义千问的中档模型,RAG 问答场景下性价比很高。如果对回答质量要求更高,可以换qwen-max;如果追求响应速度,qwen-turbo也够用。

提示:千万别把 API Key 硬编码在代码里,更别提交到 Git。我们用 Nacos 配置中心管理,本地开发用application-local.yml里的环境变量占位。

3.2 配置类与模型 Bean

application.yml中加入:

llm: dashscope: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: ${DASHSCOPE_API_KEY} chat-model: qwen-plus embedding-model: text-embedding-v3 max-tokens: 2048 temperature: 0.2

写一个配置类绑定:

@Component @ConfigurationProperties(prefix = "llm.dashscope") @Getter @Setter public class DashScopeProperties { private String baseUrl; private String apiKey; private String chatModel; private String embeddingModel; private Integer maxTokens; private Double temperature; }

然后注册 LangChain4j 需要的模型 Bean:

@Configuration public class LangChain4jConfig { @Bean public ChatLanguageModel chatLanguageModel(DashScopeProperties props) { return OpenAiChatModel.builder() .baseUrl(props.getBaseUrl()) .apiKey(props.getApiKey()) .modelName(props.getChatModel()) .maxTokens(props.getMaxTokens()) .temperature(props.getTemperature()) .logRequests(true) .logResponses(true) .build(); } @Bean public EmbeddingModel embeddingModel(DashScopeProperties props) { return OpenAiEmbeddingModel.builder() .baseUrl(props.getBaseUrl()) .apiKey(props.getApiKey()) .modelName(props.getEmbeddingModel()) .build(); } }

这里logRequests(true)logResponses(true)是调试利器。刚接入时如果发现返回内容不对,先看请求日志里发的提示词到底是什么,再判断是检索问题还是大模型理解问题。

3.3 关键参数调优:temperature 与超时

RAG 问答和闲聊不一样,答案必须以检索到的资料为依据,所以temperature要调低,我们线上用0.2。太高的话,大模型会自己发挥,把知识库没有的内容“编”出来;太低的话,回答会偏向保守,甚至直接复读原文。

另一个容易被忽略的是 HTTP 超时。百炼接口在高峰期响应可能超过 10 秒,Spring Boot 默认的RestTemplate超时往往不够。LangChain4j 的OpenAiChatModel内部用 OkHttp 发送请求,需要在构建时指定超时时间。可以这样设置:

OpenAiChatModel.builder() .baseUrl(props.getBaseUrl()) .apiKey(props.getApiKey()) .modelName(props.getChatModel()) .timeout(Duration.ofSeconds(60)) .build();

如果没调这个参数,缓存下来可能遇到“线上问答偶尔超时”的问题,日志里全是SocketTimeoutException,排查半天才发现是默认超时太短。

3.4 Embedding 模型的并发与限流

百炼的 Embedding 接口有 QPS 限制,账号默认值不高。文档入库阶段经常是大量文本片段批量向量化,如果并发一上来,很容易触发 429 限流。我们当时在文档导入接口里做了两层保护:第一层用Semaphore控制并发数,第二层对 429 响应做指数退避重试。

private static final Semaphore EMBEDDING_SEMAPHORE = new Semaphore(5); private List<Float> embedWithRetry(String text) { try { EMBEDDING_SEMAPHORE.acquire(); Response<List<Float>> response = embeddingModel.embed(text); return response.content(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("等待信号量被中断", e); } finally { EMBEDDING_SEMAPHORE.release(); } }

这一步在前期容易忽略,等到文档一多就会发现批量入库特别慢,点击入库按钮后接口迟迟不返回。并发控制加完之后,速度提升非常明显。

4. 核心链路:文档加载、分块、向量化、入库

RAG 系统的效果上限,很大程度上取决于文档入库这一步。很多人调了半天提示词,回答还是不行,实际上是分块太粗暴或者元数据丢失导致的。这一节把完整链路过一遍。

4.1 文档解析与分块策略

LangChain4j 的Document对象就是一段文本加元数据。从 PDF、Word、Markdown 文件里提取文本,可以用DocumentParser接口实现。官方默认支持文本和 URL,PDF 这类二进制格式需要自己扩展。我们项目里用 Apache PDFBox 解析 PDF,用 POI 解析 Word,最后统一转成Document

分块策略是 RAG 的“隐形胜负手”。固定长度切分最省事,但效果最差。比如一个 500 字的固定块,可能把一个完整的技术方案从中间截断,导致检索到的片段语义不完整。LangChain4j 的DocumentSplitters.recursive是递归分割,它会优先按段落分隔符切,保持语义完整,实在不行再按句子甚至单词切。

DocumentSplitter splitter = DocumentSplitters.recursive(500, 100); Document document = Document.builder() .text(extractedText) .metadata(Metadata.from(new HashMap<>(Map.of( "docName", fileName, "updateTime", LocalDate.now().toString() )))) .build(); List<TextSegment> segments = splitter.split(document);

500是块大小,100是重叠长度。块大小不是越大越好。块太大,向量表示的语义会被稀释,检索召回精度下降;块太小,语义不完整,回答时上下文不足。500 到 800 是经验值,具体要看你文档的写作风格。代码类文档我建议 400 左右,因为代码行本身信息密度高;产品文档可以放宽到 800。

重叠长度是防止切分边界把关键信息切断——前一块末尾的内容,下一块开头重复一遍,保证检索时即使落在边界,也能找到完整语义。

4.2 文本向量化:统一 Embedding 模型

文本向量化必须和检索时用同一个 Embedding 模型。如果入库用text-embedding-v3,检索时换了别的模型,向量空间不对齐,检索结果就是废的。这一点我强调得再多也不过分,因为排查起来真的很耗时间。

Response<List<Float>> response = embeddingModel.embed(segment.text()); List<Float> vector = response.content();

百炼的text-embedding-v3还支持通过dimensions参数指定输出维度,LangChain4j 的OpenAiEmbeddingModel默认不会设置这个参数,所以输出就是 1024 维。如果自己用 HTTP 方式调用,记得在请求体里加"dimensions": 1024,否则不同批次可能拿到不同维度的向量,入库直接失败。

4.3 写入 Milvus:使用 MilvusEmbeddingStore

LangChain4j 的langchain4j-milvus模块提供了一个开箱即用的MilvusEmbeddingStore。它的内部实现已经封装了集合创建、索引创建、向量插入和查询。把它注册成 Bean:

@Bean public EmbeddingStore<TextSegment> embeddingStore(DashScopeProperties props) { return MilvusEmbeddingStore.builder() .host("localhost") .port(19530) .collectionName("java_rag_demo") .dimension(1024) .username("root") .password("milvus-root-password") .build(); }

写入代码就非常简单了:

List<Embedding> embeddings = new ArrayList<>(); List<TextSegment> textSegments = new ArrayList<>(); for (TextSegment segment : segments) { embeddings.add(embeddingModel.embed(segment.text()).content()); textSegments.add(segment); } embeddingStore.addAll(embeddings, textSegments);

MilvusEmbeddingStore内部会把TextSegment的文本和元数据分别写入content字段和metadata字段,和我们 2.3 节设计的集合完全对应。这里不需要自己拼 InsertParam,省了很多样板代码。

4.4 元数据保留:检索过滤的基石

元数据在入库阶段容易被认为是“非必须字段”,但企业级 RAG 里它非常关键。我们保存的元数据至少包含:文档名称、所属部门、文档类型、更新时间、页码或章节号。有了这些字段,检索时才能做权限过滤和时间过滤。

比如查询时只允许检索当前用户有权限的文档,就需要在检索前把用户权限范围内的docId列表查出来,再拼成 Milvus 的过滤表达式:

QueryParam queryParam = QueryParam.newBuilder() .withCollectionName("java_rag_demo") .withExpr("metadata[\"dept\"] in [\"技术部\", \"产品部\"]") .build();

MilvusEmbeddingStore也支持在检索时传入filter参数。如果你先不上元数据,后面再做权限控制,就得把历史数据全部删了重建,代价极大。

4.5 批量入库的进度与失败重试

文档导入不是一次性的,后期会不断有新文档进来。我们把入库任务做成了异步接口,前端提交文档后返回一个任务 ID,后端用线程池处理,入库进度存到 Redis。每处理完一个分块就更新进度,失败的分块记录到日志表,支持页面重试。

这个设计一开始觉得没必要,结果内测时同事导入了几个 200 页的 PDF,同步接口直接超时。改成异步之后,体验完全不一样。

5. 检索问答链路:向量召回、上下文组装、大模型生成

入库只是第一步。用户真正感知到的问答体验,取决于检索和生成这条链路。这一节拆开讲。

5.1 查询向量化与相似度检索

用户问“企业微信扫码登录怎么配置”,这个 query 要先向量化,再去 Milvus 里做相似度检索:

Embedding queryEmbedding = embeddingModel.embed(question).content(); SearchParam searchParam = SearchParam.newBuilder() .withCollectionName("java_rag_demo") .withVectorFieldName("embedding") .withVectors(List.of(queryEmbedding.vector())) .withTopK(5) .withMetricType(MetricType.COSINE) .build(); R<SearchResults> response = milvusClient.search(searchParam);

topK 一般取 5 到 10。topK 太小,可能漏掉关键文档;太大,上下文塞进提示词后既浪费 token 又引入噪声,反而降低回答准确性。我们线上取 5。

检索结果里每条会带 similarity 分数。这个分数不能直接当置信度看,不同索引和模型的分数范围不一样。建议先打印一批真实查询的分数分布,再定阈值。

5.2 用 EmbeddingStoreContentRetriever 组装检索器

LangChain4j 提供了现成的检索器EmbeddingStoreContentRetriever,省得自己写上述 SearchParam。构建方式:

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.5) .build();

minScore是相似度阈值。低于这个分数的检索片段,不会进入大模型上下文。这个参数非常有用,它能在知识库没有相关内容时,让系统“诚实地说不知道”,而不是硬凑答案。

5.3 构建 AI 服务:AiServices 与 RetrievalAugmentor

LangChain4j 的AiServices是链路编排的核心。定义一个接口,直接用它生成实现:

public interface RagAssistant { @SystemMessage(""" 你是一个企业知识库问答助手。 请只根据给定的资料回答问题。 如果资料中没有相关信息,请明确回答“知识库中未找到相关资料”。 回答时请注明信息来源的文档名称和页码。 """) String answer(String question); }

然后组装:

RetrievalAugmentor augmentor = RetrievalAugmentor.builder() .contentRetriever(contentRetriever) .build(); RagAssistant assistant = AiServices.builder(RagAssistant.class) .chatLanguageModel(chatLanguageModel) .retrievalAugmentor(augmentor) .build();

RetrievalAugmentor的作用是在调用大模型前,自动把检索到的文本片段注入到提示词里。这样业务代码里就只需要调用assistant.answer(question),完全不用关心上下文怎么拼。

注意:SystemMessage 里“只根据给定资料回答”这句约束不是万能钥匙。如果 minScore 设得太低,相关片段仍然会被注入;如果文档本身就模糊,大模型还是会“发挥”。所以我在代码里加了二次校验:当检索结果最大相似度低于 0.45 时,直接返回预设兜底话术,不调用大模型。

5.4 提示词模板与引用来源返回

企业级问答有一个硬需求:答案必须可溯源。用户在系统里看到一段回答,得有依据。这个要求不能在提示词里只写“请注明来源”,因为大模型的输出格式不稳定。更可靠的做法是,在服务层把检索结果和生成结果一起返回,前端展示答案时自动附上引用来源。

public AnswerResponse answer(String question) { // 召回 List<RetrievedDocument> documents = contentRetriever.retrieve(question); double maxScore = documents.stream() .mapToDouble(d -> d.score()) .max() .orElse(0.0); if (maxScore < 0.45) { return AnswerResponse.notFound(question); } // 生成 String answerText = assistant.answer(question); List<SourceInfo> sources = documents.stream() .map(d -> { Metadata metadata = d.textSegment().metadata(); return new SourceInfo( metadata.getString("docName"), metadata.getString("pageNumber") ); }) .toList(); return new AnswerResponse(question, answerText, sources); }

这个设计比“让大模型自己说来源”可靠得多。大模型回答“根据《XX文档》可知”可能是编的,而这里的引用来自真实检索命中结果,每一篇都能在知识库里找到原文。

5.5 Controller 层与前端对接

后端只暴露一个简洁的接口给前端:

@RestController @RequestMapping("/api/rag") public class RagController { private final RagService ragService; public RagController(RagService ragService) { this.ragService = ragService; } @PostMapping("/ask") public AnswerResponse ask(@RequestBody AskRequest request) { return ragService.answer(request.question()); } }

请求示例:

{ "question": "企业微信扫码登录的默认超时时间是多少?" }

响应示例:

{ "question": "企业微信扫码登录的默认超时时间是多少?", "answer": "根据企业微信集成文档,扫码登录二维码的默认有效期为 5 分钟,超过时间需要刷新重新获取。", "sources": [ { "docName": "企业微信集成手册.pdf", "pageNumber": "12" } ] }

6. 企业级落地中的踩坑与调优

系统跑起来容易,跑稳难。把我们上线前后遇到的坑集中说一下。

6.1 Milvus 连接池与并发控制

MilvusServiceClient是线程安全的,可以复用一个客户端实例。但 gRPC 连接数如果不够,高并发时会报UNAVAILABLE: io exception。两个解决方向:一是把连接池调大,二是对入库和检索的并发做限流。

连接参数:

ConnectParam connectParam = ConnectParam.newBuilder() .withHost("localhost") .withPort(19530) .withConnectionPoolSize(20) .withKeepAliveWithoutCalls(true) .build();

另外,Milvus 的检索操作是 CPU 密集型的,单机版在 100 并发以上的场景会明显变慢。生产环境如果想支撑大流量,要么扩容 Milvus 集群,要么在前面加一层 Redis 缓存,把高频问题缓存起来。

6.2 流式输出与调用超时控制

用户问一个问题,大模型生成几百字可能要 3 到 5 秒,如果页面一直转圈,体验很差。我们后来用 LangChain4j 的StreamingChatLanguageModel接 SSE,实现打字机效果。

StreamingChatLanguageModel streamingModel = OpenAiStreamingChatModel.builder() .baseUrl(props.getBaseUrl()) .apiKey(props.getApiKey()) .modelName(props.getChatModel()) .build();

Spring MVC 里可以用SseEmitter

@PostMapping("/ask/stream") public SseEmitter askStream(@RequestBody AskRequest request) { SseEmitter emitter = new SseEmitter(120_000L); // 把用户问题丢进线程池,异步跑 RAG 链路 ragService.answerStream(request.question(), emitter); return emitter; }

流式输出还有一个好处:用户可以边看边判断回答是否靠谱,提前打断重新提问,减少了无效等待。

6.3 空库与低相关度处理

知识库刚上线时文档没导全,用户随便问一个问题,检索不到任何内容。这时候如果直接调用大模型,它可能会凭训练语料里的通用知识回答。这在企业场景里很危险,因为用户分不清哪些是知识库内容、哪些是大模型自己编的。

我们的处理规则是:

  • 检索结果为空:直接返回“知识库中暂未找到相关信息,请联系管理员补充资料”。
  • 最大相似度低于 0.45:返回“未能找到与问题高度匹配的资料,请尝试换个说法”。
  • 相似度在 0.45 到 0.6 之间:在回答开头加提示“以下内容可能不完全匹配,仅供参考”。

这套规则把“幻觉”风险压到了很低。

6.4 分块参数与检索效果的实测对比

我拿公司一份 47 页的产品手册做过一组对比实验,结果很有参考价值:

分块方式chunk_sizeoverlap召回准确率(人工标注 50 个问题)
固定切分500064%
固定切分50010070%
递归切分50010082%
递归切分3005078%
语义切分(按标题/段落)动态动态88%

分块能明显影响最终效果。当我们把分块逻辑升级为“按 Markdown 标题层级优先切分,标题和正文一起进入同一块”后,召回准确率提升最明显。LangChain4j 支持自定义DocumentSplitter,建议有精力的团队在这个点上多花时间。

6.5 安全与权限过滤

企业内部知识库涉及敏感信息,权限必须和现有系统打通。我们的方案是:文档入库时标注deptIdsecurityLevel,检索时从当前登录用户的会话中获取权限范围,拼进 Milvus 检索的 filter 表达式。

String filter = String.format( "metadata[\"deptId\"] in (%s) and metadata[\"securityLevel\"] <= %d", deptIds, userSecurityLevel );

另外,大模型生成的回答里,如果存在与知识库无关的敏感内容,可以在输出前加一层关键词过滤或调用内容安全检测接口。企业私有化部署时还得考虑模型服务的数据合规,不建议把敏感数据传到不可控的外部服务。

6.6 阿里百炼限流与降级

线上问答一旦流量上来,百炼的限流就会成为瓶颈。我们的降级方案很简单:

  1. 先查 Redis 缓存,完全相同的问句在 24 小时内的回答直接复用;
  2. 缓存未命中再走百炼;
  3. 百炼返回 429 或超时,降级返回“系统繁忙,请稍后重试”,并记录工单。

这套方案上线后,大模型服务的调用量降低了 40%,用户几乎感觉不到限流的影响。

最后再分享一点个人经验

搭建这套系统前后大概用了三周,其中真正写代码只花了一周半,剩下时间全在调分块、调阈值、调提示词。RAG 这个方向,框架和组件只是地基,真正的优化空间在数据质量和检索策略上。如果你要复刻这套方案,我建议按这样的顺序推进:第一步用最小 Demo 跑通“文档入库 + 简单问答”,第二步完善元数据和权限过滤,第三步再优化提示词和流式体验。不要一开始就追求完美,先把链路跑通,再根据真实用户的问题迭代。毕竟用户提出的问题,永远比你预想的更刁钻。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 8:04:06

Rust 内存泄漏的隐蔽角落:循环引用、ManuallyDrop 与线程悬挂

Rust 内存泄漏的隐蔽角落&#xff1a;循环引用、ManuallyDrop 与线程悬挂在现代系统级编程的认知中&#xff0c;许多开发者常有一个误区&#xff1a;“只要使用了 Rust 的所有权&#xff08;Ownership&#xff09;与 RAII 机制&#xff0c;系统就绝对不会发生内存泄漏&#xff…

作者头像 李华
网站建设 2026/9/19 8:03:13

Gmail的Gemini AI如何提升邮件管理效率

1. Gmail的AI进化&#xff1a;当Gemini遇上电子邮件管理过去三个月我一直在测试Gmail新推出的Gemini AI功能&#xff0c;这套系统彻底改变了我处理邮件的习惯。每天面对200封邮件的压力下&#xff0c;传统分类规则已经力不从心&#xff0c;而基于大语言模型的智能优先级和摘要功…

作者头像 李华
网站建设 2026/9/19 8:01:26

Arduino IDE 2 配置 ESP32-S3 工程配置全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 8:01:19

交通流量预测毕设落地指南:数据清洗、混合建模与Flask部署

1. 这不是“跑个模型就交差”的毕业设计&#xff0c;而是交通预测系统的真实落地切口 “机器学习在交通流量预测中的应用”——光看标题&#xff0c;你可能以为又是一篇调用sklearn、喂几组历史数据、画个MAE曲线就收工的课程作业。但真正做过交通领域项目的人知道&#xff0c…

作者头像 李华