多模态搜索引擎——基于CLIP实现

1 引言

1.1 背景介绍

在如今的信息时代,搜索引擎几乎无处不在:从我们每天使用的百度、Google,到电商平台上的商品搜索、短视频平台中的内容推荐,搜索已经从传统的“文本匹配”演进到更复杂的“语义理解”。

与此同时,多模态学习——也就是同时处理图像、文本、音频等多种信息形式的任务,逐渐成为人工智能领域的重要研究方向。特别是在图文匹配、图文检索、图文生成等场景下,单一模态已难以满足复杂的信息需求。

本项目正是基于这样的背景出发,目标是构建一个本地部署的图文搜索引擎,核心依赖 OpenAI 提出的 CLIP 模型(Contrastive Language–Image Pretraining)。这个搜索引擎可以让你:

  • ✅ 通过一句话描述,快速找到与之最相关的图像;

  • ✅ 将自己的图像-文本数据作为素材,建立属于自己的“私人图文搜索系统”;

  • ✅ 学习和理解图文对齐、多模态表示、语义搜索等关键技术原理;

  • ✅ 甚至将搜索引擎嵌入到你自己的产品或应用中。

无论你是:

  • ✅ 想从头实现一个小型搜索引擎;

  • ✅ 希望入门多模态表示与相似度检索;

  • ✅ 或者单纯希望尝试部署一个离线运行的 AI 项目;

这篇文章都将为你提供一个完整、可操作的思路,帮助你从 0 到 1 构建一个简单但实用的多模态搜索系统。如果各位读者觉得还算有点参考价值,麻烦点赞收藏,谢谢各位啦。

本文同时给出相关完整代码与数据集在GitHub,有实验需要可以自取:

GitHub - ZhengDongHang/Local-Search-Engine: 本项目开发了一个本地搜索引擎基于CLIP模型实现,实现了图文的推荐算法查找,并提供实验数据。

1.2 技术动机

在构建搜索引擎时,核心技术的选择至关重要。传统的搜索方法(如 TF-IDF、BM25 等)依赖词语的精确匹配,往往难以理解语义层面的关联。而近年来,深度学习尤其是 跨模态模型 的兴起,使得我们可以将搜索扩展到图文之间,真正实现“以文搜图”或“以图搜文”。

CLIP(Contrastive Language–Image Pretraining) 模型正是这类跨模态技术中的佼佼者。它由 OpenAI 提出,通过在大规模图文配对数据上进行预训练,学会了如何将图像和文本映射到同一个语义向量空间。在这个空间中,相互匹配的图文对会彼此靠近,而无关的图文则会被推远。

CLIP 的几个关键优势:

  • 跨模态表示能力强:CLIP 可以同时理解图像和自然语言,将两者转换为可比较的向量;

  • 零样本泛化能力:即使遇到未见过的文本描述或图像类型,CLIP 仍然能够进行合理的匹配;

  • 无需训练即可搜索:使用 CLIP,只需要预先提取图像和文本向量,就能直接进行相似度检索,适合搭建轻量级、离线的本地搜索系统;

  • 推荐算法中的“语义匹配”能力:在推荐场景中,CLIP 可用于评估用户输入(如一句描述)与候选内容之间的语义相似度,选择 Top-K 最相关项返回,实现“多模态推荐”。

基于上述优势,CLIP 被越来越多地用于图文检索、图像分类、内容审核等任务。本项目正是将其应用于构建图文搜索引擎,将“推荐算法”视为一次语义相似度排序问题,即:

“找到与用户输入的文本向量最接近的图像向量”。

 这种方法不但简洁高效,而且易于扩展,只需替换数据即可快速适配不同场景(如文献图检索、电商图搜商品、图文社交内容搜索等)。

2 项目架构

因本项目内容关注数据分析领域,本文使用 jupyter notebook 作为开发IDE,架构采取如下格式:

数据预处理 —— 哈希存储算法(可选) —— CLIP模型构建 —— 推荐算法实现

 数据预处理:负责构建训练数据集,本项目使用 图片-文字 作为示例,构建起对应键值对,方便后续模型调用。

