跳到主要内容

检索基石:语义 / 全文 / 混合三路搜索(pgvector)

30 秒导读: R2R 把「一次检索」拆成三条最底层的路——语义(向量相似)、全文(关键词匹配)、混合(两者融合)。三条路全部落在一个 Postgres 表上,靠 pgvector 扩展算向量距离、靠 Postgres 内置全文引擎算关键词分数,再在 Python 里用一个叫 RRF 的公式把两份排名揉成一份。本章只讲这一层:一次数据库检索的 SQL 与融合公式。查询改写(HyDE / RAG-Fusion)、图谱检索、RAG 生成留给后面几章。

本章是整个检索栈的地基。上层策略(02 章)、图谱聚合(03 章)、乃至 Agentic 迭代检索(05 章)调来调去,最终都会落到这三个方法之一。看懂这一层,上面全是组合。


1. 这是什么(零基础也能懂)

一句话定义: 给一段文字,从一堆文档片段里捞出「最相关」的 N 条——R2R 用三种不同口味的「相关」来捞。

R2R 是一个 RAG(检索增强生成)系统。RAG 的第一步永远是检索:先把用户问题变成一次数据库查询,捞回相关片段,再喂给大模型作答。这一步捞得准不准,直接决定答案质量。

为什么要三种口味? 因为「相关」有两种截然不同的含义,各有各的短板:

口味怎么判断「相关」擅长短板
语义搜索意思接近(向量距离近)换了说法也能命中(「车」↔「汽车」)精确的型号、编号、人名容易糊掉
全文搜索字面词命中(关键词匹配)精确术语、罕见词、编号换个说法就完全搜不到
混合搜索两者都算,再融合排名兼顾语义与字面慢一点(要跑两次)

用起来什么样。 调用方几乎不碰 SQL,只填一个 SearchSettings,拨几个开关:

# 示意,非源码。三种口味只由三个布尔开关切换
from core.base import SearchSettings

# 口味一:纯语义
SearchSettings(use_semantic_search=True, use_fulltext_search=False)

# 口味二:纯全文
SearchSettings(use_semantic_search=False, use_fulltext_search=True)

# 口味三:混合(两个都开 = 触发 RRF 融合)
SearchSettings(use_hybrid_search=True) # 等价于上面两个都 True

一句话直觉: 语义搜索像「懂你意思的图书管理员」,全文搜索像「Ctrl-F 精确查词」,混合搜索让这两个人各挑一批书、再按一套打分规则合并成一个书单。

本节不碰底层。下面开始拆「这一个书单是怎么在 Postgres 里算出来的」。


2. 顶层全景(一次检索怎么落到数据库)

三条路全部实现在同一个类里:PostgresChunksHandler(py/core/providers/database/chunks.py:75)。它管着一张叫 chunks 的表——每一行是一个文档片段,同时带着向量全文索引两副「身份证」。

一张表,两副索引。 建表时(create_tables,chunks.py:89)每行就同时准备好了语义和全文两种可搜形态:

chunks 表(每行 = 一个文档片段)
┌──────────────────────────────────────────────┐
│ id / document_id / owner_id / collection_ids │ ← 身份与权限
│ text 原文 │
│ vec vector(D) ← 语义搜索用(pgvector 列) │
│ fts tsvector ← 全文搜索用(GENERATED 列) │
│ = to_tsvector('english', text) 自动生成 │
└──────────────────────────────────────────────┘
│ │
HNSW/IVFFlat 向量索引 GIN 倒排索引
│ │
semantic_search full_text_search
└───────────┬─────────────┘
hybrid_search(应用层 RRF 融合)

注意 fts 列是 GENERATED ALWAYS AS (to_tsvector('english', text)) STORED(chunks.py:175)——写入片段时 Postgres 自动把原文分词成全文向量,搜索时无需现算。

三个方法,一张对照表:

方法位置(chunks.py)相关性怎么算输出的 score
semantic_search:327vec <=> $n::vector 余弦距离,越小越近1 - distance
full_text_search:484ts_rank(fts, websearch_to_tsquery(...))rank 原值
hybrid_search:538前两者各跑一遍,应用层加权 RRFrrf_score

