目录
一、为什么单一检索不够
上一篇结尾说过:向量检索对精确字符串不敏感。这一篇把这个问题摊开,然后解决它。
先看一个直觉。你要找一个叫「张三」的人:
- 按名字找(关键词检索):名字一字不差时又快又准;但他要是改叫「张三四」,你就找不到了。
- 按长相找(向量检索):名字变了也认得出;但可能把长得像的另一个人认成他。
两种方式的失效场景几乎不重叠——这正是一个人同时用两种方式找人的原因。
放到检索上,假设知识库里有四份文档,用户搜「年假政策」:
| 文档 | 纯 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) ]
不用被公式吓到,真正起作用的是两个直觉:
- 词频(TF):一个词在文档里出现越多,文档越可能和它相关。但增益是饱和的——出现 100 次并不比出现 10 次相关 10 倍,
k1控制的就是这个饱和曲线的陡缓。 - 逆文档频率(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": "请假有什么规定"
}'
对比测试要设计得有意义,否则很容易得出「混合检索更好」这个想要的结论,而不是真实的结论。至少覆盖三类查询:
- 纯关键词型:查一个文档里的专有名词或编号。这一类的预期是「混合 ≈ BM25 > 向量」。
- 纯语义型:用一个同义说法去查原文。预期是「混合 ≈ 向量 > BM25」。
- 两者兼有型:既有关键词又需要语义理解。这一类才是混合检索真正拉开差距的地方。
如果三类里有两类没有明显差异,说明你的测试集还不够难,或者分词根本没生效。
七、常见误区
❌「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 Beyond | Robertson & Zaragoza,BM25 的权威综述,讲清 k1 与 b 的作用 |
| PostgreSQL: Full Text Search | tsvector / tsquery / ts_rank 的官方语义 |
| PostgreSQL: pg_trgm | 三元组模糊匹配与索引支持 |
| zhparser | 中文分词插件,本文 5.0 的方案 |
| pg_jieba | 另一条路线,基于 jieba 的分词插件 |
| Elasticsearch: Practical BM25 | 真正实现 BM25 时的参数调优参考 |
关联笔记
- RAG 实战(二):跑通最简 RAG 链路 —— 这一篇要补的那个短板来自这里
- RAG 实战(四):Rerank 重排序 —— 融合之后仍不够准,下一步是精排
最后更新:2026-09-26