RAG 基础

RAG 的存在是为了什么

RAG 就是 检索增强生成(Retrieval Augmented Generation)

每当将大模型应用于实际业务场景时发现,通用的基础大模型基本无法满足实际业务需求,主要有以下几方面原因:

  • 知识的局限性:我们都知道,LLM 的知识仅限于其训练所用的数据。如果您想让 LLM 了解特定领域的知识或专有数据,可以使用 RAG。
  • 幻觉问题:所有的深度学习模型的底层原理都是基于数学概率,模型输出实质上是一系列数值运算,在大模型自身不具备某一方面的知识或不擅长的任务场景的时候,会产生严重的幻觉,需要外部知识库的支撑。
  • 数据安全性:对于企业来说,数据安全至关重要,没有企业愿意承担数据泄露的风险,尤其是大公司,没有人将私域数据上传第三方平台进行训练会推理。

而 RAG 就是解决上述问题的有效方案。

RAG = 检索技术 + LLM 提示。

这样感觉不太直观,直观的说法

RAG 是在将提示发送给 LLM 之前,从您的数据中找到并注入相关信息片段的方法。

通过这种方式,LLM 将获得相关信息,并能够利用这些信息进行回复,从而降低幻觉的发生概率。

那么,RAG 是如何工作的呢?目前最通用来说的过程如下

  • 索引阶段
    • 文档解析与分块:将私有知识库文档按语义边界或固定长度切分成小块(chunk),块与块之间常用重叠滑动窗口防止上下文割裂。
    • 向量化与存储:用嵌入模型为每个块生成向量,存入向量数据库(如 Milvus)
  • 检索阶段
    • 查询处理:原始用户问题可能被改写、扩展或分解,以提高召回率。
    • 多路召回:可能同时执行全文/向量/混合搜索,获取多组候选块。
    • 重排序:用更精准但稍慢的模型对候选块重新打分,选出最相关的 top-k 上下文。
  • 增强生成阶段
    • 提示构建:将检索到的上下文与用户问题、系统指令按模板拼装,控制上下文窗口并明确告知模型基于提供的资料作答。
    • 生成与引用:LLM 依据增强后的提示生成答案,并可要求其提供来源引用,便于追溯核实。

信息检索是 RAG 中最重要的一部分,目前最流行的方法有:

  • 全文(关键词)搜索:此方法使用 TF-IDF 和 BM25 等技术,通过将查询(例如用户的提问)中的关键词与文档数据库进行匹配来搜索文档。它根据关键词在每个文档中的频率和相关性对结果进行排名。
  • 向量搜索,也称为“语义搜索”:使用嵌入模型将文本文档转换为数字向量。然后,它根据查询向量和文档向量之间的余弦相似度或其他相似度/距离度量来查找和排名文档,从而捕捉更深层次的语义含义。
  • 混合搜索:结合多种搜索方法(例如全文 + 向量)通常可以提高搜索的有效性。

而且 RAG 分为很多种类,之前在 Neo4j 那边我们就提到过图 RAG 和 结构化数据 RAG,其他的还有个模块化 RAG

本文主要关注向量搜索。全文和混合搜索目前仅由 Azure AI Search 集成支持,详情请参见 AzureAiSearchContentRetriever。

高级 RAG 的实现过程和架构

RAG的架构如图中所示,在架构中,检索+生成,前者主要是利用向量数据库的高效存储和检索能力,召回目标知识;后者则是利用大模型和Prompt工程,将召回的知识合理利用,生成目标答案。

img

标准的 RAG 流程是这样的

将文本分块,然后使用一些 Transformer Encoder 模型将这些块嵌入到向量中,将所有向量放入索引中,最后创建一个 LLM 提示,告诉模型根据搜索步骤中找到的上下文回答用户的查询。

在运行时,通过使用同一编码器模型对用户的查询进行向量化,然后搜索该查询向量的索引,找到 top-k 个结果,从数据库中检索相应的文本块,并将它们作为上下文输入到 LLM 提示中。

但是现在的 RAG 很明显要复杂的很多,基于上述的标准 RAG,添加了很多内容,现代 RAG 的思想是通过一整套检索工程体系,尽可能构造出最适合 LLM 推理的上下文

因为实际上,如何让 LLM 获得真正相关、完整、可信、可推理的上下文,通过检索出够好的东西来解决问题。所以现代 RAG 的演进方向几乎全部围绕提高 Recall(召回率) 和 Precision(准确率)展开的。

通俗的来说

  • 召回率就是该找的都找回来了吗?召回率越高,漏掉的就越少。
  • 精准率是找回来的都对了吗?
  • 准确率是找到的内容,你认为它有用,但是实际上真的有用吗?
  • 分块

    • 对数据进行分块,将初始文档拆分为一定大小的块,而不会失去其含义。

      实际上在现代 RAG 里面,Chunking 这步决定了整个系统半成以上的效果。因为向量数据库根本不认识原始文档。为了不会失去其含义,有这样的几种分块方法

    • Fixed Chunk 固定大小分块:这就是按 Token 直接切。语义容易断裂。
    • Sliding Window 滑动窗口分块:这种切法会保留重叠区域。确保上下文不会被截断。
    • Semantic Chunking 语义分块:最常见的现代方案。不是按 Token 数切。而是按语义边界来切。或者说整个内容章节作为一个 Chunk。
  • 向量化:

    • 分块中我们把原文分成了优美、高内聚的块,下一步就是用向量化模型将它们处理成高维空间中的坐标点。语义越接近的文本,它们在空间中的几何距离就越近。这是决定检索召回率的命门。
      • 微调嵌入模型:这是现代 RAG 的狠活。你可以利用自己业务中高质量的 查询-正确文档 对,对通用嵌入模型进行微调,让它学会把你们行业特有的口语化问题,直接映射到正确文档块的向量附近。这能同时、大幅地提升召回率和精准率。
      • 一般情况下,你选择的块大小,必须适配你使用的嵌入模型。有的模型擅长理解短文本,有的对大段文字效果更好,两者需要匹配。
  • 索引的搜索

    光有向量还不够,生产级系统必须用多种索引和搜索策略

    • 向量存储索引

      • 这是基础。利用 FAISS、Milvus 等库为向量建立近似最近邻(ANN)索引
    • 分层索引

      • 当文档很长、很多时,先在概要层进行粗筛,再深入到细节层。
        • 摘要索引:在入库时,用大模型为每个文档生成摘要并向量化。搜索时,先搜摘要,找到相关文档后,再把该文档下的所有细节块送给大模型。这完美解决了大海捞针问题,大幅提升了长文档的召回率。
        • 句子窗口检索:入库时,只把单个句子做向量化(保证极高精准率)。但在召回时,把匹配句子的前后各 k 句作为一个扩展窗口,一并取回。这样既保证了检索精准,又还给了大模型充足的上下文,非常精巧。
    • 假设性问题和 HyDE

      • 这是解决问题-答案 语义存在鸿沟的经典方案。用户的查询往往是简短的、疑问式的,而你库里存的是陈述式的文档。这两者的向量距离可能天生较远。
        • HyDE,假设文档嵌入,搜索时,先让大模型根据用户问题,凭空编造一个假设性的答案文档。然后,用这个假设答案的向量去检索,而不是用原始问题向量。因为假设答案的表述风格和真实文档更接近,所以匹配度会奇高,能显著提升召回率。这种方式在提示词工程中也有类似的实现。
        • 反向 HyDE,也就是在入库时,让大模型为每个块生成几个它能够回答的预设问题。搜索时,用用户问题的向量去匹配这些预设问题的向量。这同样是拉平语义空间的强大技巧。
    • 内容增强

      • 直接输出的内容可能不够我们的需要或者不够好,一般情况下在 Agent 中我们需要调整
        • 添加上下文:在每个块前面,挂上它所属的文档标题、章节标题、关键术语等元数据。
        • 实体提取与问答:用大模型或 NLP 模型为每个块提取关键实体、主题标签,甚至生成摘要。这些增强信息可以作为元数据过滤条件,或单独向量化参与检索,提升精准度。
    • 混合搜索

      • 向量搜索的缺陷就是对关键词不敏感。它懂语义不懂实体。

        所以说,我们同时执行向量搜索(语义相似) 和 BM25 等关键词搜索(精确词频),然后将两组结果通过 RRF(倒数排名融合)等算法合并排序。这样能够准确提升召回率和精准率。

  • 重排(reranking)和过滤(filtering)

    • 以上步骤是为了提高召回率,是相对初筛的结果,重排阶段的目标就是精准率,从粗筛的结果里精挑细选出最精华的几个。
      • 重排就是用更精密、但计算更慢的模型,对候选列表重新打分。一般是把 查询-文档 当作一个对立的键值对一起输入模型进行联合推理,输出一个深度语义相关性分数。一般是先用向量搜索粗筛出 Top 50,送入重排模型,精选出最相关的 Top 5 送给大模型。
      • 过滤就是基于元数据的硬性约束,保证结果可信且相关。
        • 前置过滤:在搜索之前就根据用户权限、时间范围、文档类等各种用户的和外部的条件,直接过滤掉无关数据
        • 后置过滤:在重排之后,用模型判断“这个块真的能支持回答用户问题吗?这些内容确定是真实存在而且可推理有依据的吗?”,过滤掉那些虽然语义相关但信息不充分的块,防止大模型生编乱造。
  • 查询转换

    • 用户的一个问题,视角往往是单一的。查询转换就是用 LLM 的能力来扩展问题,提高召回率。
      • 方式有很多种,例如:将“苹果牛逼的笔记本”这种口语化查询,改写成更正式的“搜索苹果公司高性能笔记本电脑的评价与性能参数,为用户推荐相关产品”。
      • 而且,将一个复杂问题自动拆解成多个简单的子问题,更常见。例如:“对比GPT-4和Claude-3的编程能力”,可拆解为“GPT-4编程能力评测”、“Claude-3编程能力评测”、“两者编程能力对比”等,分别检索,最后合并上下文。
    • Step-Back Prompting:问题很具体时,先生成一个更宏观、更基础的备用问题来检索背景知识。
  • 响应合成

    • 拿到了经过层层筛选的、高精度的上下文块后,LLM 需要将它们合成为最终答案。
    • 这里比较重要的就是防幻觉检验将最初的用户问题与最终答案,再送入一个专门的文本蕴含(NLI)模型,判断“上下文能否推导出这个答案?”,实现自动质检。

