RAG 实战(三):混合检索与 RRF 融合

目录


一、为什么单一检索不够

上一篇结尾说过:向量检索对精确字符串不敏感。这一篇把这个问题摊开,然后解决它。

先看一个直觉。你要找一个叫「张三」的人:

  • 按名字找(关键词检索):名字一字不差时又快又准;但他要是改叫「张三四」,你就找不到了。
  • 按长相找(向量检索):名字变了也认得出;但可能把长得像的另一个人认成他。

两种方式的失效场景几乎不重叠——这正是一个人同时用两种方式找人的原因。

放到检索上,假设知识库里有四份文档,用户搜「年假政策」:

文档纯 BM25纯向量
B:年假申请流程✓ 含「年假」,排第一✓ 语义相关
A:休假制度(内容讲年假)✗ 不含「年假」二字✓ 语义相关
C:带薪假期规定(内容讲年假)✗ 不含「年假」二字✓ 语义相关
D:报销流程✗✗ 语义不相关

BM25 只捞得到 B,向量检索能捞到 A、B、C。看起来向量检索完胜? 把查询换成「GPT-4 的上下文长度是多少」就不一样了:向量检索很可能召回一堆讲「大模型」「上下文窗口」的泛泛之作,而真正标题里写着「GPT-4」的那篇,恰恰被淹没在其中。关键词检索在这个场景里反而是唯一可靠的。

结论:不是谁替代谁,而是两路都要,然后融合。 需要解决的新问题是——怎么融合才算合理。


二、BM25 的核心思想

BM25 是关键词检索的经典排序函数,思想可以类比成老师批改作文时按术语给分:出现「年假」加 10 分,出现「政策」加 8 分,出现「休假」加 6 分。

形式化的写法是这样:

BM25(D, Q) = Σ IDF(qi) × [ f(qi, D) × (k1 + 1) ]
                       / [ f(qi, D) + k1 × (1 - b + b × |D| / avgdl) ]

不用被公式吓到,真正起作用的是两个直觉:

  1. 词频(TF):一个词在文档里出现越多,文档越可能和它相关。但增益是饱和的——出现 100 次并不比出现 10 次相关 10 倍,k1 控制的就是这个饱和曲线的陡缓。
  2. 逆文档频率(IDF):一个词在越少的文档里出现,区分度越高。「的」满篇都是,权重接近零;「年假」只在少数几篇里出现,权重很高。

分母里那个含 |D| / avgdl 的部分是文档长度归一化,b 控制它的强度(通常取 0.75)。它解决的是「长文档天然更容易命中关键词」这个偏差——一篇 5000 字的文档当然比 200 字的更容易撞上「年假」。

2.1 PostgreSQL 里怎么用

PostgreSQL 自带全文检索,用 tsvector 存分词结果、tsquery 表示查询、ts_rank 算相关性分数。这条路走通的前提是先解决中文分词——这个坑放到代码那节细说,因为它是整篇里最容易卡住的一步。


三、向量检索补上了什么

这部分上一篇讲过,这里只补充它和 BM25 的互补关系:

用户问:"休假有多少天?"

BM25:找包含「休假」和「天」的文档
→ 可能漏掉写「年假为 10 天」的那篇(因为没出现「休假」这个词)

向量检索:
"休假有多少天?"   → [0.12, -0.34, ...]
"年假为 10 天"     → [0.11, -0.33, ...]  → 语义相近,命中

一句话概括两者的分工:BM25 保证「写着这个词的文档不会被漏掉」,向量检索保证「换了种说法的文档也能被找到」。 前者是精确率的下限,后者是召回率的下限。


四、RRF:怎么把两路结果合起来

两路结果拿到手,问题来了:BM25 的分数是 ts_rank 算的,范围不固定;向量检索的分数是余弦距离,范围 0 到 2。两套分数没有可比性,直接相加毫无意义。

RRF(Reciprocal Rank Fusion)的做法很干脆:丢掉分数,只看排名。

RRF_score(d) = Σ 1 / (k + rank_i(d))

