Embedding模型选型与语义搜索实践

引言

去年双十一大促前夜,我接到一个紧急电话。某电商平台的"猜你喜欢"推荐系统突然出现大量"牛头不对马嘴"的推荐结果——用户搜索"轻薄笔记本散热好",系统返回的却是"笔记本散热器""笔记本支架"这类周边商品,甚至出现了"笔记本电脑包"。运维同事排查了半天,数据库没问题、缓存没问题、召回链路也没问题,最后定位到问题的源头:Embedding 模型在凌晨被灰度升级了版本,但向量库里的存量向量还是老模型生成的。

新老模型的向量空间不兼容,导致相似度计算完全失效。这个事故让我深刻意识到:Embedding 模型选型绝不是"找个 SOTA 模型调 API"这么简单的事,它牵涉到向量空间语义一致性、维度匹配、归一化策略、跨模型迁移等一系列工程问题。

这篇文章我会从架构师的视角,把 Embedding 模型选型这件事拆开揉碎讲清楚。如果你正在做 RAG、语义搜索、推荐召回,或者任何涉及向量检索的系统,希望这篇文章能帮你少踩几个坑。

核心概念:从"图书馆找书"说起

什么是 Embedding?

想象你管理一座巨大的图书馆,有几百万本书。传统的检索方式是给每本书打标签(分类号、关键词),用户查询时按标签匹配。但问题是:用户的表达方式千奇百怪,标签永远不够用。

比如用户想找"讲一个人如何在逆境中成长的故事",标签系统可能匹配不到任何一本,因为没人会给《活着》打上"逆境成长"这个标签。但如果有一个"语义空间",把所有书和所有查询都映射到这个空间里的坐标点,那么"逆境成长"这个查询的坐标,就会天然地靠近《活着》《老人与海》《平凡的世界》的坐标——这就是 Embedding 的本质:把文本映射到高维向量空间,让语义相似的东西在空间里距离更近。

技术定义

Embedding 是一个函数 $f: \text{Text} \rightarrow \mathbb{R}^d$,把变长的文本序列映射为固定维度的稠密向量。语义相似度通常用余弦相似度衡量:

$$

\text{sim}(a, b) = \frac{a \cdot b}{\|a\| \|b\|}

$$

现代 Embedding 模型大多基于 Transformer 的 Encoder 结构,通过对比学习(Contrastive Learning)训练,目标是让正样本对(语义相近)的向量靠近,负样本对远离。

一个容易忽略的关键点

很多工程师把 Embedding 当成一个"黑盒 API",但向量空间是模型私有的。A 模型生成的向量和 B 模型生成的向量,即使在同一个维度上,也不能直接比较——就像两支温度计,一支用摄氏度、一支用华氏度,数值放在一起算平均值是没有意义的。

这个性质直接决定了后文的很多工程决策。

源码级深度分析:Embedding 模型到底在算什么

Transformer Encoder 的输出是怎么变成向量的

我们以最经典的 sentence-transformers 为例,看看一段文本是怎么变成向量的。核心逻辑在 SentenceTransformer.encode() 里,简化后的关键路径:

# sentence_transformers/SentenceTransformer.py (简化版)
def encode(self, sentences, batch_size=32, convert_to_numpy=True):
    # 1. Tokenize:文本 -> token id 序列
    features = self.tokenize(sentences)
    
    # 2. 前向传播:经过 Transformer Encoder
    #    output: [batch, seq_len, hidden_dim]
    out_features = self.forward(features)
    
    # 3. Pooling:把变长序列压成固定维度向量
    #    这是最关键的一步,不同模型策略不同
    embeddings = self.pooling(out_features, features['attention_mask'])
    
    # 4. Normalize:L2 归一化,让向量落在单位超球面上
    if self.normalize_embeddings:
        embeddings = F.normalize(embeddings, p=2, dim=1)
    
    return embeddings

这里面有三个容易被忽视的细节,值得展开。

细节一:Pooling 策略决定语义质量

Transformer 输出的是一个 [seq_len, hidden_dim] 的矩阵,每个 token 一个向量。怎么把它压成一个句子向量?主流有三种策略:

CLS Pooling:取 [CLS] token 位置的向量。BERT 原生的做法,但 [CLS] 向量的语义表达能力其实很弱,因为预训练时它主要服务于 NSP(Next Sentence Prediction)任务,而这个任务后来被证明效果有限。

