AI 与向量

混合搜索

结合关键词搜索与语义搜索


混合搜索结合了全文搜索(基于关键词)和语义搜索(基于含义)的优势,能够同时识别与用户查询直接相关和上下文相关的结果。

混合搜索的适用场景

有时单一的搜索方法无法准确捕捉用户的真实需求。例如,当用户在烹饪应用中搜索"意大利番茄酱食谱"时:

  • 关键词搜索会查找文本中明确包含"意大利"、"食谱"和"番茄酱"的菜谱,但可能遗漏那些本质上是意大利风格且使用番茄酱(但未明确标注这些词)的菜肴,或者使用了"意面酱"、"马瑞纳拉酱"等变体表达的食谱。
  • 语义搜索能理解烹饪上下文,找到匹配意图的食谱(如传统的"马瑞纳拉意面"),即使它们不包含完全匹配的关键词。但也可能推荐上下文相关却不符合用户需求的食谱(如"墨西哥莎莎酱"),因为它对番茄基酱料的理解过于宽泛。

混合搜索综合了这两种方法的优势:

  1. 优先展示明确包含关键词的食谱,确保满足直接匹配条件
  2. 同时包含通过语义理解识别的相关食谱(如传统使用番茄酱的各种意大利菜肴,即使未明确标注搜索词)
  3. 最终呈现既直接相关又符合上下文的结果,同时最大程度减少遗漏和不相关推荐

何时考虑混合搜索

是否采用混合搜索取决于用户在您的应用中寻找什么。对于需要查找精确代码行或错误信息的代码仓库,关键词搜索可能是理想选择,因为它能匹配特定术语。在心理健康论坛中,用户搜索与自身感受相关的建议或经历时,语义搜索可能更合适,因为它基于查询的含义而非特定词汇来寻找结果。而对于购物应用,顾客可能既会搜索具体商品名称,又愿意接受相关推荐,混合搜索就能结合两者的优势——既能找到精确匹配,又能根据购物上下文发现相似商品。

如何结合搜索方法

混合搜索融合了关键词搜索和语义搜索,但这个过程是如何运作的呢?

首先,每种搜索方法会独立执行。关键词搜索(通过内容中的特定词语或短语进行检索)会产生自己的结果集。同样,语义搜索(理解查询背后的上下文或含义而非具体用词)也会生成独特的结果列表。

获得这两个独立的结果列表后,下一步是将它们合并为统一的列表。这个过程称为"融合"(fusion)。融合会根据特定的排序或评分系统,将两种搜索方法的结果合并在一起。该系统可能基于结果与查询的相关性、在各自列表中的排名或其他标准来优先展示某些结果。最终生成的列表集成了关键词搜索和语义搜索的双重优势。

互惠排名融合(RRF)

互惠排名融合(Reciprocal Ranked Fusion, RRF)是最常见的融合方法之一。RRF的核心思想是在构建最终合并列表时,为每个独立结果列表中排名靠前的项目赋予更大权重。

在RRF中,我们遍历每条记录并分配一个分数(注意每条记录可能出现在一个或两个列表中)。分数的计算方式是将1除以该记录在每个列表中的排名,然后将两个列表的结果相加。例如,如果ID为123的记录在关键词搜索中排名第三,在语义搜索中排名第九,那么它的得分为13+19=0.444\dfrac{1}{3} + \dfrac{1}{9} = 0.444。如果记录只在一个列表中出现而另一个列表中没有,则另一个列表的得分为0。然后根据这个分数对记录进行排序生成最终列表,得分最高的项目排在最前面,得分最低的项目排在最后。

这种方法确保在多个列表中排名靠前的项目在最终列表中也能获得较高排名,同时也防止了那些只在少数列表中排名靠前但在其他列表中排名靠后的项目在最终列表中获得过高排名。在计算分数时将排名放在分母中有助于对低排名记录进行惩罚。

平滑常数k

为了防止排名第一的项目得分过高(因为我们用1除以排名),通常会在分母中添加一个k常数来平滑分数:

1k+rank\dfrac{1}{k+rank}

这个常数可以是任何正数,但通常较小。当常数为1时,排名第一的记录得分将是11+1=0.5\dfrac{1}{1+1} = 0.5而不是11。这种调整有助于在创建最终合并列表时,平衡那些在单个列表中排名非常靠前的项目的影响。

Postgres 中的混合搜索

让我们使用 tsvector(关键词搜索)和 pgvector(语义搜索)在 Postgres 中实现混合搜索。

首先创建一个 documents 表来存储我们要搜索的文档。这只是一个示例 - 请根据您的应用结构调整。

1
2
3
4
5
6
create table documents ( id bigint primary key generated always as identity, content text, fts tsvector generated always as (to_tsvector('english', content)) stored, embedding vector(512));

该表包含4列:

  • id 是自动生成的唯一ID,稍后执行RRF时将用于记录匹配
  • content 包含实际要搜索的文本内容
  • fts 是自动生成的 tsvector 列,使用 content 中的文本生成,用于全文搜索(关键词搜索)
  • embedding向量列,存储从嵌入模型生成的向量,用于语义搜索(基于含义的搜索)。本例选择512维,但请根据您使用的嵌入模型调整维度大小

