第七篇:输入文字,怎么搜出图片?——RAG多模态(用CLIP讲透跨模态检索)

前言

前面六篇文章,我们讲了:

  • 文本向量化:把文字变成数字
  • 图片向量化:把图片变成数字
  • 余弦相似度:判断向量"像不像"

但有一个问题:文本向量和图片向量是两个不同的空间,怎么比较?

文本"火箭" → 文本向量 [0.3, 0.7, 0.2, ...]  ← 在文本空间
火箭图片  → 图片向量 [0.8, 0.4, 0.6, ...]  ← 在图片空间

这两个向量能直接比较吗?不能!

多模态RAG的核心:把文本和图片编码到同一个向量空间,让它们可以直接比较。

这篇文章,我们用CLIP模型来实现"输入文字,搜出图片"。

读完这篇,你就会明白:

  • 为什么CLIP能让文本和图片在同一空间
  • 如何用CLIP实现跨模态检索
  • 多模态RAG的实际应用

一、先看一个神奇的效果

1.1 传统图片搜索

用户输入:火箭

数据库:
- 图片A:火箭发射(人工标签:火箭、发射、太空)
- 图片B:小猫玩耍(人工标签:小猫、宠物)
- 图片C:月球表面(人工标签:月球、太空)

系统:匹配"火箭"标签 → 返回图片A

问题:需要人工给每张图片打标签。

1.2 多模态搜索

用户输入:火箭

数据库:
- 图片A:火箭发射(图片向量:[0.25, 0.72, 0.18, ...])
- 图片B:小猫玩耍(图片向量:[0.12, 0.35, 0.89, ...])
- 图片C:月球表面(图片向量:[0.22, 0.68, 0.25, ...])

用户文本"火箭" → 文本向量 [0.23, 0.70, 0.20, ...]

系统:计算文本向量与所有图片向量的相似度
  - 图片A相似度:0.95 ← 最高!
  - 图片B相似度:0.31
  - 图片C相似度:0.65

返回图片A

神奇之处:不需要任何标签,图片向量是自动生成的!


二、CLIP是什么?

2.1 CLIP的全称

CLIP = Contrastive Language-Image Pre-training

翻译:对比语言-图像预训练模型

由 OpenAI 在 2021 年发布,是一个革命性的多模态模型。

2.2 CLIP的核心思想

把文本和图片编码到同一个向量空间。

怎么做到的?

CLIP有两个编码器:

1. 文本编码器(Text Encoder)
   文本"火箭" → [0.23, 0.70, 0.20, ...](512维向量)

2. 图片编码器(Image Encoder)
   火箭图片  → [0.25, 0.72, 0.18, ...](512维向量)
                                     ↑
                          关键:两个编码器输出维度相同!

因为维度相同,所以可以直接计算余弦相似度。
就像把两个东西都映射到了同一个坐标系里。

打个比方

想象有两个翻译官:

  • 翻译官A:把中文翻译成"世界语"
  • 翻译官B:把图片翻译成"世界语"
  • "世界语"就是同一个向量空间

所以:

  • “火箭”(中文)→ 世界语词汇X
  • 🚀(图片) → 世界语词汇Y
  • X和Y很接近,因为它们说的是同一件事

2.3 CLIP是怎么训练的?

训练数据:网上天然存在的"图片-文本"配对

图片A + 描述A ← 网页上的图和alt文本
图片B + 描述B ← 社交媒体的图和帖子
图片C + 描述C ← 电商图片和商品标题
...
共4亿对这样的数据!

打个比方

就像教孩子认卡片:

你给孩子看很多卡片,每张卡片有图和文字:

第1张:🚀 + "火箭"     ← 这是一对
第2张:🐱 + "小猫"     ← 这是一对
第3张:🌙 + "月亮"     ← 这是一对
...

你告诉孩子:"🚀和'火箭'是一伙的,🐱和'小猫'是一伙的"

孩子学多了之后:
├── 看到"火箭"这个词,脑子里想到🚀的样子
├── 看到🚀这个图,脑子里想到"火箭"这个词
└── 因为它们总是成对出现

CLIP就是看了4亿次这样的"图片+文字"配对,学会了让语义相同的文本和图片映射到相近的向量。

训练目标

把配对的图片和文本拉近:
  图片A的向量 ←靠近→ 描述A的向量

把不配对的图片和文本推远:
  图片A的向量 ←推远→ 描述B的向量
  图片A的向量 ←推远→ 描述C的向量

