跳转到主要内容

23 不用搜索引擎,也能搜索

在 Postgres 中构建全文搜索和向量搜索,测量解析、排名、摘要、TOAST 与召回率的代价,并判断什么时候确实需要搜索引擎。

给帮助中心加搜索框有两种做法。相同的 50,000 篇文章、相同的GIN索引、搜索“failed payment”得到相同的 4,206 个匹配项、排名最前的 20 个结果也相同。其中一种不到 5 ms 就能返回,另一种却要 170 ms,因为它索引的是表达式而不是存储列,而且每个匹配项都要再次解析才能排名。

Postgres 中的搜索就是这样:每个部分都很好,但只有测量时才会看到它的代价。大多数产品根本不需要搜索引擎;确实需要的产品也能说清楚原因。

B-树之外已经介绍了基础知识:一个tsvector列、一个 GIN 索引,以及一个 @@ 匹配操作。这里我们在一个包含 50,000 篇文章的合成帮助中心上构建文本和向量搜索(实验脚本是 22-search.sh)。其中的嵌入向量并非来自任何模型,因此这里的召回率数字用于展示取舍,不是对你的数据的预测。

文本会变成词素

全文搜索从不把查询与原文直接比较。它比较的是规范化词与规范化词。to_tsvector 会解析文档,并保留带位置的词素,也就是规范化后的词:

select to_tsvector('english',
  'The deploys were failing, so we rolled back the deployment');
 'back':8 'deploy':2,10 'fail':4 'roll':7

“Deploys”和“deployment”都会变成 deploy;“the”、“were”、“so”和“we”都会被去掉。'english' 参数是一种文本搜索配置:它由解析器以及每类词元所使用的词典组成。

停用词和词干提取有两面性

词干提取依靠后缀规则,而不是理解含义:在 english 中,“organization”会匹配“organ”。搜索乐队 The Who 时,english 会把整个查询当作停用词丢掉。词干提取有利于普通文章,却会伤害名称和代码,因此应为不同内容提供不同配置:文本使用 english,标题、标签和错误代码使用只做小写转换的 simple。

配置是索引的一部分

始终传入配置:单参数形式 to_tsvector(body) 依赖会话设置,因此索引会拒绝它(functions in index expression must be marked IMMUTABLE)。

重音符号需要自己的配置。english 会把“Zürich”保留为 zürich,因此搜索“zurich”什么也找不到。应把 contrib 中的 unaccent 词典加入配置副本,而不是把它作为函数调用。我们的配置名是 app_english,to_tsvector('app_english', 'Requests from Zürich and Montréal') 会得到 'montreal':5 'request':1 'zurich':3。

深入阅读:解析器内部,以及为什么 unaccent() 不能建立索引
create extension unaccent;
create text search configuration app_english (copy = english);
alter text search configuration app_english
  alter mapping for hword, hword_part, word with unaccent, english_stem;

english_stem 是带有 127 个停用词的 Snowball 词干提取器,因此 plainto_tsquery('english', 'to be or not to be') 会返回空结果。unaccent() 函数只有 stable 属性(它的规则文件可能变化),所以索引会拒绝 to_tsvector('english', unaccent(body)),却接受 to_tsvector('app_english', body),因为它信任该配置。你要手动兑现这个承诺:修改 app_english 后,旧行会继续保留旧向量,直到你重写它们。至于内置解析器无法拆分成词的日语或中文,见PGroonga。

动手验证 · lab 22-search.sh
select lab.prove('english stems deploys, failing, rolled, and deployment and drops the stop words',
  to_tsvector('english', 'The deploys were failing, so we rolled back the deployment')
    = $$'back':8 'deploy':2,10 'fail':4 'roll':7$$::tsvector);
select lab.prove('the english stop-word list has 127 words, and a query of only stop words has no lexemes left',
  numnode(plainto_tsquery('english', 'to be or not to be')) = 0);
select lab.prove('stemming collides: organization matches organ, university matches universe',
  to_tsvector('english', 'organization') @@ to_tsquery('english', 'organ')
  and to_tsvector('english', 'university') @@ to_tsquery('english', 'universe'));
select lab.prove('plain english keeps the accent, so a search for zurich misses Zürich',
  not (to_tsvector('english', 'Requests from Zürich') @@ websearch_to_tsquery('english', 'zurich'))
  and to_tsvector('app_english', 'Requests from Zürich') @@ websearch_to_tsquery('app_english', 'zurich'));

完整实验脚本

存储 tsvector;不要索引表达式

