UEmbed:统一稀疏与稠密的多模态嵌入,简化混合检索
做检索召回的同学大概率都经历过这种纠结用稀疏向量词袋、BM25 这类精确但呆板“电脑”搜不到“计算机”用稠密向量BERT、CLIP 这类 embedding懂语义但偶尔会自信地跑偏而且出了问题很难解释。更麻烦的是当数据从纯文本变成图文混合、短视频封面、商品主图加标题这种多模态形态时两套方案各自的短板会同时被放大。UEmbed 这个方向就是冲着这个矛盾去的。它的名字已经说得很清楚Unified Sparse and Dense Multimodal Embeddings统一稀疏与稠密的多模态嵌入。通俗点讲就是尝试让同一个模型对同一个输入同时产出两种互补的向量一种擅长词级别的精确匹配一种擅长语义级别的模糊匹配并且这两种向量都理解图片和文本之间的关联。这篇文章我想给一个比较明确的判断UEmbed 这类方案的价值不在于把模型参数堆得多大也不在于某个榜单上刷到第几名而是它把两条原本独立的工程路线合并成了一条。以前做多模态混合检索你可能要维护一个文本稀疏索引、一个文本稠密索引、一个图像稠密索引背后是两到三套模型和两到三套线上服务统一嵌入的目标是让一个模型同时输出多路表示索引和算力都可以共用一套。如果你正在做搜索、RAG、多模态召回、商品推荐这类方向或者只是想知道“到底该用稀疏还是稠密”这篇文章都值得花十分钟看完。后面我会拆清楚三件事稀疏嵌入、稠密嵌入、多模态嵌入到底是什么为什么“统一”这件事在工程上很有意义以及拿到一个 UEmbed 类模型后怎么在一个最小系统里跑通和验证。1. 这篇文章真正要解决的问题先从最实际的痛点说起。凡是写过召回逻辑的人都会遇到一个两难。你用 BM25 或词袋做召回好处是快、可控、好解释坏处是它只认字面。用户搜“怎么给猫洗澡”库里有一篇标题是“猫咪洗护完整指南”的文章两个文本在词面上几乎没有重合BM25 可能给不出一个合理的分。你用稠密向量做召回好处是能理解语义坏处是它对高频词、ID 类词、型号词不敏感。用户搜“iPhone 15 保护壳”稠密模型可能把注意力全放在“iPhone 15”上把“保护壳”这个关键限定词稀释掉召回结果里混进一堆手机参数页。多模态场景会让这个问题更严重。一张商品图往往没有完整文字描述一个短视频封面只有几个字加一张图。纯文本稀疏模型根本拿不到图片里的信息纯视觉稠密模型又抓不住标题里的精确型号、品牌、SKU 编号。如果把两种信息拼在一起喂给一个普通文本模型模型只会把它们当成一串 token丢失了图像和文本之间的对齐关系。UEmbed 想解决的核心问题可以归纳成三个能不能用一套模型同时输出稀疏和稠密两种嵌入而不是分别训练两个模型能不能让稀疏嵌入也“看到”图片让稠密嵌入也“尊重”精确词能不能在训练时用多模态对齐关系去引导两种嵌入的学习而不是让文本和图像各学各的这篇文章重点面向三类读者。第一类是做搜索和 RAG 应用的工程师你们最关心的是怎么在真实项目里用上这类模型以及混合检索的分数怎么融合。第二类是做多模态算法研究的同学你们更关心稀疏和稠密两个分支是怎么被统一进一个模型的。第三类是技术选型的人你们只需要一个判断这套方案在你的场景里值不值得引入成本是什么风险在哪里。2. 嵌入概念拆解稀疏、稠密、多模态各解决什么问题要理解 UEmbed得先把三组概念分清。2.1 什么是稀疏嵌入Sparse Embedding稀疏嵌入本质上是把文本表示成一个高维且大部分维度为 0 的向量。最经典的例子是 one-hot 词袋词典有多大向量就有多大某个词出现的位置记为 1其余位置全是 0。现代稀疏嵌入比词袋进了一步。以 SPLADE 为代表的方法会让模型“展开”一个词。比如输入“猫”模型除了激活“猫”这个维度还会顺带激活“猫咪”“宠物”“抓挠”等语义相关词的维度。这样既保留了词级别的可解释性又带了一点语义扩充能力。它的本质仍然是词粒度哪些词被激活一目了然线上也好调优。2.2 什么是稠密嵌入Dense Embedding稠密嵌入是 BERT、CLIP 这类模型产出的低维向量常见维度是 768 或 1024。和稀疏向量完全不同它的每一个维度都是浮点数且没有哪个维度单独对应某个词。它通过深度网络把整句话压缩成一个语义向量两个向量越接近含义越接近。稠密嵌入的优势是能跨越词汇鸿沟“电脑”和“计算机”在语义空间里离得很近。劣势是难解释而且偶尔会在不重要的维度上“用力过猛”导致结果出现系统性的偏差。2.3 什么是多模态嵌入多模态嵌入的目标是把不同模态的数据放进同一个向量空间。最典型的代表是 CLIP图像通过视觉编码器变成向量文本通过文本编码器变成向量训练时用对比学习拉近“匹配的图文对”推远“不匹配的图文对”。训练完之后你就能拿一张图去搜文本或者拿一段话去搜图片。多模态嵌入的关键词是“对齐”。不是把图片和文字拼在一起而是让两种模态在向量空间里建立起统一的度量关系。对齐做得好不好直接决定“图搜文”“文搜图”的上限。2.4 三者的结合点把三张表放一起看维度稀疏嵌入稠密嵌入多模态嵌入表示粒度词级别句/图级别跨模态统一空间匹配能力字面精确匹配语义相似匹配图文语义匹配可解释性强激活词可查弱难解释弱难解释典型方法BM25、SPLADEBERT、DPRCLIP、ALIGN主要问题词汇鸿沟精确词不敏感工程成本高UEmbed 的“统一”就发生在这一格它想把第三列的多模态对齐能力注入到第一列和第二列里去。也就是说它输出的稀疏嵌入不是纯文本的词展开而是经过图文对齐信号引导过的词展开它输出的稠密嵌入也不是纯文本的句向量而是同时理解图像内容的多模态语义向量。3. UEmbed 的核心设计多模态引导下的双路输出从现有技术脉络看UEmbed 类方案的设计思路可以概括为一句话一个共享编码器两条输出头一个多模态引导目标。先说共享编码器。输入可以是文本也可以是图片或者是“图片 文本”的组合。编码器先把它们映射到一组共享的中间表示。对于文本中间表示是一串 token 向量对于图片中间表示是视觉 patch 的向量序列。然后是两条输出头。第一条头用来生成稀疏嵌入。它会在 token 向量的基础上对词表做一个稀疏激活最终输出的向量中只有少数维度有非零值每一个非零维度对应词表里的一个词权重表示“这个词在多大程度上应该被检索命中”。第二条头用来生成稠密嵌入。它对中间表示做池化输出一个固定维度的语义向量用于向量相似度检索。最后是核心的训练目标也就是热搜词里提到的 multimodal guider。它的作用可以理解为用一个图文对齐的对比学习目标同时去“引导”两条输出头。对稠密输出头对比学习让匹配图文对的稠密向量靠近、不匹配的远离这是常规做法。对稀疏输出头引导方式会更特殊一些模型在展开词的时候不只看文本输入还要看图像输入让“图片里有什么东西”成为词展开的依据。举个例子输入是一张“棕色皮沙发在客厅”的图片配文是“现代美式布艺沙发”。如果只有纯文本稀疏头从“现代美式布艺沙发”展开出“沙发”“布艺”“现代”这些词就够了很难想到“皮”“客厅”。但如果多模态引导目标有效图像中的沙发材质、场景信息会促使稀疏头把“皮沙发”“客厅家居”这类维度也激活。这样一来用户搜“棕色皮沙发”或者“客厅家具”时这张图也能通过稀疏路线被召回。这就是 multimodal guider 的直观含义多模态信号不是挂在模型外面的装饰品而是参与塑造两种嵌入的引导信息。从材料看这是 UEmbed 与“先训一个文本稀疏模型、再训一个图文稠密模型最后拼起来”这类朴素方案最本质的区别。需要说明的是这个解释是基于 UEmbed 项目名称和技术趋势的合理推断。不同的公开实现在具体网络结构和训练目标上会有差异但“共享编码器 双输出头 多模态引导”这个框架是理解这一类模型的最短路径。4. 为什么“统一”对工程很重要算法上能说通的方案工程上不一定值得引入。UEmbed 这类统一嵌入方案真正的卖点是它简化了多模态检索系统的基础设施。先看传统做法。假设你要做一个电商图文搜索。一版比较完整的架构是文本稀疏索引用 BM25 或 SPLADE负责精确词召回文本稠密索引用 BERT 类模型负责语义召回图像稠密索引用 CLIP 类模型支持以图搜图。三套索引背后至少三个模型服务。模型升级时要分别重训、分别评测、分别上线。线上召回时要把三路结果做融合每一路分数分布都不一样融合逻辑写起来相当痛苦。再看 UEmbed 的做法。一套模型服务接收文本或图片输入一次性返回稀疏向量和稠密向量。文本侧可以建两个索引稀疏索引和稠密索引但共享同一个模型版本、同一套 embedding 服务。图像侧也走同一个入口输出的稠密向量直接进入图像向量库。三路召回还在但背后的模型从三套降成一套。版本升级只需要更新一个模型回归测试、灰度发布、回滚都跟着简化。这个对比也说明了 UEmbed 的适用边界。它适合的场景是多模态数据确实存在且你希望用一套基础设施同时覆盖精确匹配和语义匹配。如果你的业务只有纯文本那么引入多模态部分只会增加复杂度收益有限如果你只做以图搜图不关心文本精确词那 Sparse 分支对你帮助也不大。所以工程选型时一个更稳妥的判断是先评估你的数据里“精确词”和“语义”哪个占比更大、图像信息是不是真的有用再决定要不要整体迁移到 UEmbed 类方案还是只借鉴它的混合检索思路。5. 环境准备与最小示例进入实操之前先说明这一点UEmbed 目前没有统一的事实标准不同开源仓库的模型名、输出字段名都会不同。下面代码用“示意 通用接口”的方式写我把关键位置留成注释你用实际项目里的类名和字段名替换即可核心思路是通用的。5.1 环境依赖推荐环境Python 3.10 或更高版本PyTorch 2.xtransformers 库向量检索基础组件示例中用 NumPy Faiss如果有可选flash-attention用于加速长文本/高分辨率图片编码创建一个虚拟环境并安装基础依赖python -m venv venv source venv/bin/activate pip install torch transformers faiss-cpu numpy版本以实际项目为准这里不写死细节。重点是先跑通流程再考虑性能和显存优化。5.2 加载模型并生成双路嵌入UEmbed 类模型的常见使用模式是加载一个多模态模型传入文本和可选图片得到稀疏嵌入和稠密嵌入两个输出。# 文件路径demo_uembed.py from transformers import AutoTokenizer, AutoModel import torch # 以实际发布的模型名为准例如 your-org/uembed-model model_id your-org/uembed-model device cuda if torch.cuda.is_available() else cpu tokenizer AutoTokenizer.from_pretrained(model_id) model AutoModel.from_pretrained(model_id).to(device).eval()文本输入走 tokenizer图片输入走模型内置的视觉处理器。不同实现差异较大最小示例先用纯文本跑通。# 文件路径demo_uembed.py def encode_text(text: str): inputs tokenizer( text, return_tensorspt, truncationTrue, max_length256, paddingTrue, ).to(device) with torch.no_grad(): outputs model(**inputs) # 约定sparse_emb 是稀疏高维向量dense_emb 是稠密低维向量 # 字段名以实际模型实现为准这里用最可能的命名 sparse_emb outputs.sparse_emb dense_emb outputs.dense_emb return sparse_emb, dense_emb输出解释sparse_emb是形状近似[batch, vocab_size]的高维向量大部分位置是 0。dense_emb是形状为[batch, hidden_size]的低维稠密向量比如[1, 1024]。拿到这两路向量之后常规做法是稀疏向量转成“词 - 权重”的字典结构用于倒排索引稠密向量直接进入 Faiss 这类向量库。把稀疏向量转成可索引结构# 文件路径demo_uembed.py import torch def sparse_to_dict(sparse_emb, vocab, top_k32): 把模型输出的稀疏向量转成 {token_id: weight} 字典 values, indices torch.topk(sparse_emb.squeeze(0), ktop_k) return { vocab.get(int(idx), ftoken_{int(idx)}): float(weight) for idx, weight in zip(indices, values) if float(weight) 0.0 }这里top_k是实际部署中很重要的参数。保留的维度越多召回越全但索引越占空间保留太少词展开能力会下降。一般建议从 16 到 64 之间做实验看业务指标选择。5.3 用 RRF 融合稀疏和稠密结果混合检索的另一个关键步骤是分数融合。稀疏分数和稠密分数分布完全不同直接相加会让某一路主导结果。业界常用的方案是 Reciprocal Rank FusionRRF它不看原始分数只看名次。# 文件路径hybrid_search.py from collections import defaultdict def rrf_fusion(rank_lists, k60): 对多路召回结果做倒排融合排序。 rank_lists: 例如 [dense_rank, sparse_rank]每个元素是 doc_id 列表 k: RRF 平滑参数常用 60 fused_score defaultdict(float) for rank_list in rank_lists: for rank, doc_id in enumerate(rank_list): fused_score[doc_id] 1.0 / (k rank 1) return sorted(fused_score.items(), keylambda x: x[1], reverseTrue)RRF 的优点是简单、鲁棒不需要对两路分数做归一化也不依赖历史数据校准权重。缺点是它丢掉了分数绝对值的信息在分数本身很有区分度时会浪费一些信息。更精细的做法是学习一个加权公式比如score alpha * dense_score beta * sparse_score但这要求你有足够多的标注数据来拟合 alpha 和 beta而且两路分数必须先做归一化。6. 完整示例搭建一个多模态混合检索 Demo为了把上面的思路串起来这里给出一个最小但完整的多模态混合检索 Demo。它做的事情是把一组商品数据标题 图片路径编码成双路向量分别建稀疏索引和稠密索引然后接收一个查询返回融合排序结果。6.1 构造数据集为了不依赖外部图片下载这里用“文本 图片特征占位”的方式演示。真实项目里图片路径换成实际图片张量即可。# 文件路径demo_data.py docs [ {id: 1, title: 现代美式布艺沙发, image: sofa_blue.jpg}, {id: 2, title: 北欧实木餐桌, image: table_wood.jpg}, {id: 3, title: 黑色真皮办公椅, image: chair_black.jpg}, ]实际项目中UEmbed 的视觉输入需要把图片缩放到固定尺寸做归一化转成张量。这里因为依赖图片数据只做示意。6.2 批量编码并建立索引# 文件路径demo_index.py import faiss import numpy as np def build_index(model, tokenizer, docs, dense_dim1024): sparse_index {} dense_vectors [] doc_ids [] for doc in docs: sparse_emb, dense_emb model.encode_text(doc[title]) sparse_dict sparse_to_dict(sparse_emb, tokenizer.vocab) # 存储稀疏索引doc_id - {token - weight} sparse_index[doc[id]] sparse_dict dense_vectors.append(dense_emb.cpu().numpy()) doc_ids.append(doc[id]) dense_index faiss.IndexFlatIP(dense_dim) dense_index.add(np.vstack(dense_vectors)) return sparse_index, dense_index, doc_idsfaiss.IndexFlatIP是暴力内积检索适合小数据量验证逻辑。生产环境需要换成 IVF、HNSW 等索引类型并对稠密向量做归一化后再用内积计算余弦相似度。6.3 查询并融合# 文件路径demo_query.py def search(query, model, tokenizer, sparse_index, dense_index, doc_ids, top_k5): # 1. 得到查询的双路嵌入 sparse_emb, dense_emb model.encode_text(query) query_sparse sparse_to_dict(sparse_emb, tokenizer.vocab) # 2. 稠密召回 dense_vec dense_emb.cpu().numpy().reshape(1, -1) dense_scores, dense_rank dense_index.search(dense_vec, top_k) dense_hits [doc_ids[i] for i in dense_rank[0]] # 3. 稀疏召回 sparse_scores {} for doc_id, doc_sparse in sparse_index.items(): score 0.0 for token, weight in query_sparse.items(): if token in doc_sparse: score weight * doc_sparse[token] sparse_scores[doc_id] score sparse_hits sorted(sparse_scores, keysparse_scores.get, reverseTrue)[:top_k] # 4. RRF 融合 fused rrf_fusion([dense_hits, sparse_hits], k60) return fused, dense_hits, sparse_hits这段代码的重点是第 4 步两路结果不做分数归一化直接用名次融合。对第一次跑通 Demo 的人来说这是最不容易出错的方式。6.4 运行与验证在任意 Python 脚本中调用python demo_query.py预期输出格式是(doc_id, fused_score)的列表例如[(1, 0.0325), (3, 0.0161), (2, 0.0081)]如何判断成功如果查询词和某条文档存在明显字面重合融合结果里应包含该文档如果查询词是语义近义表达稠密路线的结果也应出现在最终列表里。如果只出现其中一路先检查另一路的召回是否为空。如果两路都为空回到模型输出字段、token 字典映射这些基础问题上排查。7. 常见问题与排查思路把实际项目里最容易踩的坑整理成一张表问题现象可能原因排查方式解决方案稀疏向量全是 0没有对稀疏头做阈值处理或激活函数不对打印 sparse_emb 的统计分布检查模型输出确认识别了正确的稀疏向量字段稠密检索结果相关性差向量没归一化内积受长度影响检查向量范数查询和文档向量都做 L2 归一化稀疏和稠密结果重复度高两个输出头没有真正分工或多模态引导目标权重过低看两路独立评测指标调整训练目标中引导损失的比例图片输入导致显存溢出图片分辨率过高或 batch 太大观察显存占用降低输入分辨率、减小 batch、开启梯度检查点线上分数分布波动大融合权重的归一化方式不一致记录两路分数分布改用 RRF 或学习融合权重相同文本在不同设备上向量不一致模型运算存在浮点差异对比 CPU 和 GPU 输出固定推理设备避免混用第一条最值得展开。很多第一次用稀疏模型的同学拿到模型的输出后直接存进倒排索引线上查出来全部为空。原因通常是稀疏头的输出需要经过 log(1 ReLU(x)) 这类的稀疏化变换或者自带一个激活阈值你取到的原始 logits 在没有过激活函数时负值全部被置 0存进去的自然没有有效 token。建议拿到模型后先做一次单样本输出检查确认稀疏输出的非零值比例在 1% 到 10% 之间再进入索引构建流程。8. 最佳实践与工程建议最后给出几条工程建议都是真实项目里反复出现的问题提前规避能省很多时间。8.1 稀疏和稠密索引要分开建、分开调优即使 UEmbed 是一套模型线上索引也应该分成稀疏倒排索引和稠密向量索引两个组件来管理。原因是它们的扩缩容策略、磁盘占用、缓存策略完全不同。稠密索引对内存带宽敏感稀疏索引对 CPU 倒排链路的合并能力敏感。混在一起管理出问题时定位会很慢。8.2 先做两路独立评测再做融合很多团队一上来就做融合最后融合分上去了但说不清是稀疏的功劳还是稠密的功劳。更规范的做法是先分别评测稀疏召回和稠密召回的 recallk、MRR、NDCG确认两路各自都有价值再评测融合后的增益。如果融合后没有提升问题大概率出在融合权重而不是模型本身。8.3 多模态引导要控制数据配比如果你需要自己训练或微调 UEmbed 类模型图文对的质量和配比非常重要。纯文本数据的配比过高模型会退化成普通的文本稀疏 稠密模型图像信息基本没用图文对比例过高模型容易对图像过拟合纯文本查询时表现不稳定。实际操作建议从 1:1 到 3:1 之间做小规模实验优先用业务数据而不是通用数据。8.4 生产环境要重视版本与回滚统一嵌入方案把三个模型变成一套意味着升级时的风险面也集中了。以前某个模型挂了另外两个还能顶上现在模型升级前必须做完整的回归评测覆盖纯文本查询、纯图像查询、图文混合查询三类用例。建议在评估集里专门留一批“稀疏擅长”和“稠密擅长”的样本升级后分别看这两批样本的指标变化防止一改模型就把某一类能力改没了。8.5 安全与权限边界如果这套系统要接入生产数据务必注意最小权限原则。Embedding 服务本身不存储业务数据但它会被很多下游服务调用必须用独立的服务账号、独立的网络策略。涉及多租户数据时索引层必须按租户隔离不能在召回阶段混入其他租户的数据。任何批量重建索引、清理索引的操作都要先在测试环境验证并保留回滚快照。9. 总结与后续学习方向这篇文章从检索系统的两难出发讲清楚了 UEmbed 代表的“统一稀疏与稠密多模态嵌入”到底是什么一个编码器、两条输出头、一个多模态引导目标。它真正的价值不是模型结构有多惊艳而是把多模态检索的工程链路从“多套模型、多套索引、多套服务”收敛成“一套模型、多路向量、一套链路”。在代码层面我们走通了一个最小混合检索流程用模型输出双路嵌入稀疏侧转成倒排索引稠密侧进入向量库最后用 RRF 做融合。这套流程不依赖 UEmbed 的某个具体实现换一个模型类也可以快速迁移。如果你接下来想深入研究建议按这个顺序走先读 SPLADE 相关论文理解稀疏展开的细节再读 CLIP 的对比学习目标理解多模态对齐最后读 UEmbed 类模型的训练代码看它是怎么把两个目标放在一个模型里的。每一步都有对应的公开实现跑通一个最小实验并不难。最后提醒一句混合检索是“工程上先能跑通、指标上再抠细节”的领域。先用 RRF 跑起来再逐步换成学习型融合先在 Demo 上验证再上生产。这样才能避免在第一天就把选型和技术细节锁死后面想改都难。