多模态 RAG 图文混合检索实战:把日调用 10 亿次的 WeMM-Embedding 搬进自己项目

多模态 RAG 图文混合检索实战:把日调用 10 亿次的 WeMM-Embedding 搬进自己项目

关键词:多模态 Embedding、WeMM-Embedding、图文混合检索、RAG、向量检索、Matryoshka、FAISS

传统 RAG 只能检索文字:图片要么被丢进"OCR 文本"再检索,要么单独打标签,图文之间语义割裂。据微信 AI 官方公告,微信视觉团队 9 月初开源的 WeMM-Embedding(Apache-2.0)把文本、图片、视频、视觉文档映射进同一个向量空间------在 MMEB-v2 上 9B 版 80.6 分登顶、2B 版 77.9 分反超此前领先的 8B 开源模型,且已在朋友圈搜索、视频号推荐等场景日调用 10 亿次。本文用它搭一个最小可运行的图文混合检索系统,从装环境到 RRF 融合排序。

一、为什么图文必须进同一个向量空间

单模态 RAG 的结构是:文档 → 文本切片 → 文本向量;图片另走一条"标签/OCR 文本"的路。问题在于查询是跨模态的------用户想找"去年年会抽奖那张照片",若照片只有 OCR 文本,向量里根本没有"年会""抽奖"的语义。

多模态 Embedding 解决的不是"给图片补一层 OCR 文本",而是"同一把尺子量所有模态":模型对文本和图片各生成一个向量,两者直接算余弦相似度。于是检索可以双向进行:文字查图 ("红色连衣裙的产品图")、图查文(拿一张竞品图,召回库里的同类描述段落)。例如用户想找"去年年会抽奖那张照片",若照片只有 OCR 文本,向量里根本没有"年会""抽奖"的语义;进了同一向量空间后,这句话就能直接命中图片。WeMM-Embedding 额外支持"任意交错输入",意味着一个长文档里的"图 + 邻接文字"可以作为一个整体编码,避免图文配对信息被拆散。

二、最快跑通:sentence-transformers 一行加载

官方仓库(Tencent/WeMM-Embedding)推荐 transformers==5.2.0,也提供 sentence-transformers 推理脚本;SentenceTransformer.encode() 统一处理文本、图片、视频输入。

bash 复制代码
pip install -U sentence-transformers faiss-cpu pillow
# 如需服务化:vllm serve 或 sglang(官方 scripts/serve_vllm.sh)
python 复制代码
# embed_demo.py ------ 文本与图片编码到同一空间
from sentence_transformers import SentenceTransformer
from PIL import Image
import numpy as np

# 直接用 Hugging Face 模型 id;首次运行会自动下载权重
model = SentenceTransformer("tencent/WeMM-Embedding-2B")

texts = ["红色连衣裙的产品介绍", "年会抽奖环节照片"]
# 官方示例中文本/图片/视频统一走 encode();图片可传路径或 PIL Image
text_vecs = model.encode(texts, normalize_embeddings=True)

img_vecs = []
for p in ["dress.jpg", "annual_party.jpg"]:
    v = model.encode(Image.open(p).convert("RGB"), normalize_embeddings=True)
    img_vecs.append(v)
img_vecs = np.stack(img_vecs)

print("文本向量形状:", text_vecs.shape)      # (2, 2048)
print("图片向量形状:", img_vecs.shape)      # (2, 2048)

# 跨模态检索:用"红色连衣裙"的文本向量找最像的产品图
scores = text_vecs[0] @ img_vecs.T          # L2 归一化后点积 = 余弦相似度
print("最匹配图片:", ["dress.jpg", "annual_party.jpg"][int(scores.argmax())])

Matryoshka(MRL)降维:模型支持 64~2048(2B 版)等截断维度,截断后需再次归一化。官方数据:2B 版在 256 维时保留全维度约 98.7% 的图文性能------内存和速度敏感的场景强烈建议先压到 256 维再谈放大。

python 复制代码
def truncate_and_norm(vec: np.ndarray, dim: int = 256) -> np.ndarray:
    v = vec[:dim]
    return v / (np.linalg.norm(v) + 1e-9)

三、建图文混合索引:切片 + 图文配对 + 双路 FAISS

生产数据通常是"图文混排文档"。建索引策略:每张图与其邻近文本块配对成一条记录,图片向量进 idx_img,配对的文本与纯文本块进 idx_txt,记录共享同一个 doc_id