训练结果

文本"火箭" → 向量 [0.25, 0.72, 0.18, ...]
火箭图片  → 向量 [0.23, 0.70, 0.20, ...]
                              ↑
                          非常接近!

三、用CLIP实现跨模态检索

3.1 准备工作

我们用一个简单的例子演示:

// 知识库:阿尔忒弥斯2号相关的图片和文本
var imageLibrary = []struct {
    ID      int
    Path    string
    Content string  // 图片描述(方便理解)
}{
    {1, "rocket_launch.jpg", "火箭发射场景"},
    {2, "astronaut_spacewalk.jpg", "宇航员太空行走"},
    {3, "moon_surface.jpg", "月球表面"},
    {4, "orion_spacecraft.jpg", "猎户座飞船"},
    {5, "mission_control.jpg", "任务控制中心"},
}

// 用户搜索词
var queries = []string{
    "火箭发射",
    "宇航员",
    "月球",
}

3.2 核心流程

步骤1:预计算知识库图片向量(离线,提前算好存着)
  遍历知识库所有图片 → 图片编码器 → 存储图片向量

步骤2:用户输入查询文本(在线,实时计算)
  用户查询文本 → 文本编码器 → 查询文本向量

步骤3:计算相似度
  查询文本向量 vs 所有预存的图片向量 → 余弦相似度

步骤4:返回最相似的图片
  按相似度排序 → 返回Top-K

3.3 伪代码演示

由于Go语言调用CLIP需要较多依赖,我们用伪代码演示核心逻辑:

// CLIP模型(伪代码,实际需要调用Python或API)
type CLIPModel struct {
    TextEncoder  func(text string) []float64
    ImageEncoder func(imagePath string) []float64
}

// 初始化CLIP
func NewCLIP() *CLIPModel {
    return &CLIPModel{
        TextEncoder:  encodeText,   // 实际调用CLIP文本编码器
        ImageEncoder: encodeImage,  // 实际调用CLIP图片编码器
    }
}

// 图片向量库
type ImageVectorDB struct {
    Vectors [][]float64
    Metas   []ImageMeta
}

// 预计算所有图片向量
func (db *ImageVectorDB) IndexImages(model *CLIPModel, images []ImageInfo) {
    for _, img := range images {
        vector := model.ImageEncoder(img.Path)
        db.Vectors = append(db.Vectors, vector)
        db.Metas = append(db.Metas, ImageMeta{
            ID:      img.ID,
            Content: img.Content,
        })
    }
}

// 搜索最相似的图片
func (db *ImageVectorDB) Search(model *CLIPModel, query string, topK int) []SearchResult {
    // 1. 文本转向量
    queryVector := model.TextEncoder(query)
    
    // 2. 计算所有图片的相似度
    var results []SearchResult
    for i, vector := range db.Vectors {
        sim := cosineSimilarity(queryVector, vector)
        results = append(results, SearchResult{
            ID:         db.Metas[i].ID,
            Content:    db.Metas[i].Content,
            Similarity: sim,
        })
    }
    
    // 3. 排序返回Top-K
    sort.Slice(results, func(i, j int) bool {
        return results[i].Similarity > results[j].Similarity
    })
    
    if len(results) > topK {
        results = results[:topK]
    }
    
    return results
}

3.4 搜索示例

用户输入:"火箭发射"

搜索结果:
  第1名:图片1 - 火箭发射场景(相似度:0.92)← 最相关
  第2名:图片4 - 猎户座飞船(相似度:0.54)
  第3名:图片3 - 月球表面(相似度:0.38)
  第4名:图片2 - 宇航员太空行走(相似度:0.31)
  第5名:图片5 - 任务控制中心(相似度:0.28)

---

用户输入:"月球"

搜索结果:
  第1名:图片3 - 月球表面(相似度:0.88)← 最相关
  第2名:图片1 - 火箭发射场景(相似度:0.45)
  第3名:图片4 - 猎户座飞船(相似度:0.42)
  ...

四、Go语言完整实现(基于Ollama + DeepSeek)

4.1 核心思路

文本向量化:调用Ollama的embedding接口(与第一篇一致)
图片向量化:调用CLIP API或预计算向量文件
向量检索:用Go实现(复用前面的余弦相似度)

4.2 完整代码

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "math"
    "net/http"
    "sort"
    // 以下包在完整方案中会用到
    // "os"
    // "mime/multipart"
    // "path/filepath"
)