把向量存储在生成列中,再为该列建立索引。直接索引表达式看起来更节省空间,却会让每次搜索都付出代价。

alter table docs add column search tsvector generated always as (
  setweight(to_tsvector('english', title), 'A') ||
  setweight(to_tsvector('english', body),  'B')) stored;
create index docs_search_idx on docs using gin (search);

标题词的权重高于正文词。作为比较,第二张表 docs_expr 不保存该列,而是在相同表达式上建立 GIN 索引。这里写成 stored:在 PostgreSQL 18 中,生成列默认是虚拟列,而虚拟列不能建立索引(类型是一份契约)。

堆页GIN 索引加载时的 WAL
表达式索引3,3479,064 KB70 MB
存储列6,7669,360 KB95 MB
表 23-1 存储 tsvector;不要索引表达式

存储列让堆的大小翻倍:带位置信息的 tsvector 平均为 511 字节,而它所来源的正文只有 380 字节。

这就是代价。对匹配项排名时,收益就出现了。存储版本在我们的机器上耗时 4.6 ms;表达式版本耗时 170 ms。两个 GIN 索引都能在不到一毫秒内找到 4,206 个匹配项,但排名需要每个匹配项的 tsvector,而表达式版本必须再次解析全部 4,206 个文档,才能为每个匹配项取得这个向量。

把堆和 WAL 的代价在写入时一次付清,而不是每次搜索都让解析器重新工作。

深入阅读:证明堆、WAL 和再次解析的代价

表达式版本为什么会输:tsvector 上的 GIN 索引只存储词素和行指针,除此之外什么也没有,因此它能说哪些行匹配,却不能把向量交给 ts_rank 打分。没有列可以读取向量时,排名过程会对每个匹配项的标题和正文再次调用 to_tsvector,每次搜索共 4,206 次。两个 GIN 索引的大小大致相同;存储列额外增加的是向量本身,它存在于堆中,也存在于记录每行写入的 WAL 中。

动手验证 · lab 22-search.sh
select lab.prove('the stored tsvector roughly doubles the heap (3,347 pages without it, 6,766 with)',
  pg_relation_size('docs') / pg_relation_size('docs_expr')::numeric between 1.8 and 2.6);
select lab.prove('the average tsvector is bigger than the text it came from',
  (select avg(pg_column_size(search)) > avg(pg_column_size(body)) from docs));
select lab.prove('loading the table with the stored column writes more WAL',
  (select s.wal > e.wal * 1.15 from cost s, cost e where s.what = 'stored' and e.what = 'expression'));
select lab.ms(:'rank_stored') as stored_ms, lab.ms(:'rank_expr') as expr_ms \gset
select lab.prove('ranking through the expression index is more than 5 times slower: it re-parses every match',
  :expr_ms > 5 * :stored_ms);

完整实验脚本

表达式索引只有在你永远不需要排名或高亮时才会胜出。

websearch_to_tsquery 从不抛出语法错误

永远不要把用户输入的内容直接交给 to_tsquery:它要求 tsquery 语法,因此 'failed payment' 会产生语法错误。websearch_to_tsquery(PostgreSQL 11+)接受搜索框语法,从不抛出语法错误:'"roll back" -flags or rollback' 会变成 'roll' <-> 'back' & !'flag' | 'rollback',而像 'payment & ) ( !' 这样的垃圾输入会变成 'payment'。

短语需要位置信息

引号会变成 <->,也就是“紧跟其后”,然后根据存储的位置进行检查:“roll”和“back”都出现在 7,849 篇文章中,但两词紧挨着作为短语出现的文章只有 4,194 篇。

深入阅读:停用词仍然会占用位置

phraseto_tsquery('english', 'roll back the deploy') 会产生 'roll' <-> 'back' <2> 'deploy',因此“roll back a deploy”也能匹配。如果你只是想做搜索,这样已经足够;如果你期待精确字符串,就会觉得意外。

动手验证 · lab 22-search.sh
do $$ begin
  perform to_tsquery('english', 'failed payment');
  raise exception 'no error';
exception when syntax_error then
  if sqlerrm not like 'syntax error in tsquery%' then raise; end if;
end $$;
select lab.prove('to_tsquery rejects two plain words with a syntax error', true);
select lab.prove('garbage input never raises an error',
  websearch_to_tsquery('english', 'payment & ) ( !')::text = $$'payment'$$);