rank_i(d) 是文档 d 在第 i 个检索器里的名次(从 1 开始),k 是平滑常数,通常取 60。

假设两个检索器给出的排名是:

BM25:  第 1 名 = B,第 2 名 = A,第 3 名 = C
向量:  第 1 名 = A,第 2 名 = C,第 3 名 = B

代入 k = 60 逐个算:

A = 1/(60+2) + 1/(60+1) = 0.01613 + 0.01639 = 0.03252
B = 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.03226
C = 1/(60+3) + 1/(60+2) = 0.01587 + 0.01613 = 0.03200

最终排名:A > B > C

A 在两个检索器里都是前二,所以胜出;B 虽然被 BM25 排第一,但向量那边掉到第三,被拉了下来。这就是「综合两个评委打分」的效果——不偏信任何一方。

4.1 为什么是 1/(k+rank) 而不是直接用排名

直接用排名有两个问题:一是不好比较(怎么把两个第 2 名合并?),二是第 1 名和第 2 名的差距,与第 10 名和第 11 名的差距,应该在排序里体现出不同的权重。

1/(k+rank) 是一个衰减曲线:名次靠前时相邻名次的分数差距大,名次靠后时差距迅速变小并趋于平坦。看上面的数就明白了——A 和 B 只差 0.00026,但它们在各自榜单上差了一到两个名次。用倒数曲线,靠后的名次几乎不再影响总分,这符合直觉:排到第 40 名的结果,它到底是 40 还是 41,不应该改变最终排序。

k 的作用是压平曲线。k 越大,各名次之间的分数差距越小,融合结果越偏向「多个检索器都认可」的文档,而不是「某一个检索器极力推荐」的文档。60 这个值来自原论文的实验,是个稳健的默认值,不是必须遵守的常数。

4.2 加权融合

如果业务上明显偏重某一路(比如产品名、订单号、错误码这类查询,关键词命中比语义相似重要得多),可以把权重乘进去:

RRF_score(d) = Σ w_i / (k + rank_i(d))

代码里两种都实现了,等价值为 1.0。但权重不该拍脑袋定,它同样需要评估集来验——这是第五篇的主题。


五、完整代码

5.0 前置:中文全文检索配置

这一段在源文档里没有,但不做这一步,后面的 SQL 一定报错。

PostgreSQL 内置的全文检索配置只有 simple、english、french 等少数几种,没有 chinese。也就是说,直接执行 to_tsvector('chinese', content) 会得到:

ERROR:  text search configuration "chinese" does not exist

要在 PostgreSQL 里做中文分词,得额外装一个分词插件,再用它的 parser 建一个 chinese 配置。以 zhparser 为例:

-- 需要先在系统里编译安装 zhparser 扩展
CREATE EXTENSION IF NOT EXISTS zhparser;

-- 用 zhparser 作为 parser,建一个名为 chinese 的全文检索配置
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);

-- 指定各类词性走哪种处理:simple 表示不做词干还原,原样保留
ALTER TEXT SEARCH CONFIGURATION chinese
    ADD MAPPING FOR n,v,a,i,e,l WITH simple;

-- 验证分词效果
SELECT to_tsvector('chinese', '入职满一年后可享受带薪年假十天');

最后那条验证语句是必做的一步。它应该输出一串被切开的词元(入职、满、一年、享受、带薪、年假、十天 之类)。如果输出的是整句话作为一个词元,说明分词没生效,后面的检索会全部失准——而且不会报错。

容器里用 pgvector 镜像的话,这个插件需要自己打进镜像,pgvector/pgvector:pg16 不带它。

5.1 新增依赖

pom.xml

<!-- PostgreSQL 全文检索支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

5.2 数据库扩展

全文检索列与索引

-- 启用全文检索扩展
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- 给 documents 表添加全文检索列
ALTER TABLE documents ADD COLUMN IF NOT EXISTS ts_content tsvector;

-- 创建全文检索索引
CREATE INDEX IF NOT EXISTS idx_documents_ts_content
ON documents USING gin(ts_content);

