一个完整的 RAG 流程

文档加载 → 文档拆分 → 文本向量化 → 写入向量库 → 基于向量做语义检索

今天我们就用 Java + LangChain4j + 通义千问的向量模型,从零跑通这一整条链路,而且搞两个版本:

  • 内存版:用 InMemoryEmbeddingStore,写个测试就能跑通;
  • Chroma 版:用 ChromaEmbeddingStore,连上真正的向量数据库。

你学完之后,完全可以换成你们公司的 FAQ、退改签规则、产品手册,搭一个自己的“公司知识库问答机器人”。


一、先把任务说清楚:这节课到底要干嘛?

这节课,我们要做到三件事:

  1. 听得懂:
    搞清楚 RAG 这条链路上都有哪些步骤,每一步是干啥的、为什么要这样设计。
  2. 写得出:
    跟着我,一行行写完并跑通两个测试:
    • RagFlowTest:内存版向量库
    • ChromaRagFlowTest:Chroma 版向量库
  1. 迁得动:
    明白“内存版 → Chroma 版”怎么迁移,只改很少的代码,就能从 Demo 走向可落地的架构。

二、先把工具箱打开:本项目里有哪些主角?

  • RagFlowTest(非常重要)
    InMemoryEmbeddingStore<TextSegment> 跑完完整链路:
    • airline_policy.txt 读文档;
    • 拆成一段一段的 TextSegment
    • 全部向量化后塞进内存向量库;
    • 问一句“取消经济舱机票要扣多少钱?”;
    • 看看最相关的片段是不是“经济舱退票”那段,并断言。
  • ChromaRagFlowTest
    RagFlowTest 几乎一模一样,只是把向量库换成了 ChromaEmbeddingStore<TextSegment>,连的是真实 Chroma 服务。

理论打底:RAG 整条链路长什么样?

先用一个简单的流程图,把 RAG 画出来:

我们这节课重点关注 左半边 + 中间

  • 文档怎么拆?
  • 向量怎么算?
  • 向量怎么存?
  • 检索是怎么“按语义”而不是按关键词?

真正“问大模型”,是下一步 RAG 的“G”(Generation)部分,这里先不展开。


内存版完整 RAG:跟着 RagFlowTest 一步走

1 创建向量模型:QwenEmbeddingModel

setUp() 里:

String apiKey = System.getenv("QWEN_API_KEY");
if (apiKey == null || apiKey.trim().isEmpty()) {
    fail("环境变量 QWEN_API_KEY 未设置,无法执行 RAG 测试");
}
// 👇 这一行,就创建好了向量化模型!
embeddingModel = QwenEmbeddingModel.builder()
        .apiKey(apiKey)
        .modelName(QwenModelName.TEXT_EMBEDDING_V3)
        .build();

2 文档加载:把 txt 读成 Document

测试里第一步:

Document document = ClassPathDocumentLoader.loadDocument(
        "docs/airline_policy.txt",
        new TextDocumentParser()
);

可以理解为:

这一行,把“文件”变成了“内存里的文档对象”,后面所有处理都基于这个对象进行。


3 文档拆分:recursive splitter

紧接着:

int maxSegmentSize = 300;
int overlap = 50;
DocumentSplitter splitter = DocumentSplitters.recursive(maxSegmentSize, overlap);
List<TextSegment> segments = splitter.split(document);

assertFalse("拆分结果不能为空", segments.isEmpty());

DocumentSplitters.recursive(...)优先按结构边界拆(句子、段落),不够再按长度强拆。
这比“纯按行”“纯按句”要鲁棒很多,适合作为默认策略。

拆完你会得到一个 List<TextSegment>,每个 TextSegment 就是一小段可检索的知识块。


4 向量化 + 内存入库:InMemoryEmbeddingStore

现在我们要给每个 TextSegment 发一张“语义身份证”,并存进一个“内存向量仓库”里:

InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);

这里有几个关键点:

  • embedAll(segments)
    一次性帮你把所有片段都向量化,比循环 embed 更高效,也更优雅。
  • InMemoryEmbeddingStore<TextSegment>
    是 LangChain4j 自带的内存向量库实现,不持久化,进程挂了就没了。
  • addAll(embeddings, segments)
    把“向量 + 原文片段”成批写进去。

这一步结束后,你已经有了一个可检索的“政策知识库”,只不过它还在内存里。


5 用户提问:Query 也要向量化

现在我们来假装一个真实用户,问一句话:

String query = "取消经济舱机票要扣多少钱?";
Embedding queryEmbedding = embeddingModel.embed(query).content();

这一步的本质:

把用户问题也变成同一个语义空间里的向量,
这样才能跟文档片段“在同一个坐标系里”比较距离。


6 在内存向量库里做检索