Mean Pooling:对所有 token 向量求平均。这是目前最常用的策略,但必须配合 attention_mask,否则 padding 的 token 会污染结果:

def mean_pooling(token_embeddings, attention_mask):
    # token_embeddings: [batch, seq_len, hidden]
    # attention_mask:   [batch, seq_len],1 表示真实 token,0 表示 padding
    input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
    # 关键:padding 位置的向量乘 0 被剔除,分母只算真实 token 数
    sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, dim=1)
    sum_mask = torch.clamp(input_mask_expanded.sum(dim=1), min=1e-9)
    return sum_embeddings / sum_mask

如果你自己实现过 mean pooling 而忘了乘 mask,会发现长文本和短文本的相似度计算出现诡异的偏差——这就是 padding 向量在作祟。

Last Token Pooling:取最后一个 token 的向量。这是 decoder-only 模型(如 E5、GTE 的部分变体)的做法,因为 causal attention 下只有最后一个 token "看到"了完整上下文。

实战建议:选型时一定要确认模型的 Pooling 策略,并在调用时对齐。用错 Pooling 策略,效果可能直接掉 10 个点。

细节二:对比学习的温度系数

现代 Embedding 模型大多用 InfoNCE Loss 训练:

$$

\mathcal{L} = -\log \frac{\exp(\text{sim}(q, k^+) / \tau)}{\sum_{i} \exp(\text{sim}(q, k_i) / \tau)}

$$

其中 $\tau$ 是温度系数,通常取 0.01~0.1。温度越低,模型越"苛刻",对难负样本越敏感。这解释了为什么有些模型在"区分细微语义差异"上表现好,有些则只能做粗粒度匹配——训练时的温度系数决定了它对语义边界的敏感度。

细节三:Matryoshka Embedding(俄罗斯套娃)

这是近两年最重要的一个创新,值得单独讲。传统 Embedding 是固定维度的,比如 768 维。如果你想省存储,只能做 PCA 降维,但效果会掉。

Matryoshka Embedding(MRL)的思路是:训练时让向量的前 k 维本身就构成一个有效的低维表示。就像俄罗斯套娃,大娃娃里套着小娃娃,每个都是完整的。

# MRL 的训练损失:对多个维度切片分别计算损失
def matryoshka_loss(query_emb, doc_emb, dims=[768, 512, 256, 128]):
    total_loss = 0
    for d in dims:
        # 只取前 d 维,重新归一化后算对比损失
        q_slice = F.normalize(query_emb[:, :d], dim=-1)
        d_slice = F.normalize(doc_emb[:, :d], dim=-1)
        logits = q_slice @ d_slice.T / temperature
        total_loss += F.cross_entropy(logits, labels)
    return total_loss / len(dims)

这带来的工程价值巨大:同一个模型,你可以根据场景动态选择维度。冷数据用 128 维省存储,热数据用 768 维保精度。OpenAI 的 text-embedding-3-large 就支持这种动态维度。

实战代码

下面三个示例覆盖从选型评测到生产部署的完整链路。

示例一:用 MTEB 思路做模型选型评测

不要迷信排行榜,一定要在自己的业务数据上评测。这是选型的第一原则。

import numpy as np
from sentence_transformers import SentenceTransformer
from typing import List, Tuple
import time