select lab.prove('phraseto_tsquery counts the dropped stop word as a gap of two',
  phraseto_tsquery('english', 'roll back the deploy')::text = $$'roll' <-> 'back' <2> 'deploy'$$
  and to_tsvector('english', 'roll back a deploy') @@ phraseto_tsquery('english', 'roll back the deploy'));
select lab.prove('the phrase matches far fewer articles than the two words anywhere (about 4,200 against 7,800)',
  :phrase between 3800 and 4600 and :anded between 7400 and 8300);

完整实验脚本

按邻近程度排名,而不是按频率

匹配只有是或否;排名则负责给这些匹配排序。使用 ts_rank_cd:它计算覆盖密度,也就是查询词彼此靠得有多近,因此短语“failed payment”会胜过相隔数句才出现的两个词。(ts_rank 只计算它们出现的频率。)

深入阅读:三个文档,三种排名

对于查询“failed payment”:

 id | doc                                  | ts_rank | norm 2 | ts_rank_cd
----+--------------------------------------+---------+--------+-----------
  1 | title "When a payment fails", short  |  0.9990 | 0.1427 |     1.4000
  2 | "failed payment" 4 times, long body  |  0.9854 | 0.0340 |     1.8111
  3 | "failed" and "payment" far apart     |  0.0869 | 0.0072 |     0.0333

norm 2 列是 ts_rank 使用规范化值 2 的结果。

ts_rank 略微偏好那篇短文的标题匹配;ts_rank_cd 偏好四个相邻命中;两者都会把第三个结果压到后面。默认情况下,标题命中(权重 A)计为正文命中(B)的 2.5 倍。规范化位会把排名得分除以文档某个属性对应的值:2 表示除以文档长度,让长文章的得分降到短文章的四分之一。不存在正确的唯一设置,只有一个得到你的用户认可的设置。

动手验证 · lab 22-search.sh
select lab.prove('ts_rank_cd puts the article with four adjacent "failed payment"s first',
  (select r2.cd > r1.cd from rank_scores r1, rank_scores r2 where r1.id = 1 and r2.id = 2));
select lab.prove('dividing by length (normalization 2) cuts the long article to less than a quarter of the short one',
  (select r2.rank_len < r1.rank_len / 4 from rank_scores r1, rank_scores r2 where r1.id = 1 and r2.id = 2));

完整实验脚本

排名会读取每个匹配项

order by rank limit 20 看起来应该只读取 20 行,但它做不到。只有给每个匹配项都排名之后,才能知道排名最前的 20 个:

 Limit (actual time=8.167..8.171 rows=20.00 loops=1)
   Buffers: shared hit=3283
         ->  Bitmap Heap Scan on docs ... rows=4206.00 ...
               ->  Bitmap Index Scan on docs_search_idx ...
                     Buffers: shared hit=13

索引查找只需 13 个页;其余页用于取出全部 4,206 个匹配项,总计仍是 3,283 个页,和没有 limit 时相同。(这里的 8 ms 包含 EXPLAIN ANALYZE 自身的开销;直接运行时,同样的搜索耗时就是前面引用的 4.6 ms。)“deploy”出现在 19,507 篇文章中,对它排名会读取 97% 的表。GIN 知道哪些行包含某个词素,却不知道它出现了多少次、位于哪里,因此无法先返回最佳结果。这就是内置全文搜索的扩展性上限。

所以要在排名前先缩小范围。在这份语料中,一个租户总共约拥有 50 篇文章,因此租户过滤只留下少量候选,而整个语料库会有 4,206 个候选。给过滤条件建立索引,让规划器可以把它和 GIN 扫描组合起来:在 account_id 上建立 B-树以得到 BitmapAnd,或者通过 btree_gin 扩展建立一个组合 GIN 索引。在多租户应用中,这通常就是完整的修复。

动手验证 · lab 22-search.sh
select lab.buffers(:'top20') as top20_pages, lab.buffers(:'all') as all_pages \gset
select lab.prove('limit 20 reads the same pages as returning all 4,206 matches: ranking needs every one',
  :top20_pages >= 0.95 * :all_pages and :top20_pages > 3000);
select lab.prove('deploy is in four articles of ten, and ranking them reads over 90% of the heap',
  (select count(*) from docs where search @@ websearch_to_tsquery('english', 'deploy')) between 18000 and 21000
  and lab.buffers($$ select id, ts_rank(search, q) as rank
     from docs, websearch_to_tsquery('english', 'deploy') q
     where search @@ q order by rank desc limit 20 $$) > 0.9 * pg_relation_size('docs') / 8192);

完整实验脚本

ts_headline 会再次解析文档