-- 创建触发器,自动更新 ts_content
CREATE OR REPLACE FUNCTION update_ts_content()
RETURNS TRIGGER AS $$
BEGIN
    NEW.ts_content := to_tsvector('chinese', NEW.content);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_update_ts_content
BEFORE INSERT OR UPDATE ON documents
FOR EACH ROW EXECUTE FUNCTION update_ts_content();

这段 migration 做了三件事:加一列 ts_content 存分词结果、给它建 GIN 索引、挂一个触发器在每次插入/更新时自动重算。

用触发器而不是在应用层算,好处是不会漏——无论从哪条路径写入,ts_content 都会跟着更新。代价是每次写入多一次分词开销,而且分词配置一旦改了,历史数据必须手动重新生成一遍。

脚本里还启用了 pg_trgm 扩展,但后面并没有用到它。pg_trgm 提供的是基于三元组的模糊匹配和相似度计算,对中文子串匹配其实也能用(建 GIN 索引后 LIKE '%关键词%' 可以走索引)。如果你不打算装 zhparser,用 pg_trgm 走模糊匹配是一条更省事的路——代价是没有词干和 IDF 这类语言学处理,排序质量不如真正的全文检索。

5.3 BM25 检索服务

Bm25Service.java

package com.rag.service;

import com.rag.model.Document;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import java.sql.ResultSet;
import java.util.*;

/**
 * BM25 关键词检索服务
 *
 * 职责:基于关键词匹配进行文档检索
 *
 * 类比:图书馆的"关键词索引"
 * - 你告诉图书管理员关键词
 * - 管理员在索引卡片里找包含这些关键词的书
 *
 * BM25 原理(简化版):
 * 1. 把查询词拆成关键词
 * 2. 找包含这些关键词的文档
 * 3. 按关键词出现的频率和重要性排序
 */
@Slf4j
@Service
public class Bm25Service {

    private final JdbcTemplate jdbcTemplate;

