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工程,将召回的知识合理利用,生成目标答案。
标准的 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 模型为每个块提取关键实体、主题标签,甚至生成摘要。这些增强信息可以作为元数据过滤条件,或单独向量化参与检索,提升精准度。
- 直接输出的内容可能不够我们的需要或者不够好,一般情况下在 Agent
中我们需要调整
混合搜索
向量搜索的缺陷就是对关键词不敏感。它懂语义不懂实体。
所以说,我们同时执行向量搜索(语义相似) 和 BM25 等关键词搜索(精确词频),然后将两组结果通过 RRF(倒数排名融合)等算法合并排序。这样能够准确提升召回率和精准率。
重排(reranking)和过滤(filtering)
- 以上步骤是为了提高召回率,是相对初筛的结果,重排阶段的目标就是精准率,从粗筛的结果里精挑细选出最精华的几个。
- 重排就是用更精密、但计算更慢的模型,对候选列表重新打分。一般是把 查询-文档 当作一个对立的键值对一起输入模型进行联合推理,输出一个深度语义相关性分数。一般是先用向量搜索粗筛出 Top 50,送入重排模型,精选出最相关的 Top 5 送给大模型。
- 过滤就是基于元数据的硬性约束,保证结果可信且相关。
- 前置过滤:在搜索之前就根据用户权限、时间范围、文档类等各种用户的和外部的条件,直接过滤掉无关数据
- 后置过滤:在重排之后,用模型判断“这个块真的能支持回答用户问题吗?这些内容确定是真实存在而且可推理有依据的吗?”,过滤掉那些虽然语义相关但信息不充分的块,防止大模型生编乱造。
- 以上步骤是为了提高召回率,是相对初筛的结果,重排阶段的目标就是精准率,从粗筛的结果里精挑细选出最精华的几个。
查询转换
- 用户的一个问题,视角往往是单一的。查询转换就是用 LLM
的能力来扩展问题,提高召回率。
- 方式有很多种,例如:将“苹果牛逼的笔记本”这种口语化查询,改写成更正式的“搜索苹果公司高性能笔记本电脑的评价与性能参数,为用户推荐相关产品”。
- 而且,将一个复杂问题自动拆解成多个简单的子问题,更常见。例如:“对比GPT-4和Claude-3的编程能力”,可拆解为“GPT-4编程能力评测”、“Claude-3编程能力评测”、“两者编程能力对比”等,分别检索,最后合并上下文。
- Step-Back Prompting:问题很具体时,先生成一个更宏观、更基础的备用问题来检索背景知识。
- 用户的一个问题,视角往往是单一的。查询转换就是用 LLM
的能力来扩展问题,提高召回率。
响应合成
- 拿到了经过层层筛选的、高精度的上下文块后,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 |
|
很明显,Easy RAG 的底层发生的各种什么事情全部由 langchain4j-easy-rag 通过 SPI 自动提供
- 文档解析:使用的是
ApacheTikaDocumentParser - 文档分割:默认的分割算法,应该是按 Token 分割的吧
- 嵌入模型:内置量化版
bge-small-en-v1.5-q,他能基于 ONNX Runtime 在同一 JVM 进程内离线运行,无需任何外部服务或 API Key
然后,在配置中把 Easy RAG 需要的嵌入模型给加载进来
1 |
|
然后编写一个接口就能够与它聊天了
说实话别看都是官方自动配置的,用的都是本地的,但是效果意外的还可以
朴素 RAG (Naive RAG)
在 LangChain4j 中,朴素 RAG 特指 索引-检索-生成 三阶段完全由开发者手动串接的模式。而且其中最重要的是,必须亲手完成以下索引阶段的 4 个零件组装:
- Document(文档):原始数据。
- DocumentSplitter(分割器):将长文档切成小文本块(TextSegment)。
- EmbeddingModel(嵌入模型):将文本块嵌入转换为向量(Embedding)。
- EmbeddingStore(向量库):存储 向量 + 文本块 的映射关系。
首先,有这样的一个代码示例
我们定义一个 AI Service 接口,把检索增强这件事透明地注入到方法调用中,就可以使用了
1 | public interface RagAssistant { |
然后,这是整个朴素 RAG 的索引阶段,负责把文档加载、分割、向量化并存入向量库,而这四部需要我们亲手配置
1 | /** |
上面的这个类负责整个 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 | /** |
minScore是朴素 RAG 最实用的调优旋钮。如果模型胡说八道(幻觉),调高此值(如 0.7);如果搜不到东西,调低此值。
然后我们需要绑定一个 AI Service,把上面的 RAG 的 LLM 模型和专用的内容检索器,绑定一个在 AI Service 接口中,来让我们的 RAG 变成一个可以被调用的服务
1 | /** |
其中,AiServices.builder(RagAssistantWithSources.class),它根据接口(RagAssistantWithSources)的方法签名,自动将
ChatModel(生成)和
ContentRetriever(检索)织入进去。
高级 RAG (Advanced RAG)
在朴素 RAG 中,流程很明显,就是:用户问题 → 嵌入检索 → 注入提示 → 大模型回答。
只不过这些步骤是自己手动组织的,而高级 RAG 把这个流程拆成 5 个可插拔的标准组件
首先,高级 RAG 需要我们自己的组装如上描述的可插拔组件,所以我们需要在配置类中自己构建高级 RAG 的检索增强器,作为 RAG 的管道本体
1 |
|
ExpandingQueryTransformer:用 LLM 把用户输入扩展成多个类似查询,因此自身也会调用一次 LLM,所以越需要为其传入一个ChatModel。LangChain4j 内部会使用一个预置的 prompt 模板要求 LLM 生成 N 个等价查询DefaultQueryRouter:把上面ExpandingQueryTransformer输出的所有改写后的查询,都路由到你指定的ContentRetriever上去执行检索。
然后,我们将上述构建好的 RAG 管道,绑定到 AI Service 接口
1 |
|
朴素 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 | public interface AdvancedRagAssistant { |
访问来源
对于访问来源,普通 RAG 助手返回一个 String
答案,你无从得知这个答案到底基于哪些知识片段得出,这对于审计是很不友好的。
LangChain4j 的解决方案非常优雅:把 AI Service 的返回类型从
String 改成
Result<String>,框架就会自动把本次用于增强提示的所有检索内容打包进
Result.sources(),让你在拿到答案的同时也能拿到引用清单。
首先我们定义这样的一个 AI Service 接口
1 | public interface RagAssistantWithSources { |
- 可以发现,返回类型是
Result<String>,而不是String。这是 LangChain4j 的特殊包装类型,框架会识别它并自动注入检索到的来源。
绑定到 AI Service,并且做好该做的管道配置
1 |
|
业务调用层中你需要做好提取答案和数据来源的内容
1 | /** |
那么,其中调用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 | public Map<String, Object> searchWithFilter(String question, String source) { |
对于 Filter 的使用,个人感觉跟 MyBatis-Plus
很相似,常用的内容基本如下
1 | // 等于 |
并非所有 EmbeddingStore
都支持过滤,且支持的操作符也因存储而异。代码中使用的
InMemoryEmbeddingStore对过滤的支持是完整的。若换成其他存储(如
Pinecone、Milvus),需要查阅文档确认支持情况。
在高级 RAG 管道中,你不需要像示例这样手动调用
EmbeddingStore.search()。你可以在配置ContentRetriever时就嵌入过滤逻辑。EmbeddingStoreContentRetriever支持通过.filter()方法设置一个固定的过滤条件就像这样
1
2
3
4
5
6
7
8
9
10
11 // 带固定过滤条件的 ContentRetriever
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 |
|
@Tool注解让这个方法成为 Agent 可调用的工具,然后方法内部执行标准的向量检索,返回的文本会作为 Tool 的执行结果被 LLM 看到。
然后,定义持有工具的 Agent,正如之前所学,Agent 本身不包含任何检索逻辑,它只是知道自己有这个工具可用。
1 | public interface KnowledgeAssistant { |
然后,把 Agent 装配,tools(ragSearchTool)
把检索工具绑定到 Agent 上——Agent 的”工具箱”里有了这件工具,LLM
在推理时就能看到它并决定是否调用。
1 |
|
那么,这样,就只剩下之前完全学习过的 Agent 的内容了,这里我们打算 RAG 作为顺序工作流进行组装,拆开成这样的步骤:查询分析 → 文档检索 → 答案合成
查询分析器
1
2
3
4
5
6
7
8public interface QueryAnalyzer {
String analyzeQuery(String question);
}为什么要 中文→英文 这样转换一下?因为嵌入模型
bge-small-en-v1.5是英文模型,中文查询的语义向量不准确。这是必要的适配文档检索器
1
2
3
4
5
6
public String retrieve(String searchQueries) {
// 对每个查询独立执行向量检索
// 合并结果、去重(相同文本只保留一次)
// 格式化返回
}- 这是非 AI Agent,它不调用 LLM,只做确定性的向量检索,是纯计算逻辑的 Agent 组件。
答案合成器
1
2
3
4
5
6
7
8
9
10
11
12public interface AnswerSynthesizer {
String synthesize(String question, String retrievedDocs);
}串成管道
1
2
3
4
5
6
7
8
9
10
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 {
String query(String question);
}
// 通用助手 —— 没有工具,只用 LLM 内置知识
public interface GeneralAssistant {
String respond(String request);
}装配 Supervisor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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对象。
Metadata
存储关于
Document的元信息(如文件名、来源、所有者、更新时间),以键值对的形式存在,值可以是String、Integer、Long、Float、Double、UUID。元数据在
Document加载时就能设置,并在后续分割时复制到每个TextSegment。一般用到它基本就是
- 增强 LLM 上下文:当把文档片段注入提示时,可附带元数据(如出处),帮助 LLM 理解来源。
- 过滤检索范围:在向量搜索时可以按元数据字段过滤,例如只检索属于某个所有者的文档。
- 同步更新:当源文档变更时,可通过唯一标识快速定位并更新向量库中的对应片段。
Document Loader
可以从
String创建一个Document,但更简单的方法是使用文档加载器,从不同来源加载文档,返回Document或List<Document>。常见实现:
FileSystemDocumentLoader:从本地文件系统加载。ClassPathDocumentLoader:从类路径加载。UrlDocumentLoader:从网络 URL 加载。AmazonS3DocumentLoader/AzureBlobStorageDocumentLoader等:从云存储加载。SeleniumDocumentLoader/PlaywrightDocumentLoader:通过浏览器渲染后加载
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
24public 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的MetadataTextSegment.from(String, Metadata)从文本和Metadata创建一个TextSegmentTextSegment.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[],它代表了某段内容的语义含义。
- 如果两段文本语义相近,它们在向量空间中的距离就接近,一般是比较余弦相似度
- 向量维度由使用的模型决定
在 RAG 中,检索完全依赖 Embedding:
- 把知识库中的
TextSegment预先计算为向量并存入EmbeddingStore。 - 用户提问时,将问题也转为向量,在向量库中搜索最相似的片段。
通常包括:
vector():获取浮点数组。dimension():获取维度。vectorAsList():获取列表形式。
EmbeddingModel
嵌入模型,
EmbeddingModel是专门用于将文本转换为Embedding的模型接口1
2Embedding 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 向量数据库这边就没弄,所以一直用的是
InMemoryEmbeddingStoreEmbeddingSearchRequest
封装一次向量搜索的所有参数:
属性 类型 说明 queryEmbeddingEmbedding查询向量(由 EmbeddingModel从用户问题生成)maxResultsint最大返回数量,默认 3 minScoredouble相似度阈值(0~1),低于此分数的结果会被过滤掉,默认 0 filterFilter按元数据字段过滤,只匹配符合条件的片段 Filter
在向量搜索的同时按
Metadata字段进行过滤,实现混合检索。1
2
3
4
5Filter filter = Filter.equalTo("userId", "12345");
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.filter(filter)
.build();EmbeddingSearchResult
包含一个
List<EmbeddingMatch>,每个匹配项代表一个检索到的片段。
EmbeddingMatch
大约意思就是向量的匹配项,包含:
embedding():匹配的向量score():相似度分数(0~1)embeddingId():向量存储时的 IDembedded():原始嵌入数据(通常是TextSegment,可从中获取文本和元数据)
EmbeddingStoreIngestor
嵌入存储摄取器,它是一个一键式摄取管道,负责把
Document变成向量并存入EmbeddingStore。而且它内部可以串联多个处理步骤我们就是这么使用的
API 使用示例
把上述的 API 整理成示例的代码,那么就是这样使用
要记住,所谓「注入提示」,无非是把这些命中片段的文本拼到用户问题后面,一起发给 LLM。
1 | package hbnu.project.langchain4jdemo.rag.service; |
高级 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。
- 重写默认的
- 可以通过 3 种方式自定义如何将
可配置
metadataKeysToInclude:指定要注入哪些元数据字段(如source、page_number等),让 LLM 知道信息的出处。支持从检索到的Content.textSegment()中注入Metadata条目1
2
3DefaultContentInjector.builder()
.metadataKeysToInclude(List.of("source"))
.build()
那么,官方文档中的描述的整个流程如下,表示为
- 用户生成一个
UserMessage,它被转换为一个Query。 QueryTransformer将Query转换为一个或多个Query。- 每个
Query由QueryRouter路由到一个或多个ContentRetriever。 - 每个
ContentRetriever为每个Query检索相关Content。 ContentAggregator将所有检索到的Content组合成一个最终的排名列表。- 这个
Content列表被注入到原始的UserMessage中。 - 最后,包含原始查询和注入的相关内容的
UserMessage被发送给 LLM。
其中
Query代表 RAG 管道中的用户查询。- 它包含查询的文本和查询元数据,而查询元数据包含,被增强的原始
UserMessage,@MemoryId注解的方法参数的值,所有以前的ChatMessage。
- 它包含查询的文本和查询元数据,而查询元数据包含,被增强的原始
Content代表与用户Query相关的内容。目前,它仅限于文本内容
实际上,上述内容可以并行化处理
让现在的
DefaultRetrievalAugmentor显式地支持并行化,只需要通过.executor()传入一个自定义的线程池即可。虽然默认行为已经会在多查询/多检索器时自动使用缓存线程池,但生产环境中最好手动控制线程池参数。
1 |
|
- 当你只有一个查询和一个检索器时,管道会在当前线程顺序执行,不会用到线程池。
- 一旦
ExpandingQueryTransformer扩展出多个查询,或者QueryRouter路由到多个检索器,框架就会用你提供的Executor来并行执行查询路由与内容检索,从而显著降低多路检索的延迟。 - 如果省略
.executor(...),框架会默认使用一个keepAliveTime为 1 秒的CachedThreadPool,这在并发量不可控的场景下可能会创建过多线程,所以生产环境建议自定义。







