译者 | 布加迪
审校 | 重楼
使用传统的基于词汇(或基于关键字)的搜索,我们可以找到含有我们搜索的确切单词的文档。关键词搜索在准确性方面表现出色,但在替代词语或自然语言方面表现差强人意。
语义搜索通过捕获文档和用户查询背后的意图来克服这些限制。这通常通过利用向量嵌入将文档和查询映射到高维空间,并计算向量相似性以检索相关结果来实现。
针对几种系统,单一的搜索方法可能会失败,导致向用户显示不完整的信息。结合上述两种搜索方法的优势将使我们能够提供出色的搜索体验。
Elasticsearch和Apache Solr等系统都很好地支持基于关键字的搜索。语义搜索通常需要使用向量数据库进行存储,市面上有多种解决方案。这篇文章解释了我们如何在Postgres中使用单单一个熟悉的存储系统来支持包括词汇搜索和语义搜索的混合搜索。
假设我们有一个应用程序使用下面的表,允许用户通过关键字或自然语言搜索产品:
SQL
CREATE TABLE products (
id bigserial PRIMARY KEY,
description VARCHAR(255),
embedding vector(384)
);
description列包含产品的文本/自然语言描述。Postgres在该列上为全文搜索提供了默认索引,但是我们也可以创建自定义索引以加速全文搜索,其作用类似信息检索的索引。
embedding列存储产品描述的向量(浮点)表示,捕获语义含义而不是单词。Postgres中的pgvector扩展带来了向量数据类型和向量相似性度量指标:L2、余弦和点积距离。有几种方法可以生成嵌入,比如使用词级嵌入(如Word2Vec)、句子/文档嵌入(如SBERT)或者来自基于Transformer的模型(如BERT模型)的嵌入。
为了演示,我们将在数据库中插入以下数据:
SQL
INSERT INTO products (description) VALUES
('Organic Cotton Baby Onesie - Newborn Size, Blue'),
('Soft Crib Sheet for Newborn, Hypoallergenic'),
('Baby Monitor with Night Vision and Two-Way Audio'),
('Diaper Bag Backpack with Changing Pad - Unisex Design'),
('Stroller for Infants and Toddlers, Lightweight'),
('Car Seat for Newborn, Rear-Facing, Extra Safe'),
('Baby Food Maker, Steamer and Blender Combo'),
('Toddler Sippy Cup, Spill-Proof, BPA-Free'),
('Educational Toys for 6-Month-Old Baby, Colorful Blocks'),
('Baby Clothes Set - 3 Pack, Cotton, 0-3 Months'),
('High Chair for Baby, Adjustable Height, Easy to Clean'),
('Baby Carrier Wrap, Ergonomic Design for Newborns'),
('Nursing Pillow for Breastfeeding, Machine Washable Cover'),
('Baby Bath Tub, Non-Slip, for Newborn and Infant'),
('Baby Skincare Products - Lotion, Shampoo, Wash - Organic');
针对嵌入,我使用了SentenceTransformer模型(又名SBERT)以生成嵌入,然后将它们存储在数据库中。下面的Python代码演示了这一点:
SQL
descriptions = [product[1] for product in products]
model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(descriptions)
# Update the database with embeddings
for i, product in enumerate(products):
product_id = product[0]
embedding = embeddings[i] # Convert to Python list
# Construct the vector string representation
embedding_str = str(embedding.tolist())
cur.execute("UPDATE products SET embedding = %s WHERE id = %s", (embedding_str, product_id))
# Commit changes and close connection
conn.commit()
全文搜索
Postgres为关键字搜索提供了广泛的开箱即用支持。我们可以为基于关键字的检索编写如下查询:
假设我们想要搜索婴儿睡眠用品。我们可以使用以下查询进行搜索:
SQL
SELECT id, description
FROM products
WHERE description @@ to_tsquery('english', 'crib | baby | bed');
这将返回以下产品:
SQL
"Soft Crib Sheet for Newborn, Hypoallergenic"
注意:ts_query搜索词素/标准化关键字,因此用newborns或babies替换newborn也会返回相同的结果。
当然,上面只是一个简单的例子,Postgres的全文搜索功能允许我们进行一番定制,比如跳过某些单词、处理同义词、使用复杂的解析等,通过覆盖默认的文本搜索配置来实现。
虽然这些查询在没有索引的情况下也可以工作,但大多数应用程序发现这种方法太慢了,可能除了偶尔的临时搜索之外。文本搜索的实际应用通常需要创建索引。下面的代码演示了如何针对description列创建GIN索引(广义倒排索引),并使用它进行高效搜索。
SQL
--Create a tsvector column (you can add this to your existing table)
ALTER TABLE products ADD COLUMN description_tsv tsvector;
--Update the tsvector column with indexed data from the description column
UPDATE products SET description_tsv = to_tsvector('english', description);
-- Create a GIN index on the tsvector column
CREATE INDEX idx_products_description_tsv ON products USING gin(description_tsv);
语义搜索示例
现在不妨尝试为我们的查询意图(“婴儿睡眠用品”)执行语义搜索请求。为此,我们计算嵌入(如上所述),并根据向量距离(在本例中为余弦距离)选择最相似的产品。下面的代码演示了这一点:
Python
# The query string
query_string = 'baby sleeping accessories'
# Generate embedding for the query string
query_embedding = model.encode(query_string).tolist()
# Construct the SQL query using the cosine similarity operator (<->)
# Assuming you have an index that supports cosine similarity (e.g., ivfflat with vector_cosine_ops)
sql_query = """
SELECT id, description, (embedding <-> %s::vector) as similarity
FROM products
ORDER BY similarity
LIMIT 5;
"""
# Execute the query
cur.execute(sql_query, (query_embedding,))
# Fetch and print the results
results = cur.fetchall()
for result in results:
product_id, description, similarity = result
print(f"ID: {product_id}, Description: {description}, Similarity: {similarity}")
cur.close()
conn.close()
这给了我们以下结果:
Plain Text
ID: 12, Description: Baby Carrier Wrap, Ergonomic Design for Newborns, Similarity: 0.9956936200879117
ID: 2, Description: Soft Crib Sheet for Newborn, Hypoallergenic, Similarity: 1.0233573590998544
ID: 5, Description: Stroller for Infants and Toddlers, Lightweight, Similarity: 1.078171715208051
ID: 6, Description: Car Seat for Newborn, Rear-Facing, Extra Safe, Similarity: 1.08259154868697
ID: 3, Description: Baby Monitor with Night Vision and Two-Way Audio, Similarity: 1.0902734271784085
除了每个结果外,我们还返回了相似性(就余弦相似性而言越低越好)。正如我们所见,通过嵌入搜索,我们得到了更丰富的结果集,这很好地补充了基于关键字的搜索。
默认情况下,pgvector执行精确的最近邻搜索,保证完美的召回。然而,随着数据集大小增加,这种方法的成本相当高。我们可以添加一个索引,以召回换取速度。一个例子是Postgres中的IVFFlat(倒置文件与平面压缩)索引,其工作原理是,使用k-means聚类将向量空间划分为簇类。在搜索期间,它识别最接近查询向量的簇类,并在这些选定的簇类中执行线性扫描,计算查询向量与这些簇类中向量之间的精确距离。下面的代码定义了如何创建这样一个索引:
SQL
CREATE INDEX ON products USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
lists indicates the number of clusters to create.
vector_cosine_ops indicates the distance metric we are using (cosine, inner product, or Euclidean/L2)
结果融合
上述两种方法在不同的场景中表现出色,并相辅相成。将两种方法的结果结合起来有望得到稳健的搜索结果。倒数排序融合(RRF)是一种将多个具有不同相关指标的结果集组合成单个结果集的方法。RRF不需要调优,不同的相关指标也没必要相互关联才能获得高质量的结果。RRF的核心体现在其公式中:
Mathematica
RRF(d) = (r R) 1 / k + r(d))
其中
- d 是文档
- R 是排序器(检索器)集
- k 是常数(通常是60)
- r(d) 是排序器(r)中的文档(d)排序
在我们的例子中,我们将这样做:
1. 通过在添加一个常数后取其排序的倒数来计算每个结果集中每个产品的排序。这个常数可以防止排名靠前的产品主导最终得分,并允许排名较低的产品做出有意义的贡献。
2. 对来自所有结果集的排序倒数求和,以获得产品的最终RRF分数。
针对关键字搜索,Postgres提供了一个排序函数ts_rank(和一些变体),它可以用作结果集中产品的排序。针对语义搜索,我们可以使用嵌入距离来计算结果集中产品的排序。它可以用SQL来实现,使用每种搜索方法的CTE,最后将它们组合起来。
此外,我们还可以在合并后使用机器学习模型对结果重新排序。由于计算成本高,在初始检索后运用基于机器学习模型的重新排序,将结果集缩减到一小部分有希望的候选对象。
结论
借助上述组件,我们构建了一个智能搜索管道,它集成了以下部分:
- 全文搜索,面向精确的关键字匹配
- 向量搜索,面向语义匹配
- 结果融合,使用机器学习结合结果和重新排序
我们通过使用存储所有数据的单一数据库系统来做到这一点。由于避免了与单独的搜索引擎或数据库集成,我们就不需要拥有多个技术堆栈,并降低了系统的复杂性。
原文标题:Hybrid Search Using Postgres DB,作者:Suraj Dharmapuram