    public Bm25Service(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /**
     * BM25 检索
     *
     * 使用 PostgreSQL 的全文检索功能实现 BM25
     *
     * @param query 查询文本
     * @param topK  返回的文档数量
     * @return 相关文档列表(带排名分数)
     */
    public List<SearchResult> search(String query, int topK) {
        log.info("BM25 检索,查询: {}, Top-K: {}", query, topK);

        // 将查询文本转换为 tsquery 格式
        // 例如:"年假政策" → "'年假' & '政策'"
        String tsQuery = convertToTsQuery(query);

        // 使用 PostgreSQL 的 ts_rank 计算相关性分数
        String sql = """
                SELECT
                    id,
                    content,
                    metadata,
                    ts_rank(ts_content, to_tsquery('chinese', ?)) AS rank_score
                FROM documents
                WHERE ts_content @@ to_tsquery('chinese', ?)
                ORDER BY rank_score DESC
                LIMIT ?
                """;

        List<SearchResult> results = jdbcTemplate.query(sql, (rs, rowNum) -> mapToSearchResult(rs),
                tsQuery, tsQuery, topK);

        log.info("BM25 检索完成,返回 {} 个文档", results.size());
        return results;
    }

    /**
     * 简单的关键词搜索(备选方案,不依赖全文检索)
     *
     * 如果 pg_trgm 扩展安装有问题,可以用这个简单的 LIKE 搜索
     *
     * @param query 查询文本
     * @param topK  返回的文档数量
     * @return 相关文档列表
     */
    public List<SearchResult> searchByKeywords(String query, int topK) {
        log.info("关键词检索,查询: {}", query);

        // 将查询拆分成关键词
        String[] keywords = query.split("\\s+");

        // 构建 LIKE 条件
        StringBuilder whereClause = new StringBuilder();
        List<Object> params = new ArrayList<>();

        for (int i = 0; i < keywords.length; i++) {
            if (i > 0) {
                whereClause.append(" OR ");
            }
            whereClause.append("content LIKE ?");
            params.add("%" + keywords[i] + "%");
        }

        String sql = String.format("""
                SELECT id, content, metadata, 1.0 AS rank_score
                FROM documents
                WHERE %s
                LIMIT ?
                """, whereClause);

        params.add(topK);

        List<SearchResult> results = jdbcTemplate.query(sql, (rs, rowNum) -> mapToSearchResult(rs),
                params.toArray());

        log.info("关键词检索完成,返回 {} 个文档", results.size());
        return results;
    }

    /**
     * 将自然语言转换为 tsquery 格式
     *
     * 例如:
     * "年假政策" → "'年假' & '政策'"
     * "休假 OR 病假" → "'休假' | '病假'"
     */
    private String convertToTsQuery(String query) {
        // 简单处理:按空格分割,用 & 连接
        String[] terms = query.split("\\s+");
        StringBuilder tsQuery = new StringBuilder();

        for (int i = 0; i < terms.length; i++) {
            if (i > 0) {
                tsQuery.append(" & ");
            }
            tsQuery.append("'").append(terms[i]).append("'");
        }

        return tsQuery.toString();
    }

    /**
     * 将 ResultSet 映射为 SearchResult
     */
    private SearchResult mapToSearchResult(ResultSet rs) throws java.sql.SQLException {
        Document doc = Document.builder()
                .id(rs.getLong("id"))
                .content(rs.getString("content"))
                .metadata(parseMetadata(rs.getString("metadata")))
                .build();

        return new SearchResult(doc, rs.getDouble("rank_score"));
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> parseMetadata(String json) {
        try {
            if (json == null || json.isEmpty()) {
                return new HashMap<>();
            }
            return new com.fasterxml.jackson.databind.ObjectMapper()
                    .readValue(json, HashMap.class);
        } catch (Exception e) {
            return new HashMap<>();
        }
    }

    /**
     * 搜索结果(带分数)
     */
    @lombok.Data
    @lombok.AllArgsConstructor
    public static class SearchResult {
        private Document document;
        private double score;
    }
}

这个类有三处值得留意:

其一,两条查询路径。 主路径走 ts_rank + ts_content @@ to_tsquery(...);拼 tsquery 为空或者查询失败时会退回到 content LIKE '%关键词%' 的兜底路径。兜底能保证「至少有结果返回」,但带前导 % 的 LIKE 用不上 B-tree 索引,是全表扫描,数据量上来之后这条路径会拖慢整个服务。

其二,convertToTsQuery 按空格切分。 这对中文基本是空转——中文句子没有空格,"年假政策" 切出来还是一整个 '年假政策',最终靠 zhparser 在数据库侧做分词。它真正起作用的是英文查询,或者用户手动输入了带空格的多个词。

其三,类名叫 Bm25Service,但用的不是 BM25。 PostgreSQL 的 ts_rank 是一个仅依赖词频和权重的位置无关排序函数,它不引入语料库级别的 IDF 统计——也就是不知道「年假」这个词在整个库里是罕见还是烂大街。BM25 的两大支柱里,它只有一根。这在结果上表现为:常见词和罕见词的权重拉不开差距,排序质量不如真正的 BM25。要拿到真 BM25,得换 Elasticsearch/OpenSearch,或者自己在数据库侧累计文档频率再手算。

5.4 混合检索服务

HybridRetrievalService.java

package com.rag.service;

import com.rag.model.Document;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

/**
 * 混合检索服务
 *
 * 职责:结合 BM25 和向量检索的优势,使用 RRF 算法融合结果
 *
 * 类比:两个侦探合作破案
 * - 侦探 A(BM25):擅长找关键词匹配的线索
 * - 侦探 B(向量检索):擅长找语义相关的线索
 * - 合作:综合两个侦探的发现,找出最可能的嫌疑人
 */
@Slf4j
@Service
public class HybridRetrievalService {

    private final Bm25Service bm25Service;
    private final EmbeddingService embeddingService;
    private final VectorStoreService vectorStoreService;

    /**
     * RRF 算法中的常数 k
     * 经验值,一般取 60
     * 作用:避免排名差距过大
     */
    private static final int RRF_K = 60;

    @Value("${rag.top-k:5}")
    private int topK;

    public HybridRetrievalService(Bm25Service bm25Service,
                                  EmbeddingService embeddingService,
                                  VectorStoreService vectorStoreService) {
        this.bm25Service = bm25Service;
        this.embeddingService = embeddingService;
        this.vectorStoreService = vectorStoreService;
    }

    /**
     * 混合检索
     *
     * @param query 查询文本
     * @param topK  返回的文档数量
     * @return 融合后的文档列表
     */
    public List<Document> search(String query, int topK) {
        log.info("开始混合检索,查询: {}", query);

        // ========== 第一步:BM25 检索 ==========
        log.info("步骤 1: BM25 检索...");
        List<Bm25Service.SearchResult> bm25Results = bm25Service.search(query, topK * 2);
        // 多检索一些,给后续融合留余量

        // ========== 第二步:向量检索 ==========
        log.info("步骤 2: 向量检索...");
        float[] queryEmbedding = embeddingService.embed(query);
        List<Document> vectorResults = vectorStoreService.searchByVector(queryEmbedding, topK * 2);

        // ========== 第三步:RRF 融合 ==========
        log.info("步骤 3: RRF 融合排序...");
        List<Document> fusedResults = fuseResults(bm25Results, vectorResults, topK);

        log.info("混合检索完成,返回 {} 个文档", fusedResults.size());
        return fusedResults;
    }

    /**
     * RRF 融合算法
     *
     * 核心公式:RRF_score(d) = Σ 1/(k + rank_i(d))
     *
     * 其中:
     * - d 是文档
     * - k 是常数(通常为 60)
     * - rank_i(d) 是文档 d 在第 i 个检索器中的排名
     *
     * @param bm25Results   BM25 检索结果
     * @param vectorResults 向量检索结果
     * @param topK          返回的文档数量
     * @return 融合后的文档列表
     */
    private List<Document> fuseResults(List<Bm25Service.SearchResult> bm25Results,
                                       List<Document> vectorResults,
                                       int topK) {

        // 记录每个文档的 RRF 分数
        Map<Long, Double> docScores = new HashMap<>();
        // 记录文档对象(用于返回)
        Map<Long, Document> docMap = new HashMap<>();

        // 计算 BM25 结果的 RRF 分数
        for (int rank = 0; rank < bm25Results.size(); rank++) {
            Document doc = bm25Results.get(rank).getDocument();
            long docId = doc.getId();

            // RRF 分数 = 1/(k + 排名)
            // 排名从 1 开始,所以用 rank + 1
            double rrfScore = 1.0 / (RRF_K + rank + 1);

            docScores.merge(docId, rrfScore, Double::sum);
            docMap.putIfAbsent(docId, doc);
        }

        // 计算向量检索结果的 RRF 分数
        for (int rank = 0; rank < vectorResults.size(); rank++) {
            Document doc = vectorResults.get(rank);
            long docId = doc.getId();

            double rrfScore = 1.0 / (RRF_K + rank + 1);

            docScores.merge(docId, rrfScore, Double::sum);
            docMap.putIfAbsent(docId, doc);
        }

        // 按 RRF 分数排序,取 Top-K
        List<Document> results = docScores.entrySet().stream()
                .sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
                .limit(topK)
                .map(entry -> docMap.get(entry.getKey()))
                .collect(Collectors.toList());

        log.info("RRF 融合完成,BM25: {} 个结果,向量: {} 个结果,融合后: {} 个结果",
                bm25Results.size(), vectorResults.size(), results.size());

        return results;
    }

    /**
     * 带权重的 RRF 融合
     *
     * 可以为不同的检索器设置不同的权重
     *
     * @param query         查询文本
     * @param topK          返回的文档数量
     * @param bm25Weight    BM25 权重(0-1)
     * @param vectorWeight  向量检索权重(0-1)
     * @return 融合后的文档列表
     */
    public List<Document> searchWithWeight(String query, int topK,
                                           double bm25Weight, double vectorWeight) {
        log.info("带权重的混合检索,BM25 权重: {}, 向量权重: {}", bm25Weight, vectorWeight);

        // BM25 检索
        List<Bm25Service.SearchResult> bm25Results = bm25Service.search(query, topK * 2);

        // 向量检索
        float[] queryEmbedding = embeddingService.embed(query);
        List<Document> vectorResults = vectorStoreService.searchByVector(queryEmbedding, topK * 2);

        // 带权重的 RRF 融合
        Map<Long, Double> docScores = new HashMap<>();
        Map<Long, Document> docMap = new HashMap<>();

        // BM25 结果(带权重)
        for (int rank = 0; rank < bm25Results.size(); rank++) {
            Document doc = bm25Results.get(rank).getDocument();
            long docId = doc.getId();

            double rrfScore = bm25Weight / (RRF_K + rank + 1);
            docScores.merge(docId, rrfScore, Double::sum);
            docMap.putIfAbsent(docId, doc);
        }

        // 向量结果(带权重)
        for (int rank = 0; rank < vectorResults.size(); rank++) {
            Document doc = vectorResults.get(rank);
            long docId = doc.getId();

            double rrfScore = vectorWeight / (RRF_K + rank + 1);
            docScores.merge(docId, rrfScore, Double::sum);
            docMap.putIfAbsent(docId, doc);
        }

        // 排序返回
        return docScores.entrySet().stream()
                .sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
                .limit(topK)
                .map(entry -> docMap.get(entry.getKey()))
                .collect(Collectors.toList());
    }
}

核心是 RRF 的那十几行:RRF_K = 60,排名从 0 开始循环,所以代入公式时是 1.0 / (RRF_K + rank + 1)——+1 是为了让名次符合 RRF 定义的「从 1 开始」。类里提供了等权融合和加权融合两个版本,加权版本就是把 bm25Weight / vectorWeight 乘在各自的倒数项上。

注意融合只消费排名,不消费分数,所以两个检索器各返回多少条(各自的 Top-K)会直接影响 RRF 的结果。粗排阶段多召回一些(比如各取 20),再让 RRF 融合出 Top-N,效果通常比各取 5 要稳。

5.5 接入 RagService

在 RagService 中注入 HybridRetrievalService

// 在 RagService 中注入 HybridRetrievalService
private final HybridRetrievalService hybridRetrievalService;

// 修改 ask 方法
public String ask(String question) {
    log.info("收到用户问题: {}", question);

    // 使用混合检索代替单一向量检索
    log.info("使用混合检索...");
    List<Document> relatedDocs = hybridRetrievalService.search(question, topK);

    if (relatedDocs.isEmpty()) {
        log.warn("未找到相关文档");
        return "抱歉,知识库中没有找到相关信息。";
    }

    log.info("找到 {} 个相关文档", relatedDocs.size());

    // 后续步骤不变...
    String prompt = buildPrompt(question, relatedDocs);
    return callLlm(prompt);
}

5.6 新增检索接口

在 RagController 中添加混合检索接口

/**
 * 混合检索测试接口
 */
@PostMapping("/hybrid-search")
public ResponseEntity<Map<String, Object>> hybridSearch(@RequestBody AskRequest request) {
    log.info("混合检索测试: {}", request.getQuestion());

    try {
        List<Document> results = hybridRetrievalService.search(request.getQuestion(), 5);
        return ResponseEntity.ok(Map.of(
                "success", true,
                "results", results.stream().map(doc -> Map.of(
                        "id", doc.getId(),
                        "content", doc.getContent(),
                        "metadata", doc.getMetadata()
                )).collect(Collectors.toList())
        ));
    } catch (Exception e) {
        log.error("混合检索失败", e);
        return ResponseEntity.badRequest().body(Map.of(
                "success", false,
                "message", "检索失败: " + e.getMessage()
        ));
    }
}

六、对比测试

6.1 测试混合检索

# 测试混合检索
curl -X POST http://localhost:8080/api/rag/hybrid-search \
  -H "Content-Type: application/json" \
  -d '{
    "question": "年假有多少天"
  }'

6.2 对比测试

# 上传更多测试文档
curl -X POST http://localhost:8080/api/rag/upload \
  -H "Content-Type: application/json" \
  -d '{
    "content": "公司休假制度:员工可享受带薪年假、病假、婚假、产假等。具体天数根据工龄和情况而定。",
    "source": "休假制度.pdf"
  }'