主线走一遍(高层): 一段查询文本 →(语义路)先被 embedding 模型转成向量,交给 semantic_search 用 pgvector 算距离;(全文路)原样交给 full_text_searchwebsearch_to_tsquery 匹配;(混合路)hybrid_search 把上面两条各调一次,再在 Python 里融合。三者都返回同一种结构 ChunkSearchResult(py/shared/abstractions/search.py:20)。


3. 核心原理(三条路,逐条拆)

3.1 语义搜索:pgvector 的一行余弦距离

它要解决的小问题: 「意思相近」怎么用数字表示?

思路: 把每段文字用 embedding 模型压成一个高维向量(几百到几千个浮点数),意思越像的两段文字,向量在空间里越靠近。「靠近」用余弦距离度量:两向量夹角越小,距离越接近 0。R2R 默认用余弦(IndexMeasure.cosine_distance,py/shared/abstractions/search.py:413)。

pgvector 帮我们把「算距离 + 排序 + 取前 N」压成一条 SQL。 pgvector 是 Postgres 的向量扩展,它提供 <=> 运算符直接算余弦距离(py/shared/abstractions/vector.py:72,cosine_distance → "<=>")。核心 SQL 长这样:

-- 示意,非源码。语义搜索的本质就这一句
SELECT id, document_id, text,
(vec <=> $1::vector) AS distance -- pgvector 算余弦距离
FROM chunks
ORDER BY vec <=> $1::vector -- 距离升序 = 最相近在前
LIMIT $2 OFFSET $3

真实实现。 标准浮点向量的分支里,距离表达式由配置动态拼出(semantic_search,chunks.py:432):

distance_calc = f"{table_name}.vec {search_settings.chunk_settings.index_measure.pgvector_repr} $1::vector{vector_dim}"