最后再计算摘要,而且只为你展示的行计算。ts_headline 每次调用都会再次解析整篇原文。显示 20 行的页面,无论计算摘要与否都约需 5 ms,因为规划器会在限制行数之后才计算摘要。为全部 4,206 个匹配项生成摘要约需 150 ms;当 API 或视图为每个匹配项都生成摘要时,这就是它要支付的账单。

ts_headline 的输出不是安全 HTML

文档说明,它不能防御跨站脚本攻击。渲染前先转义结果,或者把 StartSel 和 StopSel 设置为特殊标记,让模板先转义结果,再把这些标记转换成标签。

深入阅读:ts_headline 在计划中的位置
 Limit
   ->  Result
         Output: ..., ts_headline('english'::regconfig, docs.body, ...)
         ->  Sort
               Sort Key: (ts_rank(docs.search, ...)) DESC

一个在排名前计算摘要的物化 CTE 会破坏上述做法。MaxWords、MinWords 和 MaxFragments 决定摘要的形状。

动手验证 · lab 22-search.sh
select lab.ms(:'search') as search_ms, lab.ms(:'page') as page_ms, lab.ms(:'every') as every_ms \gset
select lab.prove('snippets for every match cost more than 10 times a page of 20 with snippets',
  :every_ms > 10 * :page_ms);
select lab.prove('the planner computes ts_headline after the sort and limit, only for the 20 rows shown',
  (select (p -> 'Plan' ->> 'Node Type') = 'Limit'
      and (p -> 'Plan' -> 'Plans' -> 0 ->> 'Node Type') = 'Result'
      and (p -> 'Plan' -> 'Plans' -> 0 -> 'Plans' -> 0 ->> 'Node Type') = 'Sort'
     from lab.plan(:'page') p));

完整实验脚本

对大多数应用来说,Postgres 的搜索已经够用

Postgres 搜索的是你自己的内容,写入与搜索处于同一个事务中,使用相同的权限和租户过滤条件,不需要同步。只有在能明确说出缺少什么时,才考虑更强的工具:把相关性当作产品能力(知道某个词在整个语料中有多稀有的BM25评分、权重提升、同义词),长篇文本的拼写容错(pg_trgm 覆盖短字符串),海量结果集上的分面和计数,或一个字段中包含多种语言。

独立的搜索引擎会带来一条有延迟且会漂移的同步管线、每条权限规则的第二份副本,以及两个系统之间无法共享事务。先从 Postgres 开始。只有当你能展示某个搜索引擎可以回答、而 Postgres 无法回答的查询时,再迁移出去。

搜索扩展把 BM25 放进索引

应该像评价搜索引擎一样评价一个能提供搜索引擎式排名的扩展:使用你的查询和语料,而不是看功能表。话虽如此,BM25 已经不再要求你离开 Postgres。较新的扩展会把词频和语料统计信息保存在索引中,因此无需取出每个匹配项,就能返回最好的 k 个结果。截至 2026 年 10 月,有四个扩展值得了解(我们的实验没有运行其中任何一个;以下内容来自各项目自己的文档):

扩展短语、模糊许可证运行位置
pg_search(ParadeDB)是,是AGPL-3.0;Enterprise 为商业版自托管、ParadeDB Cloud
pg_textsearch(Timescale)是,否PostgreSQL自托管、PostgreSQL 17 和 18
VectorChord-bm25(TensorChord)否,否AGPL-3.0 或 ELv2自托管
TIN(PlanetScale)是,是未发布仅 PlanetScale
表 23-2 搜索扩展把 BM25 放进索引

在测试其中一个之前,先确认你的托管平台提供它、它的索引能否经受故障切换,以及查询语法有多大可移植性(只有 pg_textsearch 保留 tsquery)。PlanetScale 的基准测试把 TIN 排在很前面;但这是厂商在测量自己的产品。

深入阅读:四个扩展,各用一段介绍

pg_search(0.26.0,PostgreSQL 15+)在 Tantivy 上建立日志结构合并索引;Tantivy 是一个采用 Rust 编写的 Lucene 风格库。其文档称开源版不会复制搜索索引,因此搜索只在主库上运行;副本功能只有商业版提供。

pg_textsearch(1.5.1)基于本章的配置实现 BM25,并按 order by content <@> 'failed payment' 排名。其 README 指出,任何共享 BM25 索引都有一个限制:语料统计会计算被行级安全隐藏的行,因此一个租户可以推断出其他租户数据的某些信息(不要信任任何租户)。

