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 的元信息(如文件名、来源、所有者、更新时间),以键值对的形式存在,值可以是 StringIntegerLongFloatDoubleUUID

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

    一般用到它基本就是

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

    可以从 String 创建一个 Document,但更简单的方法是使用文档加载器,从不同来源加载文档,返回 DocumentList<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-ragApacheTika 依赖时),若没有则回退到 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() 返回 TextSegmentMetadata
    • TextSegment.from(String, Metadata) 从文本和 Metadata 创建一个 TextSegment
    • TextSegment.from(String) 从文本创建一个带有空 MetadataTextSegment
  • 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 模块中找到。
        • SqlDatabaseContentRetrieverContentRetriever 的一个实验性实现,可以在 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:指定要注入哪些元数据字段(如 sourcepage_number 等),让 LLM 知道信息的出处。支持从检索到的 Content.textSegment() 中注入 Metadata 条目

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

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

  1. 用户生成一个 UserMessage,它被转换为一个 Query
  2. QueryTransformerQuery 转换为一个或多个 Query
  3. 每个 QueryQueryRouter 路由到一个或多个 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,这在并发量不可控的场景下可能会创建过多线程,所以生产环境建议自定义。