python 复制代码
# build_index.py
import json, numpy as np, faiss
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("tencent/WeMM-Embedding-2B")
DIM = 256  # MRL 截断维度

def norm(v: np.ndarray) -> np.ndarray:
    v = v[:DIM]
    return v / (np.linalg.norm(v) + 1e-9)

# 数据形态: [{doc_id, image: path|None, text: "相邻文本或整段"}]
docs = json.load(open("docs.json", encoding="utf-8"))

img_vecs, txt_vecs, img_ids, txt_ids = [], [], [], []
for d in docs:
    if d["image"]:
        v = model.encode(Image.open(d["image"]).convert("RGB"))
        img_vecs.append(norm(np.asarray(v))); img_ids.append(d["doc_id"])
    if d["text"]:
        v = model.encode(d["text"])
        txt_vecs.append(norm(np.asarray(v))); txt_ids.append(d["doc_id"])

idx_img = faiss.IndexFlatIP(DIM); idx_txt = faiss.IndexFlatIP(DIM)
if img_vecs: idx_img.add(np.stack(img_vecs).astype("float32"))
if txt_vecs: idx_txt.add(np.stack(txt_vecs).astype("float32"))
faiss.write_index(idx_img, "idx_img.faiss"); faiss.write_index(idx_txt, "idx_txt.faiss")
json.dump({"img_ids": img_ids, "txt_ids": txt_ids}, open("meta.json", "w"))

要点:IndexFlatIP(内积)+ 归一化向量 = 余弦检索,小数据量先暴力检索保证正确,再换 IndexIVFFlat/HNSW 上量。

四、混合查询:文查图 + 文查文 + RRF 融合

查询"找一张年会现场的照片",本质要同时命中"年会"文本与图片。做法:同一 query 向量分别查图库和文库,两路结果用 RRF(Reciprocal Rank Fusion)合并去重,兼顾相关性与模态多样性。

python 复制代码
# query.py
import faiss, json, numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("tencent/WeMM-Embedding-2B")
DIM = 256; TOPK = 20; K = 60  # RRF 常数

def norm(v): 
    v = np.asarray(v)[:DIM]
    return v / (np.linalg.norm(v) + 1e-9)

def rrf_fuse(rank_lists, k=60):
    score = {}
    for rl in rank_lists:
        for rank, doc_id in enumerate(rl):
            score[doc_id] = score.get(doc_id, 0) + 1.0 / (k + rank + 1)
    return sorted(score.items(), key=lambda x: -x[1])

idx_img = faiss.read_index("idx_img.faiss")
idx_txt = faiss.read_index("idx_txt.faiss")
meta = json.load(open("meta.json"))

qv = norm(model.encode("年会抽奖现场照片"))
_, I_img = idx_img.search(qv[None, :].astype("float32"), min(TOPK, idx_img.ntotal))
_, I_txt = idx_txt.search(qv[None, :].astype("float32"), min(TOPK, idx_txt.ntotal))

rank_img = [meta["img_ids"][i] for i in I_img[0]]
rank_txt = [meta["txt_ids"][i] for i in I_txt[0]]
for doc_id, s in rrf_fuse([rank_img, rank_txt], k=K)[:10]:
    print(f"{doc_id}\t{s:.4f}")

小数据集上你会发现:纯文本向量那路常把"年会"相关段落排得很高,而图库那路能捞回"没被任何文字准确描述过"的图片------比如一张只有 LOGO 没有文字的现场海报,纯文本索引永远召不回,多模态索引却能靠视觉相似度命中。两路漏掉的东西不一样,融合后才完整

五、工程取舍与选型

  • 2B / 4B / 9B 怎么选:MMEB-v2 上 2B 77.9、4B 79.2、9B 80.6。差距主要落在视觉文档(VisDoc:2B 80.7 → 9B 83.3)。纯文本为主的场景 2B+256 维性价比最高;要处理复杂版式扫描件再上 9B。Qwen3-VL-Embedding-8B 为 77.8,2B 越级反超 0.1 分是选型的真实参照。
  • 服务化 :量上来后别在业务进程里跑推理。官方给出 vLLM(--runner pooling + embedding chat template)与 SGLang 两条路,等价于起一个 OpenAI 兼容的 embedding 服务,与现有向量库链路无缝衔接。
  • 召回与重排分离:多模态 Embedding 负责粗召回(要覆盖率高),精排交给重排模型或规则。别指望一个向量把"召回+排序"都做完。
  • 增量更新:微信在 10 亿次日调用前做了 14 次线上 A/B------多模态模型换版会影响整个向量空间,升级后旧索引必须全量或批量重embed,建议索引带"模型版本"字段,避免新旧向量混算。
  • 数据合规:2B 权重约数 GB 可自部署,Apache-2.0 商用友好;敏感数据不出域是选开源模型的核心理由。

