通过bm25提升文本召回精度
召回效果的好坏很大程度决定了RAG的效果, 我在之前的文章有过详细介绍: 《为什么用了RAG, 我的AI还是笨得跟猪一样! RAG效果评测与优化》
为了提升召回精度, rerank是一个比较常见的手段, 在这篇文章中有过一些细节介绍: 《维基百科(wikipedia) RAG 优化 | PolarDB + AI》
除了使用rerank模型进行排序, bm25也是提升召回精度的排序方法之一.
下面用2个相关的插件, 简单分享如何对文本进行bm25的相似排序.
pg_tokenizer.rs 是一个 PostgreSQL 扩展,用于增强全文搜索的分词能力。支持多种语言, 包括中文.
VectorChord-BM25 是一个 PostgreSQL 扩展,提供了 BM25 排序算法的功能。它在 PostgreSQL 中实现了 Block-WeakAnd 算法,用于高效的 BM25 排序。为了实现定制化的分词,建议配合 pg_tokenizer.rs 使用。
VectorChord-BM25扩展主要由三部分组成:tokenizer、bm25vector 和 bm25vector 索引。tokenizer 用于将文本转换为 bm25vector,bm25vector 类似于稀疏向量,存储词汇 ID 和频率。bm25vector 索引用于加速搜索和排序过程。
bm25 ranker的优势在于它会考虑token在当前内容的词频、也会考虑在整个文档(或表)中出现的词频, 计算出不同token的权重, 参考token的权重最后计算两组token序列的相似度. 最终更能反映用户意图. 更多可参考文末论文解读.
PolarDB 15 + pg_tokenizer + VectorChord-BM25
测试环境使用:
《维基百科(wikipedia) RAG 优化 | PolarDB + AI》
《向量插件新贵 VectorChord(IVF+ RaBitQ量化), pgvector 和 milvus 都被秒杀》
部署rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
echo ". \"\$HOME/.cargo/env\"" >> ~/.bashrc
. ~/.bashrc
$ rustc --version
rustc 1.86.0 (05f9846f8 2025-03-31)
配置国内镜像
echo "
[source.crates-io]
replace-with = 'ustc'
[source.ustc]
registry = \"sparse+https://mirrors.ustc.edu.cn/crates.io-index/\"
" >> /home/postgres/.cargo/config.toml
下载pg_tokenizer插件代码
cd /data
git clone --depth 1 -b 0.1.0 https://github.com/tensorchord/pg_tokenizer.rs
cd /data/pg_tokenizer.rs
编译安装VectorChord插件依赖环境
cd /data/pg_tokenizer.rs
# pgrx 版本请参考不同版本的 Cargo.toml 文件
cargo install --locked --version 0.13.1 cargo-pgrx
cargo pgrx init # create PGRX_HOME 后, 立即ctrl^c 退出
cargo pgrx init --pg15=`which pg_config` # 不用管报警
Validating /home/postgres/tmp_polardb_pg_15_base/bin/pg_config
Initializing data directory at /home/postgres/.pgrx/data-15
编译安装pg_tokenizer插件
cd /data/pg_tokenizer.rs
PGRX_IGNORE_RUST_VERSIONS=y cargo pgrx install --release --pg-config `which pg_config`
下载VectorChord-bm25插件代码
cd /data
git clone --depth 1 -b 0.2.1 https://github.com/tensorchord/VectorChord-bm25
cd /data/VectorChord-bm25
编译安装VectorChord-bm25插件依赖环境
cd /data/VectorChord-bm25
# pgrx 版本请参考不同版本的 Cargo.toml 文件
cargo install --locked --version 0.13.1 cargo-pgrx
cargo pgrx init # create PGRX_HOME 后, 立即ctrl^c 退出
cargo pgrx init --pg15=`which pg_config` # 不用管报警
Validating /home/postgres/tmp_polardb_pg_15_base/bin/pg_config
Initializing data directory at /home/postgres/.pgrx/data-15
编译安装VectorChord-bm25插件
cd /data/VectorChord-bm25
PGRX_IGNORE_RUST_VERSIONS=y cargo pgrx install --release --pg-config `which pg_config`
配置postgresql.conf
vi ~/primary/postgresql.conf
shared_preload_libraries='vchord_bm25,pg_tokenizer,vchord,pg_jieba,pg_bigm,pgml,$libdir/polar_vfs,$libdir/polar_worker'
重启PolarDB for postgresql
pg_ctl restart -m fast -D ~/primary
在数据库中安装插件
postgres=# create extension pg_tokenizer;
CREATE EXTENSION
postgres=# create extension vchord_bm25;
CREATE EXTENSION
postgres=# \dx
List of installed extensions
Name | Version | Schema | Description
---------------------+---------+-------------------+---------------------------------------------------------------------------------------------
http | 1.6 | public | HTTP client for PostgreSQL, allows web page retrieval inside the database.
openai | 1.0 | public | OpenAI client.
pg_bigm | 1.2 | public | text similarity measurement and index searching based on bigrams
pg_bulkload | 3.1.22 | public | pg_bulkload is a high speed data loading utility for PostgreSQL
pg_jieba | 1.1.0 | public | a parser for full-text search of Chinese
pg_stat_statements | 1.10 | public | track planning and execution statistics of all SQL statements executed
pg_tokenizer | 0.0.0 | tokenizer_catalog | pg_tokenizer
pg_trgm | 1.6 | public | text similarity measurement and index searching based on trigrams
pgml | 2.10.0 | pgml | Machine Learning and AI functions from postgresml.org
pgstattuple | 1.5 | public | show tuple-level statistics
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
plpython3u | 1.0 | pg_catalog | PL/Python3U untrusted procedural language
polar_feature_utils | 1.0 | pg_catalog | PolarDB feature utilization
polar_vfs | 1.0 | public | polar virtual file system for different storage
sslinfo | 1.2 | public | information about SSL certificates
vchord | 0.3.0 | public | vchord: Vector database plugin for Postgres, written in Rust, specifically designed for LLM
vchord_bm25 | 0.0.0 | bm25_catalog | vchord_bm25: A postgresql extension for bm25 ranking algorithm
vector | 0.8.0 | public | vector data type and ivfflat and hnsw access methods
(18 rows)
不要忘记将tokenizer_catalog和添加bm25_catalog到搜索路径:
ALTER SYSTEM SET search_path TO "$user", public, tokenizer_catalog, bm25_catalog;
SELECT pg_reload_conf();
-- 或者在会话中设置.
tokenizer 使用举例:
postgres=# set search_path TO "$user", public, tokenizer_catalog;
SET
postgres=# show search_path ;
search_path
------------------------------------
"$user", public, tokenizer_catalog
(1 row)
-- Using pre-trained model (bert-base-uncased)
postgres=# SELECT create_tokenizer('tokenizer1', $$
model = "llmlingua2"
$$);
create_tokenizer
------------------
(1 row)
-- 切分得到token 在模型中对应的 id
postgres=# SELECT tokenize('PostgreSQL is a powerful, open-source object-relational database system. It has over 15 years of active development.', 'tokenizer1');
tokenize
--------------------------------------------------------------------------------------------------------------------------
{2795,7134,158897,83,10,113138,4,9803,9,60427,36746,9,25653,43315,63399,5426,5,1650,1556,645,423,5369,111,36457,34754,5}
(1 row)
postgres=# select list_preload_models();
list_preload_models
---------------------
{llmlingua2}
(1 row)
tokenizer_catalog schema
postgres=# \df tokenizer_catalog.*
List of functions
Schema | Name | Result data type | Argument data types
| Type
-------------------+--------------------------------------------------+------------------+----------------------------------------------------------------------------------------------------------------
--------+------
tokenizer_catalog | _pg_tokenizer_stopwords_init | void |
| func
tokenizer_catalog | add_preload_model | void | name text
| func
tokenizer_catalog | apply_text_analyzer | text[] | text text, text_analyzer_name text
| func
tokenizer_catalog | apply_text_analyzer_for_custom_model | text[] | text text, text_analyzer_name text
| func
tokenizer_catalog | create_custom_model | void | name text, config text
| func
tokenizer_catalog | create_custom_model_tokenizer_and_trigger | void | tokenizer_name text, model_name text, text_analyzer_name text, table_name text, source_column text, target_colu
mn text | func
tokenizer_catalog | create_huggingface_model | void | name text, config text
| func
tokenizer_catalog | create_lindera_model | void | name text, config text
| func
tokenizer_catalog | create_stopwords | void | name text, config text
| func
tokenizer_catalog | create_synonym | void | name text, config text
| func
tokenizer_catalog | create_text_analyzer | void | name text, config text
| func
tokenizer_catalog | create_tokenizer | void | name text, config text
| func
tokenizer_catalog | custom_model_insert_trigger | trigger |
| func
tokenizer_catalog | custom_model_tokenizer_set_target_column_trigger | trigger |
| func
tokenizer_catalog | drop_custom_model | void | name text
| func
tokenizer_catalog | drop_huggingface_model | void | name text
| func
tokenizer_catalog | drop_lindera_model | void | name text
| func
tokenizer_catalog | drop_stopwords | void | name text
| func
tokenizer_catalog | drop_synonym | void | name text
| func
tokenizer_catalog | drop_text_analyzer | void | name text
| func
tokenizer_catalog | drop_tokenizer | void | name text
| func
tokenizer_catalog | list_preload_models | text[] |
| func
tokenizer_catalog | remove_preload_model | void | name text
| func
tokenizer_catalog | tokenize | integer[] | text text, tokenizer_name text
| func
(24 rows)
Using jieba for Chinese text
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
passage TEXT,
embedding INT[]
);
-- create a text analyzer which uses jieba pre-tokenizer
SELECT create_text_analyzer('text_analyzer1', $$
[pre_tokenizer.jieba]
$$);
SELECT create_custom_model_tokenizer_and_trigger(
tokenizer_name => 'tokenizer_jieba',
model_name => 'model_jieba',
text_analyzer_name => 'text_analyzer1',
table_name => 'documents',
source_column => 'passage',
target_column => 'embedding'
);
postgres=# \d documents
Table "public.documents"
Column | Type | Collation | Nullable | Default
-----------+-----------+-----------+----------+---------------------------------------
id | integer | | not null | nextval('documents_id_seq'::regclass)
passage | text | | |
embedding | integer[] | | |
Indexes:
"documents_pkey" PRIMARY KEY, btree (id)
Triggers:
model_model_jieba_trigger BEFORE INSERT OR UPDATE OF passage ON documents FOR EACH ROW EXECUTE FUNCTION custom_model_insert_trigger('model_jieba', 'passage', 'text_analyzer1')
model_model_jieba_trigger_insert BEFORE INSERT OR UPDATE OF passage ON documents FOR EACH ROW EXECUTE FUNCTION custom_model_tokenizer_set_target_column_trigger('tokenizer_jieba', 'passage', 'embedding')
INSERT INTO documents (passage) VALUES
('红海早过了,船在印度洋面上开驶着,但是太阳依然不饶人地迟落早起,侵占去大部分的夜。'),
('夜仿佛纸浸了油变成半透明体;它给太阳拥抱住了,分不出身来,也许是给太阳陶醉了,所以夕照晚霞褪后的夜色也带着酡红。'),
('到红消醉醒,船舱里的睡人也一身腻汗地醒来,洗了澡赶到甲板上吹海风,又是一天开始。'),
('这是七月下旬,合中国旧历的三伏,一年最热的时候。在中国热得更比常年利害,事后大家都说是兵戈之象,因为这就是民国二十六年【一九三七年】。'),
('这条法国邮船白拉日隆子爵号(VicomtedeBragelonne)正向中国开来。'),
('早晨八点多钟,冲洗过的三等舱甲板湿意未干,但已坐满了人,法国人、德国流亡出来的犹太人、印度人、安南人,不用说还有中国人。'),
('海风里早含着燥热,胖人身体给炎风吹干了,上一层汗结的盐霜,仿佛刚在巴勒斯坦的死海里洗过澡。'),
('毕竟是清晨,人的兴致还没给太阳晒萎,烘懒,说话做事都很起劲。'),
('那几个新派到安南或中国租界当警察的法国人,正围了那年轻善撒娇的犹太女人在调情。'),
('俾斯麦曾说过,法国公使大使的特点,就是一句外国话不会讲;这几位警察并不懂德文,居然传情达意,引得犹太女人格格地笑,比他们的外交官强多了。'),
('这女人的漂亮丈夫,在旁顾而乐之,因为他几天来,香烟、啤酒、柠檬水沾光了不少。'),
('红海已过,不怕热极引火,所以等一会甲板上零星果皮、纸片、瓶塞之外,香烟头定又遍处皆是。'),
('法国人的思想是有名的清楚,他的文章也明白干净,但是他的做事,无不混乱、肮脏、喧哗,但看这船上的乱糟糟。'),
('这船,倚仗人的机巧,载满人的扰攘,寄满人的希望,热闹地行着,每分钟把沾污了人气的一小方小面,还给那无情、无尽、无际的大海。');
postgres=# select * from documents;
id | passage |
embedding
----+------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------
1 | 红海早过了,船在印度洋面上开驶着,但是太阳依然不饶人地迟落早起,侵占去大部分的夜。 | {1,2,3,4,5,6,7,8,9,10,11,4,13,14,15,16,17,18,19,4,21,22,2
3,24,25,26,27,28}
2 | 夜仿佛纸浸了油变成半透明体;它给太阳拥抱住了,分不出身来,也许是给太阳陶醉了,所以夕照晚霞褪后的夜色也带着酡红。 | {27,29,30,3,31,32,33,34,35,36,37,38,39,40,14,41,42,3,4,43
,44,45,46,4,47,48,40,14,50,3,4,51,52,53,54,55,26,56,57,58,11,59,60,28}
3 | 到红消醉醒,船舱里的睡人也一身腻汗地醒来,洗了澡赶到甲板上吹海风,又是一天开始。 | {61,60,62,4,63,64,26,65,66,57,67,68,69,70,71,4,72,3,73,74
,75,76,77,78,4,79,48,80,81,28}
4 | 这是七月下旬,合中国旧历的三伏,一年最热的时候。在中国热得更比常年利害,事后大家都说是兵戈之象,因为这就是民国二十六年【一九三七年】。 | {82,83,84,4,85,86,87,26,88,4,89,90,26,91,28,6,86,93,94,95
,96,97,4,98,99,100,101,48,102,103,4,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,28}
5 | 这条法国邮船白拉日隆子爵号(VicomtedeBragelonne)正向中国开来。 | {105,123,124,125,126,127,128,129,130,131,132,133,86,134,2
8}
6 | 早晨八点多钟,冲洗过的三等舱甲板湿意未干,但已坐满了人,法国人、德国流亡出来的犹太人、印度人、安南人,不用说还有中国人。 | {135,136,137,138,4,139,140,26,141,142,75,143,144,145,4,14
6,147,148,3,66,4,124,149,150,151,152,153,154,26,155,156,151,7,158,151,160,66,4,161,162,163,86,66,28}
7 | 海风里早含着燥热,胖人身体给炎风吹干了,上一层汗结的盐霜,仿佛刚在巴勒斯坦的死海里洗过澡。 | {78,64,164,165,11,166,4,167,66,168,40,169,170,3,4,76,171,
172,26,173,4,29,174,6,175,176,177,178,26,179,180,181,182,28}
8 | 毕竟是清晨,人的兴致还没给太阳晒萎,烘懒,说话做事都很起劲。 | {183,48,184,4,66,26,185,186,187,40,14,188,4,189,4,190,191
,100,192,193,28}
9 | 那几个新派到安南或中国租界当警察的法国人,正围了那年轻善撒娇的犹太女人在调情。 | {194,195,196,61,160,197,86,198,199,200,26,124,149,150,4,2
01,3,194,203,204,205,26,155,206,6,207,28}
10 | 俾斯麦曾说过,法国公使大使的特点,就是一句外国话不会讲;这几位警察并不懂德文,居然传情达意,引得犹太女人格格地笑,比他们的外交官强多了。 | {208,209,101,140,4,124,210,211,26,212,4,106,213,214,215,2
16,217,38,105,218,200,219,44,220,221,4,222,223,224,4,225,155,206,226,227,4,95,228,26,229,230,231,3,28}
11 | 这女人的漂亮丈夫,在旁顾而乐之,因为他几天来,香烟、啤酒、柠檬水沾光了不少。 | {105,206,26,232,233,4,6,234,235,236,4,104,237,238,46,4,23
9,151,240,151,241,242,243,3,244,28}
12 | 红海已过,不怕热极引火,所以等一会甲板上零星果皮、纸片、瓶塞之外,香烟头定又遍处皆是。 | {1,245,4,246,247,248,4,51,249,250,75,76,251,252,151,253,1
51,254,255,4,239,256,257,258,79,259,260,48,28}
13 | 法国人的思想是有名的清楚,他的文章也明白干净,但是他的做事,无不混乱、肮脏、喧哗,但看这船上的乱糟糟。 | {124,149,150,26,261,48,262,26,263,4,237,26,264,57,265,266
,4,13,237,26,191,4,267,268,151,269,151,270,4,146,271,105,272,26,273,274,28}
14 | 这船,倚仗人的机巧,载满人的扰攘,寄满人的希望,热闹地行着,每分钟把沾污了人气的一小方小面,还给那无情、无尽、无际的大海。 | {275,4,276,66,26,277,4,278,66,26,279,4,280,66,26,281,4,28
2,283,11,4,284,285,286,287,3,288,26,289,290,4,291,194,292,151,293,151,294,26,295,28}
(14 rows)
使用bm25例子:
-- create a tokenizer
SELECT create_tokenizer('bert', $$
model = "bert_base_uncased" # using pre-trained model
$$);
-- tokenize text with bert tokenizer
SELECT tokenize('A quick brown fox jumps over the lazy dog.', 'bert')::bm25vector;
-- Output: {1012:1, 1037:1, 1996:1, 2058:1, 2829:1, 3899:1, 4248:1, 4419:1, 13971:1, 14523:1}
-- The output is a bm25vector, 1012:1 means the word with id 1012 appears once in the text.
使用bm25对结果进行 rerank.
bm25 得分的一个特殊之处在于它依赖于全局文档频率,这意味着文档中某个单词的得分取决于该单词在所有文档中出现的频率。要计算 bm25 向量与查询之间的 bm25 得分,你需要先创建一个文档集,然后使用该<&>运算符。
set search_path TO "$user", public, tokenizer_catalog, bm25_catalog;
drop table documents;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
passage TEXT,
embedding bm25vector
);
-- create a text analyzer which uses jieba pre-tokenizer
SELECT create_text_analyzer('text_analyzer1', $$
[pre_tokenizer.jieba]
$$);
SELECT create_custom_model_tokenizer_and_trigger(
tokenizer_name => 'tokenizer_jieba1',
model_name => 'model_jieba1',
text_analyzer_name => 'text_analyzer1',
table_name => 'documents',
source_column => 'passage',
target_column => 'embedding'
);
INSERT INTO documents (passage) VALUES
('红海早过了,船在印度洋面上开驶着,但是太阳依然不饶人地迟落早起,侵占去大部分的夜。'),
('夜仿佛纸浸了油变成半透明体;它给太阳拥抱住了,分不出身来,也许是给太阳陶醉了,所以夕照晚霞褪后的夜色也带着酡红。'),
('到红消醉醒,船舱里的睡人也一身腻汗地醒来,洗了澡赶到甲板上吹海风,又是一天开始。'),
('这是七月下旬,合中国旧历的三伏,一年最热的时候。在中国热得更比常年利害,事后大家都说是兵戈之象,因为这就是民国二十六年【一九三七年】。'),
('这条法国邮船白拉日隆子爵号(VicomtedeBragelonne)正向中国开来。'),
('早晨八点多钟,冲洗过的三等舱甲板湿意未干,但已坐满了人,法国人、德国流亡出来的犹太人、印度人、安南人,不用说还有中国人。'),
('海风里早含着燥热,胖人身体给炎风吹干了,上一层汗结的盐霜,仿佛刚在巴勒斯坦的死海里洗过澡。'),
('毕竟是清晨,人的兴致还没给太阳晒萎,烘懒,说话做事都很起劲。'),
('那几个新派到安南或中国租界当警察的法国人,正围了那年轻善撒娇的犹太女人在调情。'),
('俾斯麦曾说过,法国公使大使的特点,就是一句外国话不会讲;这几位警察并不懂德文,居然传情达意,引得犹太女人格格地笑,比他们的外交官强多了。'),
('这女人的漂亮丈夫,在旁顾而乐之,因为他几天来,香烟、啤酒、柠檬水沾光了不少。'),
('红海已过,不怕热极引火,所以等一会甲板上零星果皮、纸片、瓶塞之外,香烟头定又遍处皆是。'),
('法国人的思想是有名的清楚,他的文章也明白干净,但是他的做事,无不混乱、肮脏、喧哗,但看这船上的乱糟糟。'),
('这船,倚仗人的机巧,载满人的扰攘,寄满人的希望,热闹地行着,每分钟把沾污了人气的一小方小面,还给那无情、无尽、无际的大海。');
- 在 bm25vector 列上创建索引,以便我们可以收集全局文档频率。
CREATE INDEX documents_embedding_bm25 ON documents USING bm25 (embedding bm25_ops);
- 现在我们可以计算查询和向量之间的 BM25 得分。
- 需要注意的是,bm25 得分为负数,这意味着得分越高,文档的相关性就越高。
- 我们特意将其设为负数,以便您可以使用默认的 order by 来优先获取最相关的文档。
SELECT id, passage, embedding <&> to_bm25query('documents_embedding_bm25', tokenize('人', 'tokenizer1')) AS rank
FROM documents
ORDER BY rank
LIMIT 10;
id | passage | rank
----+------------------------------------------------------------------------------------------------------------------------------------------+------------
11 | 这女人的漂亮丈夫,在旁顾而乐之,因为他几天来,香烟、啤酒、柠檬水沾光了不少。 | -1.1060982
9 | 那几个新派到安南或中国租界当警察的法国人,正围了那年轻善撒娇的犹太女人在调情。 | -1.0914663
1 | 红海早过了,船在印度洋面上开驶着,但是太阳依然不饶人地迟落早起,侵占去大部分的夜。 | -1.0772165
7 | 海风里早含着燥热,胖人身体给炎风吹干了,上一层汗结的盐霜,仿佛刚在巴勒斯坦的死海里洗过澡。 | -0.9989638
4 | 这是七月下旬,合中国旧历的三伏,一年最热的时候。在中国热得更比常年利害,事后大家都说是兵戈之象,因为这就是民国二十六年【一九三七年】。 | -0.8368523
8 | 毕竟是清晨,人的兴致还没给太阳晒萎,烘懒,说话做事都很起劲。 | -0
10 | 俾斯麦曾说过,法国公使大使的特点,就是一句外国话不会讲;这几位警察并不懂德文,居然传情达意,引得犹太女人格格地笑,比他们的外交官强多了。 | -0
12 | 红海已过,不怕热极引火,所以等一会甲板上零星果皮、纸片、瓶塞之外,香烟头定又遍处皆是。 | -0
13 | 法国人的思想是有名的清楚,他的文章也明白干净,但是他的做事,无不混乱、肮脏、喧哗,但看这船上的乱糟糟。 | -0
14 | 这船,倚仗人的机巧,载满人的扰攘,寄满人的希望,热闹地行着,每分钟把沾污了人气的一小方小面,还给那无情、无尽、无际的大海。 | -0
(10 rows)
更多用法参考:
-
https://github.com/tensorchord/pg_tokenizer.rs/tree/main/docs
-
https://github.com/tensorchord/VectorChord-bm25
参考
-
https://github.com/tensorchord/VectorChord/blob/main/sql/install/vchord--0.3.0.sql
-
https://docs.vectorchord.ai/
-
https://blog.vectorchord.ai/vectorchord-store-400k-vectors-for-1-in-postgresql
-
https://github.com/tensorchord/VectorChord
-
https://github.com/tensorchord/VectorChord-bm25
-
https://github.com/tensorchord/pg_tokenizer.rs
-
《AI论文解读 | BM25 Query Augmentation Learned End-to-End》
-
《AI论文解读 | 基于熵的BM25语义相似增强搜索 BMX : Entropy-weighted Similarity and Semantic-enhanced Lexical Search》