class EmbeddingEvaluator:
    """在自己的业务数据集上评测 Embedding 模型"""
    
    def __init__(self, model_name: str, device: str = "cuda"):
        self.model = SentenceTransformer(model_name, device=device)
        self.model_name = model_name
    
    def encode(self, texts: List[str], batch_size: int = 64) -> np.ndarray:
        """批量编码,返回归一化后的向量"""
        return self.model.encode(
            texts,
            batch_size=batch_size,
            normalize_embeddings=True,  # 关键:归一化后余弦相似度=内积,加速检索
            convert_to_numpy=True,
            show_progress_bar=False,
        )
    
    def evaluate_retrieval(
        self,
        queries: List[str],
        corpus: List[str],
        # 每个 query 对应的正样本在 corpus 中的索引
        ground_truth: List[List[int]],
        top_k: int = 10,
    ) -> dict:
        """
        计算 Recall@K、MRR、NDCG@K
        这是语义搜索场景最核心的三个指标
        """
        q_emb = self.encode(queries)
        c_emb = self.encode(corpus)
        
        # 内积 = 余弦相似度(因为都归一化了)
        scores = q_emb @ c_emb.T  # [num_q, num_corpus]
        
        recall_hits = 0
        mrr_sum = 0.0
        ndcg_sum = 0.0
        
        for i, gt_indices in enumerate(ground_truth):
            # 按分数降序排序,取 top_k
            ranked = np.argsort(-scores[i])[:top_k]
            ranked_set = set(ranked.tolist())
            gt_set = set(gt_indices)
            
            # Recall@K:top_k 里命中了多少正样本
            hit = len(ranked_set & gt_set)
            recall_hits += hit / len(gt_set)
            
            # MRR:第一个命中正样本的倒数排名
            for rank, idx in enumerate(ranked, start=1):
                if idx in gt_set:
                    mrr_sum += 1.0 / rank
                    break
            
            # NDCG@K
            dcg = sum(
                1.0 / np.log2(rank + 1)
                for rank, idx in enumerate(ranked, start=1)
                if idx in gt_set
            )
            idcg = sum(
                1.0 / np.log2(rank + 1)
                for rank in range(1, min(len(gt_set), top_k) + 1)
            )
            ndcg_sum += dcg / idcg if idcg > 0 else 0
        
        n = len(queries)
        return {
            "model": self.model_name,
            f"Recall@{top_k}": recall_hits / n,
            "MRR": mrr_sum / n,
            f"NDCG@{top_k}": ndcg_sum / n,
        }
    
    def benchmark_latency(self, texts: List[str], repeat: int = 3) -> dict:
        """测量推理延迟,生产环境吞吐量的关键指标"""
        # 预热
        self.encode(texts[:8])
        
        times = []
        for _ in range(repeat):
            start = time.perf_counter()
            self.encode(texts)
            times.append(time.perf_counter() - start)
        
        avg_time = sum(times) / len(times)
        return {
            "model": self.model_name,
            "avg_latency_ms": avg_time * 1000,
            "throughput_docs_per_sec": len(texts) / avg_time,
        }


# ============ 使用示例 ============
if __name__ == "__main__":
    # 模拟业务数据:电商商品搜索
    corpus = [
        "联想小新Pro 16 轻薄本 16英寸 2.5K屏幕 高性能散热",
        "小米笔记本Air 13.3英寸 超薄便携 商务办公",
        "笔记本散热器 铝合金 静音双风扇",
        "笔记本电脑包 防水防震 15.6英寸",
        "MacBook Pro 14 M3芯片 专业级性能",
    ]
    queries = ["轻薄笔记本散热好", "便携办公本"]
    # 手工标注:query 0 相关的是 index 0,1,4;query 1 相关的是 index 1,4
    ground_truth = [[0, 1, 4], [1, 4]]
    
    candidates = [
        "BAAI/bge-large-zh-v1.5",
        "BAAI/bge-small-zh-v1.5",
        "shibing624/text2vec-base-chinese",
    ]
    
    for name in candidates:
        evaluator = EmbeddingEvaluator(name)
        metrics = evaluator.evaluate_retrieval(queries, corpus, ground_truth, top_k=3)
        latency = evaluator.benchmark_latency(corpus)
        print(f"{metrics} | {latency}")

注意:真实评测时,ground_truth 必须来自业务标注或点击日志,绝不能用模型自己生成的正样本——那是循环论证。

示例二:生产级向量检索服务(带缓存和批处理)

这个示例展示一个可以直接上生产的检索服务,包含批量处理、向量缓存、维度动态选择。

import hashlib
import json
import numpy as np
from typing import List, Optional, Dict
from dataclasses import dataclass, field
from sentence_transformers import SentenceTransformer
import faiss  # Facebook 的向量检索库


@dataclass
class SearchResult:
    doc_id: int
    score: float
    content: str