现在,很多系统已经从传统 RAG 演进到了 Agentic RAG

1
Query → Agent 是否需要检索?→ 检索哪些知识库?→ 是否再次检索?→ 是否调用工具?→ 生成答案

而这样的话,此时检索已经不再是固定流程,而成为 Agent 推理过程中的一个动态决策步骤。

关于向量索引检索

上面提到的工作过程中,增强生成阶段不是 RAG 的主要过程,我们可以认为,RAG 过程分为如下 2 个不同的阶段,索引和检索。LangChain4j 分别为这两个阶段提供了工具。

索引

在索引阶段,文档经过预处理,以便在检索阶段能够进行高效搜索。

这个过程因所使用的信息检索方法而异。对于向量搜索,这通常包括清理文档、使用额外数据和元数据对其进行丰富、将其分割成更小的片段(又称分块)、嵌入这些片段,最后将它们存储在嵌入存储(又称向量数据库)中。

索引阶段通常在离线进行。例如,可以通过定时任务在周末每周重新索引一次内部公司文档来实现。负责索引的代码也通常是一个单独的应用程序,只处理索引任务。

然而,在某些情况下,最终用户可能希望上传他们的自定义文档,以使其可供 LLM 访问。在这种情况下,索引应该在在线执行,并作为主应用程序的一部分。

检索

向量数据库并不是直接理解文本。它存储和检索的是 Embedding。

向量索引需要向量索引算法来处理,他解决在海量高维向量中,怎么快速找到和查询向量最相似的几个(Top-K)

而检索阶段通常在在线进行,当用户提交一个需要使用已索引文档回答的问题时。

这个过程因所使用的信息检索方法而异。对于向量搜索,这通常包括嵌入用户的查询(问题),并在嵌入存储中执行相似度搜索。然后,将相关的片段(原始文档的片段)注入到提示中并发送给 LLM。

一般检索分为两种,关键词检索和向量语义搜索,解决的是两类问题。而且一般这两种方法融合使用。

检索方式 原理 局限性
BM25 关键词 字面匹配,基于词频统计 遇到同义词或改写容易失效,比如“退货”和“退款流程”
向量语义搜索 Embedding 捕获语义相似性 能处理同义词、上下文和隐含意图,但依赖 Embedding 质量

关于向量数据库

我们都知道,传统数据库以行和列的形式存储字符串、数字和其他类型的标量数据。而向量数据库则是基于向量进行操作的。在传统数据库中,我们通常查询数据库中值与查询条件完全匹配的行。而在向量数据库中,我们通过应用相似性度量来找到与查询向量最相似的向量。

很明显, RAG 场景里真正要解决的,不只是存 Embedding,重点在在大规模高维向量里,低延迟找出最相关的 Top-K。因为,传统关系型数据库可以存向量,也可以通过函数或 SQL 表达式计算相似度。但如果没有专门的向量索引,通常只能全表扫描,很难支撑生产级低延迟检索。

向量数据库结合使用多种不同的算法,这些算法都参与近似最近邻(ANN)搜索。以下向量数据库的简单流程,这里不介绍算法细节

  • 索引:向量数据库使用诸如 PQ、LSH 或 HNSW 等算法对向量进行索引
  • 查询:向量数据库将索引后的查询向量与数据集中的索引向量进行比较,找到最近邻向量
  • 后处理:在某些情况下,向量数据库从数据集中检索最终的最近邻向量,并对其进行后处理以返回最终结果。这一步可能包括使用不同的相似性度量对最近邻向量重新排序

有哪些向量数据库?如何选?

  • 传统数据库扩展: PostgreSQL + pgvector,以及 MongoDB Atlas Vector Search。
    • 技术栈统一,不需要额外引入一套数据库系统;向量数据和业务数据可以在同一事务里管理
  • 搜索引擎扩展: Elasticsearch 和 OpenSearch。
    • 这类方案的优势是混合搜索能力强,可以把 BM25 关键词检索和向量语义搜索结合起来。它也保留了传统搜索引擎在长文本、分词、高亮、聚合分析上的优势,并且分布式架构成熟。
  • 原生专业向量数据库: Milvus、Weaviate、Qdrant。
    • Milvus 功能比较全面,社区也大;Weaviate 内置 AI 模块,支持 GraphQL 查询,易用性不错;Qdrant 用 Rust 编写,内存效率高,过滤能力也比较强。

RAG 融合

传统 RAG 用单个查询向量去搜,如果原始问题措辞不佳、或者所需答案分散在多个语义差异很大的文档中,单次搜索很容易漏掉关键信息。为了提升检索质量,我们使用 RAG 融合

RAG 融合就是生成多个不同视角的查询,多路并行搜索,再将结果融合,以此同时提升召回率和答案的全面性。

例如,用户问“如何提升Java应用的性能?”那么,LLM 可能会考虑这些问题,然后去 RAG,

  • “Java 应用性能优化的最佳实践”
  • “JVM 调优参数”
  • “Java 代码层面的性能优化技巧”
  • “常见 Java 性能瓶颈及排查方法”

完事了就是基本流程了,并行检索,融合排序,重排,合成输出什么的

在 LangChain4j 里,你可以通过 QueryTransformer 返回 List<Query> 来实现多查询,并自定义一个 ContentRetriever 在内部进行并发检索和 RRF 合并。

LangChain4j 中的 RAG

LangChain4j 中的 RAG 风格

LangChain4j 提供三种风格的 RAG:

  • Easy RAG:开始使用 RAG 的最简单方法
  • Naive RAG:使用向量搜索的 RAG 基本实现
  • Advanced RAG:一个模块化的 RAG 框架,允许进行额外的步骤,例如查询转换、从多个来源检索和重新排名

Easy RAG