# 测试语义相似但关键词不同的查询
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{
    "question": "请假有什么规定"
  }'

对比测试要设计得有意义,否则很容易得出「混合检索更好」这个想要的结论,而不是真实的结论。至少覆盖三类查询:

  1. 纯关键词型:查一个文档里的专有名词或编号。这一类的预期是「混合 ≈ BM25 > 向量」。
  2. 纯语义型:用一个同义说法去查原文。预期是「混合 ≈ 向量 > BM25」。
  3. 两者兼有型:既有关键词又需要语义理解。这一类才是混合检索真正拉开差距的地方。

如果三类里有两类没有明显差异,说明你的测试集还不够难,或者分词根本没生效。


七、常见误区

❌「to_tsvector('chinese', ...) 直接就能用」 PostgreSQL 没有内置的 chinese 配置,这句会直接抛 text search configuration "chinese" does not exist。必须先装 zhparser 或 pg_jieba 并手动建配置(见 5.0)。更麻烦的是,如果你用了 simple 配置绕过这个错误,语句能跑、不报错,但中文会被整段当成一个词元,检索结果全错——从「报错」变成「静默失准」,这是最难察觉的失败模式。

❌「用了 ts_rank 就是在用 BM25」 ts_rank 不做 IDF 统计,没有语料库级的词稀有度概念,也没有 BM25 的词频饱和函数。它是「带权重的词频排序」,能用,但和 BM25 有实质差距。名字里带 BM25 的类不代表它就实现了 BM25——看它调的是哪个函数,而不是看它叫什么。

