LangChain4j RAG 实战:Java 后端如何把本地文档接入 Embedding 检索链路

LangChain4j RAG 实战:Java 后端如何把本地文档接入 Embedding 检索链路

很多 Java 后端第一次做 RAG,会先被一堆 Python 示例劝退:LangChain、LlamaIndex、向量库、Embedding、Retriever、Prompt 拼接......概念都懂,但真正落到 Java 项目里,经常不知道从哪一步开始。

这篇文章不讲大而全的企业知识库,只做一个最小可运行链路:

复制代码
本地文档
  -> 文档加载
  -> 文本切分
  -> Embedding 向量化
  -> 存入向量库
  -> 用户提问
  -> 相似度检索
  -> 拼接上下文
  -> 调用大模型回答

目标是让 Java 后端工程师先跑通一条 RAG 主链路,再理解 demo 到生产之间还差什么。

说明:下面代码以 LangChain4j 官方 RAG / EmbeddingStore / DocumentLoader / AiServices 相关能力为基础,示例更偏工程骨架。不同 LangChain4j 版本的类名、构造器和包名可能略有差异,实际项目请以当前版本官方文档和 IDE 提示为准。

1. RAG 链路到底在解决什么问题

普通大模型调用一般是:

复制代码
用户问题 -> Prompt -> 大模型 -> 回答

但如果问题依赖本地文档,比如:

  • 公司内部接口文档;
  • 项目 README;
  • 产品说明书;
  • 运维手册;
  • 历史 FAQ;

模型本身并不知道这些内容。直接问模型,它只能猜。

RAG 的做法是:先从本地资料里找出和问题最相关的片段,再把这些片段作为上下文塞进 Prompt,让模型基于上下文回答。

可以把它理解成:

步骤 作用
文档加载 把本地 md/txt/pdf 等资料读进来
文本切分 避免一整篇文档太长,切成小片段
Embedding 把文本片段转成向量
向量检索 根据用户问题找相似片段
Prompt 拼接 把检索结果放进模型上下文
LLM 回答 基于资料回答,而不是凭空编

注意:RAG 不是让模型"记住"你的文档,而是每次提问时临时检索相关上下文。

2. Maven 依赖示例

LangChain4j 模块比较多,实际依赖会随你选择的模型供应商、向量库和文档类型变化。最小 demo 可以从下面几类依赖开始:

复制代码
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-bom</artifactId>
            <version>请替换为当前稳定版本</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- LangChain4j 核心能力 -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j</artifactId>
    </dependency>

    <!-- OpenAI 兼容模型示例,也可以替换成你自己的模型供应商 -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-open-ai</artifactId>
    </dependency>

    <!-- 内存向量库,适合 demo,不适合生产持久化 -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-embeddings</artifactId>
    </dependency>
</dependencies>

如果你要接 PostgreSQL + pgvector、Milvus、Qdrant、Elasticsearch 等,需要再引入对应 integration 模块。

3. 准备一份本地文档

先不要一上来接复杂知识库。新建一个最简单的资料文件:

复制代码
src/main/resources/docs/order-api.md

示例内容:

复制代码
# 订单 API 说明

## 创建订单

POST /api/orders

请求字段:
- userId:用户 ID
- skuId:商品 SKU
- quantity:购买数量

返回字段:
- orderId:订单 ID
- status:订单状态

## 查询订单

GET /api/orders/{orderId}

如果订单不存在,返回 404。

后面的问题就围绕这份文档提问。

4. 文档加载与切分

RAG 的第一步是把文档读出来,再切成适合检索的小片段。

伪代码结构如下:

java 复制代码
`Path docPath = Paths.get("src/main/resources/docs/order-api.md");

Document document = FileSystemDocumentLoader.loadDocument(docPath);

DocumentSplitter splitter = DocumentSplitters.recursive(
        500,   // 每个片段最大字符数
        100    // 片段之间保留一点重叠,避免上下文断裂
);

List<TextSegment> segments = splitter.split(document);

System.out.println("segment size = " + segments.size());
`

切分参数没有绝对标准。

参数 过小的问题 过大的问题
chunk size 语义不完整,检索片段太碎 单个片段太长,占用上下文
overlap 上下文容易断 重复内容太多,成本升高

我的建议是:先用 300-800 字符做 demo,等真实文档接入后再根据命中效果调。

5. Embedding 与向量存储

接下来把每个文本片段转成向量,并存入 EmbeddingStore。

