并非每一次搜索旅程都始于特定的目的地。有时候,你只是想探索一下,看看外面有什么,以及你可能会喜欢什么。在美食方面尤其如此。你可能渴望吃甜食,但不知道具体是什么。你也可能正在寻找一道新菜品尝试,只想看看有哪些可选项。在这些情况下,用文本查询来表达你的需求是不可能的,因为你正在寻找的东西尚未确定。当你难以用语言表达你的口味时,Qdrant 的图像语义搜索就很有用。
总体架构
我们很高兴地宣布我们的美食探索演示的更新版本。这次作为开源项目提供,因此你可以轻松地自行部署并进行尝试。如果你更喜欢直接深入研究源代码,请随时查看GitHub 仓库。否则,请继续阅读以了解更多关于演示及其工作原理的信息!
总的来说,我们的应用程序包含三个部分:一个 FastAPI 后端、一个 React 前端和一个 Qdrant 实例。下面的架构图显示了这些组件如何相互作用。
为什么我们使用了 CLIP 模型?
CLIP 是一种神经网络,可用于将图像和文本编码成向量。更重要的是,图像和文本都被向量化到同一个潜在空间中,因此我们可以直接比较它们。这使你可以使用文本查询对图像进行语义搜索,反之亦然。例如,如果你搜索“带配料的扁平面包”,你会得到比萨饼的图像。或者如果你搜索“比萨饼”,你会得到一些带配料的扁平面包的图像,即使它们没有被标记为“比萨饼”。这是因为 CLIP 嵌入捕获了图像和文本的语义,无论措辞如何,都能找到它们之间的相似性。
CLIP 可以通过许多不同的方式获得。我们使用了 Sentence-Transformers 库中提供的预训练模型 clip-ViT-B-32
,因为这是最容易上手的方式。
数据集
该演示基于 Wolt 数据集。它包含来自不同餐厅的超过 200 万张菜品图片以及一些附加元数据。单个菜品的载荷示例如下所示:
{
"cafe": {
"address": "VGX7+6R2 Vecchia Napoli, Valletta",
"categories": ["italian", "pasta", "pizza", "burgers", "mediterranean"],
"location": {"lat": 35.8980154, "lon": 14.5145106},
"menu_id": "610936a4ee8ea7a56f4a372a",
"name": "Vecchia Napoli Is-Suq Tal-Belt",
"rating": 9,
"slug": "vecchia-napoli-skyparks-suq-tal-belt"
},
"description": "Tomato sauce, mozzarella fior di latte, crispy guanciale, Pecorino Romano cheese and a hint of chilli",
"image": "https://wolt-menu-images-cdn.wolt.com/menu-images/610936a4ee8ea7a56f4a372a/005dfeb2-e734-11ec-b667-ced7a78a5abd_l_amatriciana_pizza_joel_gueller1.jpeg",
"name": "L'Amatriciana"
}
处理如此大量的数据需要一些时间,因此我们预先计算了 CLIP 嵌入,将它们存储在 Qdrant 集合中,并将该集合导出为快照。您可以在此处下载。
不同的搜索模式
FastAPI 后端仅暴露一个端点,但它处理多种场景。让我们逐一深入了解它们,并理解为什么需要它们。
冷启动
推荐系统面临冷启动问题。当新用户加入系统时,没有关于他们偏好的数据,因此很难进行推荐。我们的演示也一样。当你打开它时,你会看到随机选择的菜品,并且每次刷新页面时都会改变。在内部,演示选择向量空间中的一些随机点。
该过程应返回多样化的结果,这样我们就有更高的机会向用户展示一些有趣的内容。
文本搜索
由于演示存在冷启动问题,我们实现了一个文本搜索模式,这有助于开始探索数据。你可以通过点击右上角的搜索图标输入任何文本查询。演示将使用 CLIP 模型将查询编码成向量,然后在向量空间中搜索最近邻。
这被实现为Qdrant 的分组搜索查询。我们没有使用简单的搜索,而是按餐厅进行分组以获得更多样化的结果。搜索分组是一种类似于 SQL 中 GROUP BY
子句的机制,当你希望每组获得特定数量的结果时(在我们的案例中仅为一个)非常有用。
import settings
# Encode query into a vector, model is an instance of
# sentence_transformers.SentenceTransformer that loaded CLIP model
query_vector = model.encode(query).tolist()
# Search for nearest neighbors, client is an instance of
# qdrant_client.QdrantClient that has to be initialized before
response = client.search_groups(
settings.QDRANT_COLLECTION,
query_vector=query_vector,
group_by=settings.GROUP_BY_FIELD,
limit=search_query.limit,
)
探索结果
该演示的主要特点是能够探索菜品的空间。你可以点击任何一个菜品查看更多详情,但首先你可以点赞或点踩它,演示将相应地更新搜索结果。
仅负面反馈
Qdrant 推荐 API 需要至少一个正面示例才能工作。然而,在我们的演示中,我们希望能够只提供负面示例。这是因为我们希望能够说“我不喜欢这道菜”,而无需先点赞任何东西。为了实现这一点,我们使用了一个技巧。我们将不喜欢的菜品的向量取反,并使用它们的平均值作为查询。这样,不喜欢的菜品就会被推离搜索结果。这之所以有效,是因为余弦距离基于两个向量之间的角度,而一个向量与其反向向量之间的角度是 180 度。
美食探索演示通过两次调用 Qdrant 来实现这个技巧。最初,我们使用 Scroll API 查找不喜欢的项目,然后计算它们所有向量的负平均值。这允许使用 搜索分组 API 来查找负平均向量的最近邻。
import numpy as np
# Retrieve the disliked points based on their ids
disliked_points, _ = client.scroll(
settings.QDRANT_COLLECTION,
scroll_filter=models.Filter(
must=[
models.HasIdCondition(has_id=search_query.negative),
]
),
with_vectors=True,
)
# Calculate a mean vector of disliked points
disliked_vectors = np.array([point.vector for point in disliked_points])
mean_vector = np.mean(disliked_vectors, axis=0)
negated_vector = -mean_vector
# Search for nearest neighbors of the negated mean vector
response = client.search_groups(
settings.QDRANT_COLLECTION,
query_vector=negated_vector.tolist(),
group_by=settings.GROUP_BY_FIELD,
limit=search_query.limit,
)
正面和负面反馈
由于 推荐 API 需要至少一个正面示例,因此我们只能在用户至少点赞一道菜后才能使用它。理论上我们可以使用与上述相同的技巧,将不喜欢的菜品向量取反,但这会有点奇怪,因为 Qdrant 已经内置了该功能,我们只需调用一次即可完成任务。最好始终在服务器端执行搜索。因此,在这种情况下,我们只需向 Qdrant 服务器发送正面和负面示例列表,以便它能找到一些靠近正面示例且远离负面示例的点。
response = client.recommend_groups(
settings.QDRANT_COLLECTION,
positive=search_query.positive,
negative=search_query.negative,
group_by=settings.GROUP_BY_FIELD,
limit=search_query.limit,
)
从用户的角度来看,与之前的情况相比没有任何变化。
基于位置的搜索
最后但同样重要的是,位置在美食探索过程中起着重要作用。你肯定正在寻找附近能找到的东西,而不是远在地球另一端的东西。因此,你的当前位置可以作为过滤条件进行切换。你可以通过点击右上角的“查找附近”图标来启用它。这样你就可以找到你附近最好的比萨饼,而不是全世界最好的。Qdrant 地理半径过滤器是实现此功能的完美选择。它允许你根据与给定点的距离来过滤结果。
from qdrant_client import models
# Create a geo radius filter
query_filter = models.Filter(
must=[
models.FieldCondition(
key="cafe.location",
geo_radius=models.GeoRadius(
center=models.GeoPoint(
lon=location.longitude,
lat=location.latitude,
),
radius=location.radius_km * 1000,
),
)
]
)
这种过滤器需要载荷索引才能高效工作,并且它是在我们用于创建快照的集合上创建的。当你将其导入到你的实例中时,索引将已经存在。
使用演示
美食探索演示可在网上获取,但如果你更喜欢在本地运行它,可以使用 Docker。README 文件更详细地描述了所有步骤,但这里是一个快速入门:
git clone git@github.com:qdrant/demo-food-discovery.git
cd demo-food-discovery
# Create .env file based on .env.example
docker-compose up -d
演示将在 http://localhost:8001
上可用,但在将快照导入到你的 Qdrant 实例之前,你将无法搜索任何内容。如果你不想麻烦地托管本地实例,可以使用Qdrant Cloud 集群。4GB 内存足以加载所有 200 万条数据。
分叉并重用
我们的演示是完全开源的。欢迎分叉它,用你自己的数据集进行更新,或根据你的用例调整应用程序。无论你是想了解语义搜索的机制,还是想构建一个更大项目的基础,这个演示都可以作为一个起点。查看美食探索演示仓库以开始。如果你有任何问题,欢迎通过 Discord 联系我们。