❌「RRF 的分数可以用来做相关性阈值」 不能。RRF 分数只反映名次,不反映相关性强度。上面例子里 A 和 B 差了 0.00026,纯粹因为它们在各自榜单上错开了一两位,跟「A 比 B 相关多少」毫无关系。RRF 分数唯一的用途是排序。 要判断「这条到底够不够相关」,得看原始检索器的分数,或者交给第四篇的 Rerank。

❌「开了 pg_trgm 就等于有了模糊检索」 CREATE EXTENSION pg_trgm 只是把函数装好了。要让 LIKE '%关键词%' 真正走索引,还得建 GIN 或 GiST 索引:

CREATE INDEX idx_documents_content_trgm
ON documents USING gin (content gin_trgm_ops);

只装扩展不建索引,查询照样全表扫描。

❌「权重设成 0.7 比 0.5 效果更好」 没有评估集,这句话无法验证。调权重的正确姿势是:固定其余变量,在评估集上扫一组权重值,看指标曲线在哪里峰值。凭手感调到 0.7 然后「感觉好多了」,是自我安慰——第五篇会告诉你为什么这种手感判断通常站不住脚。


八、自检清单

  • 能举出一个「BM25 能召回、向量检索召回不到」的具体例子
  • 能举出一个反向的例子
  • 知道 PostgreSQL 内置配置里没有 chinese,并说得出怎么补
  • 知道 ts_rank 和 BM25 的差别具体差在哪一项统计上
  • 能手算三个文档的 RRF 分数并排出顺序
  • 说得清 k 值变大时,融合结果会偏向什么
  • 知道 RRF 分数不能当作相关性强弱来用
  • 知道 LIKE '%kw%' 为什么用不上普通索引,该怎么补救

附:延伸阅读

相关资料

资源说明
Reciprocal Rank Fusion (Cormack et al., 2009)RRF 原始论文,k=60 的由来
The Probabilistic Relevance Framework: BM25 and BeyondRobertson & Zaragoza,BM25 的权威综述,讲清 k1 与 b 的作用
PostgreSQL: Full Text Searchtsvector / tsquery / ts_rank 的官方语义
PostgreSQL: pg_trgm三元组模糊匹配与索引支持
zhparser中文分词插件,本文 5.0 的方案
pg_jieba另一条路线,基于 jieba 的分词插件
Elasticsearch: Practical BM25真正实现 BM25 时的参数调优参考

关联笔记


最后更新:2026-09-26