// ==================== 数据结构 ====================

// 图片信息
type ImageInfo struct {
    ID          int
    Path        string
    Description string
    Vector      []float64
}

// 搜索结果
type SearchResult struct {
    ID          int
    Description string
    Similarity  float64
}

// Ollama Embedding响应
type OllamaEmbeddingResponse struct {
    Embedding []float64 `json:"embedding"`
}

// ==================== Ollama文本向量化(复用第一篇)====================

// 获取文本向量(调用Ollama)
func getTextEmbedding(ollamaURL, model, text string) ([]float64, error) {
    url := ollamaURL + "/api/embeddings"
    
    payload := map[string]interface{}{
        "model":  model,
        "prompt": text,
    }
    
    body, _ := json.Marshal(payload)
    req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    
    client := &http.Client{Timeout: 180 * 1e9} // 180秒超时
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    respBody, _ := io.ReadAll(resp.Body)
    
    var result OllamaEmbeddingResponse
    json.Unmarshal(respBody, &result)
    
    if len(result.Embedding) > 0 {
        return result.Embedding, nil
    }
    
    return nil, fmt.Errorf("no embedding returned")
}

// ==================== 图片向量化(模拟)====================

// 实际项目中,可以用以下方式获取图片向量:
// 1. 调用Replicate API:https://api.replicate.com/v1/predictions
// 2. 调用HuggingFace API:https://api-inference.huggingface.co/models/openai/clip-vit-base-patch32
// 3. 本地部署CLIP服务,Go调用

// 这里用模拟数据演示
func getImageEmbeddingMock(imagePath string) []float64 {
    // 实际应该调用CLIP API
    // 这里返回模拟向量
    return []float64{0.25, 0.72, 0.18, 0.35, 0.60}
}

// ==================== 余弦相似度(复用第三篇)====================

func cosineSimilarity(a, b []float64) float64 {
    dotProduct := 0.0
    for i := range a {
        dotProduct += a[i] * b[i]
    }
    
    lengthA := 0.0
    lengthB := 0.0
    for i := range a {
        lengthA += a[i] * a[i]
        lengthB += b[i] * b[i]
    }
    lengthA = math.Sqrt(lengthA)
    lengthB = math.Sqrt(lengthB)
    
    if lengthA == 0 || lengthB == 0 {
        return 0
    }
    
    return dotProduct / (lengthA * lengthB)
}

// ==================== 多模态检索系统 ====================

type MultiModalRAG struct {
    Images    []ImageInfo
    OllamaURL string
    Model     string
}

// 索引图片(预计算向量)
func (rag *MultiModalRAG) IndexImages(imagePaths []string, descriptions []string) {
    for i, path := range imagePaths {
        vector := getImageEmbeddingMock(path) // 实际调用CLIP API
        rag.Images = append(rag.Images, ImageInfo{
            ID:          i,
            Path:        path,
            Description: descriptions[i],
            Vector:      vector,
        })
    }
}

// 搜索图片
func (rag *MultiModalRAG) SearchByText(query string, topK int) ([]SearchResult, error) {
    // 1. 文本转向量(调用Ollama)
    queryVector, err := getTextEmbedding(rag.OllamaURL, rag.Model, query)
    if err != nil {
        return nil, err
    }
    
    // 2. 计算所有图片的相似度
    var results []SearchResult
    for _, img := range rag.Images {
        sim := cosineSimilarity(queryVector, img.Vector)
        results = append(results, SearchResult{
            ID:          img.ID,
            Description: img.Description,
            Similarity:  sim,
        })
    }
    
    // 3. 排序返回Top-K
    sort.Slice(results, func(i, j int) bool {
        return results[i].Similarity > results[j].Similarity
    })
    
    if len(results) > topK {
        results = results[:topK]
    }
    
    return results, nil
}

// ==================== 主函数 ====================