这里 pgvector_repr 就是把 cosine_distance 翻译成 <=> 的那层抽象——换一种距离度量(L2 <->、内积 <#>)只需改配置,SQL 骨架不变。查询随后 ORDER BY {distance_calc} LIMIT ... OFFSET ...(chunks.py:452-459)。

关键细节:距离要翻成「越大越相关」的分数。 SQL 返回的是距离(越小越好),但对外统一成「分数越高越相关」。转换在 chunks.py:470-474:

score=(
(1 - float(result["distance"]))
if "distance" in result
else -1
),

余弦距离范围约 [0, 2],1 - distance 把「距离 0(完全一致)」映射成「分数 1」。

坑 / 巧妙处:二值量化的两阶段搜索。quantization_type == INT1(chunks.py:349),向量被压成二进制位串存进 vec_binary 列。此时语义搜索走两阶段:先用便宜的汉明距离(hamming_distance)在二值列上粗筛出 limit * 20 个候选(chunks.py:353,extended_limit),再用原始浮点向量 vec <=> $n::vector 对候选精排(chunks.py:394-416WITH candidates AS (...))。这是「先粗后精」的经典省算力技巧——用位运算快速缩小范围,只对少数候选做精确浮点计算。


3.2 全文搜索:Postgres 的 ts_rank

它要解决的小问题: 精确的词、编号、罕见术语,语义搜索容易糊掉——需要一条「认字面」的路。

思路: 用 Postgres 内置的全文引擎。它把文本分词成 tsvector(全文向量,词 + 位置),把查询分词成 tsquery(查询表达式),@@ 判断是否匹配,ts_rank 给匹配程度打分。

为什么用 websearch_to_tsquery 而不是 to_tsquery? 因为它接受用户友好的查询语法——像搜索引擎那样直接写 machine learning "exact phrase" -exclude,而不用手写 &/|/! 布尔算符。R2R 全程用它(chunks.py:490):

conditions.append("fts @@ websearch_to_tsquery('english', $1)")

注意匹配的是预先生成好的 fts 列(§2 提到的 GENERATED 列),不是现算,快。

真实实现:打分与排序。 分数由 ts_rank 给出(full_text_search,chunks.py:509):

-- 真实 SQL,chunks.py:501-515
SELECT id, document_id, owner_id, collection_ids, text, metadata,
ts_rank(fts, websearch_to_tsquery('english', $1), 32) as rank
FROM chunks
WHERE fts @@ websearch_to_tsquery('english', $1)
ORDER BY rank DESC
OFFSET $2 LIMIT $3

第三个参数 32ts_rank 的归一化标志位——按文档唯一词数做对数归一,抑制长文档天然词多带来的虚高。分数直接作为 ChunkSearchResult.score(chunks.py:532,score=float(r["rank"])),不做 §3.1 那样的 1-x 翻转,因为 ts_rank 本身就是「越大越相关」。

关键细节:全文的取数上限另有其人。 全文路的 LIMIT 不用通用的 search_settings.limit,而是 hybrid_settings.full_text_limit(chunks.py:520,默认 200,见 search.py:401)。这是为混合搜索埋的伏笔——全文侧要多捞一些候选,融合时才有足够素材参与排名。


3.3 混合搜索:应用层的加权 RRF

它要解决的小问题: 语义和全文各给了一份排名,分数量纲完全不同(余弦距离 vs ts_rank),没法直接相加。怎么公平地合并成一份?

思路:RRF(Reciprocal Rank Fusion,倒数排名融合)。 核心洞察是——别信分数,只信名次。一条结果在某一路排第 r 名,就给它 1/(k + r) 的贡献分;两路的贡献分加权求和,就是最终排序依据。这样彻底绕开了「余弦距离」和「ts_rank」量纲不可比的问题。

R2R 的 RRF 不在 SQL 里做,而在 Python 应用层做。 hybrid_search(chunks.py:538)先分别调用 §3.1 和 §3.2,拿到两份 ChunkSearchResult 列表,再手工融合。流程:

query_text ─┬─► full_text_search ─► [名次1, 名次2, ...]
query_vec ─┴─► semantic_search ─► [名次1, 名次2, ...]


按 id 合并进 combined_results 字典
每条记下 {semantic_rank, full_text_rank}


对每条算加权 RRF 分:
rrf = (semantic 分×w_s + fulltext 分×w_f)/(w_s+w_f)


按 rrf_score 降序 → offset/limit 切片 → 返回

真实实现:融合公式。 核心就这几行(chunks.py:607-613):

for hyb_result in combined_results.values():
semantic_score = 1 / (rrf_k + hyb_result["semantic_rank"])
full_text_score = 1 / (rrf_k + hyb_result["full_text_rank"])
hyb_result["rrf_score"] = (
semantic_score * semantic_weight
+ full_text_score * full_text_weight
) / (semantic_weight + full_text_weight)

写成公式:

rrf_score = ( w_s · 1/(k + rank_s) + w_f · 1/(k + rank_f) ) / (w_s + w_f)

其中四个旋钮全来自 HybridSearchSettings(py/shared/abstractions/search.py:392):

旋钮字段默认值作用
w_ssemantic_weight5.0语义路权重(默认更信语义)
w_ffull_text_weight1.0全文路权重
krrf_k50平滑常数,越大越拉平高低名次的差距
full_text_limit200全文路多捞的候选数(§3.2)

关键细节 1:没在另一路出现怎么办? 若某条只在语义路命中、全文路没有,它的 full_text_rank 被赋成一个「垫底名次」full_text_limit(chunks.py:584);反之赋 semantic_limit(chunks.py:594)。相当于「缺席即按最差名次计」,而不是直接踢掉——两路都强的结果自然浮到顶。

关键细节 2:融合前先剪枝。 合并后会先过滤掉两路名次都太靠后的噪声(chunks.py:600-605):只保留 semantic_rank <= semantic_limit*2 full_text_rank <= full_text_limit*2 的候选,再算 RRF。

关键细节 3:名次写回 metadata。 最终结果的 metadata 里塞了 semantic_rank / full_text_rank(chunks.py:633-637),方便上层调试「这条到底是哪路捞上来的」。

前置校验: full_text_limit 必须 ≥ limit,否则直接抛错(chunks.py:550-556)——全文候选池不能小于最终要返回的条数,否则融合素材不够。


4. 配置抽象:三个开关如何选中一条路

三条路对调用方而言只是几个布尔量。真正的「口味选择」由 SearchSettings(py/shared/abstractions/search.py:443)承载,它把「用哪条路」和「每条路的参数」分层收纳:

管什么
顶层开关SearchSettingsuse_semantic_search / use_fulltext_search / use_hybrid_searchlimitoffsetfilters
向量参数ChunkSearchSettingsindex_measure(默认 cosine)、probesef_search(§3.1)
融合参数HybridSearchSettingssemantic_weight / full_text_weight / rrf_k / full_text_limit(§3.3)

内置两档预设。 SearchSettings.get_default(search.py:575)给了 basic(纯语义 + vanilla 策略)和 advanced(混合 + hyde 策略)两个开箱配置。注意 advanced 顺手把 search_strategy 设成 "hyde"——那是上一层(策略层)的事,本章不展开,详见 02 章

SearchMode 枚举(search.py:601)则把这两档 + custom 暴露给 API,让调用方用一个字符串 basic/advanced/custom 选档,而不必手拨每个布尔。

统一出口 ChunkSearchResult 无论走哪条路,返回的都是同一种结构(search.py:20):id / document_id / owner_id / collection_ids / text / score / metadata。这份一致性是上层能把三路结果无差别塞进 AggregateSearchResult 的前提(见 03 章)。


5. 巧妙之处(可借鉴的技术)

  • 一张表扛两种检索。 vec(pgvector)与 fts(GENERATED tsvector)同表共存,写入时全文向量自动生成(chunks.py:175),省掉一次显式索引维护。语义、全文、混合三路无需跨表 JOIN。

  • RRF 放应用层而非 SQL。 融合逻辑用纯 Python 写(chunks.py:607-613),而非拼成一坨复杂 SQL。好处是权重、rrf_k、剪枝规则都在代码里清晰可调、可测试,且「缺席按最差名次计」这类边界逻辑用 Python 表达远比 SQL 直观。

  • 距离度量彻底可插拔。 index_measure.pgvector_repr(vector.py:68)把 cosine/l2/inner_product 映射成 <=> / <-> / <#>,SQL 只引占位符——换度量零改动 SQL 骨架(chunks.py:432)。

  • 二值量化两阶段搜索。 汉明粗筛 + 浮点精排(chunks.py:349-425),在海量向量下用位运算换速度,只对 20× 候选做精确计算。

  • websearch_to_tsquery 而非裸 to_tsquery 直接吃搜索引擎式语法(短语、排除词),免去手拼布尔算符(chunks.py:490)。


6. 边界与局限

  • 全文只支持英文分词。 to_tsvector/websearch_to_tsquery 全部硬编码 'english'(chunks.py:175:490:509)。非英文语料的分词与词干还原会退化。

  • RRF 权重是全局静态的。 semantic_weight / full_text_weight 对所有查询一视同仁,不随查询类型自适应(编号类查询本应更偏全文,但需调用方自己拨)。

  • 混合搜索必然跑两次数据库检索。 hybrid_search 串行调用语义与全文两个方法(chunks.py:566-571),延迟高于单路。

  • 本章只覆盖「一次数据库检索」。 查询怎么被改写成多条子查询(HyDE / RAG-Fusion)、多路结果如何跨源聚合、以及检索结果如何喂给 LLM 生成,都不在这一层——分别见 02 / 03 / 04 章。特别注意:混合搜索内部的这次 RRF 是第一级;策略层还有第二级 RRF,别混淆(见 02 章)。


7. 代码地图(导航索引)

主题文件路径符号名
语义搜索(pgvector 余弦)py/core/providers/database/chunks.pyPostgresChunksHandler.semantic_search
全文搜索(ts_rank)py/core/providers/database/chunks.pyPostgresChunksHandler.full_text_search
混合搜索(应用层 RRF)py/core/providers/database/chunks.pyPostgresChunksHandler.hybrid_search
RRF 中间结构py/core/providers/database/chunks.pyHybridSearchIntermediateResult(semantic_rank/full_text_rank/rrf_score)
建表(vec + fts 双索引)py/core/providers/database/chunks.pyPostgresChunksHandler.create_tables
距离度量→算符映射py/core/providers/database/chunks.pyindex_measure_to_ops
二值量化py/core/providers/database/chunks.pyquantize_vector_to_binary
距离→pgvector 算符py/shared/abstractions/vector.pyIndexMeasure.pgvector_repr
顶层检索配置py/shared/abstractions/search.pySearchSettings
向量检索参数py/shared/abstractions/search.pyChunkSearchSettings(index_measure/probes/ef_search)
融合参数py/shared/abstractions/search.pyHybridSearchSettings(rrf_k/semantic_weight/full_text_weight/full_text_limit)
统一结果结构py/shared/abstractions/search.pyChunkSearchResult
检索档位枚举py/shared/abstractions/search.pySearchMode(basic/advanced/custom)