从向量检索到混合搜索优化:Spring AI 2.0.1 + pgvector RAG 实战

从向量检索到混合搜索优化: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 全文检索和向量检索组成混合召回
  • 讨论让现有的业务表,通过自定义VectorStoreDocumentRetriever 快速接入 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.score1 - 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;
    }
}

设计要点是:

  1. 优先按段落切,仍过长才逐级退化到行、句号、逗号、空格和字符
  2. 大小用 token 衡量,而不是简单字符数
  3. 每个 chunk 落袋后保留尾部约 chunkOverlap token 进入下一块
  4. 分隔符跟随前一句,避免重组时丢标点
  5. 强制 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-kchat.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 对英文、数字和按空白分隔的文本友好,但对连续中文分词较弱。中文关键词召回要求较高时,可以安装 zhparserpg_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 是平滑常数,常用 60
  • k 越大,头部与尾部排名的贡献差距越平缓
  • 权重只有相对比例有意义,例如 2:11: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 轮。多查询结果默认由 ConcatenationDocumentJoinerDocument.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 等实现。上层接口仍然是 VectorStoreSearchRequest,但各 provider 的字段映射能力并不一致。

例如,有些实现允许配置 content/embedding 字段名,有些只允许配置 collection/index,有些对 ID 类型或 metadata 结构有固定要求。切换 provider 时要重新核对四件事:

  1. 字段和 ID 映射
  2. filter converter 支持的操作与类型
  3. similarity score 和 threshold 语义
  4. schema/index 初始化策略

PostgreSQL 同时具备向量、关系过滤、JOIN 和全文检索。其他向量库这里只列迁移检查项,不展开具体配置。

相关推荐
这张生成的图像能检测吗1 小时前
(论文速读)DISCA:利用与蒸馏兼容的可学习特征缓存加速视频扩散转换器
人工智能·扩散模型·视频生成·特征缓存·步骤蒸馏
零依赖极客1 小时前
《30 天手搓 ARM 架构零依赖纯 C 推理引擎》课程介绍与大纲
c语言·开发语言·arm开发·人工智能·嵌入式硬件·ai编程
sarasuki1 小时前
别只会给 LLM 包一层 while 循环:一个 Agent 的 7 个设计取舍
人工智能·架构
蓝速科技1 小时前
酒店门店 AI 数字人前台场景适配与落地指南
大数据·运维·数据结构·数据库·人工智能·科技
羞儿1 小时前
【读点论文】From Coarse to Fine-Grained Open-Set Recognition
人工智能·深度学习·计算机视觉·细粒度·开集识别
IT枫斗者枫哥1 小时前
CompletableFuture 超时了,任务还在跑?用三个实验分清超时与取消
java
武子康1 小时前
小智发出 abort 后,旧声音为什么还可能继续?
人工智能·llm·agent
chushiyunen1 小时前
mockito笔记
java·开发语言·笔记
悟天特斯1 小时前
AI驱动的楼宇节能:从粗放管控到精准降碳的实践路径
开发语言·人工智能·python·物联网