最近在做多模态检索相关的研究与工程落地时,频繁接触到一个关键词:UEmbed。很多同学第一眼看到这个名词会以为它是一个单纯的 Embedding 工具包,但实际上它代表了一类非常关键的模型设计思路:把稀疏表示和稠密表示统一到一个多模态框架里。这篇文章会把这个方向从概念到实践完整拆开,包含 UEmbed 的设计动机、核心原理、与常规 Dense Retriever / Sparse Retriever 的差异,以及一个可运行的多模态混合检索 Demo。无论你是刚开始接触向量检索的新手,还是已经在业务中做过 RAG 的开发者,这篇文章都值得收藏备用。
1. 背景与核心概念
1.1 为什么需要 UEmbed
先看一个非常实际的问题:在搜索、推荐、RAG、图数据库召回等场景里,我们经常需要把文本、图片、音频、视频等不同模态的数据转成向量,然后做相似度检索。这个“转向量”的过程,就是生成 Embedding。
传统的做法通常分成两条路线:
- Dense Embedding:用一个深度模型(如 BERT、CLIP)把整个输入编码成一个固定长度的稠密向量,检索时用余弦相似度或内积进行 Top-K 召回。优点是语义理解强,能处理同义改写、语义匹配;缺点是训练和推理成本较高,对长尾实体(如人名、产品型号、专业术语)容易丢失精确匹配信息。
- Sparse Embedding:基于词袋、词权重或词汇表,构造一个高维稀疏向量,检索时用倒排索引加速。优点是精确匹配好、可解释性强、检索速度快;缺点是存在词汇鸿沟问题:同一个语义用不同词表达,稀疏表示可能完全匹配不上。
这两条路线一直处于“各管各”的状态。UEmbed 想要解决的核心问题,正是如何在一个统一模型中同时生成高质量的多模态稀疏和稠密向量,让检索系统既能利用稠密向量的语义泛化能力,又能保留稀疏向量的精确匹配与可解释性。
1.2 什么是多模态 Embedding
多模态 Embedding 指的是:把图像、文本、音频、视频等不同模态的数据映射到同一个向量空间中,让语义相似的多模态内容在空间中的距离更近。
比如“一张猫的图片”和“一只猫在睡觉的文字描述”,经过多模态模型编码后,二者在向量空间里的余弦相似度应该高于“一只猫的图片”和“一辆汽车的文字描述”。
CLIP 就是非常典型的多模态稠密表示模型,它通过图像编码器和文本编码器将图片和文本映射到共享空间。但这种共享空间通常是稠密向量,很难同时输出可解释的稀疏信号。UEmbed 的思路则是在多模态编码的基础上,额外设计稀疏输出分支,从而形成真正的“统一”表示。
1.3 UEmbed 的典型应用场景
| 场景 | 传统方案 | UEmbed 优势 |
|---|---|---|
| 文本检索 / RAG 召回 | BM25 + Dense Retriever 两路召回 | 单一模型同时输出稀疏+稠密,简化流水线,兼顾精确与语义 |
| 图文跨模态检索 | CLIP 稠密向量 + 标签倒排 | 精确标签匹配与语义匹配统一建模,减少漏召回 |
| 视频/音频片段检索 | 音频标签 + 音频 Embedding | 多模态 token 级的稀疏路由可定位到局部片段 |
| 商品搜索(长尾 ID) | 稠密向量为主,经常丢失商品编号 | 稀疏分支保留精确 token 匹配,显著提升 ID 类召回 |
| 多语言混合检索 | 每种语言单独建模 | 统一向量空间,跨语言查询更稳定 |
简单来说,凡是需要在“语义理解”和“精确匹配”之间取得平衡的多模态检索场景,UEmbed 都有明显的用武之地。
2. 稀疏嵌入与稠密嵌入:两条路线的关键技术点
要理解 UEmbed,首先要把 Sparse Embedding 和 Dense Embedding 各自的技术机理弄清楚。
2.1 Dense Embedding 的核心思路
Dense Embedding 通常由 Encoder 模型产生。以文本为例,输入经过 Transformer 编码后,取[CLS]token 的隐状态,或者对所有 token 向量做平均池化(mean pooling),得到一个固定维度(如 768、1024 维)的稠密向量。
# 伪代码示例:Dense Embedding 的生成过程 import torch.nn.functional as F def generate_dense_embedding(model, input_ids, attention_mask): outputs = model(input_ids=input_ids, attention_mask=attention_mask) # 取 token 级别的隐藏状态 token_embeddings = outputs.last_hidden_state # [batch, seq_len, hidden_dim] # 对非 padding 位置做 mean pooling input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, dim=1) sum_mask = torch.clamp(input_mask_expanded.sum(dim=1), min=1e-9) dense_emb = sum_embeddings / sum_mask # 归一化 dense_emb = F.normalize(dense_emb, p=2, dim=1) return dense_emb优点:
- 语义相近但词面不同的内容也能匹配。
- 向量维度固定,方便用 ANN(近似最近邻)索引加速。
- 兼容各种深度模型结构,扩展性好。
缺点:
- 可解释性差:无法直观看到“因为哪个词而匹配”。
- 长尾实体、ID 类信息容易在池化中被稀释。
- 训练需要较多样本,否则模型容易偏科。
2.2 Sparse Embedding 的核心思路
稀疏嵌入不是一个新概念,最经典的词袋(Bag of Words)、TF-IDF 都是稀疏表示。但在现代深度学习系统中,稀疏嵌入通常指基于词汇表的权重向量,例如 SPLADE 模型。
SPLADE 的做法是:对输入文本的每个 token,经过 MLM 头预测该 token 在整个词表上的概率分布,然后把分布展开成一个 30K 或 100K 维的稀疏向量。由于大部分词表位置概率为 0,实际存储时只需要保存非零项的(token_id, weight)对。
# 伪代码示例:SPLADE 风格稀疏向量生成 def generate_sparse_embedding(model, input_ids, attention_mask): outputs = model(input_ids=input_ids, attention_mask=attention_mask) token_embeddings = outputs.last_hidden_state # [batch, seq_len, hidden_dim] # 映射到词表大小的 logits logits = model.mlm_head(token_embeddings) # [batch, seq_len, vocab_size] # 对 token 维做 log 求和,得到词表维度的稀疏 logits sparse_logits = torch.log1p(F.relu(logits)).sum(dim=1) # [batch, vocab_size] return sparse_logits优点:
- 精确匹配有保障,适合专业术语、人名、编号等。
- 可利用倒排索引(Inverted Index),检索效率高。
- 可解释性好,能回溯到具体命中词。
缺点:
- 语义泛化能力弱,同义改写无法匹配。
- 向量维度极高,存储和排序都不能直接套用 ANN 常规方式。
- 多模态场景下,图像、音频如何映射到“词表”是一个需要特殊设计的难点。
2.3 两者互补关系
从信息检索的经典理论来看,稀疏和稠密分别对应了“词汇匹配”和“语义匹配”,二者不是替代关系,而是互补关系。业界常用的混合检索方案是:
- 用 BM25/SPLADE 进行稀疏召回。
- 用 Dense Retriever 进行稠密召回。
- 用 RRF(Reciprocal Rank Fusion)或 Score 加权融合两路结果。
这种方案的缺点是工程链路复杂,需要维护两套索引和两套模型。UEmbed 的出发点就是希望用一个模型同时解决两件事,从而降低系统复杂度,同时保持两种表示的优势。
3. UEmbed 核心架构原理拆解
UEmbed 全称是Unified Sparse and Dense Multimodal Embeddings,它的核心目标是把多模态编码、稀疏嵌入、稠密嵌入放进同一个训练框架。下面拆解它的几个关键技术点。
3.1 统一的编码器主干
UEmbed 采用一个共享的多模态编码器作为主干。对于文本,使用 Text Encoder;对于图像,使用 Vision Encoder。两类编码器的输出会被对齐到同一个隐空间,便于后续统一训练。
这里的关键是:不要让每个模态单独训练一套 Embedding,而是让它们共享底层的语义空间。类似 CLIP 的对比学习思路,但 UEmbed 不止输出一个稠密向量,还会在编码器之上挂载稀疏输出头。
3.2 稀疏和稠密交织路由(Sparse-Dense Interwoven Routing)
这是 UEmbed 比较有特色的设计概念。很多模型是“一个编码器接两个输出头”,一个头输出稠密,另一个头输出稀疏。但在实际实现中,如果两个头完全独立,会造成两个表示的空间不一致。
UEmbed 的思路是让稀疏路由和稠密路由在编码器内部就产生交互:模型在学习文本表示时,某些 token 会被分配给稀疏通道,用于精确匹配;某些语义灵活的 token 会被分配给稠密通道,用于泛化匹配。二者共享底层特征,但通过路由机制各司其职。
用工程语言理解,你可以把它想象成一个“多任务学习架构”:
- 主任务:多模态对比学习,让稠密表示互相靠近。
- 辅助任务:稀疏 token 预测,让模型学会哪些词该在词汇表中被激活。
3.3 多向量对比学习(Multi-Vector Contrastive Learning)
常规对比学习(如 CLIP)使用的是单向量相似度,例如:
sim = cos(text_emb, image_emb)UEmbed 场景下,由于模型既要生成稠密向量,又要生成稀疏向量,训练目标也需要设计成“多向量对比”的形式。训练时要同时优化:
- Dense-Dense 对比损失:正样本对距离近,负样本对距离远。
- Sparse-Sparse 匹配信号:稀疏向量中的非零项是否与 ground truth 中的关键词一致。
- 跨模态稀疏协同:图像经过编码器后,是否能在共享词汇表中激活与文本一致的语义词。
训练完成的模型,在推理时一次前向传播即可同时得到两种向量,不需要额外拆分模型。
3.4 一次推理、双路输出
推理阶段,UEmbed 对一个输入(比如一张图片和它的描述文本)只做一次前向计算,然后同时输出:
dense_emb:固定维度(例如 768 维)的稠密向量,归一化后用于 ANN 检索。sparse_emb:高维稀疏向量,存储为{token_id: weight}的字典,用于倒排索引检索。
这套设计带来的直接好处是:线上检索只需要维护一个模型服务、一套特征抽取流程,然后再决定是用稠密索引、稀疏索引还是两者融合。
4. 环境准备与工具说明
4.1 基础环境
因为 UEmbed 不是一个现成的 pip 包,而是代表一个建模方向,所以本文的 Demo 会基于常见的 Python 深度学习栈来演示“统一稀疏+稠密多模态嵌入”的实现思路。你需要提前准备:
| 依赖 | 用途 | 版本建议 |
|---|---|---|
| Python | 运行环境 | 3.9 或更高 |
| PyTorch | 模型训练与推理 | 2.x 版本即可,注意和 CUDA 匹配 |
| Transformers | 加载 BERT 等基础模型 | 4.x 版本 |
| Sentence-Transformers | 简化 Embedding 模型加载 | 2.x 版本可作为辅助 |
| FAISS | 稠密向量 ANN 检索 | 1.7.x 或更新版本 |
| NumPy / SciPy | 数据处理与稀疏矩阵操作 | 最新稳定版即可 |
版本需要根据你的实际开发环境调整,本文示例以常见环境为例,重点演示配置思路与代码逻辑,不保证任意版本组合都能直接跑通。
4.2 安装命令
pip install torch transformers sentence-transformers faiss-cpu numpy scipy如果使用 GPU 训练,建议按 PyTorch 官网给出的命令安装对应 CUDA 版本的 torch。
4.3 示例项目结构
为了便于阅读,我们把 Demo 项目放在uembed_demo/目录下:
uembed_demo/ ├── data/ │ ├── texts.tsv # 文本数据 │ └── images_meta.tsv # 图像元数据 ├── models/ │ └── encode.py # 统一编码模块 ├── index/ │ ├── build_index.py # 构建双路索引 │ └── search.py # 混合检索脚本 └── requirements.txt接下来我们逐个模块实现。
5. 实战:基于 UEmbed 思路搭建多模态混合检索 Demo
为了让读者更容易落地,这里不直接把完整论文源码搬过来,而是实现一个“UEmbed 风格”的最小可运行系统:输入一段查询文本,系统同时用稠密向量和稀疏向量在图文混合语料中检索,并把结果融合返回。
5.1 准备多模态示例数据
先造一批少量多模态数据,包含图片路径和对应的文本描述。文件路径:data/images_meta.tsv。
image_id text img_001 一个穿红色裙子的女孩站在海边 img_002 一辆黑色汽车行驶在高速公路上 img_003 森林里的棕色小熊正在吃蜂蜜 img_004 一只白色猫咪趴在窗台上晒太阳 img_005 城市夜景中霓虹灯闪烁的街道 img_006 厨师在厨房里制作牛排 img_007 运动员在操场上跑步 img_008 蓝色海洋上空的白色海鸥这里我们只演示文本模态的检索,但数据结构保持多模态形态,方便后续接入图像编码器。如果你需要真正接入图像,可以使用 CLIP 的 Vision Encoder 生成图像向量,并把图像向量也写入索引。接下来以文本为主展开。
5.2 统一编码模块
文件路径:models/encode.py
这个模块的作用是:给定一段文本,同时返回它的稠密向量和稀疏向量。
# 文件路径:models/encode.py import torch import torch.nn.functional as F from transformers import AutoTokenizer, AutoModelForMaskedLM class UnifiedEncoder: """ 一个极简的 UEmbed 风格编码器: 输入文本 -> 同时输出稠密向量和稀疏向量。 """ def __init__(self, model_name: str = "bert-base-uncased", max_len: int = 64): self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.tokenizer = AutoTokenizer.from_pretrained(model_name) self.model = AutoModelForMaskedLM.from_pretrained(model_name) self.model.to(self.device) self.model.eval() self.max_len = max_len self.vocab_size = self.model.config.vocab_size @torch.no_grad() def encode(self, texts): """ 返回一个 dict: - dense: [batch, hidden_dim] 归一化后的稠密向量 - sparse_indices: list[list[int]],每个文本的非零 token id - sparse_weights: list[list[float]],每个文本的非零权重 """ inputs = self.tokenizer( texts, padding=True, truncation=True, max_length=self.max_len, return_tensors="pt", ).to(self.device) outputs = self.model(**inputs, output_hidden_states=True) last_hidden = outputs.hidden_states[-1] # [batch, seq_len, hidden_dim] # ----- 稠密向量:mean pooling + L2 归一化 ----- attention_mask = inputs["attention_mask"] mask_expanded = attention_mask.unsqueeze(-1).float() sum_hidden = (last_hidden * mask_expanded).sum(dim=1) sum_mask = mask_expanded.sum(dim=1).clamp(min=1e-9) dense = sum_hidden / sum_mask dense = F.normalize(dense, p=2, dim=1) # ----- 稀疏向量:MLM 头 + log(1+ReLU) ----- mlm_logits = outputs.logits # [batch, seq_len, vocab_size] sparse_logits = torch.log1p(F.relu(mlm_logits)) # [batch, seq_len, vocab_size] # 对序列维度求和,得到 [batch, vocab_size] sparse = sparse_logits.sum(dim=1) # 转成稀疏存储格式 sparse_indices = [] sparse_weights = [] for i in range(sparse.size(0)): # 只保留非零项,并取 top-k(这里取前32个) row = sparse[i] nz = row.nonzero(as_tuple=True)[0] if len(nz) > 32: vals = row[nz] topk_idx = torch.topk(vals, k=32).indices nz = nz[topk_idx] sparse_indices.append(nz.cpu().tolist()) sparse_weights.append(row[nz].cpu().tolist()) return { "dense": dense.cpu().numpy(), "sparse_indices": sparse_indices, "sparse_weights": sparse_weights, }这段代码看起来不长,但已经把稀疏和稠密两条路径都打通了:
- 稠密路径使用
last_hidden_state做 mean pooling。 - 稀疏路径借用 MLM 输出,把每个 token 对词表的预测分数累加起来,形成词表维度的稀疏激活。
- 稀疏向量不直接保存全量 vocab 向量,而是转成
(index, weight)形式,方便后续构建倒排索引。
5.3 构建混合索引
文件路径:index/build_index.py
这里的“混合索引”包含两部分:
- 稠密索引:用 FAISS 建立近似最近邻索引。
- 稀疏索引:用 Python 字典模拟倒排表。
# 文件路径:index/build_index.py import json import numpy as np import faiss from models.encode import UnifiedEncoder def build_sparse_inverted_index(doc_sparse): """ doc_sparse 是字典列表,包含 sparse_indices 和 sparse_weights 返回倒排索引: {token_id: [(doc_id, weight), ...]} """ inverted = {} for doc_id, item in enumerate(doc_sparse): indices = item["sparse_indices"] weights = item["sparse_weights"] for token_id, weight in zip(indices, weights): if token_id not in inverted: inverted[token_id] = [] inverted[token_id].append((doc_id, weight)) return inverted def main(): encoder = UnifiedEncoder() # 读取文档数据 docs = [] with open("data/images_meta.tsv", "r", encoding="utf-8") as f: next(f) # 跳过表头 for line in f: parts = line.strip().split("\t") if len(parts) == 2: docs.append(parts[1]) # 统一编码 encoded = encoder.encode(docs) dense_embs = encoded["dense"] # 稠密索引 dim = dense_embs.shape[1] dense_index = faiss.IndexFlatIP(dim) dense_index.add(dense_embs) # 稀疏倒排索引 doc_sparse = [ { "sparse_indices": encoded["sparse_indices"][i], "sparse_weights": encoded["sparse_weights"][i], } for i in range(len(docs)) ] inverted_index = build_sparse_inverted_index(doc_sparse) # 保存索引产物 faiss.write_index(dense_index, "index/dense.index") with open("index/inverted.json", "w", encoding="utf-8") as f: json.dump(inverted_index, f, ensure_ascii=False) with open("index/docs.json", "w", encoding="utf-8") as f: json.dump(docs, f, ensure_ascii=False) print(f"构建完成,共 {len(docs)} 条文档") print(f"稠密索引维度: {dim}") print(f"稀疏词表非零 token 数: {len(inverted_index)}") if __name__ == "__main__": main()5.4 实现混合检索
文件路径:index/search.py
检索逻辑分为三个步骤:
- 用同一个编码器生成 query 的稠密向量和稀疏向量。
- 在稠密索引和稀疏索引中分别召回 Top-K。
- 用 RRF 或加权分数融合结果。
# 文件路径:index/search.py import json import numpy as np import faiss from models.encode import UnifiedEncoder def rrf_fusion(dense_hits, sparse_hits, k=60): """ Reciprocal Rank Fusion: 对两路结果按照排名融合分数,缓解不同检索路线的分数不可比问题。 """ scores = {} for rank, doc_id in enumerate(dense_hits): scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1) for rank, doc_id in enumerate(sparse_hits): scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True) def search(query, top_k=5, alpha=0.5): """ alpha 用于加权融合: final_score = alpha * sparse_score + (1 - alpha) * dense_score 这里默认使用 RRF,alpha 参数保留作为扩展。 """ encoder = UnifiedEncoder() dense_index = faiss.read_index("index/dense.index") with open("index/inverted.json", "r", encoding="utf-8") as f: inverted_index = json.load(f) with open("index/docs.json", "r", encoding="utf-8") as f: docs = json.load(f) # 转成适合 JSON 存储的形式:原代码索引是 int key,读取后需要转回 int inverted_index = {int(k): v for k, v in inverted_index.items()} # 生成 query 向量 q_enc = encoder.encode([query]) q_dense = q_enc["dense"] q_sparse_indices = q_enc["sparse_indices"][0] q_sparse_weights = q_enc["sparse_weights"][0] # 稠密召回 dense_scores, dense_ids = dense_index.search(q_dense, top_k * 3) dense_hits = [int(i) for i in dense_ids[0]] # 稀疏召回 sparse_score = {} for token_id, weight in zip(q_sparse_indices, q_sparse_weights): if token_id in inverted_index: for doc_id, doc_weight in inverted_index[token_id]: sparse_score[doc_id] = sparse_score.get(doc_id, 0.0) + weight * doc_weight sparse_hits = sorted(sparse_score.items(), key=lambda x: x[1], reverse=True) sparse_hits = [doc_id for doc_id, _ in sparse_hits[:top_k * 3]] # 融合 fused = rrf_fusion(dense_hits, sparse_hits) results = [] for doc_id, score in fused[:top_k]: results.append({"doc_id": doc_id, "text": docs[doc_id], "score": round(score, 4)}) return results if __name__ == "__main__": results = search("海边 红裙子 女孩", top_k=3) for item in results: print(item)5.5 运行与验证
在项目根目录依次执行:
python index/build_index.py python index/search.py预期输出大致如下(具体分数会因为模型、随机初始化和数据而产生差异):
构建完成,共 8 条文档 稠密索引维度: 768 稀疏词表非零 token 数: 47 {'doc_id': 0, 'text': '一个穿红色裙子的女孩站在海边', 'score': 0.3221} {'doc_id': 7, 'text': '蓝色海洋上空的白色海鸥', 'score': 0.2114} {'doc_id': 3, 'text': '一只白色猫咪趴在窗台上晒太阳', 'score': 0.1308}排名第一的结果是“海边红裙子女孩”,符合语义预期。稀疏路径也会参与打分,比如“海边”和“女孩”等 token 的精确匹配会拉高原始文档的分数。
6. 常见问题与排查思路
6.1 稀疏向量维度太大,内存爆炸
问题现象: 构建稀疏索引时,如果把整个词表大小的向量都转成矩阵,内存会迅速上涨。
原因: BERT 的词表通常有 3 万左右,如果每条文档都保存一个 3 万维的数组,8 条文档可能不明显,但百万条文档就会非常夸张。
解决方案: 只保存非零项。上面的代码已经通过sparse_indices和sparse_weights做了压缩。线上工程建议用更高效的数据结构,例如scipy.sparse.csr_matrix,或者使用支持倒排存储的检索引擎(如 Lucene、Elasticsearch)。
6.2 稀疏检索结果为空
问题现象: 查询里包含某个词,但倒排索引里没有命中任何文档。
原因: 最常见的原因是token_id跨模型不一致。例如构建索引时使用的编码器和查询时的编码器不是同一个 checkpoint,或者 tokenizer 版本差异导致同一个词被切分成不同子词。
排查步骤:
- 打印 query 和文档的
tokenize结果。 - 确认构建索引和检索时使用的是同一个
model_name。 - 检查倒排索引 key 是否保存成了字符串,导致查询时类型不匹配。上面代码已经用了
int(k)转回键类型,实际项目中也要注意这一点。
6.3 稠密检索效果差,语义相关结果没召回
原因:
- 没有对向量做 L2 归一化,余弦相似度和内积混用。
- 池化方式不合理,例如直接取
[CLS]向量在短文本上可能不够稳定。 - 模型本身不具备领域知识。
解决方案: 在encode()里做了F.normalize,保证 FAISS 的内积检索等价于余弦相似度。如果效果仍然不佳,可以考虑换用 Sentence-BERT 或领域微调模型。
6.4 混合检索的融合权重难以调优
现象:alpha参数不知道怎么设置,融合后的结果反而不如单路。
原因: 稀疏分数和稠密分数的尺度不一样,直接线性加权很难对齐。
建议: 优先使用 RRF 排名融合,而不是分数融合。RRF 不依赖具体分数值,只使用排名,鲁棒性更高。如果需要线性加权,可以先对两路分数做 min-max 归一化,再尝试不同 alpha。
7. 最佳实践与工程建议
7.1 多模态对齐要趁早
如果业务中确实需要图像和文本统一检索,不要等到最后才接图像编码器。建议在项目初期就确定多模态编码方案,例如选用 CLIP 作为主干,然后在它的输出之上再接稀疏头。这样稠密部分天然对齐,稀疏部分也更容易共享词汇空间。
7.2 索引建议分开构建、统一召回
线上系统建议采用以下架构:
- 稠密索引:使用 FAISS / Milvus / Qdrant 等向量数据库。
- 稀疏索引:使用 Elasticsearch / Lucene 倒排索引,或者直接保存稀疏向量后用支持稀疏检索的引擎。
- 融合层:统一召回后做 RRF 重排。
这样做的好处是故障隔离:即使稠密索引挂了,稀疏索引仍然能提供基础的检索能力。
7.3 评估指标要双路跟踪
建议在评测阶段分别记录:
- Dense Recall@K
- Sparse Recall@K
- Hybrid Recall@K
- MRR / NDCG
不要只看混合后的效果。如果混合结果提升了,但某一路显著下降,说明融合权重可能引入了噪声。
7.4 长尾 ID 场景优先看重稀疏
商品编码、订单号、用户名、车型代号这些精确匹配信息,稀疏分支的召回效果明显强于稠密分支。在实际落地时,可以按查询类型做路由:包含大量数字/大写字母的 query,提高稀疏分支的权重。
7.5 训练数据与负样本
UEmbed 训练最关键的是负样本构造。多模态对比学习需要高质量负样本。建议从三个来源采样:
- Batch 内负样本(in-batch negatives)。
- 稠密检索得到的“看似相关但实际不相关”的难负样本。
- 稀疏检索召回的相关但语义偏颇的样本。
负样本数量不需要太多,但难负样本的质量对模型效果提升非常明显。
8. 总结与学习路线
UEmbed 让“稀疏+稠密”不再是非此即彼的选择,而是可以在统一的多模态模型中同时生成,从而提高检索系统在语义理解和精确匹配两方面的综合表现。
读完本文,你应该对以下几个问题有了自己的答案:
- 稀疏 Embedding 和稠密 Embedding 各自解决什么问题。
- UEmbed 为什么能在一个模型中“统一”两种表示。
- 如何基于现有 Transformers 模型快速构建一个 UEmbed 风格的推理 Demo。
- 混合检索中常用的融合策略和工程注意事项。
接下来可以继续深入的方向包括:
- 阅读 SPLADE 原论文,理解稀疏表示的学习目标。
- 学习 CLIP 的原理,掌握多模态对比学习的基本思路。
- 尝试把 Demo 中的文本编码器替换成 CLIP,加入图像分支,做真正的图文混合检索。
- 研究 RRF 融合在不同数据分布下的表现,寻找更优的融合策略。
如果在实现过程中遇到问题,建议从打印中间结果开始排查:先确认 query 和 document 的 tokenizer 一致,再检查稀疏索引命中情况,最后看稠密向量的相似度分布。祝你在多模态检索的路上越走越顺。