一个技术能不能用,先看依赖和代码量。下面是LangChain4j的Maven坐标和50行核心代码,直接跑通一个RAG(检索增强生成)知识库:
<!– pom.xml 核心依赖 –>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>0.36.2</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
<version>0.36.2</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>0.36.2</version>
</dependency>
// 50行核心代码,跑通RAG
@SpringBootApplication
public class RagApplication {
public static void main(String[] args) {
SpringApplication.run(RagApplication.class, args);
}
@Bean
public CommandLineRunner demo(EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore,
ChatLanguageModel chatModel) {
return args -> {
// 1. 加载文档
Document document = FileSystemDocumentLoader.loadDocument(
Path.of("docs/公司制度.md"));
// 2. 文本分片,每段500字,重叠100字
DocumentSplitter splitter = DocumentSplitters.recursive(500, 100);
List<TextSegment> segments = splitter.split(document);
// 3. 向量化 + 存入向量库
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);
// 4. 构建RAG检索增强器
EmbeddingStoreContentRetriever retriever =
EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3) // 检索TopK=3
.minScore(0.6) // 最低相似度阈值
.build();
RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder()
.contentRetriever(retriever)
.build();
// 5. 组装对话链
AiServices<RagAssistant> aiService = AiServices.builder(RagAssistant.class)
.chatLanguageModel(chatModel)
.retrievalAugmentor(augmentor)
.build();
// 6. 问答
String answer = aiService.chat("年假怎么申请?");
System.out.println(answer);
};
}
}
// 接口定义
interface RagAssistant {
String chat(@UserMessage String question);
}
你不需要装Python环境,不需要折腾向量数据库(内存跑),不需要写复杂的Pipeline。LangChain4j把整个RAG链路封装成了Builder模式,跟Spring Boot的自动配置一样丝滑。
现在咱们拆开聊,每一行代码背后的原理是什么,出了故障怎么排查。
RAG 完整链路拆解:文档加载 → 文本分片 → 向量化 → 存储 → 检索 → 生成
RAG(Retrieval-Augmented Generation)这个名字看着唬人,说白了就是:先搜索,再生成。你把技术文档扔给系统,用户提问时,系统先从文档里搜出相关段落,然后把段落和问题一起扔给大模型,让大模型"看着资料回答"。
这样做的核心价值:大模型不会瞎编。它回答的内容有据可查,来自你喂给它的文档。
1. 文档加载(Document Loader)
LangChain4j提供了FileSystemDocumentLoader,支持PDF、Markdown、TXT、HTML等格式:
// 加载单个文件
Document doc = FileSystemDocumentLoader.loadDocument(Path.of("docs/产品手册.pdf"));
// 加载整个目录
List<Document> docs = FileSystemDocumentLoader.loadDocuments(Path.of("docs/"));
底层用了Apache Tika做格式解析,PDF里的表格、图片中的文字都能提取出来。如果你有特殊格式,可以自己实现DocumentParser接口:
public class CustomDocumentParser implements DocumentParser {
@Override
public Document parse(InputStream inputStream) {
// 自定义解析逻辑,比如解析Word文档
String text = new String(inputStream.readAllBytes());
return Document.from(text);
}
}
2. 文本分片(Text Splitting)
这是RAG最容易出问题的一环。分片太大,检索精度下降,大模型拿到的上下文噪声多;分片太小,关键信息被切碎,语义不完整。
LangChain4j提供了四种分片策略:
// 1. 递归分片(推荐):按段落→句子→词逐级切分,保证语义完整
DocumentSplitter recursive = DocumentSplitters.recursive(500, 100);
// 2. 按句子分片:适合问答类文档
DocumentSplitter sentence = DocumentSplitters.recursive(300, 50);
// 3. 按段落分片:适合制度文档、技术手册
DocumentSplitter paragraph = DocumentSplitters.recursive(1000, 200);
// 4. 固定长度分片:不推荐,容易切断句子
DocumentSplitter fixed = DocumentSplitters.recursive(500, 0);
重叠窗口(Overlap)是分片策略里最容易被忽略的关键参数。假设你设置chunkSize=500,overlap=100,意味着相邻两个分片之间有100个字的重叠。这能防止"年假申请需要满足以下条件:1. 入职满一年 2. 提前三天申请"被切成两段,导致检索时只能命中半个规则。
生产环境调优建议:先拿你的文档做实验。用几个典型问题检索,看返回的分片是否包含了完整答案。如果答案被切断,调大chunkSize或overlap;如果返回的噪声太多,调小chunkSize。
3. 向量化(Embedding)
Embedding是把文字变成一串数字(向量),让计算机能"理解"文字的语义。语义相近的文本,向量距离就近。
// 使用本地Embedding模型(无需联网,免费)
EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
// 文本转向量
Embedding embedding = embeddingModel.embed("年假怎么申请?").content();
// 返回一个384维的浮点数数组:[-0.023, 0.451, …]
LangChain4j支持的Embedding模型:
|
AllMiniLmL6V2 |
384 |
极快 |
中 |
否 |
|
BgeSmallZh |
512 |
快 |
高(中文) |
否 |
|
OpenAI text-embedding-ada-002 |
1536 |
快 |
高 |
是 |
|
通义千问 text-embedding-v2 |
1536 |
快 |
高(中文) |
是 |
注意:Embedding模型的维度决定了向量库的存储结构。如果你先用384维的模型建了索引,后换成1536维的模型,必须重建索引,否则查询会报维度不匹配的错误。这是生产环境迁移时最常见的坑。
4. 向量存储(Embedding Store)
向量库存储的是"文本→向量"的映射关系。查询时,把用户问题转成向量,在库里找最相似的几个向量,返回对应的文本。
// 内存存储(开发测试用)
EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>();
// Milvus存储(生产环境)
EmbeddingStore<TextSegment> store = MilvusEmbeddingStore.builder()
.host("192.168.1.100")
.port(19530)
.collectionName("company_docs")
.dimension(384) // 必须与Embedding模型维度一致!
.build();
// Elasticsearch存储(已有ES集群的场景)
EmbeddingStore<TextSegment> store = ElasticsearchEmbeddingStore.builder()
.serverUrl("http://es-cluster:9200")
.indexName("rag_docs")
.dimension(384)
.build();
三种存储的选型建议:
-
InMemoryEmbeddingStore:开发测试,重启就没了
-
Milvus:专业向量数据库,支持10亿级向量检索,适合大规模文档
-
Elasticsearch:团队已有ES集群,不想引入新组件,ES 8.x支持向量检索
5. 检索 + 生成
ContentRetriever负责从向量库中检索相关内容,RetrievalAugmentor把检索结果注入到Prompt中:
// 检索器配置
EmbeddingStoreContentRetriever retriever =
EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3) // 返回Top 3个最相似的分片
.minScore(0.65) // 相似度低于0.65的不要
.build();
maxResults和minScore这两个参数需要配合调优。maxResults=3意味着每次检索返回最多3个分片,如果你每个分片500字,那上下文大约1500字,加上Prompt和用户问题,很难超过大部分模型的上下文窗口(4K~8K)。minScore是相似度阈值,设太低会引入噪声,设太高可能什么都搜不到。我一般从0.6开始,根据实际效果调整。
完整链路总结
用户提问:"年假怎么申请?"
↓
问题向量化 → [0.12, -0.34, 0.56, …]
↓
Milvus/ES向量检索 → 找到Top 3相关分片
↓
分片1: "年假申请条件:入职满一年…"
分片2: "年假天数:1-10年5天,10-20年10天…"
分片3: "申请流程:OA系统→人事审批→…"
↓
拼接Prompt: "根据以下资料回答问题:{分片1}{分片2}{分片3}。问题:年假怎么申请?"
↓
大模型生成回答:"年假申请需满足入职满一年,天数根据工龄…"
完整 SpringBoot 项目 Demo
下面是一个可以直接跑起来的完整项目,包含 pom.xml 和所有代码:
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>rag-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<langchain4j.version>0.36.2</langchain4j.version>
</properties>
<dependencies>
<!– Spring Boot Web –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!– LangChain4j 核心 –>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!– 本地Embedding模型(无需联网) –>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!– OpenAI兼容接口(通义千问/DeepSeek都走这个) –>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!– 文档解析 –>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-document-parser-apache-tika</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>
</project>
application.yml
spring:
application:
name: rag-demo
# 大模型配置(这里用通义千问的OpenAI兼容接口)
langchain4j:
open-ai:
chat-model:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
api-key: ${DASHSCOPE_API_KEY:your-api-key}
model-name: qwen-plus
temperature: 0.1 # 知识库问答建议低温度,减少幻觉
max-tokens: 2000
timeout: 30s
配置类
@Configuration
public class RagConfig {
// 本地Embedding模型,无需联网
@Bean
public EmbeddingModel embeddingModel() {
return new AllMiniLmL6V2EmbeddingModel();
}
// 内存向量存储(生产环境替换为Milvus或ES)
@Bean
public EmbeddingStore<TextSegment> embeddingStore() {
return new InMemoryEmbeddingStore<>();
}
// 文档分片策略
@Bean
public DocumentSplitter documentSplitter() {
return DocumentSplitters.recursive(500, 100);
}
}
Controller
@RestController
@RequestMapping("/api/rag")
public class RagController {
private final RagAssistant assistant;
public RagController(RagAssistant assistant) {
this.assistant = assistant;
}
@PostMapping("/chat")
public ResponseEntity<Map<String, String>> chat(@RequestBody ChatRequest request) {
String answer = assistant.chat(request.question());
return ResponseEntity.ok(Map.of("answer", answer));
}
public record ChatRequest(String question) {}
}
文档初始化
@Component
public class DocumentInitializer {
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> embeddingStore;
private final DocumentSplitter splitter;
public DocumentInitializer(EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore,
DocumentSplitter splitter) {
this.embeddingModel = embeddingModel;
this.embeddingStore = embeddingStore;
this.splitter = splitter;
}
@PostConstruct
public void init() {
// 加载文档目录
Path docsPath = Path.of("docs");
if (!Files.exists(docsPath)) {
return;
}
try {
List<Document> documents = FileSystemDocumentLoader.loadDocuments(docsPath);
for (Document doc : documents) {
List<TextSegment> segments = splitter.split(doc);
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);
}
log.info("文档初始化完成,共加载 {} 个文档", documents.size());
} catch (Exception e) {
log.error("文档初始化失败", e);
}
}
}
线上高频故障复现
故障1:检索结果完全不相关
现象:用户问"年假怎么申请",返回的是"加班餐补标准"。
根因排查:
// 打印检索结果,看相似度分数
List<EmbeddingMatch<TextSegment>> matches = embeddingStore.findRelevant(
embeddingModel.embed("年假怎么申请?").content(), 5, 0.0);
for (EmbeddingMatch<TextSegment> match : matches) {
System.out.printf("相似度: %.3f, 内容: %s\\n",
match.score(), match.embedded().text());
}
通常原因有三个:
Embedding模型不合适:英文模型处理中文文本,语义理解偏差。换成中文模型(BgeSmallZh)立马解决。
分片太大:1000字一个分片,相关信息和大量无关信息混在一起,向量被稀释了。缩小到300-500字。
minScore设太高:设了0.85,但你的文档和问题本身语义距离就远,一个都搜不到。
解决方案:先不设minScore,打印Top 10的相似度分数,看实际分布,再定阈值。通常0.5-0.7是一个合理区间。
故障2:上下文超Token限制
现象:大模型返回截断的回答,或者直接报错context_length_exceeded。
根因:maxResults设了10,每个分片1000字,加上系统Prompt和用户问题,总Token超过模型上下文窗口。
解决方案:控制上下文总量,别超过模型上下文的70%。
// 方案1:限制检索数量
.maxResults(3)
// 方案2:限制每个分片大小
DocumentSplitters.recursive(300, 50) // 缩小分片
// 方案3:使用TokenWindow来截断(高级用法)
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(5)
.minScore(0.6)
.build();
// 在拼接Prompt时限制总Token数
String context = matches.stream()
.map(m -> m.embedded().text())
.collect(Collectors.joining("\\n\\n"));
// 如果上下文超过3000字,截断
if (context.length() > 3000) {
context = context.substring(0, 3000);
}
生产部署方案
1. 向量缓存层
Embedding计算是RAG链路中最耗时的环节。文档内容不变的情况下,没必要每次都重新计算向量。
@Component
public class EmbeddingCache {
private final Map<String, Embedding> cache = new ConcurrentHashMap<>();
public Embedding getOrCompute(String text, EmbeddingModel model) {
String key = DigestUtils.md5Hex(text); // 文本MD5做key
return cache.computeIfAbsent(key, k -> model.embed(text).content());
}
public void invalidate(String text) {
cache.remove(DigestUtils.md5Hex(text));
}
}
2. 分片大小调优
没有一个通用的分片大小。我在几个项目里的经验值:
|
技术文档/手册 |
300-500 |
50-100 |
知识点密集,太大容易混入无关内容 |
|
制度/法规 |
200-400 |
50-80 |
条款之间相互独立,小分片更精准 |
|
对话记录/工单 |
500-800 |
100-150 |
语义连贯,需要完整上下文 |
|
长篇小说/报告 |
800-1000 |
150-200 |
叙事需要连贯性 |
3. 检索TopK调优
// 动态调整TopK的策略
public class AdaptiveTopK {
public static int compute(int contextWindow, int avgChunkTokens) {
// 预留50%空间给Prompt和回答
int availableTokens = (int)(contextWindow * 0.5);
return Math.max(1, availableTokens / avgChunkTokens);
}
}
// 使用示例:qwen-plus上下文8K,每个分片约500 tokens,预留50%
// availableTokens = 4000, avgChunkTokens = 500 → TopK = 8
隐性坑点
坑1:LangChain4j版本兼容性
LangChain4j更新非常快,0.35和0.36的API差异能让你编译都过不了:
| DocumentSplitter.recursive(500, 100) | DocumentSplitters.recursive(500, 100) |
| HuggingFaceTokenizer | OpenAiTokenizer |
| ChatMemoryProvider | ChatMemoryProvider
(接口方法签名变了) |
避坑方案:在pom.xml里用<properties>统一管理版本号,不要混用不同版本的依赖。
坑2:Embedding模型选择对精度的影响
别以为Embedding模型都一样。同一段中文文本,不同模型生成的向量差距巨大:
// 测试代码:计算两个Embedding模型对同一对文本的相似度差异
public static void compareEmbeddingModels() {
EmbeddingModel enModel = new AllMiniLmL6V2EmbeddingModel(); // 英文模型
EmbeddingModel zhModel = new BgeSmallZhEmbeddingModel(); // 中文模型
String q = "如何申请年假?";
String doc = "年假申请需要填写OA表单,经部门经理审批后生效";
double enScore = cosineSimilarity(enModel.embed(q), enModel.embed(doc));
double zhScore = cosineSimilarity(zhModel.embed(q), zhModel.embed(doc));
System.out.printf("英文模型相似度: %.2f, 中文模型相似度: %.2f\\n", enScore, zhScore);
// 典型输出:英文模型相似度: 0.42, 中文模型相似度: 0.89
}
结论:处理中文文档,一定要用中文优化的Embedding模型(BgeSmallZh、text2vec-large-chinese、通义千问Embedding)。
坑3:InMemoryEmbeddingStore内存泄漏
// 错误:每次查询都往store里加数据,内存无限增长
@PostMapping("/add")
public void addDoc(@RequestBody String text) {
TextSegment segment = TextSegment.from(text);
Embedding embedding = embeddingModel.embed(text).content();
embeddingStore.add(embedding, segment); // 只增不删,迟早OOM
}
// 正确:加上去重逻辑和容量限制
@PostMapping("/add")
public void addDoc(@RequestBody String text) {
String docId = DigestUtils.md5Hex(text);
// 检查是否已存在
if (embeddingStore.getAll().stream().anyMatch(e -> e.id().equals(docId))) {
return;
}
TextSegment segment = TextSegment.from(text, Metadata.from("id", docId));
Embedding embedding = embeddingModel.embed(text).content();
embeddingStore.add(docId, embedding, segment);
}
坑4:文档不更新,知识库成"信息孤岛"
RAG知识库不会自动更新。文档改了,向量库里的旧数据还在。需要建立文档版本管理机制:
@Component
public class DocumentSyncService {
private final Map<String, String> docVersions = new ConcurrentHashMap<>();
@Scheduled(fixedDelay = 300_000) // 每5分钟检查一次
public void syncDocuments() {
Path docsPath = Path.of("docs");
try (var files = Files.list(docsPath)) {
files.forEach(file -> {
String md5 = DigestUtils.md5Hex(Files.readAllBytes(file));
String oldMd5 = docVersions.get(file.getFileName().toString());
if (!md5.equals(oldMd5)) {
// 文档有更新,删除旧向量,重新索引
embeddingStore.removeAll(s -> s.metadata().getString("file")
.equals(file.getFileName().toString()));
reindexDocument(file);
docVersions.put(file.getFileName().toString(), md5);
}
});
}
}
}
运维监控方案
1. 检索质量监控
@Component
public class RagMetrics {
private final MeterRegistry meterRegistry;
// 记录每次检索的平均相似度
public void recordRetrievalScore(double score) {
meterRegistry.summary("rag.retrieval.score").record(score);
}
// 记录检索耗时
public void recordRetrievalLatency(long millis) {
meterRegistry.timer("rag.retrieval.latency").record(millis, MILLISECONDS);
}
// 记录检索结果为空的情况
public void recordEmptyRetrieval() {
meterRegistry.counter("rag.retrieval.empty").increment();
}
}
2. 关键告警指标
-
**检索结果为空率 > 20%**:minScore设太高或者Embedding模型不合适
-
检索平均耗时 > 500ms:向量库索引需要重建,或者数据量太大需要扩容
-
**大模型返回被截断率 > 10%**:上下文超了,调小maxResults或分片大小
-
Embedding计算耗时 > 200ms:本地模型可能CPU不足,考虑换API模型
3. 日志规范
@Slf4j
public class RagLogger {
public static void logQuery(String question, List<EmbeddingMatch<TextSegment>> matches,
String answer, long costMs) {
log.info("RAG查询 | 问题: {} | 检索到{}条 | 最高相似度: {:.3f} | 耗时: {}ms",
question,
matches.size(),
matches.isEmpty() ? 0 : matches.get(0).score(),
costMs);
if (matches.isEmpty()) {
log.warn("RAG检索为空 | 问题: {} | 请检查minScore阈值和Embedding模型", question);
}
}
}
写在最后
RAG不是什么高深技术,说白了就是"搜索+生成"。后端程序员搞这个有天然优势:数据库、缓存、API设计这些基本功全都能复用。LangChain4j把整个链路封装得足够好,50行代码就能跑通一个Demo。
Java+AI落地实战生产级的能力,完整视频地址:https://edu.csdn.net/course/detail/41307
但真正上生产时,分片策略、相似度阈值、Embedding模型选型这些细节才是决定效果的关键。建议先用本文的Demo跑通自己的数据,再用"故障复现"部分的方法检查效果,最后根据"生产部署方案"做优化。





