利用 Elasticsearch 和 SigLIP-2 对山峰进行多模式搜索
了解如何使用 SigLIP-2 嵌入和 Elasticsearch kNN 向量搜索实现文本到图像和图像到图像的多模态搜索。项目重点:寻找珠峰徒步旅行中拍摄的阿玛达布拉姆峰照片。
您是否曾想过按含义搜索相册?试着询问 "给我看我穿着蓝色夹克坐在长椅上的照片"、"给我看珠穆朗玛峰的照片 "或 "清酒和寿司"。喝杯咖啡(或您最喜欢的饮料),继续阅读。在本博客中,我们将向您展示如何构建多模态混合搜索应用程序。多模态是指应用程序可以理解和搜索不同类型的输入(文本、图像和音频),而不仅仅是文字。混合式意味着它结合了关键词匹配、kNN 向量搜索和地理围栏等技术,以提供更清晰的结果。

为此,我们使用谷歌的 SigLIP-2 为图像和文本生成矢量嵌入,并将其存储在 Elasticsearch 矢量数据库中。在查询时,我们将搜索输入(文本或图像)转换为嵌入,并运行快速的 kNN 向量搜索来检索结果。这种设置可实现高效的文本到图像和图像到图像搜索。Streamlit 用户界面为我们提供了一个前端,不仅可以进行基于文本的搜索,从相册中查找并查看匹配的照片,还可以从上传的图片中识别山峰,并查看相册中该山峰的其他照片,从而使该项目栩栩如生。我们还介绍了为提高搜索准确性而采取的措施,以及实用技巧和窍门。为便于进一步探索,我们提供了GitHub 存储库和Colab 笔记本。
如何开始
这篇博文的灵感来自于一个 10 岁的孩子,他让我给他们看我在珠峰大本营徒步旅行时拍摄的阿玛达布拉姆山的所有照片。在翻阅相册时,我还被要求辨认其他几座山峰,其中一些我还叫不出名字。
这让我想到,这可以成为一个有趣的计算机视觉项目。我们的目标
按名称查找山峰图片
从图片中猜测山峰名称,并在相册中找到类似的山峰
让概念查询发挥作用(人、河流、祈祷旗 等)

阿玛-达布拉姆山
组建梦之队:SigLIP-2、Elasticsearch& Streamlit
很快我们就发现,要想实现这一目标,我们需要将文字("阿玛达布拉姆")和图像(我相册中的照片)都转化为可以进行有意义比较的矢量,即在同一个矢量空间中。一旦我们做到了这一点,搜索就只是 "寻找最近的邻居"。