但也要看到多模态 Embedding 的另一面:它不是万能的。榜单分数(MMEB-v2/v3)覆盖图像/视频/视觉文档/文本任务,但真实业务的长尾版式未必在评测集里,上线前务必用你自己的数据跑一遍 hit@k;模型升级会推倒整个向量空间,索引重建不是"跑个脚本"那么简单,需要纳入版本管理与灰度计划。这些隐性成本,往往比模型本身的授权费更高。

六、踩坑实录

  • transformers 版本敏感 :官方推荐 transformers==5.2.0,版本过新可能导致预处理行为不一致、向量分布漂移------先锁版本再跑评测。
  • 忘记截断后归一化:MRL 截断不重归一化,内积相似度会被维度截断放大/缩小,检索质量悄悄变差。
  • 图片直接传路径与 PIL 的差异 :以官方 examples/sentence_transformers_inference.py 为准,不同输入形态预处理路径不同,混用前先对齐。
  • "Embedding 是 <embedding> token 位置的最后一层隐状态 + L2 归一化":自己魔改 pooling(如取 CLS 或 mean)会显著掉点,别自作聪明。
  • 纯文本任务也要单独评测:MMEB-v3 把文本(NDCG@5)与 agent/MCMR 任务分开计分,别拿一个榜单总分当所有场景的保证。
  • 视频输入成本:模型支持视频,但帧采样开销大,小项目先从"关键帧当图片"做起。

七、互动提问

  1. 你的 RAG 目前是纯文本还是已经带图?图片是靠 OCR 文本进索引,还是真·向量化?
  2. 2B 版 256 维能保住 98.7% 性能,你会用"小模型+低维"换吞吐,还是坚持全维度上 9B?
  3. 多模态 Embedding 换版后旧索引作废,你的生产链路打算怎么设计"模型版本 + 索引重建"?

欢迎在评论区聊聊你的图文检索落地场景和踩坑经历。

数据与事件来源:

  • 微信视觉团队 WeMM-Embedding 技术报告与 GitHub 仓库(Tencent/WeMM-Embedding,Apache-2.0):模型族(2B/4B/9B)、MMEB-v2/v3 分数(据官方技术报告与 Hugging Face 模型卡)、<embedding> token 池化与 L2 归一化、MRL 截断说明、sentence-transformers/vLLM/SGLang 用法。
  • Hugging Face 模型卡:tencent/WeMM-Embedding-2B/4B/9B(Matryoshka 维度、Apache-2.0)。
  • 微信 AI 官方公告及 IT之家/凤凰网科技(2026-09-04~08):模型已在朋友圈搜索、视频号推荐、公众号推荐、微信电商落地,日调用量达 10 亿次,上线前完成 14 次在线 A/B 测试。
  • 官方技术报告 arXiv:2608.24053(MMEB-v2 78 数据集、MMEB-v3 190 任务评测口径)。
  • 上述榜单分数、模型规格与调用方式,均对照官方技术报告、GitHub 仓库与 Hugging Face 模型卡交叉核实;落地规模经多家媒体报道相互印证,最终以微信官方口径为准。
相关推荐
happyness443 小时前
AI时代的软件工程:软件开发正在进入一个全新的时代
大数据·ai编程
七牛云行业应用3 小时前
OpenCode 跑本地 Llama:编程 Agent 接入本地大模型的完整思路
人工智能·ai编程·llama
云雀衔光4 小时前
MCP + 应用生成:让 AI 直接产出可交互的应用
java·人工智能·测试工具·microsoft·交互·ai编程
必须会一定会4 小时前
DeepSeek-V4.1-Flash 内测 API 接入:模型 ID、OpenAI 兼容调用、图片格式与价格边界
人工智能·ai编程
天天喝旺仔5 小时前
AI 编程实战:用 Cursor + Claude Code + Copilot 把效率翻倍
ide·chatgpt·prompt·copilot·ai编程
小虎AI生活5 小时前
客户问AI答不出你的公司名,才是中小企业最大的获客事故
ai编程
四七伵5 小时前
Codex 里很好用但容易被忽视的功能:Hook
gpt·ai编程
Kapaseker6 小时前
GPT-6 VS GPT-5.6:你该怎么选
openai·ai编程