这一篇写给正在做企业内网知识库问答的 Java 同学们。这两年 RAG(检索增强生成)已经被聊烂了,但网上教程十有八九是 Python 配 LlamaIndex,Java 生态一直没个像样的保姆级案例。我自己的项目组拿着 LangChain4j 折腾了小半年,从最开始的几百条文档搭出可用的企业知识库问答,中间踩了不少坑。这篇文章就是想把"Java + RAG 从零到落地"这条路完整捋一遍,包括设计取舍、核心代码、参数是怎么调出来的、哪些坑必须避开,通通写给你。
不管你是架构师、后端开发,还是刚接触 RAG 的新人,只要你会 Spring Boot 的基本操作,跟着这篇文章能搭出一套能回答问题、能接企业知识库、能上生产环境的问答服务。我会用真实项目中走过的弯路来讲解,代码基于 JDK 17 + Spring Boot 3.2 + LangChain4j 0.35,版本差异我会额外标注。
1. 整体架构与技术选型:为什么在 Java 里选 LangChain4j
1.1 RAG 不是魔法,是一条"先检索再生成"的流水线
RAG 的核心逻辑其实特别朴素:大模型知识有截止日期,企业内部文档又完全不在它脑子里,所以干脆不靠它硬想。先把你的一堆 PDF、Word、Markdown 切成小块,转成向量存起来;用户问问题时,把问题也转成向量,去向量库里找最相似的内容片段;最后把这几个片段和用户问题拼在一起,送给大模型,让它"开卷考试"。
这套流程拆开就是五个环节:文档解析、文本切分、向量化、检索召回、生成回答。Java 生态里能把这五个环节串起来且不折腾人的,目前我实测下来 LangChain4j 是最顺手的。它的定位很像 Python 的 LangChain,但是原生 Java 实现,不依赖 Python 服务,StringBoot 工程里加两个依赖就能跑,这对 Java 技术栈的企业项目来说太重要了。
1.2 对比按需选型:Spring AI、LangChain4j、自研 Pipeline
我在选型时对比了三条路:
- Spring AI:Spring 官方出的 AI 框架,起步较晚,早期版本 API 经常变。如果项目愿意等它成熟,是个选择,但对我这种当时要马上交付的团队来说太激进。
- LangChain4j:社区活跃、封装度高,自带大量内置组件,比如文本切分器、向量存储适配器、对话记忆管理,文档也比 Spring AI 完整不少。0.35 这个版本已经相对稳定。
- 自研 Pipeline:全部自己写,可定制性最强,但要处理的细节实在太多,embedding api 超时、向量库连接池、重试策略、切分块重叠……没有专门人维护根本跑不起来。
最终我选了 LangChain4j,因为它把"标准化流程"和"自定义能力"的平衡点掌握得最好。比如内置的EmbeddingStoreIngestor会自动做解析、切分、向量化、入库,但每个环节你又可以注入自定义实现类。后文的所有步骤也能照搬到自研 Pipeline 上,核心原理是完全通用的。
提示:当前 LangChain4j 版本迭代比较快,0.35 与 1.x 之间的 API 有一定调整,但核心类如
EmbeddingStoreIngestor、RAGRetriever的设计思路是连贯的,看懂思想比背 API 更重要。
1.3 企业知识库场景的特殊约束
个人玩 RAG 可以拿一篇 PDF 随便试,企业知识库不一样,有几个硬约束:
- 文档格式杂:Word、PDF、PPT、Excel、TXT、Markdown、网页导出 HTML,解析器要能统一处理。
- 内容量大:几千份文档、几万段落,向量库的数据规模会直接影响检索延迟。
- 时效性:知识库文档会更新,增删改流程不能重建整个库。
- 权限隔离:不是所有人都能看所有文档,检索范围得细分。
- 答案可追溯:内部用可以,但回答得能指出依据来源,不然没人敢信。
这些约束不是 RAG 框架本身回答的,而是要你在架构层面预留接口。后面我会专门讲"文档切分参数怎么调""权限过滤怎么加""引用来源怎么回显"这些问题。
2. 核心代码框架:依赖、配置与基础数据结构
2.1 工程初始化与依赖引入
先建一个标准的 Spring Boot 3.2 工程,Java 版本 17,pom.xml里加这样几个依赖:
<properties> <langchain4j.version>0.35.0</langchain4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-embeddings</artifactId> <version>${langchain4j.version}</version> </dependency> <!-- 如果你要把向量存到本地文件或数据库 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-easy-rag</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>langchain4j-open-ai默认走 OpenAI 兼容接口,但国内企业大多数用的是各种大模型平台提供的"OpenAI 兼容地址",所以配置层面重点是把baseUrl换掉。用通义、智谱、DeepSeek、或者自建 vLLM 服务都行,只要兼容 /v1/chat/completions 即可。
2.2 配置文件里的关键参数
spring: application: name: kb-rag-service langchain4j: open-ai: chat-model: base-url: ${LLM_BASE_URL:https://your-llm-gateway.com/v1} api-key: ${LLM_API_KEY:sk-xxx} model-name: ${LLM_MODEL:qwen-max} temperature: 0.1 timeout: 60s embedding-model: base-url: ${EMBED_BASE_URL:https://your-llm-gateway.com/v1} api-key: ${EMBED_API_KEY:sk-xxx} model-name: ${EMBED_MODEL:text-embedding-v4} timeout: 30s两个重点:
- temperature 设置为 0.1 甚至 0:知识库问答要求事实准确,不需要模型发挥创造力。我生产环境固定 0,宁可回答得保守,也不要它自由发挥。
- 两个模型可以来自不同供应商:对话模型和 embedding 模型是可以完全解耦的。很多团队用开源 embedding 模型本地部署,对话模型走云端 API,这在 LangChain4j 里配置两个独立块就行。
2.3 核心对象工作流程理解
在贴代码之前,先理解 LangChain4j 里的几个核心对象,不然看代码会一头雾水:
Document:表示一个来源文件,可带 metadata(文件名、路径、分类、更新时间)。TextSegment:文档切出来的片段,默认最大 300 token,带与来源一致的 metadata。EmbeddingModel:把文本变成向量数组的模型接口。EmbeddingStore:向量存储的抽象接口,支持内存、Redis、PGVector、Milvus、Elasticsearch 等实现。ContentRetriever:从向量检索到召回片段的组件。ChatMemory:对话历史管理,决定多轮问答怎么把上下文喂给模型。
理解这六个对象,后面的代码逻辑就是"把文档流程串起来"和"回答流程串起来"两件事。
3. 实操第一步:从零完成知识库入库全流程
3.1 文档切分:为什么 300 token 一格,而不是 500 或 1000
这是第一个必须讲透的参数。LangChain4j 默认的DocumentSplitter按 300 token 切分,且相邻块重叠 50 token。很多人上来就改成 1000 token,理由是"让模型看到更多上下文",结果召回准确率反而下降。
原因在于 embedding 模型的能力上限。现在的 embedding 模型大多按 512 或 1024 token 训练,超过有效长度后,向量表示的语义精度会大幅衰减。另外,检索召回的粒度越粗,命中噪声越高:一块 1000 token 的内容里可能混合了三四个不同主题,用户问题只和其中一小段相关,但召回来的整块都会塞进 prompt,反而干扰答案。
我实际测试过一组数据,用 8000 多条产品问题做评测集,300 token + 50 token 重叠的配置,召回命中率比 600 token 要高出约 8 个百分点。如果你做的是"单篇长文档问答",可以适当调高,但企业知识库场景建议保持 300 token 左右作为起点。
@Configuration public class IngestionConfig { @Bean public DocumentSplitter documentSplitter() { return DocumentSplitters.recursive(300, 50); } }如果你有特殊的切分需求,比如 Markdown 里的代码块不能断、表格不能拆,那就实现自己的DocumentSplitter,按标题层级、代码块边界做"智能切分",再控制每段大小。这个自定义复杂一些,但很多企业文档准确性要求高,值得投入。
3.2 完整入库代码:解析、切分、向量化、写入
下面是一个标准入库 Service 的完整代码,包含元数据附加和批次写入:
@Service @RequiredArgsConstructor public class KnowledgeBaseIngestionService { private final EmbeddingModel embeddingModel; private final EmbeddingStore<TextSegment> embeddingStore; private final DocumentSplitter documentSplitter; public void ingestFile(MultipartFile file, String category, String ownerDept) { // 1. 解析文档为 LangChain4j 的 Document Document document = loadDocument(file); // 2. 附加业务元数据,后续可按条件过滤 document.metadata().put("fileName", file.getOriginalFilename()); document.metadata().put("category", category); document.metadata().put("ownerDept", ownerDept); document.metadata().put("uploadTime", LocalDate.now().toString()); // 3. 切分 List<TextSegment> segments = documentSplitter.split(document); // 4. 向量化 + 入库,10 条一批,避免单次请求过大 for (int i = 0; i < segments.size(); i += 10) { List<TextSegment> batch = segments.subList(i, Math.min(i + 10, segments.size())); List<Embedding> embeddings = embeddingModel.embedAll(batch).content(); embeddingStore.addAll(embeddings, batch); } } private Document loadDocument(MultipartFile file) { // 根据文件后缀分发到不同的解析器 String fileName = file.getOriginalFilename(); String ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); try { if ("pdf".equals(ext)) { return PdfDocumentParser.parse(file.getInputStream()); } else if ("docx".equals(ext)) { return WordDocumentParser.parse(file.getInputStream()); } else if ("md".equals(ext) || "txt".equals(ext)) { return new Document( new String(file.getBytes(), StandardCharsets.UTF_8), Metadata.from(fileName) ); } else { throw new UnsupportedOperationException("暂不支持的文件类型: " + ext); } } catch (IOException e) { throw new RuntimeException("文档解析失败: " + fileName, e); } } }这里要注意PdfDocumentParser和WordDocumentParser是我在团队里封装的工具类,内部用 Apache PDFBox 和 POI 实现。LangChain4j 有个超好用的langchain4j-easy-rag模块,FileSystemDocumentLoader可以直接加载本地目录下所有文档,但企业上传场景还是得自己解析。
为什么批次写入按 10 条?因为大多数 embedding API 有单次请求条数和 token 上限,10 条 300 token 能控制在 3000 token 以内,绝大多数供应商都能接受。批次太大不仅容易触发限流,出错返工的成本也高。
3.3 向量存储选型:内存、PGVector、Milvus 怎么定
这一步很多教材一句话带过,实际生产上却最容易翻车。LangChain4j 内置这几类存储:
| 存储方案 | 适合场景 | 最大优势 | 痛点 |
|---|---|---|---|
| InMemoryEmbeddingStore | 本地测试、Demo | 零配置启动 | 重启数据全丢 |
| Redis | 中小规模、已有 Redis | 部署简单 | 需要自管索引持久化 |
| PostgreSQL + PGVector | 中大型、已有 PG | 能和业务数据共用事务 | 查询性能上限偏低 |
| Milvus | 大型向量库、千万级数据 | 海量向量检索性能极强 | 运维成本高 |
| Elasticsearch | 既要做全文搜索又要向量检索 | 混合召回,一套引擎 | 内存资源消耗大 |
如果是做企业知识库 MVP,我建议直接用 PGVector:绝大多数企业内部本身有 PostgreSQL,加个 vector 插件,不用多维护一套独立数据库。百万以下向量规模,PGVector 加 HNSW 索引完全够用。
依赖加这个:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-pgvector</artifactId> <version>${langchain4j.version}</version> </dependency>配置 Bean:
@Bean public EmbeddingStore<TextSegment> embeddingStore() { return PgVectorEmbeddingStore.builder() .host("localhost") .port(5432) .database("rag_demo") .user("postgres") .password("postgres") .table("kb_embedding") .dimension(1024) // 必须和 embedding 模型输出维度一致 .build(); }dimension 必须写对。不同 embedding 模型的向量维度不同,比如 text-embedding-v4 是 1024 维,bge-m3 是 1024 维,text-embedding-3-small 是 1536 维。写错了建表时直接报错。以你实际模型返回的维度为准,可以在测试代码里打印embeddingModel.embed("test").content().dimension()。
4. 实操第二步:检索问答链路搭建与调优
4.1 最基础的一版问答流程
先看一个能跑的最小代码,然后我们再逐步优化:
@Service @RequiredArgsConstructor public class RagChatService { private final ChatLanguageModel chatLanguageModel; private final EmbeddingModel embeddingModel; private final EmbeddingStore<TextSegment> embeddingStore; public String answer(String userMessage) { // 1. 问题向量化 Embedding queryEmbedding = embeddingModel.embed(userMessage).content(); // 2. 向量库检索,默认取 3 条最相似片段 List<Relevant> relevant = embeddingStore.findRelevant(queryEmbedding, 3); List<TextSegment> segments = relevant.stream() .map(Relevant::embedded) .toList(); // 3. 拼装 Prompt String context = segments.stream() .map(TextSegment::text) .collect(Collectors.joining("\n\n---\n\n")); SystemMessage systemMessage = SystemMessage.from(""" 你是一个企业知识库助手。 请严格根据以下提供的知识库内容回答问题。 如果知识库中没有相关信息,请直接回答"知识库中没有找到相关内容"。 """); UserMessage userMsg = UserMessage.from(""" 知识库内容: %s 用户问题: %s """.formatted(context, userMessage)); // 4. 调用大模型生成回答 return chatLanguageModel.chat( List.of(systemMessage, userMsg) ).aiMessage().text(); } }这段代码能跑通基本流程,但离生产能用差得很远。接下来逐个环节优化。
4.2 让答案"有据可查":引用来源与元数据回传
企业知识库最忌讳的就是模型胡说八道。我们在生产环境的第一条硬性要求就是:每一个回答必须带引用来源。
实现方式不难,在拼装 prompt 时把片段编号,同时在片段文本前面加上来源标记:
StringBuilder contextBuilder = new StringBuilder(); for (int i = 0; i < segments.size(); i++) { TextSegment segment = segments.get(i); String source = segment.metadata().getString("fileName"); String category = segment.metadata().getString("category"); contextBuilder.append("[").append(i + 1).append("] ") .append("来源:").append(source) .append("(分类:").append(category).append(")\n") .append(segment.text()).append("\n\n"); }然后在 System prompt 里再加一句:"回答时请在每个结论后用 [编号] 标注引用来源。若回答依据多个来源,请分别标注。"
这样模型回答出来的内容会带上 [1]、[2] 这样的引用标记,前端可以解析这些标记,把对应的原始文档链接展示出来。不要嫌麻烦,内部用户对"答案有没有出处"的在意程度远高于"回答是否流畅"。
4.3 多轮对话:怎么控制 ChatMemory 的窗口
第一版代码里没有对话记忆,用户问"那它的预算呢"这种指代性问题,模型根本不知道"它"是谁。解决方案是引入ChatMemory,但这里有一个坑:不能把所有历史都塞进去。
每个大模型都有上下文窗口上限,知识库检索出来的片段本身就占不少 token。如果对话历史太长,轻则把知识片段挤出去,重则直接超限报错。
LangChain4j 里可以用MessageWindowChatMemory:
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .chatMemoryStore(new MyPersistentChatMemoryStore()) .build();maxMessages(20)表示最多保留最近 20 条消息,也就是 10 轮左右对话。如果你的模型上下文够大,可以适当多保留;如果上下文紧张,就少保留,同时开启"问题改写"策略。
"问题改写"是进阶玩法:先把用户当前问题,结合最近对话上下文,重写成一个独立的问题,再拿这个改写后的问题去做向量检索。举例,用户先问"报销流程是什么",又问"需要几张发票",改写器会把第二句变成"根据前面提到的报销流程,需要几张发票?",检索效果远好于直接拿"需要几张发票"去搜。
LangChain4j 中可以通过自定义QueryTransformer实现:
public class ContextualQueryTransformer implements QueryTransformer { private final ChatLanguageModel chatModel; @Override public String transform(Query query, SearchRequest sr) { String original = query.text(); if (original.length() < 2) { return original; } // 构造一个简短的重写 prompt,不需要知识库上下文 PromptTemplate template = PromptTemplate.from(""" 请将下面的问题改写成可独立检索的完整问题,只输出改写后的内容,不要解释。 原问题:{{question}} """); return chatModel.chat(template.apply(Map.of("question", original)).toUserMessage()) .aiMessage().text(); } }这里一个重要心得是:问题改写用的模型 temperature 必须为 0,改写任务不需要任何创造性,只需要把指代补全。
4.4 检索调参:topK、分数阈值、混合检索
向量检索的核心参数有三个:maxResults(召回数量)、minScore(相关性阈值)、过滤条件。LangChain4j 的EmbeddingStore接口已经支持这些。
我在真实项目中实测大量样本后发现,**"召回数量 3~5 条,分数阈值 0.5~0.6,强制元数据过滤"**是一套能覆盖 80% 场景的稳妥配置。注意分数不是绝对值,不同 embedding 模型产出的相似度分数分布差异很大,bge 系列的分数通常偏低,0.4 已经是高相关;OpenAI embedding 的 cosine 相似度在 0.7 左右比较可信。所以minScore要基于你自己的数据实测观察,不要抄网上的固定值。
一个提升准确率的有效操作是混合检索:向量检索 + 关键词检索互补。原因很简单,用户问题里如果包含领域专有名词或英文缩写,向量检索可能把它映射到似是而非的语义上,但关键词检索能精确命中。Elasticsearch 方案天然适合做混合,但如果你用 PGVector,也可以用 PG 的tsvector做全文检索,和向量检索做 RRF(Reciprocal Rank Fusion)融合。LangChain4j 里可以自定义ContentRetriever来做这个融合,代码量不大。
我给一个"预设过滤条件"的常用写法:
SearchRequest request = SearchRequest.builder() .queryEmbedding(queryEmbedding) .maxResults(5) .minScore(0.55) .filter(new Filter.And( new Filter.Metadata("category", Filter.ComparisonOperator.EQ, category), new Filter.Metadata("ownerDept", Filter.ComparisonOperator.EQ, userDept) )) .build();这就实现了最基础的权限隔离:用户只能检索到他所属部门上传的文档。更复杂的粒度控制,可以在检索后、拼 prompt 前再做一层片段级别的 ACL 过滤。
5. 文档切片策略与元数据设计:容易被忽视但决定上限
5.1 切分策略的实战选择
官方默认的递归字符切分器不是万能的。我见过最惨的案例是有人把一整份 100 页的产品手册丢进去,切出来 800 多块,其中大量片段是从表格中间切开的,检索出来内容逻辑破碎,模型根本读不懂。
我的建议是按文档类型定制切分策略:
- 规章制度类(条款式):按章节标题切分,每个二级标题下一整块作为一个文档单元,再进行 token 级二次切分。
- 操作手册类(步骤式):按"前置条件、操作步骤、注意事项"结构切,保证步骤序号不被打散。
- 产品 FAQ:一问一答天然是一段,不要硬切。
- 技术方案/代码示例:代码块单独作为一段,不要和正文混在一个片段里。
LangChain4j 里实现自定义结构切分,可以继承DocumentSplitter重写split方法。如果你用 Markdown 文档,DocumentSplitters.recursive支持按标题层级#、##等标记做结构切分,这是最简单且效果最好的一种:
DocumentSplitters.recursive( DocumentSplitters.recursive(300, 50), new MarkdownHeadingSplitter() );不过更通用的做法是引入内容清洗管道:先去掉页眉页脚、目录、重复空行,再按照文档结构切分。数据质量永远是 RAG 效果的第一决定因素。
5.2 元数据设计的三个原则
元数据是 RAG 检索的"隐藏变量",设计不好会直接卡住权限控制和灵活的过滤需求。原则就三条:
- 每个片段继承文件级元数据:文件名、文件编号、所属部门、密级、上传时间、最后更新时间、文档类型、业务线。
- 能在切分时生成的尽量在片段级补充:章节号、标题路径、页码,这些对定位引用非常重要。
- 不要把所有元数据塞进 prompt:检索和过滤用元数据,生成回答时只需要文件名、章节、页码范围这几项。
我见过一个团队把uploadTime也塞进 System Prompt,浪费几百 token,大模型又不依赖时间判断内容新旧,纯属浪费。元数据作用在检索过滤,不是给模型读的。
5.3 embedding 模型选型:决定检索效果的天花板
检索效果的上限在 embedding 模型。我在整个项目中对比过开源和商用模型,实际差别没有传说中那么巨大,但有几点值得注意:
- 中文场景别用只在英文语料上训练的模型:如果你看到 embedding 结果中"苹果"和"手机"的相似度远低于预期,先怀疑模型语言覆盖。
- 模型维度影响存储和检索性能:1536 维和 1024 维在百万级数据上的检索耗时有明显差距,不是越大越好。
- 同一个模型要固定使用:语法上你可以换模型,但切换 embedding 模型意味着旧向量全部失效、必须重新入库,上线前定好就别换。
企业如果数据敏感,想让 embedding 也走内网,可以用 Ollama 本地部署开源模型,LangChain4j 提供了langchain4j-ollama模块。但注意 embedding 和对话模型不同,embedding 模型本地部署对硬件要求相对低,很多小模型 8G 内存就能跑,值得考虑。
6. 生产落地:异常处理、性能优化与运维埋点
6.1 限流与重试策略
企业知识库问答一旦上线,就会面临 embedding API 和 chat API 的双重限流。LangChain4j 内置了简单的重试机制,但我建议自己在业务层做一个防抖:
public String answerWithRetry(String userMessage) { int maxAttempts = 3; int baseDelayMs = 1000; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { return answerInternal(userMessage); } catch (RateLimitException e) { if (attempt == maxAttempts) { throw new ServiceException("AI 服务繁忙,请稍后再试"); } try { Thread.sleep(baseDelayMs * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new ServiceException("服务中断", ie); } } catch (JsonMappingException e) { // 模型返回格式异常,可能是被安全策略拦截 log.error("模型返回不可解析", e); return "抱歉,模型暂时无法生成有效回答,请换个问法试试"; } } return null; }用量大的场景,必须给 embedding 和 chat 分别配线程池和信号量,避免上游抖动拖垮整个服务。我们在生产上把 embedding 的并发控制在 20,chat 的并发控制在 10,剩下的全靠队列排队。
6.2 日志与可观测性:RAG 的每个环节都要留痕
RAG 流程比普通接口长得多,排查问题时如果没有日志,几乎无法定位是哪一环出了问题。我强烈建议至少输出以下日志:
- 用户原始问题
- 检索前改写后的问题(如果用了改写)
- 检索召回结果(片段 ID、文件名、相似度分数)
- 最终送入模型的上下文 token 数量
- 模型返回耗时、回答内容
如果是 Spring Boot 工程,用@Slf4j加几个log.info就行,注意不要把完整 prompt 打到日志里,那里面可能包含企业机密文档内容,建议只记录 token 数量和片段 ID。如果必须留 trace,脱敏后再打到独立的审计日志文件。
6.3 性能瓶颈在向量检索还是模型推理
很多团队优化 RAG 时先盯着模型推理耗时,其实大部分场景的瓶颈不在大模型,而在向量检索和文档解析。一份 100 页的 PDF,解析加切分可能要几十秒,如果用户上传文件是同步等待这一步,体验会非常差。
正确的做法是入库流程走异步任务:用户上传文件后马上返回"上传成功,解析中",后台任务去做加载、切分、向量化、入库。Spring 的@Async注解就能实现,也可以直接丢消息队列。
查询链路的耗时大头是 chat 模型推理,这个很难优化,但能通过"缓存常见问题"绕开。同一个问题被不同用户问了 N 遍,缓存命中后秒回。我设计了一个简单的语义缓存:把用户问题的 embedding 先到缓存表里查,相似度 0.95 以上且知识库版本没变,就直接返回历史答案。上线后缓存命中率差不多 25%,对整体性能和成本都有明显帮助。
6.4 越权与内容安全:牢记数据隔离
企业知识库问答最容易出问题的不是技术,而是越权。向量库本身不区分用户,如果检索接口没有按用户权限过滤,任何一个人都能把不该看的文档内容问出来。
我的建议是双层控制:
- 检索层过滤:在
SearchRequest里限制用户的可见文档范围,这是第一道闸门。 - 展示层过滤:即使片段已经被召回,拼装 prompt 前再次校验当前用户是否有权限查看该片段的
category、ownerDept、permissionLevel。用拦截器或注解做统一权限判断,不要在业务代码里散落权限判断逻辑。
注意:不要在回答文本里输出"我没有权限查看该文档"这类提示,直接说"知识库中没有找到相关内容"即可,避免用户通过错误提示试探出敏感文档的存在。
7. 实际踩坑记录:从"能用"到"好用"的七个关键问题
7.1 检索效果差,问题不在向量化而在切分
有一段时间我们的检索质量特别差,用户问"报销单怎么填",返回的片段全是"结算单怎么填"的表格内容。后来定位发现是切分器把表格混进了正文片段,模型召回时认为"填"这个动作的相关性把表格内容拉高了。换用按标题结构化切分后,这个问题的命中率从 63% 提到 81%。切片的质量决定了检索质量,这句总结会在你踩过坑之后才真正理解。
7.2 多轮对话让上下文爆炸
上线早期我们允许用户无限对话,结果用户连续问了 20 轮之后,模型开始胡言乱语。查日志发现送入模型的 token 数从 2000 涨到了 7000,知识片段被历史对话挤到几乎不剩。后来加上maxMessages(20)并用问题改写,才稳定下来。对话历史窗口、知识库片段、System Prompt 三者的 token 配比要心中有数,最简单的比例是 1:1:0.1。
7.3 分数阈值真的不能照抄
网上很多教程建议minScore设 0.8,但我的 bge-m3 模型最高分都到不了 0.75。第一次上线因为阈值设太高,大量问题直接返回"知识库中没有找到相关内容"。建议每个项目上线第一天,先跑 50 条真实用户问题,打印出所有召回片段的分数分布,再定阈值。
7.4 特殊字符让 embedding 失效
文档里经常有 emoji、特殊符号、不可见字符,这些字符经过 PDF 解析后可能变成乱码,向量化之后完全无效,还不报错。入库前最好做一次字符清洗,去掉空白控制字符。用正则[\\p{Cntrl}\\u200B-\\u200F\\uFEFF]清理一遍,很多诡异问题都会消失。
7.5 模型上下文窗口很容易被忽略
LangChain4j 的chat()不限制 token,底层 API 会直接抛 400 错误,但错误信息藏得比较深。我们遇到过一个典型案例:某个用户的提问特别长,加上检索片段直接超限,前端收到的是一个裸的 HTTP 400,页面显示"服务异常",非常不友好。后来加了全局异常拦截器,对上下文超限的异常统一提示"您的提问内容过长,请精简后再试"。
7.6 知识库更新导致答案过期
知识库并不是静态的,制度改了、价格变了,新文档入了库,但用户的旧问题缓存还是老答案。后来我们在每次入库操作时让语义缓存整体过期,这样知识库一更新,所有缓存立即失效,最多损失一点缓存红利,但保证答案不过期。
7.7 解析 PDF 时表格和页眉页脚容易污染切分结果
大多数 PDF 解析工具的核心能力在正文文本,遇到复杂 Layout 的 PDF,页眉、页脚、表格横跨多栏、图片中的文字等都会变成"脏文本"。我的思路是清洗先行:把解析出的文本按页拼接后,先删掉页眉页脚、目录和"第 X 页 / 共 X 页"之类的噪声,再做切分。宁可少一段内容,也不要一段脏内容。
8. 如何评估一套企业知识库问答的效果
8.1 不要只看"回答像不像"——用评测集说话
企业内部不可能人工测几百条问题来验证效果,一定要建离线评测集。三个指标就够了:
- 召回命中率:知识库片段是否真的包含正确答案,这是基础。
- 答案正确率:由领域人员人工打标,判断模型回答是否准确且基于知识库。
- 引用完整率:回答里是否带了正确引用来源。
评测集不要只写产品相关的,要覆盖制度、流程、薪酬、IT 帮助等不同部门的问题,每个分类至少 30 条。我用这种方式在迭代中发现了不少"看起来能答其实在瞎编"的案例:模型把两个不相关的文档片段拼接成了一段看起来通顺但实际错误的答案,引用来源也是错误的。如果只靠肉眼抽查,这种问题很难发现。
8.2 上线后建立反馈闭环
在问答界面加"赞同/反对"按钮,反对时让用户填一下原因,积累一段时间后拉出所有反对样本,先定位是检索问题还是生成问题:把检索到的片段直接给用户看,如果片段相关,问题在生成;如果片段不相关,问题在检索。这个简单的反馈闭环,能帮团队把准确率从摸不到头脑慢慢拉到稳定状态。
8.3 预算与延迟的平衡
企业知识库问答如果真的 7x24 服务,token 费用会是一个持续成本。建议做两件事:
- 模型分级:大部分普通问题用便宜的小模型,只有复杂业务问题才路由到顶级模型。LangChain4j 可以配置多个
ChatLanguageModel,按问题长度、复杂度、是否含技术名词等路由。 - 流式输出:
StreamingChatLanguageModel把首字延迟从 2~4 秒降到 200 毫秒以内,用户体验差别巨大。LangChain4j 对流式支持很完善,建议从第一天就接入。
9. 最后分享一点实际体会
这套系统从零到上线给我最大的启发是:RAG 真正难的不是接大模型的 API,而是把"切分、元数据、检索、权限、评测"这些工程问题做扎实。LangChain4j 省去了我们造轮子的时间,但业务侧的认知深度、对数据的理解、对评测指标的坚持,才是项目能做好的根本。如果你的团队正打算用 Java 做企业知识库问答,我建议先别急着追最新的模型,而是花两周时间把上面这些基础流程跑顺、评测集建好,后面换再强的模型都是水到渠成的事。我个人踩过最多的坑,就是以为模型能解决一切,结果发现工程细节才是那个"最后一公里"。你现在动手,把数据切分和评测体系先立起来,后面会一路顺畅很多。