为了生成图像嵌入,我们使用了多语言 视觉语言编码器,因此山峰的照片和 "Ama Dablam "这样的短语会出现在同一个向量空间中。
谷歌最近发布的SigLIP-2 在这方面非常适合。它可以在没有特定任务训练的情况下生成嵌入式(零镜头设置),并能很好地适用于我们的使用案例:未标记的照片和具有不同名称和语言的山峰。由于它是针对文本与图像匹配进行训练的,因此即使查询语言或拼写不同,徒步旅行中的山峰图片和简短的文字提示最终也能接近嵌入。
SigLIP-2 在质量与速度之间实现了很好的平衡,支持多种输入分辨率,并可在 CPU 和 GPU 上运行。SigLIP-2 在设计上比以前的型号(如最初的 CLIP)更适合户外拍摄。在我们的测试中,SigLIP-2 始终能生成可靠的结果。此外,它还得到了很好的支持,因此是本项目的不二之选。
接下来,我们需要一个向量数据库来存储嵌入和强力搜索。它不仅应支持对图像嵌入进行余弦 kNN 搜索,还应在单个查询中应用地理围栏和文本过滤器。Elasticsearch 在这方面非常适合:它能很好地处理向量(在 dense_vector 字段上使用 HNSW kNN),支持结合文本、向量和地理查询的混合搜索,并提供开箱即用的过滤和排序功能。它还可以横向扩展,因此很容易从少量照片扩展到数千张照片。最后,我们需要一个轻量级前端,以便输入搜索查询并查看结果。对于基于 Python 的快速演示,Streamlit 非常适合。它提供了我们所需的基本功能--文件上传、响应式图像网格以及用于排序和地理围栏的下拉菜单。它很容易克隆并在本地运行,也可以在 Colab 笔记本中使用。
实施
Elasticsearch 索引设计和索引策略
我们将在这个项目中使用两个索引:peaks_catalog 和photos 。
峰值_目录索引
该索引是珠峰大本营徒步旅行期间可看到的著名山峰的简明目录。该索引中的每份文件都对应一座山峰,如珠穆朗玛峰。对于每个山峰文档,我们都会存储名称/别名、可选的经纬度坐标以及由 SigLIP-2 文本提示(+ 可选的参考图片)混合而成的单一原型向量。
索引映射:
现场 | 类型 | 示例 | 目的/说明 | 矢量/索引 |
|---|---|---|---|---|
本我 | 关键词 | 阿玛-达布拉姆 | 稳定的弹头/ID | - |
姓名 | 文本 + 关键字子字段 | ["Ama Dablam","Amadablam"] | 别名/多语言名称;names.raw 用于精确筛选 | - |
纬纶 | 地理点 | {"lat":27.8617,"lon":86.8614} | 以经纬度组合形式显示的山顶 GPS 坐标(可选) | - |
海拔_m | 整数 | 6812 | 海拔(可选) | - |
嵌入文本 | dense_vector | 768 | 该山峰的混合原型(提示和可选的 1-3 幅参考图片 | index:true, similarity:"cosine", index_options:{type:"hnsw", m:16, ef_construction:128} |
该索引主要用于图像到图像的搜索,例如从图像中识别山峰。我们还使用该索引来增强文本到图片的搜索结果。
总之,peaks_catalog 将问题""这是什么山?" "转化为一个重点突出的 "最近邻问题",有效地将概念理解与图像数据的复杂性分离开来。
peaks_catalog 索引的索引策略: 首先,我们创建了一份在 EBC 徒步旅行中可见的最突出山峰的列表。对于每个山峰,我们都会在yaml 文件中存储其地理位置、名称、同义词和海拔高度。下一步是 生成 每个峰值的 嵌入 值,并将其存储在text_embed 字段中。为了生成稳健的嵌入,我们使用了以下技术:
创建文本原型:
如果提供了峰值的参考图像,可选择创建图像原型。
然后,我们混合文本和图像原型,生成最终的嵌入。最后,文件将被索引到所有必填字段:
def l2norm(v: np.ndarray) -> np.ndarray:
return v / (np.linalg.norm(v) + 1e-12)
def compute_blended_peak_vec(
emb: Siglip2,
names: List[str],
peak_id: str,
peaks_images_root: str,
alpha_text: float = 0.5,
max_images: int = 3,
) -> Tuple[np.ndarray, int, int, List[str]]:
"""
Build blended vector for a single peak.
Returns:
vec : np.ndarray (L2-normalized)
found_count : number of reference images discovered
used_count : number of references used (<= max_images)
used_filenames: list of filenames used (for logging)
"""
# 1) TEXT vector
tv = embed_text_blend(emb, names)
# 2) IMAGE refs: prefer folder by id; fallback to slug of the primary name
root = Path(peaks_images_root)
candidates = [root / peak_id]
if names:
candidates.append(root / slugify(names[0]))
all_refs: List[Path] = []
for c in candidates:
if c.exists() and c.is_dir():
all_refs = list_ref_images(c)
if all_refs:
break
found = len(all_refs)
used_list = all_refs[:max_images] if (max_images and found > max_images) else all_refs
used = len(used_list)
img_v = embed_image_mean(emb, used_list) if used_list else None
# 3) Blend TEXT and IMAGE vectors, clamp alpha to [0,1]
a = max(0.0, min(1.0, float(alpha_text)))
vec = l2norm(tv if img_v is None else (a * tv + (1.0 - a) * img_v)).astype("float32")
return vec, found, used, [p.name for p in used_list]peaks_catalog 索引中的文件样本:

照片索引
该主索引存储相册中所有照片的详细信息。每份文档代表一张照片,包含以下信息:
相册中照片的相对路径。可用于查看匹配图像或在搜索用户界面中加载图像。
图片的 GPS 和时间信息。
SigLIP-2 生成的图像编码密集矢量。
predicted_peaks可让我们根据峰名进行筛选。索引映射
现场 | 类型 | 示例 | 目的/说明 | 矢量/索引 |
|---|---|---|---|---|
路径 | 关键词 | data/images/IMG_1234.HEIC | 用户界面如何打开缩略图/全图 | - |
剪贴图片 | dense_vector | 768 | SigLIP-2 图像嵌入 | index:true, similarity:"cosine", index_options:{type:"hnsw", m:16, ef_construction:128} |
预测峰值 | 关键词 | ["ama-dablam","pumori"] | 索引时的 Top-K 猜想(廉价用户体验过滤器/面) | - |
全球定位系统 | 地理点 | {"lat":27.96,"lon":86.83} | 启用地理筛选器 | - |
拍摄时间 | date | 2023-10-18T09:41:00Z | 捕捉时间:排序/过滤 | - |
照片索引的索引策略: 对于相册中的每张照片,我们会采取以下措施:
从图像元数据中提取图像shot_time 和gps 信息。
SigLIP-2 图像嵌入:通过模型传递图像并对向量进行 L2 归一化。将嵌入内容存储在
clip_image字段中。预测峰值并将其存储在
predicted_peaks字段中。为此,我们首先获取上一步生成的照片图像向量,然后针对peaks_catalog索引中的 text_embed 字段快速运行 kNN 搜索。我们保留顶部的 3-4 个山峰,忽略其余的。我们通过对图片名称和路径进行散列计算
_id字段。这可以确保我们在多次运行后不会出现重复。
一旦我们确定了照片的所有字段,就会使用 批量 索引对照片文件进行 批量 索引:
def bulk_index_photos(
es: Elasticsearch,
images_root: str,
photos_index: str = "photos",
peaks_index: str = "peaks_catalog",
topk_predicted: int = 5,
batch_size: int = 200,
refresh: str = "false",
) -> None:
"""Walk a folder of images, embed + enrich, and bulk index to Elasticsearch."""
root = Path(images_root)
if not root.exists():
raise SystemExit(f"Images root not found: {images_root}")
emb = Siglip2()
batch: List[Dict[str, Any]] = []
n_indexed = 0
for p in iter_images(root):
rel = relpath_within(root, p)
_id = id_for_path(rel)
# 1) Image embedding (and reuse it for predicted_peaks)
try:
with Image.open(p) as im:
ivec = emb.image_vec(im.convert("RGB")).astype("float32")
except (UnidentifiedImageError, OSError) as e:
print(f"[skip] {rel} — cannot embed: {e}")
continue
# 2) Predict top-k peak names
try:
top_names = predict_peaks(es, ivec.tolist(), peaks_index=peaks_index, k=topk_predicted)
except Exception as e:
print(f"[warn] predict_peaks failed for {rel}: {e}")
top_names = []
# 3) EXIF enrichment (safe)
gps = get_gps_decimal(str(p))
shot = get_shot_time(str(p))
# 4) Build doc and stage for bulk
doc = {"path": rel, "clip_image": ivec.tolist(), "predicted_peaks": top_names}
if gps:
doc["gps"] = gps
if shot:
doc["shot_time"] = shot
batch.append(
{"_op_type": "index", "_index": photos_index, "_id": _id, "_source": doc}
)
# 5) Periodic flush
if len(batch) >= batch_size:
helpers.bulk(es, batch, refresh=refresh)
n_indexed += len(batch)
print(f"[photos] indexed {n_indexed} (last: {rel})")
batch.clear()
# Final flush
if batch:
helpers.bulk(es, batch, refresh=refresh)
n_indexed += len(batch)
print(f"[photos] indexed {n_indexed} total.")
print("[done] photos indexing")照片索引中的样本文件:

总之,照片索引是相册中所有照片的快速、可过滤、kNN 就绪存储。它的映射结构非常简单,只需足够的结构就能快速检索、清晰显示,并按空间和时间对结果进行切分。该索引可同时满足这两种搜索用途。创建这两个索引的 Python 脚本可在此处找到。
下面的 Kibana 地图可视化将相册中的文档显示为绿色圆点,将peaks_catalog 索引中的山峰显示为红色三角形,其中绿色圆点与珠峰大本营徒步路线非常吻合。

搜索用例
按名称搜索(文本到图像):该功能可让用户使用文本查询查找山峰照片(甚至是 "祈祷旗 "等抽象概念)。为此,使用 SigLIP-2 将文本输入转换为文本向量。为了生成稳健的文本向量,我们采用了与在peaks_catalog 索引中创建文本嵌入相同的策略: 将 文本输入与小型 提示集合 相结合 ,减去次要的 反概念向量 ,并应用 L2 归一化 生成最终的查询向量。然后在photos.clip_image 字段上执行 kNN查询,根据余弦相似度检索匹配度最高的峰值,从而找到最接近的图像。作为查询的一部分,还可选择应用地理和日期筛选器和/或photos.predicted_peaks 术语筛选器来提高搜索结果的相关性(见下文查询示例)。这有助于排除在徒步过程中看不到的相似山峰。

带有地理过滤器的 Elasticsearch 查询:
POST photos/_search
{
"knn": {
"field": "clip_image",
"query_vector": [ ... ],
"k": 60,
"num_candidates": 2000
},
"query": {
"bool": {
"filter": [
{ "geo_bounding_box": { "gps": { "top_left": "...", "bottom_right": "..." } } }
]
}
},
"_source": ["path","predicted_peaks","gps","shot_time"]
}
Response (first two documents):
{
"hits": {
"total": {
"value": 56,
"relation": "eq"
},
"max_score": 0.5779596,
"hits": [
{
"_index": "photos",
"_id": "d01da3a1141981486c3493f6053c79e92a788463",
"_score": 0.5779596,
"_source": {
"path": "IMG_2738.HEIC",
"predicted_peaks": [
"Pumori",
"Kyajo Ri",
"Khumbila",
"Nangkartshang",
"Kongde Ri"
],
"gps": {
"lat": 27.97116388888889,
"lon": 86.82331111111111
},
"shot_time": "2023-11-03T08:07:13"
}
},
{
"_index": "photos",
"_id": "c79d251f07adc5efaedc53561110a7fd78e23914",
"_score": 0.5766071,
"_source": {
"path": "IMG_2761.HEIC",
"predicted_peaks": [
"Kyajo Ri",
"Makalu",
"Baruntse",
"Cho Oyu",
"Khumbila"
],
"gps": {
"lat": 27.975558333333332,
"lon": 86.82515
},
"shot_time": "2023-11-03T08:51:08"
}
}
}按图像搜索(图像到图像):通过该功能,我们可以识别照片中的某座山,并在相册中查找该座山的其他图像。图像上传后,将由 SigLIP-2 图像编码器处理,生成图像矢量。然后在peaks_catalog.text_embed 字段上进行kNN 搜索,以确定最匹配的峰值名称。随后,根据这些匹配的山峰名称 生成 一个 文本向量 ,并在照片索引中进行另一次 kNN 搜索 ,以找到相应的照片。

Elasticsearch 查询:
第 1 步:找到匹配的山峰名称
GET peaks_catalog/_search
{
"knn": {
"field": "text_embed",
"query_vector": [...image-vector... ],
"k": 3,
"num_candidates": 500
},
"_source": [
"id",
"names",
"latlon",
"text_embed"
]
}
Response (first two documents):
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 3,
"relation": "eq"
},
"max_score": 0.58039916,
"hits": [
{
"_index": "peaks_catalog",
"_id": "pumori",
"_score": 0.58039916,
"_source": {
"id": "pumori",
"names": [
"Pumori",
"Pumo Ri"
],
"latlon": {
"lat": 28.01472,
"lon": 86.82806
},
"text_embed": [
... embeddings...
]
}
},
{
"_index": "peaks_catalog",
"_id": "kyajo-ri",
"_score": 0.57942784,
"_source": {
"id": "kyajo-ri",
"names": [
"Kyajo Ri",
"Kyazo Ri"
],
"latlon": {
"lat": 27.909167,
"lon": 86.673611
},
"text_embed": [
... embeddings...
]
}
}
]
}
}第 2 步:在photos 索引上进行搜索,找到匹配的图片(与文本到图片搜索用例中的查询相同):
POST photos/_search
{
"knn": {
"field": "clip_image",
"query_vector": [ ...image-vector... ],
"k": 30,
"num_candidates": 2000
},
"_source": [
"path",
"gps",
"shot_time",
"predicted_peaks",
"clip_image"
],
"query": {
"bool": {
"filter": [
{
"term": {
"predicted_peaks": "Pumori"
}
}
]
}
}
}
Response (first two documents):
{
"hits": {
"total": {
"value": 56,
"relation": "eq"
},
"max_score": 0.5779596,
"hits": [
{
"_index": "photos",
"_id": "d01da3a1141981486c3493f6053c79e92a788463",
"_score": 0.5779596,
"_source": {
"path": "IMG_2738.HEIC",
"predicted_peaks": [
"Pumori",
"Kyajo Ri",
"Khumbila",
"Nangkartshang",
"Kongde Ri"
],
"gps": {
"lat": 27.97116388888889,
"lon": 86.82331111111111
},
"shot_time": "2023-11-03T08:07:13"
}
},
{
"_index": "photos",
"_id": "c79d251f07adc5efaedc53561110a7fd78e23914",
"_score": 0.5766071,
"_source": {
"path": "IMG_2761.HEIC",
"predicted_peaks": [
"Kyajo Ri",
"Makalu",
"Baruntse",
"Cho Oyu",
"Khumbila"
],
"gps": {
"lat": 27.975558333333332,
"lon": 86.82515
},
"shot_time": "2023-11-03T08:51:08"
}
}
}流光 UI
为了将所有功能整合在一起,我们创建了一个简单的 Streamlit 用户界面,让我们可以同时执行两种搜索用例。左侧栏显示可滚动的峰值列表(从photos.predicted_peaks 中汇总),并带有复选框和小地图/地理过滤器。顶部有一个按姓名搜索框和一个从照片上传识别按钮。中心窗格采用响应式缩略图网格,显示 kNN 分数、预测峰值徽章和捕获时间。每张图片都有一个查看图片按钮,用于全分辨率预览。
通过上传图片进行搜索:我们会预测峰值,并从相册中找到匹配的峰值。

文本搜索从文本中查找相册中匹配的峰值

结论
我们能看看 阿玛-达布拉姆 的照片吗?变成了一个可运行的小型多模态搜索系统。我们采集了原始的徒步旅行照片,将其转化为SigLIP-2 嵌入,并使用Elasticsearch对向量进行快速的kNN处理,再加上简单的地理/时间过滤器,根据意义浮现出正确的图像。在此过程中,我们将两个索引的关注点分开:一个是混合原型的小peaks_catalog (用于识别),另一个是图像向量和 EXIF 的可扩展photos 索引(用于检索)。它实用、可复制、易扩展。
如果您想对其进行调整,有几项设置可供使用:
查询时间设置:
k(您希望返回多少个邻居)和num_candidates(最终评分前的搜索范围)。这些设置将在此处的博客中讨论。索引时间设置:
m(图形连接性)和ef_construction(构建时间精度与内存)。对于查询,也可以尝试使用ef_search--更高通常意味着更高的召回率,但需要权衡一定的延迟。有关这些设置的更多详情,请参阅本博客。
展望未来,用于多模态和多语言搜索的本地模型/路由器即将登陆 Elastic生态系统,这将使图像/文本检索和混合排名功能更加强大。
如果你想亲自尝试一下:
我们的旅程就此结束,是时候飞回去了。希望这对你有帮助,如果你改动(或改进)了它,我很乐意听听你的改动。