检索代码是这样的:

EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
        .queryEmbedding(queryEmbedding)
        .maxResults(1)
        .minScore(0.5)
        .build();

EmbeddingSearchResult<TextSegment> result = embeddingStore.search(request);
EmbeddingMatch<TextSegment> topMatch = result.matches().get(0);

System.out.println("用户问题: " + query);
System.out.println("最相关片段相似度: " + topMatch.score());
System.out.println("最相关片段内容: " + topMatch.embedded().text());

解释一下参数:

  • queryEmbedding:就是刚才问题的向量;
  • maxResults(1):只要最相关的一条;
  • minScore(0.5):如果相似度太低(< 0.5),就直接不给结果了,宁可说“查不到”。

search 返回的是一个 EmbeddingSearchResult,里面有:

  • matches():一个 EmbeddingMatch<TextSegment> 列表;
  • 每个 EmbeddingMatch 里有:
    • score():相似度;
    • embedded():原始 TextSegment

至此,一个完整的 RAG 流程(内存版)就打通了。

Chroma 版完整 RAG:把“Demo”迁到“向量数据库”

刚才我们炖的是“小锅菜”:一切都在内存里。
现在我们要上大菜:把向量存进 Chroma,变成一个可持久化、可共享的向量库

对应的测试类:

src/test/java/com/xiaobian/ChromaRagFlowTest.java

1 构建 ChromaEmbeddingStore

setUp() 里,我们这样初始化向量库:

embeddingStore = ChromaEmbeddingStore.<TextSegment>builder()
        .apiVersion(V2)
        .baseUrl("http://localhost:8000")
        .collectionName("flight_policies_test")
        .logRequests(false)
        .logResponses(false)
        .build();

// 为保证测试可重复,清空该 collection
embeddingStore.deleteAll();

2 加载 + 拆分 + 向量化:完全照抄内存版

这三步在 ChromaRagFlowTest 里几乎没变:

Document document = ClassPathDocumentLoader.loadDocument(
        "docs/airline_policy.txt",
        new TextDocumentParser()
);

DocumentSplitter splitter = DocumentSplitters.recursive(300, 50);
List<TextSegment> segments = splitter.split(document);

List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);

你会发现:除了向量库类型不一样,代码写法是一样的
这就是我们一开始就选用 EmbeddingStore 统一抽象的好处。

3 在 Chroma 里做检索

检索逻辑也几乎一模一样,只是多打印了所有结果,方便你观察:

String query = "取消经济舱机票要扣多少钱?";
Embedding queryEmbedding = embeddingModel.embed(query).content();

EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
        .queryEmbedding(queryEmbedding)
        .maxResults(segments.size())
        .minScore(0.0)
        .build();

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

System.out.println("[Chroma] 用户问题: " + query);
for (int i = 0; i < matches.size(); i++) {
    EmbeddingMatch<TextSegment> m = matches.get(i);
    System.out.printf("[Chroma] #%d score=%.4f%n", i + 1, m.score());
    System.out.println(m.embedded().text());
    System.out.println("--------------------------------------------------");
}

EmbeddingMatch<TextSegment> topMatch = matches.get(0);
String topText = topMatch.embedded().text();

断言同样是检查:

assertTrue("[Chroma] 最相关片段应包含 '经济舱'", topText.contains("经济舱"));
assertTrue("[Chroma] 最相关片段应包含 '退票'", topText.contains("退票"));
assertTrue("[Chroma] 相似度应大于 0.5,当前为 " + topMatch.score(), topMatch.score() > 0.5);

如果一切正常,你会发现 Chroma 版和内存版在行为上是一致的

  • 同样的问题 → 命中同一段政策;
  • 分数可能略有浮动,但不会离谱。

实战作业:把自己的文档搬进来

  • 替换 airline_policy.txt 为你自己的 FAQ / 手册;
  • 跑一遍 RagFlowTest,看看能不能命中你想要的条款。

收个尾:这节课你真正学到啥?

咱们最后 30 秒复盘一下:

  • 你不再只是“调个大模型接口”,而是能:
    • 把文档变成 Document
    • 把文档拆成一块一块的 TextSegment
    • QwenEmbeddingModel 把每一块变成向量;
    • InMemoryEmbeddingStore / ChromaEmbeddingStore 管理这些向量;
    • search 做语义检索,而不是关键词匹配。
  • 你跑通了两个完整测试:
    • 内存版 RagFlowTest
    • Chroma 版 ChromaRagFlowTest

下一节课,我们就在这个基础上,把检索到的片段塞进对话模型 Prompt 里
让大模型不再“胡说八道”,而是“有据可依”地回答问题。

这才是真正的:

“让大模型帮你干活,而不是陪你聊天。”

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