class ProductionSemanticSearch:
    """
    生产级语义搜索服务
    核心设计:
    1. 向量缓存:相同文本不重复编码
    2. FAISS 索引:支持百万级向量的毫秒检索
    3. Matryoshka 维度:按需选择维度,平衡精度与存储
    """
    
    def __init__(
        self,
        model_name: str = "BAAI/bge-m3",
        embedding_dim: int = 1024,
        use_matryoshka: bool = False,
        matryoshka_dim: int = 256,
    ):
        self.model = SentenceTransformer(model_name)
        self.full_dim = embedding_dim
        # Matryoshka:只用前 N 维,向量存储和检索成本大幅降低
        self.use_matryoshka = use_matryoshka
        self.active_dim = matryoshka_dim if use_matryoshka else embedding_dim
        
        self.documents: List[str] = []
        self.index: Optional[faiss.Index] = None
        
        # 简单的内存缓存,生产环境应换成 Redis
        self._cache: Dict[str, np.ndarray] = {}
    
    def _cache_key(self, text: str) -> str:
        return hashlib.md5(text.encode("utf-8")).hexdigest()
    
    def encode_with_cache(self, texts: List[str], batch_size: int = 64) -> np.ndarray:
        """带缓存的批量编码"""
        results = [None] * len(texts)
        uncached_indices = []
        uncached_texts = []
        
        for i, text in enumerate(texts):
            key = self._cache_key(text)
            if key in self._cache:
                results[i] = self._cache[key]
            else:
                uncached_indices.append(i)
                uncached_texts.append(text)
        
        if uncached_texts:
            # 批量编码未命中的文本
            embs = self.model.encode(
                uncached_texts,
                batch_size=batch_size,
                normalize_embeddings=True,
                convert_to_numpy=True,
            )
            # Matryoshka 截断:只保留前 active_dim 维,并重新归一化
            if self.use_matryoshka:
                embs = embs[:, :self.active_dim]
                norms = np.linalg.norm(embs, axis=1, keepdims=True)
                embs = embs / np.clip(norms, 1e-9, None)
            
            for idx, text, emb in zip(uncached_indices, uncached_texts, embs):
                self._cache[self._cache_key(text)] = emb
                results[idx] = emb
        
        return np.vstack(results).astype(np.float32)
    
    def build_index(self, documents: List[str]):
        """构建 FAISS 索引"""
        self.documents = documents
        embeddings = self.encode_with_cache(documents)
        
        # 内积索引(配合归一化向量 = 余弦相似度)
        # IVF 索引适合百万级以上,小规模用 Flat 即可
        if len(documents) < 100_000:
            self.index = faiss.IndexFlatIP(self.active_dim)
        else:
            quantizer = faiss.IndexFlatIP(self.active_dim)
            nlist = int(np.sqrt(len(documents)))
            self.index = faiss.IndexIVFFlat(
                quantizer, self.active_dim, nlist, faiss.METRIC_INNER_PRODUCT
            )
            self.index.train(embeddings)
        
        self.index.add(embeddings)
    
    def search(self, query: str, top_k: int = 10) -> List[SearchResult]:
        """检索"""
        q_emb = self.encode_with_cache([query])
        scores, indices = self.index.search(q_emb, top_k)
        
        results = []
        for score, idx in zip(scores[0], indices[0]):
            if idx == -1:  # FAISS 无结果时返回 -1
                continue
            results.append(SearchResult(
                doc_id=int(idx),
                score=float(score),
                content=self.documents[idx],
            ))
        return results


# ============ 使用示例 ============
if __name__ == "__main__":
    service = ProductionSemanticSearch(
        model_name="BAAI/bge-m3",
        use_matryoshka=True,
        matryoshka_dim=256,  # 存储和检索成本降到 1/4
    )
    
    docs = [
        "如何用 Python 实现一个向量数据库",
        "Java 并发编程中的 AQS 原理详解",
        "Transformer 架构中的自注意力机制",
        "Go 语言 GC 调优实战",
        "RAG 系统中的向量召回优化技巧",
    ]
    service.build_index(docs)
    
    for r in service.search("向量检索怎么优化", top_k=3):
        print(f"[{r.score:.4f}] {r.content}")

示例三:跨模型迁移——事故复盘后的解决方案

回到开头的故事。当模型升级时,如何避免向量空间不兼容的灾难?答案是:双写 + 影子索引 + 灰度切换。

import numpy as np
from typing import List
from sentence_transformers import SentenceTransformer