VectorChord-bm25(0.3.0,2025 年 12 月)对词元计数的稀疏向量排名:没有位置信息,因此不支持短语;按照其 README,它只在英语上测试过。

TIN(2026 年 9 月 16 日正式发布)使用行的ctid作为文档 ID,因此倒排列表会按页、再按元组组织成位图进行压缩。我们没有找到已发布的源代码或许可证;Lead(AGPL-3.0)是一个供开发使用的替代品,自称“有意不适合生产环境”。我们没有复现厂商的基准测试。

向量搜索会找到临近的点,不在乎文字是什么

嵌入模型会把文本转换为浮点数组,并把相似输入放在彼此附近。“找出与这个问题相似的文章”就变成“寻找最近的点”,而不论它们使用什么词。pgvector(实验运行 0.8.7)增加了 vector 类型和距离运算符:<=> 表示余弦距离,这是大多数文本模型需要的距离;<-> 表示欧几里得(L2)距离,<#> 表示负内积,<+> 表示 L1 距离。

一个 1,536 维向量会存进 TOAST

一个常见的 1,536 维嵌入向量有 6,152 字节(每个维度 4 字节,再加 8),占页的四分之三,远远超过约 2 KB 的阈值;Postgres 超过这个阈值后会把值移到行外的 TOAST 中(大值会先压缩,再移出行外)。存储 2,000 个向量时,表有 15 个只保存 ID 和指针的堆页,另有 2,000 个 TOAST 页。任何跳过向量的操作都很便宜:count(*) 读取 15 个页。精确搜索会逐块读取每个向量:12,024 个缓冲区,每个向量约 6 个。

深入阅读:普通存储、halfvec 和 bits:向量存在哪里

把列设置为 storage plain 后,2,000 个向量会内联存储,每个占一个堆页;同样的搜索读取 2,003 个缓冲区。只有在行能装进一个页时这才有效(2,100 个维度会被拒绝:“row is too big”)。halfvec(1536)(pgvector 0.7+)为 3,080 字节,并使用 1,000 个 TOAST 页;经过 binary_quantize() 的 bit(1536) 在堆中只占 200 字节,而且表根本不会有 TOAST 表。两者都是近似形式(halfvec 可以缩小索引,召回率没有可测量的损失会测量它们的召回率)。

动手验证 · lab 22-search.sh
select lab.prove('a vector(1536) is 4 x 1,536 + 8 = 6,152 bytes',
  (select pg_column_size(l2_normalize(embedding)) = 6152 from wide limit 1));
select lab.prove('2,000 vectors: 15 heap pages and one TOAST page per vector',
  pg_relation_size('wide') / 8192 < 20
  and (select pg_relation_size(reltoastrelid) / 8192 between 1990 and 2010 from pg_class where relname = 'wide'));
select lab.prove('counting rows never touches the vectors: under 20 pages',
  lab.buffers('select count(*) from wide') < 20);
select lab.prove('exact search reads about six buffers per toasted vector, five times the plain layout',
  :toasted between 11000 and 13000 and :plain between 1990 and 2100);
select lab.prove('a binary-quantized bit(1536) is 200 bytes and stays in the heap: the table has no TOAST table at all',
  (select pg_column_size(embedding) = 200 from wide_bit limit 1)
  and (select reltoastrelid = 0 from pg_class where relname = 'wide_bit'));

完整实验脚本

精确搜索是顺序扫描

不要先去使用近似索引。从这里开始,嵌入向量的维度是 384,存放在单独的 doc_embeddings 表中,这样文章列表就不会把它们带进缓存。没有索引时,order by embedding <=> $1 limit 10 会扫描全部 10,000 个页,在这里约需 20 ms。它是精确的,不需要调参;对于几万个向量,或单个租户对应的那份数据,速度已经足够快。

HNSW 会贪心地遍历图,也可能漏掉结果

HNSW(Hierarchical Navigable Small World,分层可导航小世界)是一张图。每个向量都会连接到一些临近邻居;其中少数向量还会出现在更稀疏、连接更长的上层,就像地铁线路上的快车站:

  layer 2   A ─────────────────────────── K
            │                             │
  layer 1   A ─────── E ───────── H ───── K
            │         │           │       │
  layer 0   A ─ B ─ C ─ E ─ F ─ G ─ H ─ J ─ K ─ ...   (every vector)

搜索从顶层进入,向查询点靠近,再下降一层;它只接触几百个节点,而不是全部节点,因此可能漏掉贪心遍历无法到达的结果。要给构建过程足够的内存和工作进程:默认 64 MB 的 maintenance_work_mem 且没有并行工作进程时需要 57 秒,256 MB 和两个工作进程时需要 8 秒。索引为 98 MB,比 78 MB 的表更大。