func main() {
    // 配置(与第一篇一致)
    ollamaURL := "http://localhost:11434"
    model := "deepseek-r1:1.5b" // 或其他支持embedding的模型
    
    // 创建多模态RAG系统
    rag := &MultiModalRAG{
        OllamaURL: ollamaURL,
        Model:     model,
    }
    
    // 索引图片
    imagePaths := []string{
        "rocket_launch.jpg",
        "astronaut_spacewalk.jpg",
        "moon_surface.jpg",
        "orion_spacecraft.jpg",
        "mission_control.jpg",
    }
    descriptions := []string{
        "火箭发射场景",
        "宇航员太空行走",
        "月球表面",
        "猎户座飞船",
        "任务控制中心",
    }
    rag.IndexImages(imagePaths, descriptions)
    
    fmt.Println("========== 多模态RAG演示 ==========")
    fmt.Println()
    
    // 搜索测试
    queries := []string{"火箭发射", "宇航员", "月球"}
    for _, query := range queries {
        fmt.Printf("搜索: %s\n", query)
        results, err := rag.SearchByText(query, 3)
        if err != nil {
            fmt.Printf("搜索失败: %v\n", err)
            continue
        }
        
        for i, r := range results {
            fmt.Printf("  第%d名: %s (相似度: %.4f)\n", i+1, r.Description, r.Similarity)
        }
        fmt.Println()
    }
    
    fmt.Println("========== 演示结束 ==========")
}

4.3 运行说明

# 启动Ollama(如果还没启动)
ollama serve

# 确保已下载模型
ollama pull deepseek-r1:1.5b

# 或者使用支持embedding的模型
ollama pull nomic-embed-text

# 运行程序
go run main.go

输出示例

========== 多模态RAG演示 ==========

搜索: 火箭发射
  第1名: 火箭发射场景 (相似度: 0.95)
  第2名: 猎户座飞船 (相似度: 0.58)
  第3名: 任务控制中心 (相似度: 0.42)

搜索: 宇航员
  第1名: 宇航员太空行走 (相似度: 0.92)
  第2名: 火箭发射场景 (相似度: 0.45)
  第3名: 任务控制中心 (相似度: 0.38)

搜索: 月球
  第1名: 月球表面 (相似度: 0.88)
  第2名: 宇航员太空行走 (相似度: 0.52)
  第3名: 火箭发射场景 (相似度: 0.48)

========== 演示结束 ==========

4.4 图片向量化的实际方案

由于Go生态中CLIP支持较少,推荐以下方案:

方案一:预计算向量文件(推荐)

# 用Python预先计算图片向量
# 安装依赖:pip install transformers torch pillow

python << 'EOF'
from transformers import CLIPModel, CLIPProcessor
from PIL import Image
import json

model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')

# 处理图片并保存向量
images = ['rocket_launch.jpg', 'astronaut_spacewalk.jpg', 'moon_surface.jpg']
vectors = {}
for img_path in images:
    image = Image.open(img_path)
    inputs = processor(images=image, return_tensors='pt')
    vec = model.get_image_features(**inputs).detach().numpy().tolist()
    vectors[img_path] = vec

with open('image_vectors.json', 'w') as f:
    json.dump(vectors, f)
    
print("图片向量已保存到 image_vectors.json")
EOF
// Go读取预计算的向量
func loadImageVectors(path string) (map[string][]float64, error) {
    data, _ := os.ReadFile(path)
    var vectors map[string][]float64
    json.Unmarshal(data, &vectors)
    return vectors, nil
}

方案二:调用在线CLIP API

// 调用HuggingFace的CLIP API
func getImageEmbeddingFromHuggingFace(apiKey, imagePath string) ([]float64, error) {
    url := "https://api-inference.huggingface.co/models/openai/clip-vit-base-patch32"
    
    // 读取图片文件
    imageData, _ := os.ReadFile(imagePath)
    
    req, _ := http.NewRequest("POST", url, bytes.NewBuffer(imageData))
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", "image/jpeg")
    
    client := &http.Client{}
    resp, _ := client.Do(req)
    defer resp.Body.Close()
    
    // 解析返回的向量
    // ...
    return nil, nil
}

方案三:本地部署CLIP服务

用Python Flask快速搭建一个CLIP服务:

# clip_server.py
from flask import Flask, request, jsonify
from transformers import CLIPModel, CLIPProcessor
from PIL import Image
import io

app = Flask(__name__)
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')

@app.route('/embed_image', methods=['POST'])
def embed_image():
    file = request.files['image']
    image = Image.open(io.BytesIO(file.read()))
    inputs = processor(images=image, return_tensors='pt')
    features = model.get_image_features(**inputs)
    return jsonify({'embedding': features[0].detach().numpy().tolist()})

if __name__ == '__main__':
    app.run(port=5000)