demo 阶段可以先用内存向量库:

java 复制代码
`EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
        .apiKey(System.getenv("OPENAI_API_KEY"))
        .modelName("text-embedding-3-small")
        .build();

EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
        .documentSplitter(splitter)
        .embeddingModel(embeddingModel)
        .embeddingStore(embeddingStore)
        .build();

Document document = FileSystemDocumentLoader.loadDocument(docPath);
ingestor.ingest(document);
`

这一步做完以后,本地文档就被转成了可以检索的向量片段。

内存向量库只适合验证链路,因为应用重启后数据会丢。生产环境至少要考虑:

  • 向量持久化;
  • 文档增量更新;
  • 文档版本管理;
  • 删除文档后的向量清理;
  • 向量库备份和权限控制。

6. 用户提问时如何检索

用户问题也需要转成向量,然后去 EmbeddingStore 里查相似片段。

示例结构:

java 复制代码
`String question = "创建订单接口需要哪些请求字段?";

Embedding queryEmbedding = embeddingModel.embed(question).content();

EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
        .queryEmbedding(queryEmbedding)
        .maxResults(3)
        .minScore(0.6)
        .build();

EmbeddingSearchResult<TextSegment> result = embeddingStore.search(request);

List<TextSegment> matchedSegments = result.matches().stream()
        .map(EmbeddingMatch::embedded)
        .toList();
`

这里有两个参数很关键:

参数 作用 建议
maxResults 最多返回几个片段 demo 可先设 3-5
minScore 相似度低于多少就不要 需要结合数据调,不要盲目写死

如果 minScore 太低,模型会拿到很多无关资料;如果太高,可能检索不到任何上下文。

7. 拼接 Prompt 调用模型

检索到片段后,就可以把它们拼成上下文,让模型基于资料回答。

java 复制代码
`String context = matchedSegments.stream()
        .map(TextSegment::text)
        .collect(Collectors.joining("\n---\n"));

String prompt = """
你是一个 Java 项目文档助手。
请只根据下面的资料回答问题。
如果资料中没有答案,请回答:文档中没有找到相关信息。

资料:
%s

问题:
%s
""".formatted(context, question);

ChatLanguageModel chatModel = OpenAiChatModel.builder()
        .apiKey(System.getenv("OPENAI_API_KEY"))
        .modelName("gpt-4o-mini")
        .build();

String answer = chatModel.generate(prompt);

System.out.println(answer);
`

理想输出类似:

复制代码
创建订单接口需要 userId、skuId 和 quantity 三个请求字段。

这段 Prompt 里最重要的不是语气,而是约束:

复制代码
如果资料中没有答案,请回答:文档中没有找到相关信息。

否则 RAG 很容易变成"检索了资料,但模型继续自由发挥"。

8. 用 AiServices 封装成接口

如果不想每次手工拼 Prompt,可以用 LangChain4j 的 AiServices 思路,把问答能力封装成 Java 接口。

示例:

java 复制代码
`interface DocAssistant {
    String chat(String question);
}
`

然后把 ChatModel、EmbeddingStore、ContentRetriever 等组件组合起来:

java 复制代码
`ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(embeddingStore)
        .embeddingModel(embeddingModel)
        .maxResults(3)
        .minScore(0.6)
        .build();

DocAssistant assistant = AiServices.builder(DocAssistant.class)
        .chatLanguageModel(chatModel)
        .contentRetriever(retriever)
        .build();

String answer = assistant.chat("查询订单接口不存在时返回什么?");
`

这个封装更接近实际项目里的写法:Controller 调 Service,Service 调 Assistant,Assistant 背后完成检索和模型调用。

9. Spring Boot Controller 示例

可以把它封装成一个简单接口:

java 复制代码
`@RestController
@RequestMapping("/rag")
public class RagController {

    private final DocAssistant assistant;

    public RagController(DocAssistant assistant) {
        this.assistant = assistant;
    }

    @PostMapping("/ask")
    public Map<String, String> ask(@RequestBody Map<String, String> body) {
        String question = body.get("question");
        String answer = assistant.chat(question);
        return Map.of("answer", answer);
    }
}
`

测试命令:

复制代码
curl -X POST http://localhost:8080/rag/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"创建订单接口需要哪些请求字段?"}'

预期返回:

复制代码
{
  "answer": "创建订单接口需要 userId、skuId 和 quantity 三个请求字段。"
}

如果问一个文档里没有的问题:

复制代码
curl -X POST http://localhost:8080/rag/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"订单接口支持微信支付回调吗?"}'

理想回答应该是:

复制代码
文档中没有找到相关信息。

如果模型开始编支付回调字段,说明你的 Prompt 约束或检索结果处理还不够严。

10. demo 到生产还差什么

最小 RAG demo 跑通以后,不要急着说"知识库完成了"。生产至少还要补这些能力:

生产问题 说明
文档同步 文档新增、修改、删除后,向量如何增量更新
权限隔离 不同用户能不能检索同一批资料
引用来源 回答里最好带文档名、章节、片段来源
召回质量 检索不到、检索错、召回过多都要评估
成本控制 Embedding、向量库、Chat Model 都有成本
可观测性 需要记录 query、命中片段、score、耗时
安全边界 不要把敏感文档随便进入 Prompt

尤其是权限问题,很多 demo 会忽略。

如果你的向量库里混了多部门资料,但检索时没有按用户权限过滤,那么 RAG 可能会变成数据泄露入口。

11. 常见坑

1)文档切得太碎

切得太碎,模型拿到的片段可能只有半句话,回答就会缺上下文。

解决办法:调大 chunk size,或者增加 overlap。

2)文档切得太大

切得太大,检索结果虽然完整,但会占用大量上下文,模型容易抓不到重点。

解决办法:先按标题、段落、章节切,再做递归切分。

3)只看回答,不看命中片段

RAG 调试时一定要打印命中片段和 score。

java 复制代码
`for (EmbeddingMatch<TextSegment> match : result.matches()) {
    System.out.println("score = " + match.score());
    System.out.println(match.embedded().text());
}
`

如果命中片段本身就错了,后面 Prompt 写得再好也救不回来。

4)没有"不知道"机制

如果资料里没有答案,模型应该明确说没有找到,而不是自由发挥。

这需要在 Prompt、检索阈值、后处理里一起约束。

5)demo 用内存向量库,生产也照搬

InMemoryEmbeddingStore 适合学习和验证链路,不适合生产。

生产建议根据场景选择 pgvector、Qdrant、Milvus、Elasticsearch 等,并补充备份、权限和监控。

12. 小结

用 Java 做 RAG,不一定要先引入复杂平台。最小链路其实就五件事:

复制代码
加载文档 -> 切分文本 -> Embedding -> 向量检索 -> 拼 Prompt 回答

LangChain4j 的价值在于,它把这些步骤都放回 Java 工程体系里:你可以用熟悉的 Maven、Spring Boot、Controller、Service 和配置管理方式,把 AI 检索问答接进后端项目。

但要记住:跑通 demo 只是第一步。

真正上线前,重点不是"模型会不会回答",而是:

  • 检索命中的资料对不对;
  • 没有资料时会不会拒答;
  • 用户权限有没有过滤;
  • 文档更新后向量有没有同步;
  • 回答是否可追溯到原文。

如果这几个问题没解决,RAG 很容易从"知识库助手"变成"更会编的搜索框"。

对 Java 后端来说,建议先用 LangChain4j 把这条最小链路跑通,再逐步替换为持久化向量库、引入权限过滤和可观测性。这样比一开始就堆完整平台更稳。

如果你正在做 Java AI 工程落地,可以先从这类最小 RAG demo 开始,把每个环节的输入、输出和失败边界都看清楚。

相关推荐
许彰午2 小时前
17_synchronized关键字深度解析
java·开发语言
真实的菜3 小时前
微服务注册配置中心终极选型:2026指南
微服务·云原生·架构
Xzh04233 小时前
AI Agent 学习路线(Java 后端方向)
java·人工智能·学习
艾利克斯冰4 小时前
Java 设计模式-行为型模式(更新中)
java·开发语言·设计模式
倒霉蛋小马4 小时前
Java新特性:record关键字
java·开发语言
HavenlonLabs4 小时前
硬件 + SaaS 产品的工程化路径:从系统架构、PCB 设计到工程样机
网络·安全·架构·系统架构·安全架构
折哥的程序人生 · 物流技术专研4 小时前
《Java 100 天进阶之路》第95篇:消息队列基础(RocketMQ/Kafka)(2026版)
java·面试·kafka·rocketmq·java-rocketmq·求职招聘
budingxiaomoli4 小时前
Spring日志
java·开发语言
IT空门:门主4 小时前
Spring 注入三剑客:@Resource、@Autowired、@RequiredArgsConstructor 到底该用哪个?
java·后端·spring