class DualWriteEmbeddingService:
    """
    双写 Embedding 服务:模型升级时的平滑迁移方案
    
    核心思路(类似数据库的双写迁移):
    1. 新老模型同时编码,结果分别写入两个索引
    2. 查询走老索引,影子流量走新索引,对比效果
    3. 效果达标后,切换查询到新索引
    4. 全量数据后台异步重编码,完成后下线老索引
    """
    
    def __init__(self, old_model_name: str, new_model_name: str):
        self.old_model = SentenceTransformer(old_model_name)
        self.new_model = SentenceTransformer(new_model_name)
        
        # 记录两个模型的元信息,便于排查
        self.old_dim = self.old_model.get_sentence_embedding_dimension()
        self.new_dim = self.new_model.get_sentence_embedding_dimension()
    
    def _encode_one(self, model, texts: List[str]) -> np.ndarray:
        return model.encode(
            texts,
            normalize_embeddings=True,
            convert_to_numpy=True,
            batch_size=64,
        )
    
    def dual_encode(self, texts: List[str]) -> dict:
        """双写:返回两个模型各自的向量"""
        return {
            "old": self._encode_one(self.old_model, texts),
            "new": self._encode_one(self.new_model, texts),
        }
    
    def evaluate_migration(
        self,
        sample_queries: List[str],
        sample_docs: List[str],
        ground_truth: List[List[int]],
        top_k: int = 10,
    ) -> dict:
        """
        迁移评测:在新老两个向量空间里分别检索,对比指标
        只有当新模型指标显著优于老模型时才切换
        """
        def evaluate(model) -> dict:
            q_emb = self._encode_one(model, sample_queries)
            d_emb = self._encode_one(model, sample_docs)
            scores = q_emb @ d_emb.T
            
            recall = 0.0
            for i, gt in enumerate(ground_truth):
                top_idx = set(np.argsort(-scores[i])[:top_k].tolist())
                recall += len(top_idx & set(gt)) / len(gt)
            return {"recall": recall / len(sample_queries)}
        
        old_metrics = evaluate(self.old_model)
        new_metrics = evaluate(self.new_model)
        
        return {
            "old_model": old_metrics,
            "new_model": new_metrics,
            "improvement": new_metrics["recall"] - old_metrics["recall"],
            "dim_changed": self.old_dim != self.new_dim,
            # 关键决策信号
            "safe_to_migrate": new_metrics["recall"] >= old_metrics["recall"],
        }


# ============ 使用示例 ============
if __name__ == "__main__":
    service = DualWriteEmbeddingService(
        old_model_name="BAAI/bge-base-zh-v1.5",
        new_model_name="BAAI/bge-m3",
    )
    
    queries = ["向量数据库怎么选", "Python 异步编程"]
    docs = [
        "FAISS 是 Facebook 开源的向量检索库",
        "Milvus 是国产的开源向量数据库",
        "asyncio 是 Python 的异步 IO 框架",
        "Goroutine 是 Go 的并发原语",
    ]
    gt = [[0, 1], [2]]
    
    report = service.evaluate_migration(queries, docs, gt)
    print(f"迁移评测报告:{report}")
    
    if report["safe_to_migrate"]:
        print("✅ 新模型效果达标,可以启动灰度切换")
    else:
        print("❌ 新模型效果下降,暂缓迁移")

方案对比:主流 Embedding 模型怎么选

我把目前主流方案按场景分类对比:

模型 维度 中文能力 多语言 最大长度 适用场景
bge-large-zh-v1.5 1024 强 弱 512 纯中文检索,精度优先
bge-m3 1024 强 强 8192 多语言 + 长文本,综合首选
text-embedding-3-large 3072 中 强 8191 有 OpenAI 生态,支持 MRL
gte-large-zh 1024 强 中 512 中文语义匹配
m3e-base 768 中 弱 512 轻量级,资源受限场景
jina-embeddings-v3 1024 中 强 8192 支持任务 LoRA,灵活

架构选型的三个决策维度:

  1. 数据是否出域:涉及隐私/合规的,必须本地部署(bge、gte 系列),不能用 OpenAI API。
  2. 文本长度分布:如果你的文档平均长度超过 512 token,bge-m3 或 jina-v3 几乎是必选,否则要引入切分逻辑,而切分会破坏语义完整性。
  3. 维度成本:向量存储成本与维度线性相关。1024 维 vs 3072 维,存储和检索成本差 3 倍。支持 MRL 的模型在这里有天然优势。

检索架构全景

graph TD A[用户查询] --> B[Query 改写/扩展] B --> C{路由决策} C -->|短查询| D[Embedding 编码] C -->|长查询| E[切分 + 分别编码] D --> F[向量检索 FAISS/Milvus] E --> F F --> G[粗排 Top-100] G --> H[Cross-Encoder 精排] H --> I[业务规则过滤] I --> J[返回 Top-K] K[(文档库)] --> L[离线编码 Pipeline] L --> M[向量索引] M --> F N[模型版本管理] --> L N --> D style N fill:#ffe6cc style F fill:#d4e6f1

