从向量检索到混合搜索优化:Spring AI 2.0 + pgvector RAG 实战
示例基于 Spring Boot 4.1.0、Spring AI 2.0.1、Java 25 和 PostgreSQL + pgvector。
类名和扩展点均来自真实 API。为了突出主流程,代码省略了部分 import、DTO、异常处理和数据库映射。
常见的 RAG Demo 只有三步:切分文档、写入向量库、按相似度召回。接到真实业务后,问题很快就会变复杂。型号、编号和专有名词更适合关键词检索;语义问法需要向量检索;两路结果要统一排序;候选文档还要经过 reranker 精排。已有业务表往往还带着租户、权限、软删除和 JOIN,也很难直接套用 Spring AI 的默认表结构。
这篇文章以 pgvector 为主,完成下面几件事:
- 用 PostgreSQL 全文检索和向量检索组成混合召回
- 讨论让现有的业务表,通过自定义VectorStore 或
DocumentRetriever快速接入 Spring AI RAG - 用加权 RRF(Reciprocal Rank Fusion)合并两路排名
- 通过Spring AI扩展点、
DocumentPostProcessor接入 reranker 模型 - 自定义 TextSplitter,处理 overlap 和自定义语义断点的分块
- 设计一个便于观察召回效果的
/search接口
组合后的链路如下:
文档入库:Reader -> TextSplitter -> https://zhida.zhihu.com/search?content_id=283320772&content_type=Article&match_order=1&q=EmbeddingModel&zhida_source=entity -> PgVectorStore
在线检索:Query -> Vector Search -----\
+-> Weighted RRF -> Reranker -> topK
-> Keyword Search --/
RAG 问答:Query Transform/Expand -> Retrieve -> Join -> Post-process
-> Prompt Augment -> ChatModel
1. Spring AI 的 RAG 组件
Spring AI 将 RAG 分成离线入库和在线检索生成两条链路,vectorStore.similaritySearch(...) 只是其中一个环节。
1.1 离线入库:ETL
离线侧采用 ETL 抽象,由三部分组成:
| 阶段 | 核心接口 | 常见实现或用途 |
|---|---|---|
| Extract | DocumentReader | PDF、Markdown、Text、Tika、JSON 等读取器 |
| Transform | DocumentTransformer | TextSplitter、TokenTextSplitter,也可以自定义切分器 |
| Load | DocumentWriter | VectorStore 本身就是一种 writer |
最小入库链路是:
// 伪代码:读取 -> 切块 -> 生成 embedding 并写入向量库
List<Document> sourceDocuments = documentReader.read();
List<Document> chunks = textSplitter.apply(sourceDocuments);
vectorStore.add(chunks);
1.2 在线检索与生成:模块化 RAG
在线侧按以下阶段执行:
| 阶段 | Spring AI 扩展点 | 典型能力 |
|---|---|---|
| Pre-Retrieval | QueryTransformer | 对话压缩、问题改写、翻译 |
| Pre-Retrieval | QueryExpander | 把一个问题扩成多个语义变体 |
| Retrieval | DocumentRetriever | 向量、搜索引擎、SQL、知识图谱或混合检索 |
| Retrieval | DocumentJoiner | 合并多查询或多数据源结果并去重 |
| Post-Retrieval | DocumentPostProcessor | rerank、去噪、压缩、截断 |
| Generation | QueryAugmenter | 把检索上下文拼入 prompt |
| Orchestration | RetrievalAugmentationAdvisor | 编排上述模块并接入 ChatClient |
Spring AI 还提供较简单的 QuestionAnswerAdvisor。如果只需要"向量检索后把文本塞进 prompt",它足够方便;如果要组合多查询、混合召回、RRF 和 rerank,RetrievalAugmentationAdvisor 更适合。
1.3 框架能力与业务扩展的边界
框架与应用代码的职责边界如下:
| 能力 | 来源 |
|---|---|
| VectorStore、SearchRequest、metadata filter | Spring AI 原生 |
| PgVectorStore、HNSW/IVFFLAT、cosine/L2/inner product | Spring AI pgvector 实现 |
| MultiQueryExpander、ConcatenationDocumentJoiner | Spring AI 模块化 RAG |
| DocumentPostProcessor rerank 扩展点 | Spring AI 原生接口 |
| 某个具体 rerank 服务的 HTTP 客户端 | 通常由应用实现 |
| PostgreSQL 全文关键词检索 | 应用自定义 DocumentRetriever 或检索服务 |
| 加权 RRF | 应用层算法 |
| 递归 overlap 切分、语义切分 | 应用自定义 TextSplitter |
2. pgvector 环境准备
2.1 Maven 依赖
示例只需要以下依赖,版本统一交给 Spring AI BOM 管理。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- 模块化 RAG:RetrievalAugmentationAdvisor 等 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>
<!-- 仅使用 QuestionAnswerAdvisor 时需要;模块化 RAG 本身不依赖它 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 按实际文件类型选装 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
</dependencies>
2.2 建表与索引
schema 交给 Flyway、Liquibase 或独立 SQL 管理时,应关闭 Spring AI 自动建表。建表脚本同时准备向量索引和混合搜索使用的全文索引:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS vector_store (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
content text NOT NULL,
metadata json NOT NULL DEFAULT '{}'::json,
embedding vector(1024) NOT NULL
);
CREATE INDEX IF NOT EXISTS vector_store_embedding_idx
ON vector_store USING HNSW (embedding vector_cosine_ops);
-- 只有开启关键词时才需要。
CREATE INDEX IF NOT EXISTS vector_store_content_fts_idx
ON vector_store USING GIN (to_tsvector('simple', content));
1024 是示例 embedding 模型的输出维度,并非固定值。模型、已有向量、vector(N) 和索引 operator class 必须匹配。
2.3 Spring 配置
spring:
datasource:
url: jdbc:postgresql://localhost:5432/rag_demo
username: postgres
password: ${POSTGRES_PASSWORD}
ai:
openai:
embedding:
base-url: ${EMBEDDING_BASE_URL}
api-key: ${EMBEDDING_API_KEY}
model: text-embedding-v4
vectorstore:
pgvector:
initialize-schema: false
schema-validation: true
schema-name: public
table-name: vector_store
dimensions: 1024
index-type: HNSW
distance-type: COSINE_DISTANCE
这些配置会直接影响表结构与查询行为:
| 参数 | 作用 | 注意点 |
|---|---|---|
| dimensions | embedding 维度 | 建议显式配置,并与 vector(N) 一致 |
| distance-type | 运行时距离运算 | cosine 对应 <=> 和 vector_cosine_ops |
| index-type | 自动初始化时使用的索引类型 | initialize-schema=false 时它不会替你创建索引 |
| initialize-schema | 是否让 Spring AI 建表 | 生产通常交给 Flyway/Liquibase/SQL 管理 |
| schema-validation | 启动时校验必要结构 | 不能替代索引和 embedding 模型一致性检查 |
| schema-name / table-name | 修改表位置 | 不能修改四个标准列名 |
在 cosine 模式下,PgVectorStore 的查询可近似理解为:
SELECT *, embedding <=> :queryVector AS distance
FROM public.vector_store
WHERE embedding <=> :queryVector < 1 - :similarityThreshold
AND metadata::jsonb @@ :filterJsonPath
ORDER BY distance
LIMIT :topK;
返回的 Document.score 是 1 - distance,原始距离会放进 metadata.distance。
3. 文档入库与切分
3.1 根据文件类型选择 Reader
// 伪代码:按扩展名选择解析器
DocumentReader reader = switch (extension(filename)) {
case "pdf" -> new PagePdfDocumentReader(resource);
case "md", "markdown" -> new MarkdownDocumentReader(resource, markdownConfig);
case "txt", "text" -> new TextReader(resource);
default -> new TikaDocumentReader(resource); // docx/pptx/html/rtf...
};
Reader 负责把源文件转换成 Document,长文档仍需经过 Splitter 才适合检索。
3.2 文件解析到最小入库服务
在引入spring ai rag模块后,我们可以快速实现PDF/Word文档的接入和向量库入口。
// 伪代码
int parseAndSave(File file, String tenantId, Map<String, Object> businessMetadata) {
DocumentReader reader = chooseReader(file);
List<Document> chunks = textSplitter.apply(reader.read());
for (Document chunk : chunks) {
// tenantId 是强制隔离字段,不允许被外部 metadata 覆盖。
copyNonNullMetadataExceptReservedKeys(chunk, businessMetadata);
chunk.getMetadata().put("tenantId", requireTenant(tenantId));
}
// PgVectorStore 在这里调用 EmbeddingModel,并批量写入 id/content/metadata/embedding。
vectorStore.add(chunks);
return chunks.size();
}
metadata 通常承载租户、文档类型、语言、权限标签、来源和版本,查询时还会参与过滤。它的 key 和类型应在入库阶段固定下来。
3.3 文档分块TokenTextSplitter
Spring AI 2.0.1 已经允许配置 tokenizer、chunk 大小和中英文标点:
TextSplitter splitter = TokenTextSplitter.builder()
.withEncodingType(EncodingType.CL100K_BASE)
.withChunkSize(800)
.withMinChunkSizeChars(350)
.withMinChunkLengthToEmbed(10)
.withMaxNumChunks(10_000)
.withKeepSeparator(true)
.withPunctuationMarks(List.of('。', '!', '?', ';', '\n', '.', '!', '?', ';'))
.build();
| 参数 | 含义 |
|---|---|
| encodingType | token 估算编码,默认 CL100K_BASE |
| chunkSize | 目标 token 数,默认 800 |
| minChunkSizeChars | 尝试寻找断句位置前的最小字符数 |
| minChunkLengthToEmbed | 过短块过滤阈值 |
| maxNumChunks | 单文档最大切块数 |
| keepSeparator | 是否保留换行等分隔符 |
| punctuationMarks | 句子边界标点,中文场景应显式补齐 |
官方实现没有 overlap 参数。如果文档经常在 chunk 边界处丢上下文,可以继承抽象类 TextSplitter。
3.4 自定义递归 + overlap 切分器示例
spring ai中可以非常容易的自定义切分器,需要实现
splitText(String text):
// 伪代码:省略 tokenCount、splitKeepingSeparator 等辅助方法
final class RecursiveTokenTextSplitter extends TextSplitter {
private final int chunkSize;
private final int chunkOverlap;
private final List<String> separators = List.of(
"\n\n", "\n", "。", "!", "?", ";", ". ", "! ", "? ", ",", ", ", " ", ""
);
@Override
protected List<String> splitText(String text) {
if (text == null || text.isBlank()) {
return List.of();
}
return splitRecursively(text, separators);
}
List<String> splitRecursively(String text, List<String> levels) {
String separator = firstSeparatorPresentIn(text, levels);
List<String> pieces = splitAndKeepSeparator(text, separator);
List<String> result = new ArrayList<>();
for (String piece : pieces) {
if (tokenCount(piece) <= chunkSize) {
appendToGreedyWindow(result, piece, chunkSize, chunkOverlap);
}
else {
result.addAll(splitRecursively(piece, finerLevelsAfter(separator)));
}
}
return result;
}
}
设计要点是:
- 优先按段落切,仍过长才逐级退化到行、句号、逗号、空格和字符
- 大小用 token 衡量,而不是简单字符数
- 每个 chunk 落袋后保留尾部约
chunkOverlaptoken 进入下一块 - 分隔符跟随前一句,避免重组时丢标点
- 强制
0 <= chunkOverlap < chunkSize
3.5 语义切分器
主题跳跃明显的文档还可以按语义断点切分:
// 伪代码
List<String> semanticSplit(String text) {
List<String> sentences = splitSentences(text);
List<float[]> vectors = embeddingModel.embedInBatches(sentences);
double[] adjacentDistances = new double[sentences.size() - 1];
for (int i = 0; i < adjacentDistances.length; i++) {
adjacentDistances[i] = cosineDistance(vectors.get(i), vectors.get(i + 1));
}
double breakpoint = percentile(adjacentDistances, 95);
List<String> semanticGroups = breakWhereDistanceIsGreaterThan(sentences, breakpoint);
// 语义组仍然可能过长,最终必须有长度兜底。
return semanticGroups.stream()
.flatMap(group -> recursiveSplitter.splitText(group).stream())
.toList();
}
语义切分会在入库时额外计算句子 embedding。先用递归切分建立基线,再根据离线评测结果决定是否承担这部分成本。
3.6 切分参数怎么选
| 参数 | 起始建议 | 调大后的影响 | 调小后的影响 |
|---|---|---|---|
| chunk-tokens | 500-1000 | 上下文完整,但召回噪声和 rerank token 增加 | 更聚焦,但语义容易割裂 |
| chunk-overlap | chunk 的 10%-20% | 边界召回更稳,但重复内容和存储增加 | 重复少,但跨块信息容易丢失 |
| 语义断点分位数 | 90-95 | 分位越大,断点越少、块越大 | 断点更多、块更碎 |
最终参数应由真实问答集上的 recall、MRR/NDCG、答案正确率、延迟和成本共同决定。
4. 编写控制器 /search 测试召回结果
先提供一个只做检索、不调用聊天模型的接口,方便单独检查召回和排序结果。
// 接近真实代码;省略 metadata JSON 解析和参数校验细节
@GetMapping("/search")
List<SearchHit> search(
@RequestParam String query,
@RequestParam String tenantId,
@RequestParam(required = false) String metadata,
@RequestParam(defaultValue = "0.5") Double similarityThreshold,
@RequestParam(defaultValue = "4") Integer topK) {
Map<String, Object> filters = parseJsonObject(metadata);
return ragService.search(query, tenantId, filters, similarityThreshold, topK)
.stream()
.map(doc -> new SearchHit(
doc.getId(),
doc.getText(),
doc.getMetadata(),
doc.getScore(),
originalVectorScore(doc)))
.toList();
}
4.1 参数表
| 参数 | 必填 | HTTP 默认值 | 作用范围 | 说明 |
|---|---|---|---|---|
| query | 是 | 无 | 向量、关键词、rerank | 用户查询文本 |
| tenantId | 是 | 无 | 强制租户隔离,不能被 metadata 覆盖 | |
| metadata | 否 | 空 | JSON 对象,各字段与 tenantId 按 AND 组合 | |
| similarityThreshold | 否 | 0.5 | ||
| topK | 否 | 4 | 最终输出 | 开启 hybrid/rerank 后不等于底层每一路候选数 |
Web 层应校验参数范围,例如把 similarityThreshold 限定在 [0, 1],并限制 topK 的最大值,避免一次请求产生过大的数据库查询和 rerank 开销。
@RequestParam 配置了 defaultValue 后,即使请求没有传参,Controller 也会向 Service 传入非空值。因此,chat.rag.top-k 和 chat.rag.similarity-threshold 只能充当 Service 的兜底。可以让两处默认值保持一致,也可以移除 Controller 默认值,由 Service 统一决定。
4.2 请求示例
curl -G 'http://localhost:8080/ai-health-assistant/search' \
--data-urlencode 'query=Spring AI 如何配置 pgvector' \
--data-urlencode 'tenantId=acme' \
--data-urlencode 'metadata={"category":"manual","lang":"zh"}' \
--data-urlencode 'similarityThreshold=0.5' \
--data-urlencode 'topK=5'
4.3 构造强制过滤条件
// 伪代码:tenantId 永远由可信参数产生,外部 metadata 中的同名 key 被忽略
Filter.Expression buildFilter(String tenantId, Map<String, Object> filters) {
FilterExpressionBuilder b = new FilterExpressionBuilder();
Op expression = b.eq("tenantId", requireTenant(tenantId));
for (var entry : filters.entrySet()) {
if (entry.getValue() != null && !entry.getKey().equals("tenantId")) {
expression = b.and(expression, b.eq(entry.getKey(), entry.getValue()));
}
}
return expression.build();
}
租户过滤不能只放在可由客户端覆盖的 advisor context 中。VectorStoreDocumentRetriever 的 request-specific filter 会替换构造时的默认 filter,而不是自动与默认条件做 AND。租户和 ACL 应在不可覆盖的业务层合并。
4.4 纯向量检索
// 伪代码
List<Document> vectorSearch(
String query,
String tenantId,
Map<String, Object> filters,
double threshold,
int candidateTopK) {
SearchRequest request = SearchRequest.builder()
.query(query)
.similarityThreshold(threshold)
.topK(candidateTopK)
.filterExpression(buildFilter(tenantId, filters))
.build();
return vectorStore.similaritySearch(request);
}
5. 混合召回:关键词检索 + 向量检索
5.1 关键词检索适合哪些内容
向量检索擅长语义近似,关键词检索擅长字面精确命中。以下内容往往更依赖关键词:
- 产品型号、工单号和法规编号
- 人名、缩写和内部系统名
- 代码标识符
- embedding 模型不熟悉的新词
- 用户明确输入的短语
PostgreSQL 已内置全文检索,可以直接对 vector_store.content 增加第二条召回。
5.2 关键词 SQL
SELECT
id::text AS id,
content,
metadata::text AS metadata,
ts_rank_cd(
to_tsvector('simple', content),
websearch_to_tsquery('simple', :query)
) AS keyword_score
FROM vector_store
WHERE metadata ->> 'tenantId' = :tenantId
AND to_tsvector('simple', content)
@@ websearch_to_tsquery('simple', :query)
ORDER BY keyword_score DESC
LIMIT :candidateTopK;
ts-config 必须与 GIN 表达式索引一致。默认 simple 对英文、数字和按空白分隔的文本友好,但对连续中文分词较弱。中文关键词召回要求较高时,可以安装 zhparser 或 pg_jieba,同时修改建索引和查询使用的配置。
上线前用 EXPLAIN (ANALYZE, BUFFERS) 确认查询是否命中全文索引,尤其是在动态传入 regconfig 时。
5.3 关键词检索服务
// 伪代码
final class KeywordSearchService {
List<Document> search(
String query,
String tenantId,
Map<String, Object> filters,
int candidateTopK) {
SqlAndParams sql = buildAllowlistedSql(
query, tenantId, filters, candidateTopK);
return jdbc.query(sql.text(), sql.params(), row ->
Document.builder()
.id(row.getString("id"))
.text(row.getString("content"))
.metadata(copyMetadataWithScore(
parseJson(row.getString("metadata")),
"keyword_score",
row.getDouble("keyword_score")))
.score(row.getDouble("keyword_score"))
.build());
}
}
keyword_score 同时写入 score 和 metadata,便于单独检查关键词召回;RRF 仍然只读取结果名次。
业务 metadata 参与动态 SQL 时,字段名必须经过白名单映射或安全参数处理,值必须绑定,不能直接拼接用户输入。租户、软删除和 ACL 条件由服务端强制追加。
6. 加权 RRF:融合两路排序
cosine 场景下的向量相似度通常接近 [0, 1](理论上可以更低),PostgreSQL ts_rank_cd 则是另一套量纲。直接计算:
0.7 * vectorScore + 0.3 * keywordScore
未经稳定的分数归一化,这样相加没有可解释性。RRF 只看排名,因此不受两种分数量纲的影响。
6.1 公式
对文档 d:
RRF(d) = sum(weight_i / (k + rank_i(d)))
rank_i(d)从 1 开始- 文档未出现在某一路时,该路贡献为 0
k是平滑常数,常用 60k越大,头部与尾部排名的贡献差距越平缓- 权重只有相对比例有意义,例如
2:1与1:0.5排序等价
假设向量排序为 A, B, C,关键词排序为 B, D, A,两路权重都为 1:
A = 1/(60+1) + 1/(60+3)
B = 1/(60+2) + 1/(60+1)
C = 1/(60+3)
D = 1/(60+2)
B 在两路都靠前,因此融合后通常排第一。
6.2 配置
chat:
rag:
hybrid:
enabled: true
ts-config: simple
rrf-k: 60
vector-weight: 1.0
keyword-weight: 1.0
candidate-top-k: 20
| 参数 | 含义 | 调优方向 |
|---|---|---|
| rrf-k | 平滑排名贡献 | 小一些更强调头部,大一些更平缓 |
| vector-weight | 向量贡献权重 | 语义问法多时可适当增大 |
| keyword-weight | 关键词贡献权重 | 型号、编号、专名多时可适当增大 |
| candidate-top-k | 每一路进入融合的候选数 | 太小损失召回,太大增加 DB/rerank 成本 |
6.3 RRF 代码示例
// 伪代码:输入列表已经各自按相关度降序
List<Document> weightedRrf(
List<List<Document>> rankedLists,
List<Double> weights,
int k,
int outputTopK) {
Map<String, Document> documentById = new LinkedHashMap<>();
Map<String, Double> scoreById = new LinkedHashMap<>();
for (int leg = 0; leg < rankedLists.size(); leg++) {
List<Document> list = rankedLists.get(leg);
double weight = weights.get(leg);
for (int index = 0; index < list.size(); index++) {
Document document = list.get(index);
int rank = index + 1;
documentById.putIfAbsent(document.getId(), document);
scoreById.merge(
document.getId(),
weight / (k + rank),
Double::sum);
}
}
return scoreById.entrySet().stream()
.sorted(byValueDescending())
.limit(outputTopK)
.map(entry -> copyDocumentWithScore(
documentById.get(entry.getKey()),
entry.getValue(),
"rrf_score"))
.toList();
}
融合依赖稳定的 Document.id 去重。同一业务 chunk 从向量和关键词两路返回时,ID 必须完全一致。
6.4 组合两条检索
// 伪代码
List<Document> retrieveCandidates(String query, int finalTopK) {
int outputK = rerankEnabled
? Math.max(rerankCandidateTopK, finalTopK)
: finalTopK;
if (!hybridEnabled) {
return vectorSearch(query, outputK);
}
int legK = Math.max(hybridCandidateTopK, outputK);
List<Document> vectorHits = vectorSearch(query, legK);
List<Document> keywordHits = keywordSearch(query, legK);
return weightedRrf(
List.of(vectorHits, keywordHits),
List.of(vectorWeight, keywordWeight),
rrfK,
outputK);
}
完整的检索顺序如下:
vector topN + keyword topN
-> weighted RRF
-> RerankDocumentPostProcessor(可选)
-> final topK
RRF 分不是向量相似度,similarityThreshold 只应用在向量检索阶段,不能用来过滤融合结果。
7. 接入 reranker 做精排
7.1 粗召回与精排
向量检索适合从大规模语料中快速召回候选文档,但单个 embedding 很难完整表达问题与文档之间的细粒度关系。reranker 会同时读取 query 和候选文档,再给出更细致的相关性排序。
如果最终需要 4 条文档,可以先召回 20 条候选,再交给 reranker 筛选:
向量或混合召回 candidateTopK=20
-> reranker 精排
-> 最终 topK=4
7.2 配置
Spring AI 提供 DocumentPostProcessor 扩展点,但不内置下面这个 DashScope rerank 客户端,HTTP 调用仍由应用实现。
chat:
rag:
rerank:
enabled: true
base-url: https://dashscope.aliyuncs.com/compatible-api
api-key: ${DASHSCOPE_API_KEY}
model: qwen3-rerank
instruct: "Given a query, rank passages by answer relevance."
candidate-top-k: 20
max-documents: 500
max-tokens-per-doc: 8000
max-tokens-per-request: 120000
这些上限只用于保护当前客户端。更换供应商后,需要按对应模型的规格调整。
7.3 rerank 客户端
// 伪代码
@Component
@ConditionalOnProperty(prefix = "chat.rag.rerank", name = "enabled", havingValue = "true")
final class RerankClient {
List<Document> rerank(String query, List<Document> candidates, int finalTopK) {
if (candidates.isEmpty()) {
return candidates;
}
List<String> texts = truncateToProviderLimits(candidates);
RerankResponse response = restClient.post()
.uri("/v1/reranks")
.body(Map.of(
"model", model,
"query", query,
"documents", texts,
"top_n", Math.min(finalTopK, texts.size()),
"instruct", instruct))
.retrieve()
.body(RerankResponse.class);
return response.results().stream()
.map(result -> copyDocumentWithScore(
candidates.get(result.index()),
result.relevanceScore(),
"rerank_score"))
.toList();
}
}
生产环境还要处理这些边界:
- 单文档和单请求 token 上限
- provider 返回的 index 越界或缺失
- 超时、限流和重试
- 外部服务失败时回退到粗召回顺序
- 截断只用于打分,返回结果仍保留完整的
Document.text - API key 只从环境变量或密钥系统注入
7.4 接入 RerankDocumentPostProcessor
通过应用自定义类,实现 Spring AI 的标准扩展接口。
final class RerankDocumentPostProcessor implements DocumentPostProcessor {
private final RerankClient rerankClient;
private final int finalTopK;
RerankDocumentPostProcessor(RerankClient rerankClient, int finalTopK) {
this.rerankClient = rerankClient;
this.finalTopK = finalTopK;
}
@Override
public List<Document> process(Query query, List<Document> documents) {
return rerankClient.rerank(query.text(), documents, finalTopK);
}
}
模块化 RAG 通过 builder 注册该处理器:
RetrievalAugmentationAdvisor.builder()
.documentRetriever(retriever)
.documentPostProcessors(
new RerankDocumentPostProcessor(rerankClient, finalTopK))
.build();
独立的 /search 不经过 Advisor,需要显式调用同一个客户端:
List<Document> candidates = retrieveCandidates(...);
return rerankEnabled
? rerankClient.rerank(query, candidates, finalTopK)
: candidates;
候选数的计算规则如下:
int retrieveTopK(int finalTopK) {
return rerankEnabled
? Math.max(rerankCandidateTopK, finalTopK)
: finalTopK;
}
8. 组装 Spring AI 模块化 RAG
8.1 动态组装 Advisor、配置到ChatClient
// 伪代码
RetrievalAugmentationAdvisor buildRagAdvisor(RequestOptions options) {
int finalTopK = options.topK();
DocumentRetriever retriever;
if (hybridEnabled) {
retriever = query -> retrieveCandidates(
query.text(),
options.tenantId(),
options.metadata(),
options.similarityThreshold(),
finalTopK);
}
else {
retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(options.similarityThreshold())
.topK(retrieveTopK(finalTopK))
.filterExpression(buildFilter(options.tenantId(), options.metadata()))
.build();
}
RetrievalAugmentationAdvisor.Builder builder = RetrievalAugmentationAdvisor.builder()
.documentRetriever(retriever)
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(options.allowEmptyContext())
.build());
if (rerankEnabled) {
builder.documentPostProcessors(
new RerankDocumentPostProcessor(rerankClient, finalTopK));
}
if (options.expanderNumberOfQueries() > 0) {
builder.queryExpander(MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(chatModel))
.numberOfQueries(options.expanderNumberOfQueries())
.includeOriginal(true)
.build());
}
return builder.build();
}
组装完成后,按普通 Advisor 使用:
Flux<ChatResponse> chat(RequestOptions options, String question) {
return chatClient.prompt()
.user(question)
.advisors(buildRagAdvisor(options))
.stream()
.chatResponse();
}
8.2 问答接口常见参数
| 参数 | 作用 |
|---|---|
| userMessageContent | 用户问题 |
| tenantId / metadata | 检索边界 |
| chatId | 会话记忆 ID,与知识库过滤不是一回事 |
| similarityThreshold | 向量阈值 |
| topK | 最终送入生成阶段的目标条数 |
| expanderNumberOfQueries | 生成多少条额外查询变体,0 表示关闭 |
| allowEmptyContext | 无命中时是否仍允许模型回答 |
includeOriginal=true 时,expanderNumberOfQueries=3 表示"原问题 + 3 个变体",最多执行 4 轮检索,而不是总共 3 轮。多查询结果默认由 ConcatenationDocumentJoiner 按 Document.id 去重和排序。
hybrid、multi-query 和 rerank 同时启用时,实际执行顺序是:
原问题 + N 个变体
-> 每个 Query 分别执行 vector + keyword + RRF
-> ConcatenationDocumentJoiner 跨 Query 合并去重
-> RerankDocumentPostProcessor 对合并结果做一次精排
-> ContextualQueryAugmenter 写入 prompt
-> ChatModel 生成答案
粗召回规模可能接近 (N + 1) * candidateTopK。评估延迟和成本时,需要把查询扩展、embedding、数据库查询和 rerank 一并计算。
allowEmptyContext=false 更适合要求严格基于知识库作答的场景;设置为 true 时,没有检索结果也会把原问题交给模型,模型可能依赖自身知识回答。
9. 自定义 VectorStore:让现有业务表接入 Spring AI
9.1 选择接入方式
Spring AI 的 RAG 层不关心物理表,但官方 PgVectorStore 有自己的表契约。在 2.0.1 中,它的 SQL 和 RowMapper 固定使用:
id / content / metadata / embedding
不同表结构对应不同的接入方式:
| 业务表情况 | 建议 |
|---|---|
| 只有 schema/table 名不同,四列兼容 | 配置官方 PgVectorStore |
| 只有列名不同,且只读检索 | 建兼容 VIEW,并验证 ANN 索引下推 |
| 标量 metadata、多表 JOIN、复合主键、自定义 ACL | 实现自定义 VectorStore |
| OLTP 模型复杂、读多写少、允许最终一致 | 建标准化向量投影表,用 CDC/ETL 同步 |
initialize-schema=false 只代表"不自动建表",不代表官方 Store 可以识别任意列名。
9.2 示例业务表
CREATE TABLE knowledge_chunk (
chunk_id bigint PRIMARY KEY,
article_id bigint NOT NULL,
tenant_id bigint NOT NULL,
body text NOT NULL,
embedding_vector vector(1024) NOT NULL,
enabled boolean NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE article_acl (
article_id bigint NOT NULL,
principal varchar(128) NOT NULL,
PRIMARY KEY (article_id, principal)
);
这类结构无法只靠 table-name 配置解决:列名不同,metadata 是标量列,还需要 ACL JOIN。
9.3 实现 AbstractObservationVectorStore
完整的读写适配器可以继承 AbstractObservationVectorStore,继续使用 Spring AI 的 observation 包装。
// 伪代码:省略批量 SQL、DTO 和异常转换
final class BusinessPgVectorStore extends AbstractObservationVectorStore {
private final BusinessRepository repository;
private final BusinessFilterCompiler filterCompiler;
private BusinessPgVectorStore(Builder builder) {
super(builder);
this.repository = builder.repository;
this.filterCompiler = new BusinessFilterCompiler();
}
static Builder builder(
BusinessRepository repository,
EmbeddingModel embeddingModel) {
return new Builder(repository, embeddingModel);
}
static final class Builder extends AbstractVectorStoreBuilder<Builder> {
private final BusinessRepository repository;
private Builder(
BusinessRepository repository,
EmbeddingModel embeddingModel) {
super(embeddingModel);
this.repository = Objects.requireNonNull(repository);
}
BusinessPgVectorStore build() {
return new BusinessPgVectorStore(this);
}
}
@Override
public void doAdd(List<Document> documents) {
List<float[]> vectors = embeddingModel.embed(
documents,
EmbeddingOptions.builder().build(),
batchingStrategy);
repository.upsert(mapToBusinessRows(documents, vectors));
}
@Override
public void doDelete(List<String> ids) {
repository.deleteByChunkIds(parseBusinessIds(ids));
}
@Override
protected void doDelete(Filter.Expression filter) {
CompiledFilter compiled = filterCompiler.compile(filter);
repository.deleteByFilter(compiled.sql(), compiled.params());
}
@Override
public List<Document> doSimilaritySearch(SearchRequest request) {
float[] queryVector = embeddingModel.embed(request.getQuery());
CompiledFilter filter = filterCompiler.compile(request.getFilterExpression());
return repository.search(
queryVector,
request.getSimilarityThreshold(),
request.getTopK(),
filter)
.stream()
.map(row -> Document.builder()
.id(Long.toString(row.chunkId()))
.text(row.body())
.metadata(Map.of(
"tenantId", row.tenantId(),
"articleId", row.articleId(),
DocumentMetadata.DISTANCE.value(), row.distance()))
.score(1.0 - row.distance())
.build())
.toList();
}
@Override
public VectorStoreObservationContext.Builder createObservationContextBuilder(
String operationName) {
return VectorStoreObservationContext
.builder(VectorStoreProvider.PG_VECTOR.value(), operationName)
.namespace("knowledge")
.collectionName("knowledge_chunk")
.fieldName("embedding_vector")
.dimensions(1024)
.similarityMetric("cosine");
}
}
对应的 SQL 可以是:
-- 伪 SQL:实际代码必须绑定所有值,并对白名单字段生成过滤条件。
SELECT
c.chunk_id,
c.article_id,
c.tenant_id,
c.body,
c.embedding_vector <=> :queryVector AS distance
FROM knowledge_chunk c
JOIN article_acl acl ON acl.article_id = c.article_id
WHERE c.enabled = true
AND c.tenant_id = :tenantId
AND acl.principal = :principal
AND c.embedding_vector <=> :queryVector < 1 - :threshold
/* application filter predicates */
ORDER BY distance
LIMIT :topK;
自定义 Store 需要遵守上层 RAG 依赖的契约:
| 契约 | 原因 |
|---|---|
| 查询与存量使用同一 embedding 模型、维度和预处理 | 否则向量不可比较 |
| Document.id 稳定且非空 | 多查询 join 和 RRF 依赖 ID 去重 |
| Document.text 是完整召回文本 | augmenter 和 reranker 会直接消费它 |
| metadata 不含 null key/value | Document 构造和下游处理要求稳定数据 |
| Document.score 越高越相关 | joiner 和最终排序依赖该方向 |
| threshold 与 score 语义一致 | 避免接口参数名义和实际结果相反 |
| tenant/ACL 是强制谓词 | 普通过滤条件不能删除安全边界 |
业务 filter 编译器必须把逻辑字段映射到白名单列:
// 伪代码
Map<String, String> ALLOWED_COLUMNS = Map.of(
"tenantId", "c.tenant_id",
"articleId", "c.article_id",
"updatedAt", "c.updated_at"
);
CompiledFilter compile(Filter.Expression expression) {
// 递归遍历 Filter AST;字段查白名单,值走 JDBC 参数。
// 不支持的字段或运算符直接拒绝,绝不能拼接客户端传入的 key/value。
}
9.4 Bean 装配
如果应用只使用自定义 Store,可以排除官方 PgVector 自动配置,只保留一个 VectorStore:
spring:
autoconfigure:
exclude:
- org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration
@Bean
VectorStore businessVectorStore(
BusinessRepository repository,
EmbeddingModel embeddingModel,
ObservationRegistry observationRegistry) {
return BusinessPgVectorStore.builder(repository, embeddingModel)
.observationRegistry(observationRegistry)
.build();
}
BusinessRepository 内部可以使用 NamedParameterJdbcTemplate 完成参数绑定和 JDBC 批量写入。
如果保留多个 Store,使用 @Primary 或 @Qualifier 明确每个消费点。一个自定义 VectorStore 接口 Bean 不一定让按具体 PgVectorStore 类型判断的自动配置退避。
上层 RAG 仍然通过标准接口使用它:
DocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(businessVectorStore)
.topK(8)
.similarityThreshold(0.65)
.build();
VectorStore 隔离了存储细节,更换底层适配器不需要重写 RAG 编排。
如果业务只允许读,直接实现 DocumentRetriever 也很合理。Spring AI 2.0.1 的 VectorStoreDocumentRetriever 构造器接收完整 VectorStore,虽然它实际只调用 similaritySearch;没有必要为了满足类型而伪造可写方法。
10. 换成其他向量库时检查什么
Spring AI 还支持 Milvus、Redis、Elasticsearch、MongoDB Atlas、Qdrant、Weaviate、Cassandra、MariaDB 等实现。上层接口仍然是 VectorStore 和 SearchRequest,但各 provider 的字段映射能力并不一致。
例如,有些实现允许配置 content/embedding 字段名,有些只允许配置 collection/index,有些对 ID 类型或 metadata 结构有固定要求。切换 provider 时要重新核对四件事:
- 字段和 ID 映射
- filter converter 支持的操作与类型
- similarity score 和 threshold 语义
- schema/index 初始化策略
PostgreSQL 同时具备向量、关系过滤、JOIN 和全文检索。其他向量库这里只列迁移检查项,不展开具体配置。