接下来为 ftsembedding 列创建索引,确保大规模查询时仍保持高性能:

1
2
3
4
5
-- 为全文搜索创建索引create index on documents using gin(fts);-- 为语义向量搜索创建索引create index on documents using hnsw (embedding vector_ip_ops);

对于全文搜索,我们使用通用倒排索引(GIN),专为处理 tsvector 这类复合值设计。

对于语义向量搜索,我们使用HNSW索引,这是一种高性能的近似最近邻(ANN)搜索算法。注意这里使用 vector_ip_ops(内积)运算符,因为后续查询中将使用内积(<#>)运算符。如果使用其他运算符如余弦距离(<=>),请相应调整索引。详见距离运算符

最后创建 hybrid_search 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
create or replace function hybrid_search( query_text text, query_embedding vector(512), match_count int, full_text_weight float = 1, semantic_weight float = 1, rrf_k int = 50)returns setof documentslanguage sqlas $$with full_text as ( select id, -- 注意:ts_rank_cd不可索引但仅对where子句匹配项排序 -- 数据量应该不会太大 row_number() over(order by ts_rank_cd(fts, websearch_to_tsquery(query_text)) desc) as rank_ix from documents where fts @@ websearch_to_tsquery(query_text) order by rank_ix limit least(match_count, 30) * 2),semantic as ( select id, row_number() over (order by embedding <#> query_embedding) as rank_ix from documents order by rank_ix limit least(match_count, 30) * 2)select documents.*from full_text full outer join semantic on full_text.id = semantic.id join documents on coalesce(full_text.id, semantic.id) = documents.idorder by coalesce(1.0 / (rrf_k + full_text.rank_ix), 0.0) * full_text_weight + coalesce(1.0 / (rrf_k + semantic.rank_ix), 0.0) * semantic_weight desclimit least(match_count, 30)$$;

解析如下:

  • 参数:函数接受多个参数,主要(必需)参数是 query_textquery_embeddingmatch_count

    • query_text 是用户查询文本
    • query_embedding 是嵌入模型生成的用户查询向量表示。本例使用512维,请根据您的嵌入模型调整。必须与 documents 表中 embedding 向量维度匹配(且使用相同模型)
    • match_count 是返回记录数量的限制

    其他参数可选,用于控制融合过程:

    • full_text_weightsemantic_weight 决定每种搜索方法在最终得分中的权重,默认均为1表示权重相等。若设为 full_text_weight=2semantic_weight=1,则全文搜索权重是语义搜索的两倍
    • rrf_k平滑常数k,默认50
  • 返回类型:函数返回 documents 表的记录集

  • CTE:创建两个公共表表达式(CTE),分别处理全文搜索和语义搜索,在连接前各自独立执行查询

  • RRF:最终查询使用倒数排序融合(RRF)合并两个CTE的结果

执行混合搜索

要在SQL中使用此函数,可以运行以下命令:

1
2
3
4
5
6
7
8
select *from hybrid_search( '意大利番茄酱食谱', -- 用户查询 '[...]'::vector(512), -- 从用户查询生成的嵌入向量 10 );

实际应用中,您可能会通过Supabase客户端或自定义后端层调用此功能。以下是一个使用JavaScript在边缘函数中调用的快速示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { createClient } from 'jsr:@supabase/supabase-js@2'import OpenAI from 'npm:openai'const supabaseUrl = Deno.env.get('SUPABASE_URL')!const supabaseServiceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!const openaiApiKey = Deno.env.get('OPENAI_API_KEY')!Deno.serve(async (req) => { // 从JSON负载中获取用户查询 const { query } = await req.json() // 初始化OpenAI客户端 const openai = new OpenAI({ apiKey: openaiApiKey }) // 为用户查询生成一次性嵌入向量 const embeddingResponse = await openai.embeddings.create({ model: 'text-embedding-3-large', input: query, dimensions: 512, }) const [{ embedding }] = embeddingResponse.data // 初始化Supabase客户端 // (如果使用Supabase认证和RLS,请将服务角色密钥替换为用户JWT) const supabase = createClient(supabaseUrl, supabaseServiceRoleKey) // 通过RPC调用hybrid_search Postgres函数 const { data: documents } = await supabase.rpc('hybrid_search', { query_text: query, query_embedding: embedding, match_count: 10, }) return new Response(JSON.stringify(documents), { headers: { 'Content-Type': 'application/json' }, })})

此示例使用OpenAI的text-embedding-3-large模型生成嵌入向量(缩短至512维以实现更快检索)。您可以根据需要替换为偏好的嵌入模型(和维度大小)。

要测试此功能,可以向函数端点发送POST请求,并在JSON负载中包含用户查询。以下是使用cURL的示例POST请求:

1
2
3
4
5
curl -i --location --request POST \ 'http://127.0.0.1:54321/functions/v1/hybrid-search' \ --header 'Authorization: Bearer <anonymous key>' \ --header 'Content-Type: application/json' \ --data '{"query":"意大利番茄酱食谱"}'

有关如何创建、测试和部署边缘函数的更多信息,请参阅入门指南

相关阅读