ef_search 用延迟换召回率

查询时有一个设置很重要:hnsw.ef_search,它控制候选列表的大小(默认 40)。Recall@10 是真实的十个最近邻中最终返回的比例:

hnsw.ef_searchRecall@10每次查询毫秒数(我们的测量)缓冲区,单次查询
40(默认)0.9690.7722
4000.9954.03,925
精确搜索(无索引)1.0002110,035
表 23-3 ef_search 用延迟换召回率

在默认设置下,只需十四分之一的页数,就能得到真实邻居的 97%。HNSW 构建过程带有随机性,因此每次构建的召回率会相差几个百分点:在我们的多次运行中,默认设置得到 0.92 到 0.99,ef_search 400 得到 0.98 到完整的 1.0。这就是“近似”的含义,也是你要在自己的数据上测量召回率的原因。对于高风险查询,按查询提高它(set local hnsw.ef_search = 100),并将真实查询样本与精确搜索比较,在自己的数据上测量召回率。

深入阅读:构建设置,以及不同构建之间召回率变化多少

在 64 MB 下,构建会警告 hnsw graph no longer fits into maintenance_work_mem after 28369 tuples。我们的计时从串行构建开始(max_parallel_maintenance_workers = 0;服务器默认允许两个工作进程)。仅增加内存(256 MB)就把耗时从 57 秒降到 23 秒;仅增加两个并行工作进程则降到 17 秒;两者都增加时降到 8 秒。并行构建会把图保存在共享内存中,因此在 Docker 中应给容器更大的 /dev/shm。

这轮参数测试还测得 10(0.956,340 个缓冲区)、20(0.968)、100(0.986,1,684 个缓冲区)和 200(0.986)。构建不是确定性的:pgvector 会随机选择每个向量的图层级(setseed 不能影响它),而并行构建每次插入顺序都不同。在我们的多次运行中,默认设置的召回率范围为 0.923 到 0.992,因此实验检查的是 0.9 或更高,而不是某一次运行的数字。

动手验证 · lab 22-search.sh
build=$(sql -c "set client_min_messages = notice" -c "show maintenance_work_mem" \
  -c "create index doc_embeddings_hnsw on doc_embeddings using hnsw (embedding vector_cosine_ops)" 2>&1)
prove "with 64 MB the build warns: hnsw graph no longer fits into maintenance_work_mem" \
  has "hnsw graph no longer fits into maintenance_work_mem" "$build"
select lab.prove('the HNSW index (98 MB here) is bigger than the table it indexes',
  pg_relation_size('doc_embeddings_hnsw') > pg_relation_size('doc_embeddings'));
select lab.prove('at the default ef_search of 40, recall@10 is 0.9 or better',
  (select recall >= 0.9 from sweep where ef = 40));
select lab.prove('raising ef_search raises recall, to 0.98 or better at 400',
  (select max(recall) filter (where ef >= 200) > min(recall) filter (where ef = 10)
      and max(recall) filter (where ef = 400) >= 0.98 from sweep));
select lab.prove('ef_search 400 reads more than 4 times the pages of ef_search 40',
  (select b.buffers > 4 * a.buffers from sweep a, sweep b where a.ef = 40 and b.ef = 400));

完整实验脚本

IVFFlat 的默认一次探测是陷阱

IVFFlat 在构建时把向量聚成 lists 个组,并且只搜索距离查询点最近的 probes 个组。它的构建耗时不到 2 秒,而 HNSW 要 8 秒;它的默认设置却是陷阱:50 个组且 ivfflat.probes = 1 时,recall@10 只有 0.636。5 次探测达到 1.000。应从 sqrt(lists) 开始,并在加载数据后构建,因为这些组来自当时已经存在的行。我们的默认选择是 HNSW。

halfvec 可以缩小索引,召回率没有可测量的损失

表可以继续保存 4 字节浮点数,而索引保存更小的形式:using hnsw ((embedding::halfvec(384)) halfvec_cosine_ops) 把索引从 98 MB 缩小到 56 MB,召回率没有可测量的损失。二值量化每个维度只使用一位,生成了一个 17 MB 的索引,找回真实近邻的比例为 0.303;按完整向量对 400 个候选重新排名后,比例为 0.915(先用位向量取 400 个最近邻,再进行精确重排)。先在自己的嵌入向量上测量。