LangChain4j 有一个 Easy RAG,使入门 RAG 尽可能简单。无需学习嵌入、选择向量存储、找到合适的嵌入模型、弄清楚如何解析和分割文档等。只需指向您的文档,LangChain4j 就会自动处理。

但是通常情况下,我们不使用这个内容,在本文中我只会编写简单的例子来供大家浏览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Service
public class EasyRagService {
private static final Logger log = LoggerFactory.getLogger(EasyRagService.class);
private static final String DOCS_DIR = "rag-docs";

/**
* Easy RAG 的内存向量库。
*
* <p>之所以用 {@link InMemoryEmbeddingStore},是因为它是官方为的入门向量库,数据保存在内存中、随进程结束而丢失
*/
private final InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();

/**
* Easy RAG 使用的嵌入模型:量化版 bge-small-en-v1.5-q(官方为 Easy RAG 选定的默认模型)。
* 索引与检索两端必须复用同一个实例
*/
private final EmbeddingModel embeddingModel = new BgeSmallEnV15QuantizedEmbeddingModel();

/**
* 应用启动后立即摄取知识库文档。
* 这一步对应 RAG 的「索引阶段」
*/
@PostConstruct
public void ingestDocuments() {
// 加载 classpath 下 rag-docs 目录中的所有文档。
// 没有显式指定 DocumentParser,会通过 SPI 使用 easy-rag 提供的 ApacheTikaDocumentParser。
List<Document> documents = ClassPathDocumentLoader.loadDocuments(DOCS_DIR);
log.info("[Easy RAG] 已加载 {} 篇文档,开始摄取……", documents.size());

// 2) 摄取:分割(默认 recursive 分割器,经 SPI 提供)+ 嵌入(显式指定量化模型)+ 存储。
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
ingestor.ingest(documents);
log.info("[Easy RAG] 摄取完成,向量库已就绪。");
}

public EmbeddingModel getEmbeddingModel() {
return embeddingModel;
}

/**
* 暴露内存向量库给配置类,用于构建 {@code EmbeddingStoreContentRetriever}。
*/
public InMemoryEmbeddingStore<TextSegment> getEmbeddingStore() {
return embeddingStore;
}
}

很明显,Easy RAG 的底层发生的各种什么事情全部由 langchain4j-easy-rag 通过 SPI 自动提供

  • 文档解析:使用的是 ApacheTikaDocumentParser
  • 文档分割:默认的分割算法,应该是按 Token 分割的吧
  • 嵌入模型:内置量化版 bge-small-en-v1.5-q,他能基于 ONNX Runtime 在同一 JVM 进程内离线运行,无需任何外部服务或 API Key

然后,在配置中把 Easy RAG 需要的嵌入模型给加载进来

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public RagAssistant easyRagAssistant(@Qualifier("ragChatModel") ChatModel ragChatModel,
EasyRagService easyRagService) {
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(easyRagService.getEmbeddingStore())
.embeddingModel(easyRagService.getEmbeddingModel())
.build();

return AiServices.builder(RagAssistant.class)
.chatModel(ragChatModel)
.contentRetriever(contentRetriever)
.build();
}

然后编写一个接口就能够与它聊天了

image-20260623172005859

说实话别看都是官方自动配置的,用的都是本地的,但是效果意外的还可以

朴素 RAG (Naive RAG)

在 LangChain4j 中,朴素 RAG 特指 索引-检索-生成 三阶段完全由开发者手动串接的模式。而且其中最重要的是,必须亲手完成以下索引阶段的 4 个零件组装:

  • Document(文档):原始数据。
  • DocumentSplitter(分割器):将长文档切成小文本块(TextSegment)。
  • EmbeddingModel(嵌入模型):将文本块嵌入转换为向量(Embedding)。
  • EmbeddingStore(向量库):存储 向量 + 文本块 的映射关系。

首先,有这样的一个代码示例

我们定义一个 AI Service 接口,把检索增强这件事透明地注入到方法调用中,就可以使用了

1
2
3
4
5
6
7
public interface RagAssistant {
@SystemMessage("""
你是「微笑里程」公司的智能客服助手。
请【仅依据】提供给你的参考信息回答用户问题...
""")
String chat(String userMessage);
}

