主题
16 · RAG(检索增强生成)
检索增强生成(Retrieval-Augmented Generation, RAG)是一种在生成回答之前, 先从知识库中检索相关信息来增强 LLM 响应的技术。 它让 LLM 的输出扎根于具体的、可验证的来源,而不是只依赖训练数据。
RAG 概念的更多背景见:
- Wikipedia: Retrieval-Augmented Generation
- AWS: What is RAG?
Embabel Agent 通过 LlmReference 接口提供 RAG 支持 —— 它允许你把 reference(包括 RAG 存储)挂到 LLM 调用上。 两个关键类是 ToolishRag(把搜索操作暴露为 LLM 工具)与 SearchOperations(底层搜索能力)。
智能体式 RAG 架构(Agentic RAG Architecture)
与传统 RAG 只做单次检索不同,Embabel Agent 的 RAG 是完全智能体化、基于工具的。 LLM 对检索过程拥有完全控制权:
- 自主搜索:LLM 决定何时搜索、用什么查询、取多少条结果;
- 迭代精化:LLM 可以用不同查询做多次搜索,直到找到相关信息;
- 交叉引用发现:LLM 可以顺着引用走、扩展 chunk 看周边上下文、向上放大到父章节;
- HyDE 支持:LLM 可以生成假设性文档(HyDE 查询)来改善语义检索效果。
这种智能体式做法比"单发 RAG"效果更好,因为 LLM 可以:
- 先做宽泛搜索再逐步收窄;
- 初查效果差时换个说法再试;
- 把有希望的结果扩展开来获取更多上下文;
- 把多个 chunk 的信息组合起来。
用门面模式安全地暴露工具
Embabel Agent 用门面模式(facade pattern)在不同存储实现之上安全且一致地暴露 RAG 能力。 ToolishRag 类就是这样一个门面,它:
- 检视存储能力:检查该存储实现了哪些
SearchOperations子接口; - 只暴露合适的工具:只为受支持的操作创建工具包装;
- 提供一致的接口:无论底层存储是什么,所有工具都使用相同的参数模式。
java
@Override
public List<Tool> tools() {
List<Object> toolObjects = new ArrayList<>();
if (searchOperations instanceof VectorSearch) {
toolObjects.add(new VectorSearchTools((VectorSearch) searchOperations));
}
if (searchOperations instanceof TextSearch) {
toolObjects.add(new TextSearchTools((TextSearch) searchOperations));
}
if (searchOperations instanceof ResultExpander) {
toolObjects.add(new ResultExpanderTools((ResultExpander) searchOperations));
}
if (searchOperations instanceof RegexSearchOperations) {
toolObjects.add(new RegexSearchTools((RegexSearchOperations) searchOperations));
}
return toolObjects.stream()
.flatMap(obj -> Tool.fromInstance(obj).stream())
.toList();
}这意味着:
- Lucene 存储会暴露向量搜索、文本搜索、正则搜索以及结果扩展工具;
- Spring AI
VectorStore适配器只暴露向量搜索工具; - 只支持文本的基础存储只暴露文本搜索工具;
- 基于目录的文本搜索暴露文本搜索与正则搜索。
LLM 只会看到真正能用在该存储上的工具,从而避免"不支持的操作"在运行时炸掉。
快速开始(Getting Started)
要在 Embabel Agent 应用里使用 RAG,把 rag-core 模块与一个存储实现加进 pom.xml:
xml
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-rag-lucene</artifactId>
<version>${embabel-agent.version}</version>
</dependency>
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-rag-tika</artifactId>
<version>${embabel-agent.version}</version>
</dependency>embabel-agent-rag-lucene模块提供基于 Lucene 的向量与文本搜索;embabel-agent-rag-tika模块提供 Apache Tika 集成,用于解析各种文档格式。
数据模型(Our Model)
Embabel Agent 使用一套层级化内容模型,超越了传统的扁平 chunk 存储:
Datum(sealed 接口)
│ 核心字段:id, uri, metadata, labels()
│
├── ContentElement ─────────────────────────────────────┐ 结构化内容(不做嵌入)
│ ├── ContentRoot / NavigableDocument 带 URI 与标题的文档
│ └── ContainerSection / LeafSection 层级化文档章节
│
└── Retrievable ─────────────────────────────────────────┤ 可嵌入 / 可检索内容
├── Chunk text, parentId, embedding
│ 向量搜索的基本单位
└── NamedEntity 领域实体契约(Person、Product)
│ name、description + 领域属性
└── NamedEntityData 带 properties map 的存储格式
经 toTypedInstance() 水合关键设计点:
Datum是所有数据对象的根 sealed 接口;ContentElement分支包含不做嵌入的结构化内容(文档、章节);Retrievable分支包含带嵌入的可检索内容(chunk、entity);NamedEntity是类型化实体的领域契约;NamedEntityData是存储格式,带通用 properties map 与"水合(hydration)"支持。
内容元素(Content Elements)
ContentElement 接口是 RAG 系统中所有内容的父类型。关键子类型包括:
ContentRoot/NavigableDocument:文档层级的根,要求有 URI 与标题;Section:带标题的层级化内容划分;ContainerSection:包含其它 section 的 section;LeafSection:包含实际文本内容的 section;Chunk:传统 RAG 文本块,由切分LeafSection内容产生。
Chunk(文本块)
Chunk 是向量搜索的基本单位。每个 chunk:
- 包含一个
text字段存放内容; - 有
parentId指向其来源 section; - 带 metadata,记录其来源信息(根文档、容器 section、叶子 section);
- 可以通过文档层级计算出自己的
pathFromRoot。
这套层级模型支撑了高级 RAG 能力,例如向上放大到父章节或扩展到相邻 chunk。
SearchOperations
SearchOperations 是搜索功能的标记接口(tag interface)。 具体实现按其能力实现一个或多个子接口。 这个设计让存储只实现对它自然且高效的部分 —— 向量数据库不必假装支持全文搜索,文本搜索引擎也不必伪造向量相似度。
VectorSearch
经典语义向量搜索:
java
public interface VectorSearch extends SearchOperations {
<T extends Retrievable> List<SimilarityResult<T>> vectorSearch(
TextSimilaritySearchRequest request,
Class<T> clazz
);
}TextSearch
使用 Lucene 查询语法的全文搜索:
java
public interface TextSearch extends SearchOperations {
<T extends Retrievable> List<SimilarityResult<T>> textSearch(
TextSimilaritySearchRequest request,
Class<T> clazz
);
}支持的查询语法包括:
+term—— 该词必须出现;-term—— 该词必须不出现;"phrase"—— 精确短语匹配;term*—— 前缀通配;term~—— 模糊匹配。
ResultExpander
把搜索结果扩展到周边上下文:
java
public interface ResultExpander extends SearchOperations {
List<ContentElement> expandResult(
String id,
Method method,
int elementsToAdd
);
}扩展方式:
SEQUENCE—— 扩展到前一个与后一个 chunk;ZOOM_OUT—— 扩展到其所属的 section。
RegexSearchOperations
跨内容的模式化搜索:
java
public interface RegexSearchOperations extends SearchOperations {
<T extends Retrievable> List<SimilarityResult<T>> regexSearch(
Pattern regex,
int topK,
Class<T> clazz
);
}适合查找错误码、标识符之类的特定模式,或那些语义/关键词检索都匹配不好的结构化内容。
CoreSearchOperations
一个组合了最常见搜索能力的便利接口:
java
public interface CoreSearchOperations extends VectorSearch, TextSearch { }同时支持向量与文本搜索的存储可以实现这一个接口以图方便。
ToolishRag
ToolishRag 是一个 LlmReference,它把 SearchOperations 暴露为 LLM 工具, 从而让 LLM 对 RAG 搜索拥有细粒度控制。
配置
把你的 SearchOperations 包一层即可创建 ToolishRag:
java
public ChatActions(SearchOperations searchOperations) {
this.toolishRag = new ToolishRag(
"sources",
"Sources for answering user questions",
searchOperations
);
}与 LLM 调用配合使用
用 .withReference() 把 ToolishRag 挂到 LLM 调用上:
java
@Action(canRerun = true, trigger = UserMessage.class)
void respond(Conversation conversation, ActionContext context) {
var assistantMessage = context.ai()
.withLlm(properties.chatLlm())
.withReference(toolishRag)
.rendering("ragbot")
.respondWithSystemPrompt(conversation, Map.of(
"properties", properties
));
context.sendMessage(conversation.addMessage(assistantMessage));
}根据底层 SearchOperations 的能力,ToolishRag 会暴露:
VectorSearchTools:vectorSearch(query, topK, threshold)—— 语义相似度搜索;TextSearchTools:textSearch(query, topK, threshold)—— 带 Lucene 语法的 BM25 全文搜索;RegexSearchTools:regexSearch(regex, topK)—— 基于正则的模式化搜索;ResultExpanderTools:broadenChunk(chunkId, chunksToAdd)—— 扩展到相邻 chunk;zoomOut(id)—— 扩展到父章节。
LLM 会基于用户查询自主决定何时使用这些工具。
预置搜索(Eager Search)
默认情况下,ToolishRag 完全是智能体式的 —— LLM 决定何时搜索、用什么查询。 但当对话主题已经已知时,你可以在 LLM 开始之前预加载相关结果,让它有个好的起跑,减少所需的工具调用轮次。
ToolishRag 实现了 EagerSearch 接口,它提供 withEagerSearchAbout():
java
// 在 LLM 开始前预加载用户主题相关的结果
ToolishRag eagerRag = toolishRag
.withEagerSearchAbout("Kotlin coroutines", 10);
context.ai()
.withReference(eagerRag)
.respondWithSystemPrompt(conversation, Map.of());预加载的结果会作为提示(hints)写进提示词。 LLM 仍然可以使用全部常规搜索工具,按需做额外搜索。
要对搜索参数有更多控制,可直接传入 TextSimilaritySearchRequest:
java
var request = new TextSimilaritySearchRequest("Kotlin coroutines", 0.7, 10);
ToolishRag eagerRag = toolishRag.withEagerSearchAbout(request);把预置搜索与智能体工具结合,才是最佳平衡点: 预加载结果让 LLM 立即起跑(无需往返),而工具仍在手边, 在预加载结果不够用时可供后续搜索。 于是你同时得到传统 RAG 的低延迟与智能体式 RAG 的高质量。
⚠️ 预置搜索要求底层
SearchOperations支持VectorSearch。 如果存储不支持向量搜索,withEagerSearchAbout()会在配置阶段就抛UnsupportedOperationException。
EagerSearch<T>是com.embabel.agent.api.reference包中的一个通用接口, 适用于任何能通过相似度搜索预加载上下文的LlmReference。ToolishRag只是其中一个实现,其它 reference 类型也可以实现EagerSearch, 从而在 LLM 调用前以同一套一致模式预加载相关上下文。
ToolishRag 的生命周期
创建 ToolishRag 实例并在多次 LLM 调用间复用是安全的。 不过实例创建成本并不高,所以你也可以每次 LLM 调用新建一个。
如果你提供了一个 ResultListener 来收集查询与结果(用于日志或分析),你可能会选择后者: 例如追踪哪些查询对回答用户问题最有用、以及搜索次数所反映的复杂度。 这对实现学习反馈回路很有用 —— 比如识别出哪些查询表现糟糕,说明相关文档等内容需要改进。
结果过滤(Result Filtering)
在多租户应用、或搜索需要限定在特定数据子集的场景中,ToolishRag 支持结果过滤。 过滤器会透明地作用于所有搜索 —— LLM 看不到也控制不了它们,从而保证安全性与数据隔离。
Embabel Agent 提供两类过滤器:
- Metadata Filters(元数据过滤):过滤
Datum对象(chunk、section 等)的 metadata map; - Property Filters(属性过滤):过滤类型化实体的对象属性(例如
NamedEntityData的字段或自定义实体类的字段)。
两者使用同一个 PropertyFilter 类型,只是应用在不同层级。
动机
考虑一个文档管理系统:
- 每个文档属于某个所有者(用户或组织);
- 有些文档是所有人可访问的共享参考资料;
- LLM 只应搜索当前用户有权限访问的文档。
没有过滤,你要么为每个用户建一套独立 RAG 存储,要么冒着数据泄漏的风险。 有了过滤,一个 ToolishRag 实例就能按请求限定到当前用户的数据。
过滤 API
Embabel Agent 为 RAG 搜索提供两个过滤接口:
PropertyFilter:过滤基于 map 的属性(metadata、实体属性);EntityFilter:扩展PropertyFilter以增加实体特有的过滤,尤其是基于 label 的过滤。
PropertyFilter
PropertyFilter 的 sealed 类层级为基于 map 的属性提供类型安全的过滤表达式:
| 过滤类型 | 说明 | 示例 |
|---|---|---|
Eq | 等于 | PropertyFilter.eq("owner", "alice") |
Ne | 不等于 | PropertyFilter.ne("status", "deleted") |
Gt, Gte | 大于(或大于等于) | PropertyFilter.gte("score", 0.8) |
Lt, Lte | 小于(或小于等于) | PropertyFilter.lt("priority", 5) |
In | 值在列表中 | PropertyFilter.in("category", "tech", "science") |
Nin | 值不在列表中 | PropertyFilter.nin("status", "deleted", "archived") |
Contains | 字符串包含子串 | PropertyFilter.contains("tags", "important") |
And | 逻辑与 | PropertyFilter.and(filter1, filter2) |
Or | 逻辑或 | PropertyFilter.or(filter1, filter2) |
Not | 逻辑非 | PropertyFilter.not(filter) |
EntityFilter
EntityFilter 扩展 PropertyFilter 以增加实体特有的过滤。目前它通过 HasAnyLabel 增加了基于 label 的过滤:
| 过滤类型 | 说明 | 示例 |
|---|---|---|
HasAnyLabel | 匹配带有任意一个指定 label 的实体 | EntityFilter.hasAnyLabel("Person", "Organization") |
HasAnyLabel 特别适用于:
- 类型安全的实体搜索:把结果限定为特定实体类型;
- 多类型查询:一次查询跨多个实体类型搜索。
java
import com.embabel.agent.rag.filter.EntityFilter;
import com.embabel.agent.filter.PropertyFilter;
// 按单个 label 过滤
EntityFilter personFilter = EntityFilter.hasAnyLabel("Person");
// 按多个 label 过滤(OR 语义 —— 实体需带有其中任意一个 label)
EntityFilter entityFilter = EntityFilter.hasAnyLabel("Person", "Organization");
// 用流式 API 组合 HasAnyLabel 与属性过滤
PropertyFilter simpleCombo = EntityFilter.hasAnyLabel("Person")
.and(PropertyFilter.eq("status", "active"));
// 多个条件
PropertyFilter complexFilter = EntityFilter.hasAnyLabel("Person")
.and(PropertyFilter.eq("status", "active"))
.and(PropertyFilter.gte("score", 0.8));
// OR 组合
PropertyFilter orFilter = EntityFilter.hasAnyLabel("Person")
.or(PropertyFilter.eq("fallback", true));
// 带取反
PropertyFilter notDeleted = EntityFilter.hasAnyLabel("Person")
.and(PropertyFilter.not(PropertyFilter.eq("status", "deleted")));
// 复杂分组
PropertyFilter accessFilter = PropertyFilter.or(
PropertyFilter.and(
EntityFilter.hasAnyLabel("Person", "Employee"),
PropertyFilter.eq("active", true)
),
PropertyFilter.eq("role", "admin")
);由于 EntityFilter 扩展了 PropertyFilter,所有过滤类型共享同样的 and、or、not 操作符,可以自由组合。
EntityFilter.HasAnyLabel通常通过内存过滤处理,因为大多数向量存储没有原生的 label 支持。 使用 Neo4j 后端时,label 可以被翻译成原生 Cypher label 谓词,从而获得最优性能。
限制:不支持嵌套属性
过滤器目前只作用于顶层属性。形如 "address.city" 或 "metadata.source" 的嵌套属性路径不受支持。 过滤键必须匹配 metadata map 中的直接键,或实体对象上的顶层属性。
例如:
PropertyFilter.eq("owner", "alice")—— 支持:过滤顶层的owner属性;PropertyFilter.eq("address.city", "London")—— 不支持:嵌套路径不会匹配。
Kotlin 操作符语法
Kotlin 用户可以使用操作符与中缀函数,写出更自然的 DSL:
kotlin
import com.embabel.agent.filter.PropertyFilter
// 带 not 操作符的简单过滤
PropertyFilter notDeleted = PropertyFilter.not(PropertyFilter.eq("status", "deleted"))
// 用 and / or 组合
PropertyFilter userAccess = PropertyFilter.and(
PropertyFilter.eq("owner", userId),
PropertyFilter.gte("confidenceScore", 0.7)
)
// 带分组的复杂表达式
PropertyFilter accessFilter = PropertyFilter.or(
PropertyFilter.and(
PropertyFilter.eq("owner", userId),
PropertyFilter.ne("status", "deleted")
),
PropertyFilter.eq("role", "admin")
)元数据过滤 vs 实体过滤
ToolishRag 接受两个独立的过滤参数:
metadataFilter:一个PropertyFilter,过滤Datum对象的 metadata map。 Metadata 通常是入库时的信息,如来源 URI、入库日期、所有者 ID 等;entityFilter:一个EntityFilter,过滤实体属性与 label。 对NamedEntityData而言,它过滤 properties map 与labels(); 对类型化实体而言,则用反射访问其顶层字段。
java
// 过滤 metadata(例如文档属于哪个用户)
PropertyFilter metadataFilter = PropertyFilter.eq("ownerId", currentUserId);
// 过滤实体 label 与属性
EntityFilter entityFilter = EntityFilter.hasAnyLabel("Person");
// 同时应用两个过滤
ToolishRag scopedRag = toolishRag
.withMetadataFilter(metadataFilter)
.withEntityFilter(entityFilter);大多数情况下,你会用 metadata filter 做访问控制,用 entity filter 做基于类型与业务逻辑的过滤。
Neo4j Cypher 过滤
通过 Drivine 模块使用 Neo4j 时,metadata 过滤器会被 CypherFilterConverter自动转换成 Cypher WHERE 子句:
java
// 该过滤会被自动转换成 Cypher WHERE 子句
PropertyFilter filter = PropertyFilter.and(
PropertyFilter.eq("owner", "alice"),
PropertyFilter.gte("confidenceScore", 0.7)
);
// 在 DrivineNamedEntityDataRepository 中:
List<SimilarityResult<T>> results = repository.vectorSearch(request, filter);
// 生成:WHERE (e.owner = $_filter_0) AND (e.confidenceScore >= $_filter_1) AND ...转换器产出参数化查询以保证安全,并处理所有过滤类型,包括嵌套的逻辑表达式。
对 DrivineStore(chunk)与 DrivineNamedEntityDataRepository(命名实体)而言, metadata 与 property 过滤器都会被翻译为原生 Cypher WHERE 子句。 原因是 Neo4j 把所有数据都存为节点属性 —— metadata 不过是"非核心字段(id、text、parentId 等)的那些属性"。 这通过在数据库层过滤而非内存过滤,提供了最优性能。
基本用法
应用一个 metadata 过滤器,把所有搜索限定到某个所有者:
java
// 为当前用户创建过滤器
PropertyFilter ownerFilter = PropertyFilter.eq("ownerId", currentUserId);
// 应用到 ToolishRag —— 所有搜索都会被过滤
ToolishRag scopedRag = toolishRag.withMetadataFilter(ownerFilter);
// 在 LLM 调用中使用 —— LLM 看不到、也无法绕过该过滤
context.ai()
.withReference(scopedRag)
.respondWithSystemPrompt(conversation, Map.of());复杂过滤器
组合过滤器以实现更精细的访问控制:
java
// 用户可以访问自己的文档,或其所属部门的文档
PropertyFilter accessFilter = PropertyFilter.or(
PropertyFilter.eq("ownerId", currentUserId),
PropertyFilter.in("departmentId", userDepartmentIds)
);
ToolishRag scopedRag = toolishRag.withMetadataFilter(accessFilter);
// 组织范围内、且带状态限制
PropertyFilter orgFilter = PropertyFilter.and(
PropertyFilter.eq("orgId", currentOrgId),
PropertyFilter.ne("status", "deleted"),
PropertyFilter.gte("confidenceScore", 0.7)
);
ToolishRag scopedRag2 = toolishRag.withMetadataFilter(orgFilter);按请求限定范围的模式
Web 应用中一个常见模式是每个请求创建一个被限定范围的 ToolishRag:
java
@Action(trigger = UserMessage.class)
void respond(Conversation conversation, ActionContext context) {
// 从安全上下文中取当前用户
String userId = SecurityContextHolder.getContext()
.getAuthentication().getName();
// 为本次请求创建用户范围限定的 RAG
ToolishRag userScopedRag = toolishRag.withMetadataFilter(
PropertyFilter.eq("ownerId", userId)
);
context.ai()
.withReference(userScopedRag)
.rendering("assistant")
.respondWithSystemPrompt(conversation, Map.of());
}后端实现
过滤器在不同后端上应用在不同层级:
- Spring AI
VectorStore:metadata 过滤器被翻译为Filter.Expression以做原生过滤; 实体过滤器(含HasAnyLabel)在内存中应用; - Neo4j(Drivine):metadata 与实体过滤器(含
HasAnyLabel)都被翻译为原生 CypherWHERE子句与 label 谓词(性能最优); - Lucene:两类过滤器都作为 post-filter 应用,并抬高
topK以补偿被过滤掉的结果; - 自定义存储:可以实现
FilteringVectorSearch/FilteringTextSearch做原生翻译,或退化为内存过滤。
InMemoryPropertyFilter 工具类为任何存储实现提供兜底过滤:
java
// 在你的 SearchOperations 实现中
List<SimilarityResult<T>> results = performSearch(request);
return InMemoryPropertyFilter.filterResults(results, metadataFilter, entityFilter);对 EntityFilter.HasAnyLabel,内存过滤器会通过 NamedEntityData.labels() 检查该实体是否带有任意一个指定 label。
这确保了过滤在所有后端上都能工作,并在可用之处对 metadata 过滤器做原生优化。
入库(Ingestion)
用 Tika 解析文档
Embabel Agent 使用 Apache Tika 做文档解析。 TikaHierarchicalContentReader 读取各种格式(Markdown、HTML、PDF、Word 等)并提取出层级结构:
java
@ShellMethod("Ingest URL or file path")
String ingest(@ShellOption(defaultValue = "./data/document.md") String location) {
var uri = location.startsWith("http://") || location.startsWith("https://")
? location
: Path.of(location).toAbsolutePath().toUri().toString();
var ingested = NeverRefreshExistingDocumentContentPolicy.INSTANCE
.ingestUriIfNeeded(
luceneSearchOperations,
new TikaHierarchicalContentReader(),
uri
);
return ingested != null
? "Ingested document with ID: " + ingested
: "Document already exists, no ingestion performed.";
}切块配置(Chunking Configuration)
内容会被切分成 chunk,参数可配置:
yaml
ragbot:
chunker-config:
max-chunk-size: 800
overlap-size: 100配置项:
maxChunkSize—— 每个 chunk 的最大字符数(默认1500);overlapSize—— 相邻 chunk 之间的字符重叠数(默认200);includeSectionTitleInChunk—— 是否把 section 标题包含进 chunk 文本(默认true)。
Chunk 变换(Chunk Transformation)
从文档切出来的 chunk 往往缺乏有效检索所需的上下文。 一个写着 "This approach improves performance by 40%" 的 chunk, 如果读者不知道 "this approach" 指什么,就没什么用。 ChunkTransformer 接口让你在 chunk 被索引之前用额外上下文增强它们。
urtext 字段
每个 Chunk 都有两个文本字段:
text—— 被索引的内容,可能已被加上额外上下文而变换过;urtext—— 原始的、未修改的 chunk 文本。
urtext 字段保留原始内容以支持准确的引用。 向用户展示搜索结果时,用 urtext 显示源文档中确切的原文, 而把增强后的 text 用于向量嵌入与搜索。
AddTitlesChunkTransformer
推荐的默认变换器是 AddTitlesChunkTransformer,它会给每个 chunk 前置文档与章节标题:
java
@Bean
ChunkTransformer chunkTransformer() {
return AddTitlesChunkTransformer.INSTANCE;
}它会把这样的 chunk:
This approach improves performance by 40% compared to the baseline.变成:
# Title: Performance Optimization Guide
# URI: https://docs.example.com/performance
# Section: Caching Strategies
This approach improves performance by 40% compared to the baseline.现在这个 chunk 自带上下文,从而同时提升检索准确率与LLM 的理解。
自定义变换器
你可以实现 ChunkTransformer 或继承 AbstractChunkTransformer 来写自定义变换器:
java
public class MetadataEnrichingTransformer extends AbstractChunkTransformer {
@Override
public Map<String, Object> additionalMetadata(
Chunk chunk,
ChunkTransformationContext context) {
return Map.of(
"documentType", context.getDocument().getMetadata().get("type"),
"lastModified", Instant.now().toString()
);
}
@Override
public String newText(Chunk chunk, ChunkTransformationContext context) {
// 可选:修改文本
return chunk.getText();
}
}ChunkTransformationContext 提供对以下内容的访问:
section—— 包含该 chunk 的Section;document——ContentRoot(对孤立 section 可能为null)。
链式变换器
用 ChainedChunkTransformer 按顺序应用多个变换:
java
@Bean
ChunkTransformer chunkTransformer() {
return new ChainedChunkTransformer(List.of(
AddTitlesChunkTransformer.INSTANCE,
new MetadataEnrichingTransformer(),
new CustomCleanupTransformer()
));
}变换器按顺序应用,每个接收前一个的输出。
配置存储
把你的 ChunkTransformer 传给存储实现:
java
@DependsOn("onnxEmbeddingInitializer") // ①
@Bean
DrivineStore drivineStore(
PersistenceManager persistenceManager,
EmbeddingService embeddingService,
ChunkTransformer chunkTransformer, // ②
MyProperties properties) {
return new DrivineStore(
persistenceManager,
properties.neoRag(),
properties.chunkerConfig(),
chunkTransformer, // ③
embeddingService,
platformTransactionManager,
new DrivineCypherSearch(persistenceManager)
);
}① 确保
EmbeddingServicebean 在本配置装配之前已注册(见下方说明) ② 注入ChunkTransformerbean ③ 传给存储构造器
⚠️
EmbeddingServicebean 是由各模型提供方的自动配置通过registerSingleton动态注册的。 如果你的@Configuration类像上面那样直接注入EmbeddingService, 就应当在提供方的 initializer bean 上加@DependsOn—— 例如 ONNX 提供方的@DependsOn("onnxEmbeddingInitializer")。 不加的话,Spring 可能在 initializer 运行之前就解析该依赖,导致NoSuchBeanDefinitionException。 只在直接消费模型 bean 时才需要这样做;像ModelProvider这类框架 bean 会在内部处理。
对大多数用例来说,AddTitlesChunkTransformer 就够了。 它加入了必不可少的上下文,显著提升检索质量,而不增加复杂度。
用 Docling 做 Markdown 转换
虽然我们认为你应当用 Java 或 Kotlin 写生成式 AI 应用, 但入库(ingestion)更偏数据科学领域,而 Python 在这块无疑很强。
对于 PDF 这类复杂文档,可以考虑先用 Docling 转成 Markdown:
bash
docling https://example.com/document.pdf --from pdf --to md --output ./dataMarkdown 比从 PDF 直接抽取更容易做层级解析,切出的 chunk 质量也更好。
支持的存储
Embabel Agent 提供若干 RAG 存储实现:
Lucene(embabel-agent-rag-lucene)
功能完备的存储,支持向量搜索、文本搜索与结果扩展。同时支持内存与基于文件的持久化:
java
@Bean
LuceneSearchOperations luceneSearchOperations(
ModelProvider modelProvider,
RagbotProperties properties) {
var embeddingService = modelProvider.getEmbeddingService(
DefaultModelSelectionCriteria.INSTANCE);
return LuceneSearchOperations
.withName("docs")
.withEmbeddingService(embeddingService)
.withChunkerConfig(properties.chunkerConfig())
.withIndexPath(Paths.get("./.lucene-index")) // 文件持久化
.buildAndLoadChunks();
}只想要内存存储就省略 .withIndexPath()。
Neo4j
面向 RAG 的图数据库存储(位于独立模块 embabel-agent-rag-neo-drivine 与 embabel-agent-rag-neo-ogm)。 当你需要内容元素之间的图关系时最理想。
PostgreSQL pgvector(embabel-rag-pgvector)
基于 PostgreSQL 的 RAG 存储,使用 pgvector 扩展 (位于独立的 embabel/embabel-rag-pgvector 仓库)。 支持混合搜索:向量相似度 + 基于 tsvector/tsquery 的全文搜索 + 基于 pg_trgm 的模糊匹配。 当你已经在用 PostgreSQL、希望为 RAG 用一个熟悉且久经考验的数据库时最理想。
Spring AI VectorStore(SpringVectorStoreVectorSearch)
包装任意 Spring AI VectorStore 的适配器,让你能用上 Spring AI 支持的任何向量数据库:
java
public class SpringVectorStoreVectorSearch implements VectorSearch {
private final VectorStore vectorStore;
public SpringVectorStoreVectorSearch(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@Override
public <T extends Retrievable> List<SimilarityResult<T>> vectorSearch(
TextSimilaritySearchRequest request,
Class<T> clazz) {
SearchRequest searchRequest = SearchRequest
.builder()
.query(request.getQuery())
.similarityThreshold(request.getSimilarityThreshold())
.topK(request.getTopK())
.build();
List<Document> results = vectorStore.similaritySearch(searchRequest);
// ... 转换结果
}
}这让你可以经 Spring AI 接入 Pinecone、Weaviate、Milvus、Chroma 等存储。
实现自己的 RAG 存储
要实现自定义 RAG 存储,只实现对你的存储来说自然且高效的 SearchOperations 子接口。 这是一条关键设计原则:存储只应实现它能做好的部分。
例如:
- 像 Pinecone 这样的向量数据库可能只实现
VectorSearch—— 那正是它的强项; - 一个全文搜索引擎可能实现
TextSearch与RegexSearchOperations; - 一个层级文档存储可能额外实现
ResultExpander做上下文扩展; - 像 Lucene 这样的功能完备存储可以实现所有接口。
ToolishRag 门面会自动只暴露你的存储支持的工具。 这意味着你不需要提供桩实现,也不需要抛 "not supported" 异常 —— 不合适的能力,不实现那个接口就行。
java
// 只支持向量搜索的存储
public class MyVectorOnlyStore implements VectorSearch {
@Override
public <T extends Retrievable> List<SimilarityResult<T>> vectorSearch(
TextSimilaritySearchRequest request,
Class<T> clazz) {
// 实现向量相似度搜索
}
}
// 同时支持向量与文本搜索的存储
public class MyFullTextStore implements VectorSearch, TextSearch {
@Override
public <T extends Retrievable> List<SimilarityResult<T>> vectorSearch(
TextSimilaritySearchRequest request,
Class<T> clazz) {
// 实现向量相似度搜索
}
@Override
public <T extends Retrievable> List<SimilarityResult<T>> textSearch(
TextSimilaritySearchRequest request,
Class<T> clazz) {
// 实现全文搜索
}
@Override
public String getLuceneSyntaxNotes() {
return "Full Lucene syntax supported";
}
}要支持入库,请继承 ChunkingContentElementRepository 来处理文档存储与切块。
完整示例(Complete Example)
完整可运行的例子见 rag-demo 项目,包括:
- 基于 Lucene 的 RAG 存储配置;
- 通过 Tika 做文档入库;
- 带 RAG 增强响应的聊天机器人;
- 用于系统提示词的 Jinja 提示词模板;
- 用于交互式测试的 Spring Shell 命令。