哈希存储算法(可选):在数据预处理部分默认存储为顺序表(不了解的同学可以去补充下‘数据结构与算法’相关知识),在少量数据集的索引并不明显,但在大量数据集上的效果比较慢,因此使用哈希存储加速索引,建议大量数据集的读者使用。

CLIP模型构建:Clip模型通过文本transformer转换器和图像transformer转换器,将其映射到一个矩阵上进行跨模态分析(也就是可以将文本/图像映射到一个高维空间内),为后续的跨膜太搜索引擎提供支持。

推荐算法实现:使用最简单的余弦相似度计算(因为经过CLIP已经在同一高维空间内),并且补充最低相似度过滤操作(防止搜索相关度很低的结果),并给出搜索相关值。

3 代码实现 

3.1 数据预处理

import os
import fitz  # PyMuPDF
from PIL import Image
from tqdm import tqdm
import json

def extract_text_image_pairs(pdf_folder, output_image_folder, output_json="text_image_mapping.json"):
    os.makedirs(output_image_folder, exist_ok=True)
    result = []
    idx = 0

    for file_name in tqdm(os.listdir(pdf_folder)):
        if not file_name.endswith(".pdf"):
            continue
        pdf_path = os.path.join(pdf_folder, file_name)
        doc = fitz.open(pdf_path)
        for page_number in range(len(doc)):
            page = doc.load_page(page_number)
            text = page.get_text().strip()
            if not text:
                continue  # 跳过没有文本的页面

            # 将整个页面截图保存为图像
            pix = page.get_pixmap(dpi=200)
            image_path = os.path.join(output_image_folder, f"{os.path.splitext(file_name)[0]}_page_{page_number + 1}.png")
            pix.save(image_path)

            result.append({
                "text": text,
                "image_path": image_path
            })
            idx += 1
            if page_number == 3: # 为节省时间,只取前 n 张进行训练
                break

    # 保存 JSON 映射
    with open(output_json, "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    
    print(f"共提取 {idx} 个文本-图像对,已保存至 {output_json}")
    return result

extract_text_image_pairs(
    pdf_folder="ori_data/The_combination_of_images_and_text",
    output_image_folder="mid_data/extracted_images",
    output_json="mid_data/text_image_mapping.json"
)
共提取 70 个文本-图像对,已保存至 mid_data/text_image_mapping.json
[{'text': '2 0 2 2\nL I V I N G  P R O D U C T S',
  'image_path': 'mid_data/extracted_images/Living_Products_2022_page_1.png'},
 {'text': 'Living Products\n2022\ncassina.com',
  'image_path': 'mid_data/extracted_images/Living_Products_2022_page_2.png'},

3.2 哈希存储(可选)

import json
import pickle

def build_hashed_mapping(json_path, output_pickle_path="hashed_mapping.pkl", key="image_path"):
    """
    从 JSON 列表创建哈希表,并保存为 pickle
    :param json_path: 原始 JSON 路径(列表格式)
    :param output_pickle_path: 输出的 pickle 路径
    :param key: 使用哪个字段作为哈希表键(如 image_path 或 text)
    """
    with open(json_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    
    # 构建哈希字典:key => entire entry
    hashed_data = {entry[key]: entry for entry in data}

    # 保存为 pickle
    with open(output_pickle_path, "wb") as f:
        pickle.dump(hashed_data, f)

    print(f"✅ 已保存哈希结构数据,共计 {len(hashed_data)} 项,路径:{output_pickle_path}")
    return hashed_data
build_hashed_mapping(
    json_path="mid_data/text_image_mapping.json",
    output_pickle_path="mid_data/image_path_hash.pkl",
    key="image_path"
)
✅ 已保存哈希结构数据,共计 70 项,路径:mid_data/image_path_hash.pkl{'mid_data/extracted_images/Living_Products_2022_page_1.png': {'text': '2 0 2 2\nL I V I N G  P R O D U C T S',
  'image_path': 'mid_data/extracted_images/Living_Products_2022_page_1.png'},
 'mid_data/extracted_images/Living_Products_2022_page_2.png': {'text': 'Living Products\n2022\ncassina.com',
import json
import pickle
import time
import random

# 加载原始 JSON 列表
with open("mid_data/text_image_mapping.json", "r", encoding="utf-8") as f:
    json_data = json.load(f)

# 加载哈希表(以 image_path 为 key)
with open("mid_data/image_path_hash.pkl", "rb") as f:
    hash_data = pickle.load(f)

# 获取所有 image_path 用于采样
all_image_paths = [entry["image_path"] for entry in json_data]
sample_paths = random.sample(all_image_paths, min(100, len(all_image_paths)))  # 最多采样100个

# -----------------------
# 1️⃣ 列表查找测试
# -----------------------
json_start = time.time()
for path in sample_paths:
    _ = next((entry for entry in json_data if entry["image_path"] == path), None)
json_end = time.time()
json_total_time = json_end - json_start
json_avg_time = json_total_time / len(sample_paths)

# -----------------------
# 2️⃣ 哈希查找测试
# -----------------------
hash_start = time.time()
for path in sample_paths:
    _ = hash_data.get(path, None)
hash_end = time.time()
hash_total_time = hash_end - hash_start
hash_avg_time = hash_total_time / len(sample_paths)

# -----------------------
# ✅ 结果展示
# -----------------------
print(f"查找轮数:{len(sample_paths)}")
print(f"🔍 JSON 列表查找总耗时:{json_total_time:.6f} 秒,平均每次:{json_avg_time:.8f} 秒")
print(f"⚡ 哈希表查找总耗时:{hash_total_time:.6f} 秒,平均每次:{hash_avg_time:.8f} 秒")
print(f"🚀 哈希查找加速比:约 {json_avg_time / hash_avg_time:.1f} 倍")
查找轮数:70
🔍 JSON 列表查找总耗时:0.000800 秒,平均每次:0.00001143 秒
⚡ 哈希表查找总耗时:0.000187 秒,平均每次:0.00000267 秒
🚀 哈希查找加速比:约 4.3 倍

3.3 CLIP模型构建

在进行CLIP模型构建的时候,需要先进行文本向量化与图片向量化,输入到分别对transformer中进行同一高维向量中,输入CLIP模型(因为有包了,我就直接调用了,我就是调包侠,同时感谢源码分享者),如下代码所示:

import os
import json
import torch
import clip
from PIL import Image
from tqdm import tqdm

# 设置设备
device = "cuda" if torch.cuda.is_available() else "cpu"

# 加载模型
model, preprocess = clip.load("ViT-B/32", device=device)

# 加载图文对
with open("mid_data/text_image_mapping.json", "r", encoding="utf-8") as f:
    data_pairs = json.load(f)

# 结果保存
text_features_list = []
image_features_list = []
pair_ids = []

for i, item in enumerate(tqdm(data_pairs)):
    try:
        text = clip.tokenize(item["text"]).to(device)
        image = preprocess(Image.open(item["image_path"]).convert("RGB")).unsqueeze(0).to(device)

        with torch.no_grad():
            image_features = model.encode_image(image)
            text_features = model.encode_text(text)

        # 归一化向量
        image_features = image_features / image_features.norm(dim=-1, keepdim=True)
        text_features = text_features / text_features.norm(dim=-1, keepdim=True)

        image_features_list.append(image_features.cpu())
        text_features_list.append(text_features.cpu())
        pair_ids.append(i)

    except Exception as e:
        print(f"跳过第 {i} 个 pair")

# 拼接保存
image_tensor = torch.cat(image_features_list, dim=0)
text_tensor = torch.cat(text_features_list, dim=0)
torch.save({
    "image_features": image_tensor,
    "text_features": text_tensor,
    "pair_ids": pair_ids
}, "mid_data/clip_features.pt")

print("图文向量已提取并保存至 mid_data/clip_features.pt")
100%|██████████| 70/70 [00:04<00:00, 15.82it/s]
图文向量已提取并保存至 mid_data/clip_features.pt

3.4 本地搜索引擎

为了让测试方便,先输入前三个图片对应的文本,方便后续测试,此步不涉及训练,只是单纯的输出,因此是可选操作。

import random
import json
from PIL import Image
import clip
import torch

# 路径配置
mapping_json = "mid_data/text_image_mapping.json"
device = "cuda" if torch.cuda.is_available() else "cpu"

# 加载CLIP模型
model, preprocess = clip.load("ViT-B/32", device=device)

# 加载图文对映射
with open(mapping_json, "r", encoding="utf-8") as f:
    data = json.load(f)

# 随机选择3个图像
samples = random.sample(data, k=3)

# 显示图像并输出文本
for idx, item in enumerate(samples, 1):
    image_path = item["image_path"]
    text = item["text"]
    
    print(f"🔹 图片 {idx}: {image_path}")
    print(f"📝 对应文本: {text}\n")

    try:
        img = Image.open(image_path).convert("RGB")
        img.show(title=f"Image {idx}")
    except Exception as e:
        print(f"❌ 无法打开图片: {e}")
🔹 图片 1: mid_data/extracted_images/Ghost-wall-mikal-harrsen_page_1.png
📝 对应文本: L I V I N G  C O L L E C T I O N
G h o s t  W a l l
2 0 2 3
Design Mikal Harrsen

🔹 图片 2: mid_data/extracted_images/Cassina-LB-DETAILS_page_3.png
📝 对应文本: D E TA I L S  C O L L E C T I O N

🔹 图片 3: mid_data/extracted_images/08 - OUTDOOR COLLECTION 2024_page_2.png
📝 对应文本: 3

并给出基于高维空间余弦相似度的推荐算法,通过输入个文本,向量化后找到最近的图片向量,即给出搜索结果以及相似程度,并且设置阈值来筛选无关结果。

import os
import json
import torch
import clip
from PIL import Image
from torchvision import transforms
from tqdm import tqdm
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

# 参数
image_folder = "mid_data/extracted_images"
mapping_json = "mid_data/text_image_mapping.json"
device = "cuda" if torch.cuda.is_available() else "cpu"

# 1. 加载模型
model, preprocess = clip.load("ViT-B/32", device=device)

# 2. 加载图像数据和路径
with open(mapping_json, "r", encoding="utf-8") as f:
    pairs = json.load(f)

image_paths = [item["image_path"] for item in pairs]
images = []
for p in tqdm(image_paths, desc="预处理图像"):
    try:
        img = Image.open(p).convert("RGB")
        img = preprocess(img)
        images.append(img)
    except:
        print(f"跳过损坏图像: {p}")

# 3. 批量嵌入图像向量
image_input = torch.stack(images).to(device)
with torch.no_grad():
    image_features = model.encode_image(image_input)
    image_features /= image_features.norm(dim=-1, keepdim=True)  # 归一化

# 4. 搜索函数
def search_images_by_text(query, top_k=1, threshold=0.1):
    model.eval()
    with torch.no_grad():
        text_tokens = clip.tokenize([query]).to(device)
        text_features = model.encode_text(text_tokens)
        text_features /= text_features.norm(dim=-1, keepdim=True)

        # 计算余弦相似度
        sims = (image_features @ text_features.T).squeeze().cpu().numpy()

    top_indices = np.argsort(sims)[::-1][:top_k]
    best_score = sims[top_indices[0]]

    if best_score < threshold:
        print("❌ 无浏览结果(得分低于阈值)")
    else:
        print(f"✅ Top-{top_k} 匹配图像(相似度:{best_score:.4f}):")
        for i in top_indices:
            print(f" - 相似度: {sims[i]:.4f} -> 图像路径: {image_paths[i]}")
            # 显示图像(可选)
            Image.open(image_paths[i]).show()

# 5. 示例查询
query_text = "L I V I N G  C O L L E C T I O N G h o s t  W a l l 2 0 2 3 Design Mikal Harrsen"
search_images_by_text(query_text)
预处理图像: 100%|██████████| 70/70 [00:06<00:00, 10.64it/s]
✅ Top-1 匹配图像(相似度:0.2786):
 - 相似度: 0.2786 -> 图像路径: mid_data/extracted_images/Cassina-PRO_2022_page_4.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值