然后,这是整个朴素 RAG 的索引阶段,负责把文档加载、分割、向量化并存入向量库,而这四部需要我们亲手配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* 朴素 RAG(Naive RAG)+ 核心 RAG API
* <pre>
* Document(文档)
* → DocumentSplitter(分割成 TextSegment 片段)
* → EmbeddingModel(把每个片段嵌入成向量 Embedding)
* → EmbeddingStore(把 向量 + 片段 存进向量库)
* </pre>
*
* <p>检索阶段则由 {@code EmbeddingStoreContentRetriever}(在 {@code RagConfig} 中构建)负责:
* 把用户问题嵌入 → 在向量库里做相似度搜索 → 取出 Top-N 相关片段注入提示。
*
* <p><b>本类显式使用的核心 API:</b>
* <ul>
* <li>{@link ClassPathDocumentLoader}:加载文档。</li>
* <li>{@link DocumentSplitters#recursive(int, int)}:官方推荐的递归分割器。</li>
* <li>{@link BgeSmallEnV15EmbeddingModel}:进程内离线嵌入模型(全精度版)。</li>
* <li>{@link EmbeddingStoreIngestor}:把上面几步编排成「摄取管道」,并演示
* {@code documentTransformer} 写入自定义元数据 {@code source}(供示例 4 的元数据过滤使用)。</li>
* </ul>
*/
@Service
public class NaiveRagService {

private static final Logger log = LoggerFactory.getLogger(NaiveRagService.class);

private static final String DOCS_DIR = "rag-docs";

/** 内存向量库。 */
private final InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();

/**
* 显式持有的嵌入模型(全精度 bge-small-en-v1.5)。
*
* <p>索引阶段(摄取文档)与检索阶段(嵌入用户问题)<b>必须使用同一个嵌入模型</b>,
* 否则两边向量不在同一语义空间,相似度计算将毫无意义。
* 因此这里把它持有为字段,供配置类构建检索器时复用同一个实例。
*/
private final EmbeddingModel embeddingModel = new BgeSmallEnV15EmbeddingModel();

@PostConstruct
public void ingestDocuments() {
// 加载 classpath 知识库文档
List<Document> documents = ClassPathDocumentLoader.loadDocuments(DOCS_DIR);
log.info("[Naive RAG] 已加载 {} 篇文档,开始构建摄取管道……", documents.size());

// 显式构建分割器:每段最多 300 个 token,相邻片段重叠 30 个 token。
// 重叠是为了避免把一句完整语义从中间切断,让相邻片段保留一点上下文衔接。
DocumentSplitter splitter = DocumentSplitters.recursive(300, 30);

// 用 Builder 显式编排摄取管道:转换 → 分割 → 嵌入 → 存储。
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
// documentTransformer:在嵌入前给每篇文档补一个 source 元数据
.documentTransformer(document -> {
String fileName = document.metadata().getString(Document.FILE_NAME);
document.metadata().put("source", fileName == null ? "unknown" : fileName);
return document;
})
.documentSplitter(splitter)
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();

// 执行摄取
ingestor.ingest(documents);
log.info("[Naive RAG] 摄取完成,向量库已就绪(共 {} 篇文档)。", documents.size());
}

public InMemoryEmbeddingStore<TextSegment> getEmbeddingStore() {
return embeddingStore;
}

public EmbeddingModel getEmbeddingModel() {
return embeddingModel;
}
}

上面的这个类负责整个 RAG 的数据基建

来看,@PostConstruct 的这个(Spring Boot Bean 实例化完成且依赖注入结束后)初始化摄取流程

  • ClassPathDocumentLoader.loadDocuments(DOCS_DIR) 会扫描 resources/rag-docs 下的 .txt 和 .md 文件,自动解析内容。
  • 然后用DocumentSplitters.recursive(300, 30)分割,这里面 Easy RAG 用的也是这个分割器好像,我自己就没有实现分割器
  • 然后就编排摄取器,是EmbeddingStoreIngestor 的 Builder 模式。
    • .documentTransformer就是在存入向量库前,给每个文档的元数据补了一个 source 字段,加元数据很有用的
    • .documentSplitter(splitter).embeddingModel(embeddingModel).embeddingStore(embeddingStore)什么的就是指定文档分割器、嵌入模型什么的了,向量数据库什么的了
  • 执行 ingestor.ingest(documents) 后,它会自动执行:文档转换 → 分割 → 嵌入 → 入库

代码注释中我也提到了,为什么要把模型声明为字段而非局部变量?

因为索引和检索必须用同一个模型实例(或至少同一个模型名称)。如果用 A 模型给文档建索引,用 B 模型给问题做检索,二者向量所在的“语义空间”完全不同,余弦相似度将毫无意义。

而检索阶段则发生在 RagConfig 中,在这里我们将 RAG 组件组装起来,通过 EmbeddingStoreContentRetriever 将用户问题也变成向量,去库里做相似度计算,捞出 Top-N。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 朴素 RAG 用的内容检索器(显式配置,体现可调参数)。
*
* <p>{@code maxResults} 控制注入几条片段、{@code minScore} 过滤掉相关性过低的片段,
* 这两个旋钮是调优朴素 RAG 检索质量最常用的参数。
*/
@Bean("naiveContentRetriever")
public ContentRetriever naiveContentRetriever(NaiveRagService naiveRagService) {
return EmbeddingStoreContentRetriever.builder()
// 注意复用同一个模型
.embeddingStore(naiveRagService.getEmbeddingStore())
.embeddingModel(naiveRagService.getEmbeddingModel())
.maxResults(5) // 最多取 5 个片段
.minScore(0.5) // 相似度低于 0.5 的过滤掉
.build();
}
  • minScore 是朴素 RAG 最实用的调优旋钮。如果模型胡说八道(幻觉),调高此值(如 0.7);如果搜不到东西,调低此值。

然后我们需要绑定一个 AI Service,把上面的 RAG 的 LLM 模型和专用的内容检索器,绑定一个在 AI Service 接口中,来让我们的 RAG 变成一个可以被调用的服务

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 朴素 RAG 助手:把上面的检索器绑定到 AI Service。
*/
@Bean
public RagAssistantWithSources ragAssistantWithSources(
@Qualifier("ragChatModel") ChatModel ragChatModel,
@Qualifier("naiveContentRetriever") ContentRetriever naiveContentRetriever) {
return AiServices.builder(RagAssistantWithSources.class)
.chatModel(ragChatModel)
.contentRetriever(naiveContentRetriever)
.build();
}

其中,AiServices.builder(RagAssistantWithSources.class),它根据接口(RagAssistantWithSources)的方法签名,自动将 ChatModel(生成)和 ContentRetriever(检索)织入进去。

高级 RAG (Advanced RAG)

在朴素 RAG 中,流程很明显,就是:用户问题 → 嵌入检索 → 注入提示 → 大模型回答。

只不过这些步骤是自己手动组织的,而高级 RAG 把这个流程拆成 5 个可插拔的标准组件

首先,高级 RAG 需要我们自己的组装如上描述的可插拔组件,所以我们需要在配置类中自己构建高级 RAG 的检索增强器,作为 RAG 的管道本体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Bean("advancedRetrievalAugmentor")
public RetrievalAugmentor advancedRetrievalAugmentor(
@Qualifier("ragChatModel") ChatModel ragChatModel,
@Qualifier("naiveContentRetriever") ContentRetriever naiveContentRetriever) {

// 1. 查询转换器:把一个问题扩展成多个等价问法
ExpandingQueryTransformer queryTransformer = ExpandingQueryTransformer.builder()
.chatModel(ragChatModel) // 需要 LLM 来“思考”如何扩展
.build();

// 2. 内容注入器:注入时带上 source 元数据,让 LLM 知道每段信息的出处
DefaultContentInjector contentInjector = DefaultContentInjector.builder()
.metadataKeysToInclude(List.of("source"))
.build();

// 3. 用 DefaultRetrievalAugmentor 组装完整管道
return DefaultRetrievalAugmentor.builder()
.queryTransformer(queryTransformer) // 步骤1
.queryRouter(new DefaultQueryRouter(naiveContentRetriever)) // 步骤2+3(路由与检索一体)
.contentInjector(contentInjector) // 步骤5
// .contentAggregator(...) 未显式设置 → 使用默认的 DefaultContentAggregator(RRF)
.build();
}
  • ExpandingQueryTransformer:用 LLM 把用户输入扩展成多个类似查询,因此自身也会调用一次 LLM,所以越需要为其传入一个ChatModel。LangChain4j 内部会使用一个预置的 prompt 模板要求 LLM 生成 N 个等价查询
  • DefaultQueryRouter:把上面ExpandingQueryTransformer输出的所有改写后的查询,都路由到你指定的 ContentRetriever 上去执行检索。

然后,我们将上述构建好的 RAG 管道,绑定到 AI Service 接口

1
2
3
4
5
6
7
8
9
@Bean
public AdvancedRagAssistant advancedRagAssistant(
@Qualifier("ragChatModel") ChatModel ragChatModel,
@Qualifier("advancedRetrievalAugmentor") RetrievalAugmentor advancedRetrievalAugmentor) {
return AiServices.builder(AdvancedRagAssistant.class)
.chatModel(ragChatModel)
.retrievalAugmentor(advancedRetrievalAugmentor) // 绑定整条高级管道
.build();
}

朴素 RAG 用 .contentRetriever(...) 绑定一个检索器;高级 RAG 用 .retrievalAugmentor(...) 绑定整条管道。其他完全一样。

为什么朴素 RAG 和 高级 RAG 能这样绑定?

首先,AiServices 的底层为你的接口创建一个动态代理,拦截每次方法调用,在将消息发给 LLM 之前,自动执行注入的逻辑

1
2
3
4
AiServices.builder(AdvancedRagAssistant.class)
.chatModel(model)
.retrievalAugmentor(augmentor)
.build();

AiServices 走完动态代理然后拦截之后,会检查是否有 RAG 增强配置

  • 高级 RAG 配置了 retrievalAugmentor,就调用它的 augment(userMessage, metadata) 方法,得到增强后的 UserMessage。

    此时框架不再自己拼装检索逻辑,而是完全委托给你传入的 RetrievalAugmentor。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    UserMessage userMessage = ...;

    if (retrievalAugmentor != null) {
    // 调用增强器的 augment 方法,完成全部管道步骤
    UserMessage augmentedMessage = retrievalAugmentor.augment(userMessage, metadata);
    userMessage = augmentedMessage;
    }

    // 发给 chatModel
  • 朴素 RAG 只配置了 contentRetriever,则框架内部会组装一个简易的增强流程,同样生成增强后的 UserMessage。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    UserMessage userMessage = ...; // 从方法参数提取

    // 检查到有 contentRetriever,但没有 retrievalAugmentor
    if (contentRetriever != null) {
    // 1. 用检索器直接检索
    List<Content> contents = contentRetriever.retrieve(userMessage.singleText());
    // 2. 用默认的 ContentInjector 注入
    PromptTemplate template = DefaultContentInjector.DEFAULT_PROMPT_TEMPLATE;
    String augmentedText = template.apply(Map.of(
    "userMessage", userMessage.singleText(),
    "contents", format(contents)
    ));
    userMessage = UserMessage.from(augmentedText);
    }

    // 然后发给 chatModel
    // 框架在内部帮你隐式地组装了一条最简管道

所以说,框架把整个增强过程的控制权交给了你预先装配好的对象,你组装什么组件,它就执行什么流程。

然后定义一个 AI Service 接口,就可以被调用了

1
2
3
4
5
6
7
public interface AdvancedRagAssistant {
@SystemMessage("""
你是一个严谨的知识助手。请【仅依据】下方提供的参考信息回答问题,用简体中文作答。
若多条信息有冲突,请指出;若资料中没有答案,请如实说明,不要编造。
""")
String chat(String userMessage);
}

访问来源

对于访问来源,普通 RAG 助手返回一个 String 答案,你无从得知这个答案到底基于哪些知识片段得出,这对于审计是很不友好的。

LangChain4j 的解决方案非常优雅:把 AI Service 的返回类型从 String 改成 Result<String>,框架就会自动把本次用于增强提示的所有检索内容打包进 Result.sources(),让你在拿到答案的同时也能拿到引用清单。

首先我们定义这样的一个 AI Service 接口

1
2
3
4
5
6
7
public interface RagAssistantWithSources {
@SystemMessage("""
你是「微笑里程」公司的智能客服助手。
请【仅依据】提供给你的参考信息回答用户问题,用简体中文作答;找不到就如实说明。
""")
Result<String> chat(String userMessage);
}
  • 可以发现,返回类型是 Result<String>,而不是 String。这是 LangChain4j 的特殊包装类型,框架会识别它并自动注入检索到的来源。

绑定到 AI Service,并且做好该做的管道配置

1
2
3
4
5
6
7
8
9
@Bean
public RagAssistantWithSources ragAssistantWithSources(
@Qualifier("ragChatModel") ChatModel ragChatModel,
@Qualifier("naiveContentRetriever") ContentRetriever naiveContentRetriever) {
return AiServices.builder(RagAssistantWithSources.class)
.chatModel(ragChatModel)
.contentRetriever(naiveContentRetriever)
.build();
}

业务调用层中你需要做好提取答案和数据来源的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 提问并同时返回答案与「来源片段」。
*
* @param question 用户问题
* @return 含答案与来源明细的结构化结果
*/
public Map<String, Object> chatWithSources(String question) {
Result<String> result = assistantWithSources.chat(question);

List<Map<String, Object>> sources = new ArrayList<>();
for (Content content : result.sources()) {
TextSegment segment = content.textSegment();
Map<String, Object> src = new LinkedHashMap<>();
src.put("source", segment.metadata().getString("source"));
src.put("text", segment.text());
sources.add(src);
}

Map<String, Object> response = new LinkedHashMap<>();
response.put("question", question);
response.put("answer", result.content());
response.put("sourceCount", sources.size());
response.put("sources", sources);
if (result.tokenUsage() != null) {
response.put("tokenUsage", result.tokenUsage().toString());
}
return response;
}

那么,其中调用assistantWithSources.chat(question),拿到的 Result<String>,其中有什么我们能利用的内容

  • result.content():LLM 生成的最终文本答案。
  • result.sources():List<Content>,每个 Content 都是一个被注入到提示中的检索片段。

遍历 sources,对每个 Content:

  • content.textSegment() 得到 TextSegment 对象,包含文本和元数据。
  • segment.text() 获取片段文本。
  • segment.metadata().getString("source") 提取元数据中的 source 字段(这是你在文档摄取时存入的)。

还可以用 result.tokenUsage() 获取本次交互消耗的 token 统计。

元数据过滤

向量检索通常是在整个知识库中按语义相似度搜。但现实场景中,我们经常需要缩小检索范围

  • 只检索某个具体文档(source == "manual.pdf")
  • 只检索某个用户拥有的数据(userId == "123")
  • 只检索某段时间更新的内容(updateDate > 2025-01-01)

这些都是非常常见的,而且是有必要做的剪枝,所以说,在向量检索的时候,就需要根据元数据的情况去按需要查询数据,对 Metadata 施加过滤条件

LangChain4j 提供了 Filter DSL,让你能用链式调用的方式构建过滤条件,然后设置到 EmbeddingSearchRequest 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public Map<String, Object> searchWithFilter(String question, String source) {
EmbeddingModel embeddingModel = naiveRagService.getEmbeddingModel();
InMemoryEmbeddingStore<TextSegment> store = naiveRagService.getEmbeddingStore();

// 1. 将用户问题转为向量
Embedding queryEmbedding = embeddingModel.embed(question).content();

// 2. 构建搜索请求
EmbeddingSearchRequest.EmbeddingSearchRequestBuilder requestBuilder = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(5)
.minScore(0.0);

// 3. 关键:按 source 字段过滤
boolean filtered = StringUtils.hasText(source);
if (filtered) {
Filter filter = metadataKey("source").isEqualTo(source);
requestBuilder.filter(filter);
}

// 4. 执行搜索
EmbeddingSearchResult<TextSegment> searchResult = store.search(requestBuilder.build());

// 5. 整理结果
List<Map<String, Object>> hits = new ArrayList<>();
for (EmbeddingMatch<TextSegment> match : searchResult.matches()) {
Map<String, Object> hit = new LinkedHashMap<>();
hit.put("score", match.score());
hit.put("source", match.embedded().metadata().getString("source"));
hit.put("text", match.embedded().text());
hits.add(hit);
}

Map<String, Object> response = new LinkedHashMap<>();
response.put("question", question);
response.put("filtered", filtered);
response.put("filterSource", filtered ? source : "(未过滤,全库检索)");
response.put("hitCount", hits.size());
response.put("hits", hits);
return response;
}

对于 Filter 的使用,个人感觉跟 MyBatis-Plus 很相似,常用的内容基本如下

1
2
3
4
5
6
7
8
9
10
11
12
// 等于
Filter filter = metadataKey("source").isEqualTo("manual.pdf");

// 大于
Filter filter = metadataKey("year").isGreaterThan(2023);

// 组合条件:source = "manual.pdf" AND page > 5
Filter filter = metadataKey("source").isEqualTo("manual.pdf")
.and(metadataKey("page").isGreaterThanOrEqualTo(5));

// 使用 IsIn
Filter filter = metadataKey("userId").isIn("123", "456");

并非所有 EmbeddingStore 都支持过滤,且支持的操作符也因存储而异。代码中使用的 InMemoryEmbeddingStore对过滤的支持是完整的。若换成其他存储(如 Pinecone、Milvus),需要查阅文档确认支持情况。

在高级 RAG 管道中,你不需要像示例这样手动调用 EmbeddingStore.search()。你可以在配置 ContentRetriever 时就嵌入过滤逻辑。EmbeddingStoreContentRetriever 支持通过 .filter() 方法设置一个固定的过滤条件

就像这样

1
2
3
4
5
6
7
8
9
10
11
// 带固定过滤条件的 ContentRetriever
@Bean("filteredContentRetriever")
public ContentRetriever filteredContentRetriever(NaiveRagService naiveRagService) {
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(naiveRagService.getEmbeddingStore())
.embeddingModel(naiveRagService.getEmbeddingModel())
.maxResults(5)
.minScore(0.5)
.filter(metadataKey("source").isEqualTo("manual.pdf")) // 固定过滤
.build();
}

更常见的做法是在业务调用时动态传递过滤参数,但这需要 AI Service 接口携带过滤参数,并在 RAG 管道中访问它。

结合 Agents 使用 RAG

在 Agent 中引入 RAG 模块,实际上,RAG 管道也是静态配置的,检索器在启动时就绑死了,所以说,不少内容不会有太大的变化。

Agent 是在 LangChain4j 中是一个被 @Agent 注解标记的接口方法,而 RAG 的本质是在把提示词发给 LLM 之前,先从你的数据中找出相关信息并注入提示词。

所以说,RAG 作为一个 Tool 融入 Agent 的一环,是最方便的集成方式,下篇文章我们会重点讲解 Tool,这样,Agent 就能自主决定什么时候需要查知识库。所以,按照如下,我们把 RAG 的检索能力封装成一个 @Tool 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Tool("Search the company knowledge base for documents related to the given query. " +
"Use this tool when you need factual information from internal documents, " +
"such as company policies, rental terms, membership benefits, or technical documentation."+
"Returns relevant text segments with similarity scores.")
public String searchKnowledgeBase(@P("Natural language search query") String query) {
// 1) 将查询嵌入为向量
Embedding queryEmbedding = embeddingModel.embed(query).content();

// 2) 构造搜索请求 → 在向量库中搜索 Top-K 相似片段
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(maxResults)
.minScore(minScore)
.build();
EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
List<EmbeddingMatch<TextSegment>> matches = searchResult.matches();

// 3) 格式化输出
if (matches.isEmpty()) {
return "No relevant information found in the knowledge base for query: " + query;
}

StringBuilder sb = new StringBuilder();
sb.append("=== Knowledge Base Search Results (max ").append(maxResults)
.append(", minScore=").append(minScore).append(") ===\n");
for (int i = 0; i < matches.size(); i++) {
EmbeddingMatch<TextSegment> match = matches.get(i);
sb.append(String.format("[Result %d] relevance score: %.4f\n%s\n\n",
i + 1, match.score(), match.embedded().text()));
}
return sb.toString().trim();
}
  • @Tool 注解让这个方法成为 Agent 可调用的工具,然后方法内部执行标准的向量检索,返回的文本会作为 Tool 的执行结果被 LLM 看到。

然后,定义持有工具的 Agent,正如之前所学,Agent 本身不包含任何检索逻辑,它只是知道自己有这个工具可用。

1
2
3
4
5
6
7
8
9
10
public interface KnowledgeAssistant {
@SystemMessage("""
你是「微笑里程」的智能客服助手。
你可以使用 searchKnowledgeBase 工具查询公司内部知识库。
涉及公司政策、条款 → 先调用 searchKnowledgeBase;
常识性问题 → 直接回答,无需查询。
""")
@Agent(description = "Customer service assistant with knowledge base search...")
String answer(String userQuestion);
}

然后,把 Agent 装配,tools(ragSearchTool) 把检索工具绑定到 Agent 上——Agent 的”工具箱”里有了这件工具,LLM 在推理时就能看到它并决定是否调用。

1
2
3
4
5
6
7
8
@Bean
public KnowledgeAssistant knowledgeAssistant(ChatModel chatModel, RagSearchTool ragSearchTool) {
return AgenticServices.agentBuilder(KnowledgeAssistant.class)
.chatModel(chatModel)
.tools(ragSearchTool) // ← 把工具注册给 Agent
.outputKey("answer")
.build();
}

那么,这样,就只剩下之前完全学习过的 Agent 的内容了,这里我们打算 RAG 作为顺序工作流进行组装,拆开成这样的步骤:查询分析 → 文档检索 → 答案合成

  • 查询分析器

    1
    2
    3
    4
    5
    6
    7
    8
    public interface QueryAnalyzer {
    @UserMessage("""
    用户的问题是(中文):「{{question}}」
    请生成 1-3 个英文检索查询(因为向量模型是英文的 bge-small-en-v1.5)
    """)
    @Agent(outputKey = "searchQueries")
    String analyzeQuery(String question);
    }

    为什么要 中文→英文 这样转换一下?因为嵌入模型 bge-small-en-v1.5 是英文模型,中文查询的语义向量不准确。这是必要的适配

  • 文档检索器

    1
    2
    3
    4
    5
    6
    @Agent(outputKey = "retrievedDocs")
    public String retrieve(String searchQueries) {
    // 对每个查询独立执行向量检索
    // 合并结果、去重(相同文本只保留一次)
    // 格式化返回
    }
    • 这是非 AI Agent,它不调用 LLM,只做确定性的向量检索,是纯计算逻辑的 Agent 组件。
  • 答案合成器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public interface AnswerSynthesizer {
    @UserMessage("""
    用户的问题是:{{question}}
    以下是从知识库中检索到的相关文档:
    ---
    {{retrievedDocs}}
    ---
    请基于以上文档回答问题。
    """)
    @Agent(outputKey = "answer")
    String synthesize(String question, String retrievedDocs);
    }
  • 串成管道

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Bean
    public RagResearchAgent ragResearchAgent(
    QueryAnalyzer queryAnalyzer,
    RagDocumentRetriever ragDocumentRetriever,
    AnswerSynthesizer answerSynthesizer) {
    return AgenticServices.sequenceBuilder(RagResearchAgent.class)
    .subAgents(queryAnalyzer, ragDocumentRetriever, answerSynthesizer)
    .outputKey("answer")
    .build();
    }
    • sequenceBuilder 把三个子 Agent 串成管道,AgenticScope 自动在步骤间传递状态: question → searchQueries → retrievedDocs → answer。这都是前面的内容了

那么,上面的模式相比于 Tool,它更固定更省 Token,适合固定内容的客服,但是内容相当不固定的清空呢?上次 Agent 提到的最后的一个模式,Supervisor 管理 RAG 子 Agent 的模式,就更适合在这

  • 先定义两个子 Agent

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 知识库专家 —— 持有 RAG 搜索工具
    public interface KnowledgeExpert {
    @SystemMessage("你是知识库专家,可以使用 searchKnowledgeBase 工具...")
    @Agent(description = "Search the company knowledge base...")
    String query(String question);
    }

    // 通用助手 —— 没有工具,只用 LLM 内置知识
    public interface GeneralAssistant {
    @SystemMessage("你是通用对话助手,没有外部工具...")
    @Agent(description = "Handle general conversation...")
    String respond(String request);
    }
  • 装配 Supervisor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Bean
    public RagSupervisor ragSupervisor(
    ChatModel chatModel,
    KnowledgeExpert knowledgeExpert,
    GeneralAssistant generalAssistant) {
    return AgenticServices.supervisorBuilder(RagSupervisor.class)
    .chatModel(chatModel)
    .supervisorContext("""
    You are a routing supervisor.
    Route to KnowledgeExpert when:
    - The question asks about company policies, rental terms...
    Route to GeneralAssistant when:
    - The question is casual conversation...
    """)
    .subAgents(knowledgeExpert, generalAssistant)
    .build();
    }

LangChain4j 中 RAG 的API

Document 系 API

这些 API 构成了 LangChain4j 中数据摄取与预处理的内容。它们负责把原始文档(PDF、网页等)一步步变成可供检索的细粒度片段,并附带丰富的元数据。

  • Document

    代表一个完整的文档,比如一个 PDF 文件、一个网页。目前只支持文本

    • text():获取文档的纯文本内容。
    • metadata():获取文档的元数据。

    它是整个处理链的起点。你从外部源加载的任何内容,都会被包装成一个 Document 对象。

    image-20260629111034688
  • Metadata

    存储关于 Document 的元信息(如文件名、来源、所有者、更新时间),以键值对的形式存在,值可以是 String、Integer、Long、Float、Double、UUID。

    元数据在 Document 加载时就能设置,并在后续分割时复制到每个 TextSegment。

    一般用到它基本就是

    • 增强 LLM 上下文:当把文档片段注入提示时,可附带元数据(如出处),帮助 LLM 理解来源。
    • 过滤检索范围:在向量搜索时可以按元数据字段过滤,例如只检索属于某个所有者的文档。
    • 同步更新:当源文档变更时,可通过唯一标识快速定位并更新向量库中的对应片段。
    image-20260629111421855
  • Document Loader

    可以从 String 创建一个 Document,但更简单的方法是使用文档加载器,从不同来源加载文档,返回 Document 或 List<Document>。

    常见实现:

    • FileSystemDocumentLoader:从本地文件系统加载。
    • ClassPathDocumentLoader:从类路径加载。
    • UrlDocumentLoader:从网络 URL 加载。
    • AmazonS3DocumentLoader / AzureBlobStorageDocumentLoader 等:从云存储加载。
    • SeleniumDocumentLoader / PlaywrightDocumentLoader:通过浏览器渲染后加载
    image-20260629111603141
  • Document Parser

    上述加载器只负责获取原始数据,那么这里解析交给 DocumentParser。解析不同格式的文件(PDF、DOC、TXT),将二进制或结构化文件转换成纯文本 Document。

    常见实现:

    • TextDocumentParser:解析纯文本(TXT、HTML、MD 等)。
    • ApachePdfBoxDocumentParser:解析 PDF。
    • ApachePoiDocumentParser:解析 MS Office(DOC/DOCX/PPT/XLS)。
    • ApacheTikaDocumentParser:自动检测并解析几乎所有文件格式。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 加载单个文档
    Document document = FileSystemDocumentLoader.loadDocument("/home/langchain4j/file.txt", new TextDocumentParser());

    // 加载目录中的所有文档
    List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j", new TextDocumentParser());

    // 加载目录中所有 *.txt 文档
    PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.txt");
    List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j", pathMatcher, new TextDocumentParser());

    // 加载目录及其子目录中的所有文档
    List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively("/home/langchain4j", new TextDocumentParser());

    加载器可以不显式指定解析器,此时会通过 SPI 机制自动加载可用的解析器(比如引入了 langchain4j-easy-rag 或 ApacheTika 依赖时),若没有则回退到 TextDocumentParser。

  • Document Transformer

    文档转换器,在文档分割之前,对原始 Document 进行清洗、过滤、丰富或摘要等操作。

    • 清理:从 Document 的文本中删除不必要的噪音,可以节省 token 并减少干扰。
    • 过滤:完全排除某些 Document 不进行搜索。
    • 丰富:可以向 Document 添加额外信息,以潜在地增强搜索结果。
    • 摘要:可以对 Document 进行摘要,并将其简短摘要存储在 Metadata 中,以便稍后包含在每个 TextSegment 中,以潜在地改善搜索。

    目前,开箱即用提供的唯一实现是 langchain4j-document-transformer-jsoup 模块中的 HtmlToTextDocumentTransformer,它可以从原始 HTML 中提取所需的文本内容和元数据条目。

    没有通用的解决办法,通常情况下需要根据自己独特数据实施自己的 DocumentTransformer

  • Graph Transformer

    • 将非结构化的文档转换为结构化的图数据(GraphDocument),包含节点和关系,便于存入图数据库或知识图谱。

    • GraphDocument包括:

      • 一组代表文本中实体或概念的节点 (GraphNode)。
      • 一组代表这些实体如何连接的关系 (GraphEdge)。
      • 作为 source 的原始 Document。
    • 默认实现是 LLMGraphTransformer,它使用语言模型通过提示工程从自然语言中提取图信息。

    • 简单示例,需要引入 langchain4j-community-llm-graph-transformer。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      public class GraphTransformerExample {
      public static void main(String[] args) {
      // 创建一个由 LLM 支持的 GraphTransformer
      GraphTransformer transformer = new LLMGraphTransformer(
      OpenAiChatModel.builder()
      .apiKey(System.getenv("OPENAI_API_KEY"))
      .timeout(Duration.ofSeconds(60))
      .build()
      );

      // 输入文档
      Document document = Document.from("Barack Obama was born in Hawaii and served as the 44th President of the United States.");

      // 转换文档
      GraphDocument graphDocument = transformer.transform(document);

      // 访问节点和关系
      Set<GraphNode> nodes = graphDocument.nodes();
      Set<GraphEdge> relationships = graphDocument.relationships();

      nodes.forEach(System.out::println);
      relationships.forEach(System.out::println);
      }
      }
  • Text Segment

    文本片段,它表示 Document 的一个片段,是文档被分割后得到的最小检索单元。每个 TextSegment 包含一段文本和从原文档继承的 Metadata

    有用的方法

    • TextSegment.text() 返回 TextSegment 的文本
    • TextSegment.metadata() 返回 TextSegment 的 Metadata
    • TextSegment.from(String, Metadata) 从文本和 Metadata 创建一个 TextSegment
    • TextSegment.from(String) 从文本创建一个带有空 Metadata 的 TextSegment
  • Document Splitter

    文档分割器,将 Document 切割成多个 TextSegment。

    常见实现:

    • DocumentByParagraphSplitter:按段落分割。
    • DocumentBySentenceSplitter:按句子分割(依赖 OpenNLP)。
    • DocumentByLineSplitter / ByWordSplitter / ByCharacterSplitter:按行/词/字符分割。
    • DocumentByRegexSplitter:按正则表达式分割。
    • DocumentSplitters.recursive(...):递归分割,尝试用多种分隔符,保证片段大小合理。

    实例化一个 DocumentSplitter,然后指定 maxSegmentSize(最大片段大小,以字符或 token 计)和 overlap(相邻片段的重叠大小)。

    调用 split(Document) 或 splitAll(List<Document>)。

    分割器先按基础单元(段落/句子等)拆分,再将这些单元尽量塞进一个 TextSegment,若某个单元仍然超长,则调用子分割器进一步切割。

    文档的所有元数据会复制到每个 TextSegment,并自动添加 index 元数据(递增整数)。

  • Text Segment Transformer

    文本片段转换器,对分割后的 TextSegment 进行二次加工,类似 DocumentTransformer

    没有万能实现,需要根据你自己的数据自定义。

Embedding 系列 API

接下来来到向量检索部分涉及到的 API,这部分的 API 主要是索引和检索步骤中会用到的

  • Embedding

    嵌入向量,Embedding 类封装了一个数值向量 float[],它代表了某段内容的语义含义。

    image-20260629115248278
    • 如果两段文本语义相近,它们在向量空间中的距离就接近,一般是比较余弦相似度
    • 向量维度由使用的模型决定

    在 RAG 中,检索完全依赖 Embedding:

    • 把知识库中的 TextSegment 预先计算为向量并存入 EmbeddingStore。
    • 用户提问时,将问题也转为向量,在向量库中搜索最相似的片段。

    通常包括:

    • vector():获取浮点数组。
    • dimension():获取维度。
    • vectorAsList():获取列表形式。
  • EmbeddingModel

    嵌入模型,EmbeddingModel 是专门用于将文本转换为 Embedding 的模型接口

    1
    2
    Embedding embed(String text);
    List<Embedding> embedAll(List<String> texts);

    LangChain4j 支持大量嵌入模型,包括:

    • 本地/离线模型:如 BGE-Small-EN(通过 ONNX 或 DJL 运行时加载),无需 API 调用
    • 云端 API:OpenAI、Azure OpenAI、Vertex AI、Hugging Face、Ollama 等。
    • 自定义:实现 EmbeddingModel 接口即可。

    模型的选择直接影响检索质量,需要与后续的向量搜索使用同一个模型。

  • EmbeddingStore

    嵌入存储 / 向量数据库,负责:存储 Embedding(通常附带原始数据 TextSegment)。按相似度搜索最接近的向量。

    我这边没有 Linux 环境,Milvus 向量数据库这边就没弄,所以一直用的是InMemoryEmbeddingStore

  • EmbeddingSearchRequest

    封装一次向量搜索的所有参数:

    属性 类型 说明
    queryEmbedding Embedding 查询向量(由 EmbeddingModel 从用户问题生成)
    maxResults int 最大返回数量,默认 3
    minScore double 相似度阈值(0~1),低于此分数的结果会被过滤掉,默认 0
    filter Filter 按元数据字段过滤,只匹配符合条件的片段
  • Filter

    在向量搜索的同时按 Metadata 字段进行过滤,实现混合检索。

    1
    2
    3
    4
    5
    Filter filter = Filter.equalTo("userId", "12345");
    EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
    .queryEmbedding(queryEmbedding)
    .filter(filter)
    .build();
  • EmbeddingSearchResult

    包含一个 List<EmbeddingMatch>,每个匹配项代表一个检索到的片段。

    image-20260629124020306
  • EmbeddingMatch

    大约意思就是向量的匹配项,包含:

    • embedding():匹配的向量
    • score():相似度分数(0~1)
    • embeddingId():向量存储时的 ID
    • embedded():原始嵌入数据(通常是 TextSegment,可从中获取文本和元数据)
  • EmbeddingStoreIngestor

    嵌入存储摄取器,它是一个一键式摄取管道,负责把 Document 变成向量并存入 EmbeddingStore。而且它内部可以串联多个处理步骤

    我们就是这么使用的

    image-20260629124141710

API 使用示例

把上述的 API 整理成示例的代码,那么就是这样使用

要记住,所谓「注入提示」,无非是把这些命中片段的文本拼到用户问题后面,一起发给 LLM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package hbnu.project.langchain4jdemo.rag.service;

import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* 核心 RAG API 演示
* <p>本类复用 {@link NaiveRagService} 已经摄取好的向量库与嵌入模型(务必同一个嵌入模型)。
*/
@Service
public class CoreRagApiService {

private final NaiveRagService naiveRagService;

public CoreRagApiService(NaiveRagService naiveRagService) {
this.naiveRagService = naiveRagService;
}

/**
* 手动执行一次向量检索,返回命中的片段及其分数
*
* @param question 用户问题
* @param maxResults 返回的最大命中数
* @param minScore 最小相似度分数(0~1),低于该分数的片段会被过滤掉
* @return 含查询信息与命中明细的结构化结果
*/
public Map<String, Object> search(String question, int maxResults, double minScore) {
EmbeddingModel embeddingModel = naiveRagService.getEmbeddingModel();
InMemoryEmbeddingStore<TextSegment> store = naiveRagService.getEmbeddingStore();

// 1) 把问题嵌入成查询向量。
Embedding queryEmbedding = embeddingModel.embed(question).content();

// 2) 构造搜索请求。
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(maxResults)
.minScore(minScore)
.build();

// 3) 执行相似度搜索。
EmbeddingSearchResult<TextSegment> result = store.search(request);

// 4) 整理命中明细。
List<Map<String, Object>> hits = new ArrayList<>();
for (EmbeddingMatch<TextSegment> match : result.matches()) {
Map<String, Object> hit = new LinkedHashMap<>();
hit.put("score", match.score());
hit.put("source", match.embedded().metadata().getString("source"));
hit.put("text", match.embedded().text());
hits.add(hit);
}

Map<String, Object> response = new LinkedHashMap<>();
response.put("question", question);
response.put("maxResults", maxResults);
response.put("minScore", minScore);
response.put("embeddingDimension", queryEmbedding.dimension());
response.put("hitCount", hits.size());
response.put("hits", hits);
return response;
}
}

高级 RAG 的 API

高级 RAG 的 5 个可插拔的标准组件如下

  • QueryTransformer:查询转换器

    • 在检索前,对用户原始查询进行改写、扩展或压缩,生成一个或多个更适合检索的查询,从而提升召回质量。
    • 典型实现:
      • ExpandingQueryTransformer:利用 LLM 把一个问题扩展成多个等价问法
      • CompressingQueryTransformer:利用 LLM 压缩对话历史中的冗余信息,生成一个精炼查询,一般多轮对话使用
      • DefaultQueryTransformer:不做任何改变,原样返回输入查询
  • QueryRouter :查询路由器

    • 将 QueryTransformer 产出的 每一个查询,路由到合适的 ContentRetriever,而且可以是多个不同的检索源

      什么意思,就是经过 QueryTransformer 查询转换后,你可能得到多个查询。每个查询都会被独立处理,路由器会根据其内容特点,把它分配到最擅长回答这类问题的检索器上,从而实现按需多源检索。

    • 典型实现

      • DefaultQueryRouter:把所有的查询都路由到同一个检索器
      • LanguageModelQueryRouter:基于语言模型决策的实现,它利用 LLM 分析每个查询,自动判断该查询最适合发给哪一个ContentRetriever,而不是像 DefaultQueryRouter 那样无差别地全部发送。
  • ContentRetriever:内容检索器

    • 负责实际的内容检索,查询,返回内容列表,按相关性从高到低排序。它是高级 RAG 中唯一需要你自己构建的核心组件。
    • 典型实现
      • EmbeddingStoreContentRetriever:基于嵌入向量相似度从 EmbeddingStore 中检索。
      • WebSearchContentRetriever:在线网页搜索。
      • 自定义检索器的场景非常常见,一般就是实现 ContentRetriever 接口,对接数据库、Elasticsearch 等任意数据源。底层数据源几乎可以是任何东西
        • Neo4jContentRetriever 是与 Neo4j 图数据库的集成。它将自然语言查询转换为 Neo4j Cypher 查询,并通过在 Neo4j 中运行这些查询来检索相关信息。它可以在 langchain4j-community-neo4j-retriever 模块中找到。
        • SqlDatabaseContentRetriever 是 ContentRetriever 的一个实验性实现,可以在 langchain4j-experimental-sql 模块中找到。它使用 DataSource 和 LLM 来为给定的自然语言 Query 生成并执行 SQL 查询。
  • ContentAggregator:内容聚合器

    • 当有多个查询或多个检索器产生多组结果时,将它们合并、去重、重排序,输出一个高质量的最终内容列表。
    • 典型实现
      • DefaultContentAggregator:使用 倒数排名融合(RRF)算法,纯基于统计。它只看每个文档在不同结果列表中的排名位置,然后计算一个融合分数(排名越靠前,贡献的分数越高),按总分重新排序。
      • ReRankingContentAggregator:它需要配合一个 重排序模型,对聚合后的候选文档逐一进行语义相关性重新打分。它会将查询和每个文档配对,由模型给出精准的相关性分数,再据此排序。
  • ContentInjector :内容注入器

    • 把聚合后的 Content 列表注入到原始的 UserMessage 中,形成最终的提示。注入方式可以定制,也可以选择性地携带元数据。

    • 典型实现:

      • DefaultContentInjector:它只是简单地在 UserMessage 的末尾附加 Content,并带有前缀 Answer using the following information: (使用以下信息回答:)。

        • 可以通过 3 种方式自定义如何将 Content 注入 UserMessage
          • 重写默认的 PromptTemplate
          • 扩展 DefaultContentInjector 并重写其中一个 format 方法。
          • 实现一个自定义的 ContentInjector。
      • 可配置 metadataKeysToInclude:指定要注入哪些元数据字段(如 source、page_number 等),让 LLM 知道信息的出处。支持从检索到的 Content.textSegment() 中注入 Metadata 条目

        1
        2
        3
        DefaultContentInjector.builder()
        .metadataKeysToInclude(List.of("source"))
        .build()

那么,官方文档中的描述的整个流程如下,表示为

  1. 用户生成一个 UserMessage,它被转换为一个 Query。
  2. QueryTransformer 将 Query 转换为一个或多个 Query。
  3. 每个 Query 由 QueryRouter 路由到一个或多个 ContentRetriever。
  4. 每个 ContentRetriever 为每个 Query 检索相关 Content。
  5. ContentAggregator 将所有检索到的 Content 组合成一个最终的排名列表。
  6. 这个 Content 列表被注入到原始的 UserMessage 中。
  7. 最后,包含原始查询和注入的相关内容的 UserMessage 被发送给 LLM。

其中

  • Query 代表 RAG 管道中的用户查询。
    • 它包含查询的文本和查询元数据,而查询元数据包含,被增强的原始 UserMessage,@MemoryId 注解的方法参数的值,所有以前的 ChatMessage。
  • Content 代表与用户 Query 相关的内容。目前,它仅限于文本内容

实际上,上述内容可以并行化处理

让现在的 DefaultRetrievalAugmentor 显式地支持并行化,只需要通过 .executor() 传入一个自定义的线程池即可。虽然默认行为已经会在多查询/多检索器时自动使用缓存线程池,但生产环境中最好手动控制线程池参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Bean("advancedRetrievalAugmentor")
public RetrievalAugmentor advancedRetrievalAugmentor(
@Qualifier("ragChatModel") ChatModel ragChatModel,
@Qualifier("naiveContentRetriever") ContentRetriever naiveContentRetriever) {

ExpandingQueryTransformer queryTransformer = ExpandingQueryTransformer.builder()
.chatModel(ragChatModel)
.build();

DefaultContentInjector contentInjector = DefaultContentInjector.builder()
.metadataKeysToInclude(List.of("source"))
.build();

// 自定义线程池,实际参数可根据业务调整
ExecutorService executor = Executors.newFixedThreadPool(4);

return DefaultRetrievalAugmentor.builder()
.queryTransformer(queryTransformer)
.queryRouter(new DefaultQueryRouter(naiveContentRetriever))
.contentInjector(contentInjector)
.executor(executor) // 启用自定义并行执行器
.build();
}
  • 当你只有一个查询和一个检索器时,管道会在当前线程顺序执行,不会用到线程池。
  • 一旦 ExpandingQueryTransformer 扩展出多个查询,或者 QueryRouter 路由到多个检索器,框架就会用你提供的 Executor 来并行执行查询路由与内容检索,从而显著降低多路检索的延迟。
  • 如果省略 .executor(...),框架会默认使用一个 keepAliveTime 为 1 秒的 CachedThreadPool,这在并发量不可控的场景下可能会创建过多线程,所以生产环境建议自定义。