深入阅读:对二值候选重新排名
select doc_id
from (select doc_id, embedding
      from doc_embeddings
      order by binary_quantize(embedding)::bit(384) <~> binary_quantize($1)
      limit 400) candidates
order by embedding <=> $1
limit 10;

位索引是 using hnsw ((binary_quantize(embedding)::bit(384)) bit_hamming_ops),而且 hnsw.ef_search 必须至少等于内层查询的行数上限。对 100 个候选重新排名得到 0.747。高斯噪声是单比特的困难案例;真实模型(其中一些就是为此训练的)表现会好得多。在这次构建中,半精度的 0.980 胜过全精度的 0.969:这是建图运气。

动手验证 · lab 22-search.sh
select lab.prove('with 50 lists and the default single probe, recall@10 is under 0.8',
  (select recall < 0.8 from probes where probes = 1));
select lab.prove('the halfvec index is under two thirds the size, the bit index under a fifth',
  pg_relation_size('doc_embeddings_half') < 0.67 * pg_relation_size('doc_embeddings_hnsw')
  and pg_relation_size('doc_embeddings_bit') < 0.2 * pg_relation_size('doc_embeddings_hnsw'));
select lab.prove('halfvec keeps recall above 0.95', :half >= 0.95);
select lab.prove('re-ranking 400 bit candidates by the full vectors lifts recall above 0.85',
  :rerank400 > 0.85 and :rerank400 > :rerank100 and :rerank100 > :bits);

完整实验脚本

过滤条件与近似索引不能混用

使用近似索引时,where 会在索引扫描之后运行:HNSW 找到 ef_search 个最佳候选,Postgres 丢弃不满足过滤条件的候选,剩下的才是答案。kind = 'faq' 每十篇文章中匹配一篇。请求十篇:

 Limit (actual time=0.823..0.861 rows=4.00 loops=1)
   ->  Index Scan using doc_embeddings_hnsw on doc_embeddings (actual rows=4.00 ...)
         Filter: (kind = 'faq'::text)
         Rows Removed by Filter: 36

结果只有四行,100 次查询平均只返回 3.7 行,有些查询一行也没有。你的应用请求十行,却只拿到四行,而且没有报错,这是最糟糕的一种错误。

对于宽泛过滤条件,打开 pgvector 0.8 的迭代索引扫描(set hnsw.iterative_scan = relaxed_order),它会持续获取候选,直到得到足够多符合过滤条件的结果:每次都返回十行,但页数超过原来的三倍。对于高选择性的过滤条件,在过滤列上建立 B-树:对于某个租户的 46 篇文章,规划器使用该索引并计算 46 个精确距离,读取 83 个页,不使用 HNSW。每个值一个部分 HNSW 索引,或分区,可以覆盖剩余情况。

深入阅读:迭代扫描、顺序和租户计划

使用 relaxed_order 时,一次查询读取 2,529 个页,而未开启迭代扫描时读取 708 个页;它会扫描最多 hnsw.max_scan_tuples(20,000)个候选。结果可能并不严格按距离排序;strict_order 会保持顺序,但召回率会降低。连接表上的过滤条件无法在扫描期间应用,这就是 account_id 和 kind 会被复制到 doc_embeddings 上的原因。

动手验证 · lab 22-search.sh
select lab.prove('with a 10% filter and ef_search 40, limit 10 returns about 4 rows on average',
  (select avg_rows between 2.5 and 5.5 and min_rows = 0 from filtered where mode = 'off'));
select lab.prove('hnsw.iterative_scan = relaxed_order returns all 10 rows for every query',
  (select avg_rows = 10 and min_rows = 10 from filtered where mode = 'relaxed_order'));
select lab.prove('a tenant filter (46 rows) uses the B-tree and sorts exactly, ignoring HNSW',
  (select count(*) = 46 from doc_embeddings where account_id = 42)
  and lab.plan($$ select doc_id from doc_embeddings where account_id = 42
     order by embedding <=> (select embedding from queries where id = 1) limit 10 $$)::text
     like '%doc_embeddings_account_idx%'
  and lab.plan($$ select doc_id from doc_embeddings where account_id = 42
     order by embedding <=> (select embedding from queries where id = 1) limit 10 $$)::text
     not like '%doc_embeddings_hnsw%');

完整实验脚本

关键词和向量的失败方式不同,所以要融合

全文搜索能精准命中确切词语(错误代码、“SAML”),却会漏掉释义相同的表达:“card declined”在我们的语料中什么也匹配不到。向量能找到这种释义,却会模糊确切词语。