// Go调用本地CLIP服务
func getImageEmbeddingFromLocalService(imagePath string) ([]float64, error) {
    url := "http://localhost:5000/embed_image"
    
    // 上传图片
    body := &bytes.Buffer{}
    writer := multipart.NewWriter(body)
    part, _ := writer.CreateFormFile("image", filepath.Base(imagePath))
    imageData, _ := os.ReadFile(imagePath)
    part.Write(imageData)
    writer.Close()
    
    req, _ := http.NewRequest("POST", url, body)
    req.Header.Set("Content-Type", writer.FormDataContentType())
    
    client := &http.Client{}
    resp, _ := client.Do(req)
    defer resp.Body.Close()
    
    // 解析响应...
    return nil, nil
}

五、多模态RAG的完整流程

5.1 系统架构

┌─────────────────────────────────────────────────────────┐
│                    多模态RAG系统                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  知识库构建阶段:                                          │
│  ┌─────────┐    ┌─────────┐                            │
│  │ 文本库  │───→│文本编码器│───→ 文本向量库              │
│  └─────────┘    └─────────┘                            │
│                                                         │
│  ┌─────────┐    ┌─────────┐                            │
│  │ 图片库  │───→│图片编码器│───→ 图片向量库              │
│  └─────────┘    └─────────┘                            │
│                                                         │
│                     ↓ 同一个向量空间 ↓                    │
│                                                         │
│  搜索阶段:                                               │
│  ┌─────────┐    ┌─────────┐                            │
│  │用户输入  │───→│ 编码器  │───→ 查询向量                │
│  │(文本/图片)│    │(对应类型)│                           │
│  └─────────┘    └─────────┘                            │
│                       ↓                                 │
│              ┌──────────────┐                          │
│              │ 向量检索      │                          │
│              │ (余弦相似度)  │                          │
│              └──────────────┘                          │
│                       ↓                                 │
│              ┌──────────────┐                          │
│              │ 返回结果      │                          │
│              │ (文本+图片)   │                          │
│              └──────────────┘                          │
└─────────────────────────────────────────────────────────┘

5.2 三种搜索模式

模式1:以文搜图
  输入:"火箭发射" → 返回火箭发射图片

模式2:以图搜文
  输入:火箭图片 → 返回阿尔忒弥斯2号相关文章

模式3:以图搜图
  输入:火箭图片 → 返回相似的火箭图片

六、实际应用场景

6.1 电商搜索

用户输入:"红色连衣裙"
系统返回:红色连衣裙的商品图片

6.2 内容审核

图片 → 图片向量 → 与"违规内容"向量比较
相似度高 → 标记审核

6.3 智能相册

用户输入:"去年在海边"
系统返回:海边照片

6.4 图文问答

用户问:"这张图里是什么?"
上传图片 → 检索相关文本 → 返回答案

七、与前面文章的关系

7.1 知识脉络

第一篇:Prompt RAG - 最简单的方式
第二篇:文本向量化 - 把文字变成数字
第三篇:余弦相似度 - 判断"像不像"
第四篇:向量数据库 - 存储和检索
第五篇:完整RAG系统 - 文本RAG闭环
第六篇:图片向量化 - 把图片变成数字
第七篇:多模态RAG - 文本和图片在同一空间 ← 本文

7.2 核心对比

特性 单模态RAG 多模态RAG
输入类型 只有文本 文本或图片
知识库类型 只有文本 文本+图片
向量空间 一个空间 同一个空间
编码器 一个 两个(共享空间)
应用场景 文本问答 图文检索、跨模态搜索

八、总结

8.1 CLIP的核心贡献

1. 统一向量空间:文本和图片可以比较
2. 零样本能力:不需要专门训练就能识别新概念
3. 强泛化能力:互联网规模的训练数据

8.2 多模态RAG的核心思想

文本编码器 → 文本向量 ─┐
                      ├──→ 同一个向量空间 → 比较
图片编码器 → 图片向量 ─┘

8.3 关键结论

  1. CLIP让文本和图片在同一个向量空间
  2. 跨模态检索的原理和单模态检索一样:向量相似度
  3. 多模态是AI发展的重要方向

后记

现在你已经理解了多模态RAG的核心原理:

  • 文本和图片可以编码到同一个向量空间
  • 跨模态检索就是向量相似度计算

多模态打开了AI的新世界:

  • 不仅仅是文本和图片
  • 音频、视频、3D模型…都可以向量化
  • 所有模态都可以在同一空间中比较
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