关键点在于模型版本管理必须是全局统一的——离线编码和在线查询必须用同一个模型版本,否则就是开头那个事故。

最佳实践与避坑指南

1. 归一化:永远不要忘记

余弦相似度在归一化后等价于内积,而内积可以被 FAISS 的 IndexFlatIP 高效计算。如果你的向量没归一化,请用 IndexFlatL2 并记得把余弦相似度转换成欧氏距离。混用会导致检索结果完全错乱。

2. 指令前缀:很多模型有"隐藏协议"

bge 系列在训练时对 query 和 passage 用了不同的前缀:

# BGE 系列的正确用法
query_emb = model.encode("为这个句子生成表示以用于检索相关文章:" + query)
doc_emb = model.encode(doc)  # doc 不加前缀

用错前缀,效果会掉 5-10 个点。而且这个坑很难发现,因为不加前缀也能跑,只是效果差。选型时务必查模型的官方 README。

3. 长度截断的隐性损失

大多数模型 max_length=512,超长文本会被静默截断。对于长文档,正确做法是:

def chunk_document(text, max_tokens=500, overlap=50):
    """带重叠的切分,避免语义断裂"""
    # 按句子切分,再贪心合并到 max_tokens
    sentences = split_sentences(text)
    chunks, current = [], []
    for sent in sentences:
        if count_tokens(current + [sent]) > max_tokens:
            chunks.append("".join(current))
            # 保留 overlap 个句子,保证跨 chunk 语义连续
            current = current[-overlap:]
        current.append(sent)
    if current:
        chunks.append("".join(current))
    return chunks

然后对每个 chunk 编码,检索时取 max 分数或做聚合。

4. 别信排行榜,信你的数据

MTEB 排行榜上排名靠前的模型,在你的业务数据上不一定好。尤其是垂直领域(医疗、法律、金融),通用模型的优势可能完全失效。花两天时间做业务评测,比盲目选 SOTA 省下的时间多得多。

5. 冷启动:微调 vs 换模型

如果通用模型在你的领域效果差,有两个选择:

  • 换模型:成本低,但天花板有限
  • 微调:用 sentence-transformers 的 MultipleNegativesRankingLoss,几百条业务数据就能显著提升

我的经验:先换模型排查,效果仍不达标再微调。微调的数据标注成本往往被低估。

6. 索引选型:别一上来就 Milvus

很多团队一上来就上 Milvus/Weaviate 集群,其实:

  • < 10 万向量:FAISS Flat 索引,纯内存,毫秒级
  • 10 万 ~ 100 万:FAISS IVF 或 HNSW,单机搞定
  • > 100 万:分布式向量库(Milvus、Qdrant)

过早引入分布式组件,运维成本会吃掉大部分收益。

7. 版本管理:把模型当代码管理

把模型的 model_name + revision + pooling + normalize + prefix 打包成一个"向量空间版本号",写入向量库元数据。查询时校验版本一致性,不一致直接报错。这是避免开头那类事故的唯一可靠手段。

总结

Embedding 模型选型这件事,表面上是"选个模型调 API",本质上是向量空间工程。回顾一下核心要点:

  1. 向量空间是模型私有的:不同模型的向量不能混用,模型升级必须走双写迁移流程。
  2. Pooling 和前缀是隐藏协议:用错直接影响效果,选型时务必查文档。
  3. 归一化是工程基石:它决定了你能不能用内积加速,以及相似度计算是否正确。
  4. Matryoshka 是存储优化的利器:支持 MRL 的模型可以动态选择维度,平衡精度与成本。
  5. 评测要用自己的数据:排行榜只是参考,业务评测才是决策依据。

延伸思考

Embedding 领域正在快速演进,几个值得关注的方向:

  • Late Interaction(ColBERT 范式):不做单向量,而是保留 token 级向量做 MaxSim,精度更高但存储成本大。ColBERTv2 通过残差压缩把成本降下来了,是精排环节的有力竞争者。
  • 多模态 Embedding:CLIP 之后的统一图文空间,正在往视频、音频扩展。未来的检索系统可能是跨模态的。
  • 稀疏 + 稠密混合检索:BM25 的稀疏优势(精确匹配、可解释)和 Embedding 的稠密优势(语义泛化)互补,混合检索在很多场景已经超越纯向量方案。bge-m3 就同时输出稠密、稀疏、多向量三种表示,值得深入研究。

架构选型没有银弹,只有权衡。理解原理,用数据说话,才能做出经得起生产考验的决策。