它们的分数没有共同尺度,因此应改为组合排名位置:倒数排名融合(RRF)会从每个返回该文档的列表中按 1 / (60 + rank) 给它一个分数,再按总和排序。两张列表都找到的文章总会胜出:每张列表返回 50 个结果时,两个至少为 1/110 的分数之和,会超过最好的单个分数 1/61。对于“card declined”,向量一侧会填满全部十个位置。

把查询向量作为参数传入:如果查询向量来自连接所得行中的某一列,距离计算就无法使用 HNSW 索引。

深入阅读:把混合搜索写成一个 SQL 函数
create function hybrid_search(query text, query_embedding vector(384),
                              k int default 10)
returns table (id bigint, score numeric, fts_rank bigint, vec_rank bigint)
language sql stable as $$
  with fts as (
    select d.id,
           row_number() over (order by ts_rank_cd(d.search, q) desc, d.id) as r
    from docs d, websearch_to_tsquery('english', query) q
    where d.search @@ q
    order by r
    limit 50
  ), vec as (
    select e.doc_id as id,
           row_number() over (order by e.embedding <=> query_embedding,
                                       e.doc_id) as r
    from (select doc_id, embedding from doc_embeddings
          order by embedding <=> query_embedding limit 50) e
  )
  select id,
         round(coalesce(1.0 / (60 + fts.r), 0)
             + coalesce(1.0 / (60 + vec.r), 0), 4) as score,
         fts.r, vec.r
  from fts full join vec using (id)
  order by score desc, id
  limit k
$$;

以计费主题的中心作为查询嵌入向量时,前十名会交错来自两个列表。在一次索引构建中,排名第一的结果在文本列表中排第 35、在向量列表中排第 19;另一次构建中,没有文章同时进入两个列表的前 50。HNSW 图每次构建都不同,因此向量列表也不同。常数 60 来自 RRF 论文,该论文称它为 k;我们函数中的 k 只是结果数量。

动手验证 · lab 22-search.sh
select lab.prove('fusion returns 10 rows from both lists, and any article both lists found ranks above every article only one found',
  (select count(*) = 10 and count(fts_rank) > 0 and count(vec_rank) > 0
     from hybrid_search('failed payment', (select embedding from query_embeddings where name = 'billing')))
  and (select coalesce(min(score) filter (where fts_rank is not null and vec_rank is not null), 1)
              > coalesce(max(score) filter (where fts_rank is null or vec_rank is null), 0)
         from hybrid_search('failed payment', (select embedding from query_embeddings where name = 'billing'), 100)));
select lab.prove('card declined matches no text at all; the vector side still answers',
  (select count(*) = 0 from docs where search @@ websearch_to_tsquery('english', 'card declined'))
  and (select count(*) = 10 and bool_and(vec_rank is not null)
         from hybrid_search('card declined', (select embedding from query_embeddings where name = 'billing'))));
select lab.prove('ordering by distance to a vector from a join can''t use the HNSW index',
  'Seq Scan' = any (lab.nodes($$ select e.doc_id from doc_embeddings e, query_embeddings q
                                 where q.name = 'billing'
                                 order by e.embedding <=> q.embedding limit 10 $$)));

完整实验脚本

向量数据库需要一个说得出的理由

把属性说清楚:数亿个向量、繁忙主库无法满足的延迟、迭代扫描读取过多页的过滤搜索。若需求还没达到这些程度,让向量和行放在一起更有优势:一份备份、一套权限模型,以及与文本在同一事务中更新的嵌入向量。

本章要点

  • 大多数产品根本不需要搜索引擎。只有为了一个说得出的属性才迁移:BM25 相关性、拼写容错、规模化分面。
  • 到处传入文本搜索配置。
  • 存储 tsvector:通过表达式索引排名耗时 170 ms,而存储列只需 4.6 ms。
  • 对用户输入的一切内容使用 websearch_to_tsquery。
  • 排名会读取每个匹配项。先按租户或日期缩小范围。
  • 只为展示的行计算 ts_headline,并对其进行转义。
  • 1,536 维的 vector 会存进 TOAST:跳过它不增加开销,但扫描每个向量要访问 6 个缓冲区。
  • 先做精确搜索。默认 HNSW 的召回率为 0.92 到 0.99,只需十四分之一的页数。
  • 过滤条件在近似扫描之后运行:打开 hnsw.iterative_scan,或用 B-树对高选择性过滤后的数据进行精确搜索。

延伸阅读

表格阅读