做检索这件事

如果你不是会员但想读这篇,这是免费阅读链接。
embedding 是 NLP 的基石之一。它能干的事不少,但最常见的一件就是检索场景里的语义搜索。
虽然整个圈子最近都在聊知识图谱检索 pipeline,但传统的向量检索其实并没过时。
网上能找到一堆文章讲怎么把语义搜索里不相关的结果过滤掉——这篇也会聊这个,用聚类和重排(re-ranking)这两招。
不过这篇真正想做的事,是把开源和闭源 embedding 模型放一起对比——还要看不同体量。

我们会对比 9 个 embedding 模型,全是 MTEB 排行榜上排名靠前的。这样你大概能感觉到大模型 vs 小模型的差距,以及规模上去之后成本会怎么涨。
如果你用过 OpenAI 生成 embedding,估计也好奇过它到底竞争力够不够。
简单回顾一下 embedding——如果你刚接触:我们给每段文本生成 embedding(一组向量),就是把文本转成计算机能理解的东西。

具体到语义搜索,我们是把不同文本的 embedding 拿来比,看它们语义上有多像。这样查询就可以走"模糊搜索"——按关系找,不是按关键词精确匹配。

引言里我会讲 embedding 怎么工作,重点是怎么算语义相似度。
我每写一篇都喜欢拿一个具体场景练手,这次也不例外。这个想法来自一位咨询公司老板——他问我能不能做一个应用,把岗位描述自动匹配到 LinkedIn profile。
如果真做生产用,肯定要上百万真实 profile,但这篇只是演示,我用合成数据做了 6,900 份 LinkedIn profile。
数据集在这里。
这场景算简单的:profile 不需要切块(一份就一段),领域也不复杂——一眼就能看出模型有没有抓对关系。
但思路是通用的,可以套到类似问题上。
如果你想直接跳过引言、用 LinkedIn 数据集动手玩不同模型,往下翻就行。
引言
这篇里 profile 数量不多,可以把聚类当成一种无监督的分类方法用在整个数据集上。
下面是聚类长什么样的示意图。

聚类还能让我们看出不同模型怎么理解事物之间的关联。
看模型表现,我们可以先把正确的那一组隔离出来,再在簇内做语义搜索。

这样能过滤掉不相关的结果,比如模型把 product manager 和 product marketing manager 搞混的情况。
最后还能加一步:用 LLM 做重排,确保最相关的结果排在最上面。
为了省事也省钱,我把每个待评测模型的 embedding 都已经预先算好放进数据集里了,匿名岗位描述的 embedding 也算好了。
提醒一下:想直接动手可以跳到用例那一节,但成本那部分别跳过。
Embedding
前面提过,embedding 是文本的数值表示,把含义编码下来——这样计算机就能处理和理解自然语言。

到了 transformer 这一代模型,它们能理解整句的 context,从而抓住一个词或一句话的多重含义——这事几年前还做不到。
我们其实可以把 embedding 画在图上,当成几何空间里的点。embedding 之间的语义关系,就体现为几何上的远近。

不同模型为不同任务训练,但稍大一些的模型一般都是通才,能干检索、聚类、分类这类活。
语义搜索用在检索里——它就是用图上的"近"来判断 query 应该匹到哪些 embedding,本质上是在算图上 embedding 之间的距离。
要在语义搜索里算 embedding 之间的相似度有好几种方法,但最常用的是余弦相似度。

你选哪个模型,直接决定语义搜索的结果——这归根结底取决于它是怎么训练的。
训练用的数据集、目标、架构都很关键,会直接影响模型对各种文本的理解和关联能力。
聚类则不太一样——它是把数据组织成一组组(簇),让同一簇内的相似度高于跨簇相似度。聚类更擅长把 embedding 之间的相似关系挖出来,让我们能干净地隔离出某一组。

这一步让我们能在做语义搜索之前先过滤掉不相关的匹配——相当于一道降噪工序。
起码思路是这样。
不是每个模型都能按我们想要的方式做聚类。有的擅长,有的拉胯,全看它怎么训出来的。
Embedding 模型
那怎么知道选哪个?这时候 MTEB 排行榜就该出场了——它按各种任务上的表现给 embedding 模型排名。
我从中挑了几个出来对比:OpenAI 那几款用得多的,再加一个 fine-tune 过的 Mistral-7B,还有更小更新的模型比如 Mixedbread AI 的 Mxbai。
差不多都是过去两年里发布的。

如果你刚接触开源模型,可能会惊讶——好多开源模型其实排得很靠前。如果你已经玩过这些模型,看一眼哪个在这个任务上跑得最好也挺有意思。
下面这张表列了每个模型的体量、最大 token 数、以及检索和聚类的排名。

很多人都用过 OpenAI 的 Ada-002,它在我们的列表里垫底。不过 OpenAI 后来出了 text-embed-3 的 small 和 large 两版——表现更好,价格也更便宜。
那你可能会问:开源模型在排行榜上排得这么高,干嘛还有人用商业模型?
开源模型的成本账
用开源模型听起来挺好的,隐私上也是首选。排行榜上靠前的不少,但你得算一笔账:自己托管 vs 调 API,到底哪个划算。
我看了一下成本——把小一些(约 350M)和大一些(7B)的开源模型放 GPU 上自托管,对比按 token 付费的几款主流商业模型。

前提是每段文本 400 token——这样 334M 模型在单张 L4 GPU 上每秒能处理 75–90 段,7B 模型在单张 A100 上每秒约 30–40 段(也可能更多)。
你会发现:一旦要 embed 几百万段文本,text-embed-3-large 或 ada-002 这种模型成本就会飙——而且这还没算存储。
如果你是企业客户,又在看 Nvidia 的 embedding 模型比如 nv-embed-v1,他们的 API 挺好用的——我用它测过几个模型。
不过用小模型(500M 以下)肯定是最稳的选择。能上小一点的开源模型就上——算力成本能直接砍掉 90%。
我也算了一下小模型和大模型放单张 GPU 上要跑多久。

调 API 也要花时间,而且有推理速率限制——所以不管选哪条路,都要算把整份数据集 embed 完要多久。
开源模型嘛,可以多堆几张 GPU。但这至少给你点感觉——选小一点的可能更省电。
不过测几个看看哪个适合你的任务总没错——接下来就这么干。
量化
上面看到了,7B 这种规模的大模型还是又贵又费电。我们只算了 250 万条 embedding 的成本——一旦再往上 scale,量化就值得看一看了。
量化是用更少的位数表示模型的数据,从而把模型压小。思路是:用 4-bit、8-bit 这类量化手段,让原本带不动大模型的硬件也能跑起来。
也有几个人测过量化对各种指标的影响——我最近看到的一篇说整体性能下降约 12%。
这个以后我得专门写一篇——这种"经济性 vs 性能损失"的话题我特别喜欢挖。
用例
我不知道你怎么样——反正我喜欢直接拿不同模型测一测,而不是只看指标。这样我能直观感受到:小模型和大模型在文本关系上理解力差多少。
合成 LinkedIn profile 数据集在这里,要匹配的岗位描述数据集也在。
要用的 Colab notebook 在这里。
导入数据
要跟着做就得打开那个 notebook——打开后你会看到我们从 Hugging Face 拉了两个数据集。
# 带 embedding 的合成 LinkedIn profile
dataset = load_dataset("ilsilfverskiold/linkedin_profiles_synthetic")
profiles = dataset['train']
# 带 embedding 的匿名岗位描述
dataset = load_dataset("ilsilfverskiold/linkedin_recruitment_questions_embedded")
applications = dataset['train']
这两个数据集让我们能在 6,900 份合成 LinkedIn profile 上对比不同 embedding 模型。
合成数据嘛,毕竟是合成的,要打折听。这批是 Llama 3.1 生成的——而且确实有点"过度对齐"的味道:profile 里反复出现 "results-driven"、"seasoned"、"dedicated" 这种词。
embedding 已经预先算好放进 profiles 里了——你点进去就能看到。
# profiles 数据集
Dataset({
features: [...,'embeddings_nv-embed-v1', 'embeddings_nv-embedqa-e5-v5', 'embeddings_bge-m3', 'embeddings_arctic-embed-l', 'embeddings_mistral-7b-v2', 'embeddings_gte-large-en-v1.5', 'embeddings_text-embedding-ada-002', 'embeddings_text-embedding-3-small', 'embeddings_voyage-3', 'embeddings_mxbai-embed-large-v1 '],
num_rows: 6904
})
P.S. embeddings_gte-large-en-v1.5 这个跑不通——我尝试过自己托管但没把所有 embedding 都跑齐,所以别用它。
接下来你需要选一份要匹配的岗位描述。
看下面代码——我选了第 2 份,你可以换别的编号。
application = applications[1] # 选第 2 份——一个 product marketing manager 岗位
application_text = application['natural_language']
print("application we're looking for: ", application_text)
懒得跑代码也行,直接在 Hugging Face 上看数据集。

这一步你可以挑想用的 embedding 模型。我大部分都试过了,这次用 embeddings_mxbai-embed-large-v1。
这是一个 334M 的开源模型——在前面那张表里它的检索和聚类排名都挺靠前。
想换别的模型只要改一下名字就行。看上面那个数据集就知道有哪些选项。
# 拿这个 embedding 模型对应的 query embedding——这里挑的是 mxbai-embed-large-v1
query_embedding_vector = np.array(application['embeddings_mxbai-embed-large-v1'])
embeddings_list = [np.array(emb) for emb in profiles['embeddings_mxbai-embed-large-v1 ']] # 注意这里多了个空格
texts = profiles['text']
语义搜索
我们先不上聚类,直接做一次语义搜索——看看裸跑能到什么效果。
要算 profile 跟 query(也就是岗位描述)之间的语义相似度,跑下面这段代码。
# 先单独算一下余弦相似度(不带聚类)
def cosine_similarity(a, b):
a = np.array(a)
b = np.array(b)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
similarities = []
for idx, emb in enumerate(embeddings_list):
sim = cosine_similarity(query_embedding_vector, emb)
similarities.append(sim)
然后按相似度从高到低排序,显示前 30 条。
results = list(zip(range(1, len(texts) + 1), similarities, texts))
sorted_results = sorted(results, key=lambda x: x[1], reverse=True)
# 把结果打出来
print("\nSimilarity Results (sorted from highest to lowest):")
for idx, sim, text in sorted_results[:30]: # 想看更多就调这里
percentage = (sim + 1) / 2 * 100
text_preview = ' '.join(text.split()[:10])
print(f"Text {idx} similarity: {percentage:.2f}% - Preview: {text_preview}...")
结果大概长这样——具体看你选的是哪一份岗位描述。
Similarity Results (sorted from highest to lowest):
Text 3615 similarity: 89.59% - Preview: Product Marketing Manager | Building Go-to-Market Strategies for Growth Results-driven...
Text 6299 similarity: 89.56% - Preview: Product Marketing Manager | Driving Growth & Customer Engagement Results-driven...
Text 3232 similarity: 89.09% - Preview: Product Marketing Manager | Driving Product Growth through Data-Driven Strategies...
Text 5959 similarity: 88.90% - Preview: Product Marketing Manager | Data-Driven Growth Expert Results-driven Product Marketing...
Text 5635 similarity: 88.84% - Preview: Product Marketing Manager | Driving Growth through Data-Driven Marketing Strategies...
Text 5835 similarity: 88.74% - Preview: Product Marketing Manager | Cloud-Based SaaS Results-driven Product Marketing Manager...
Text 139 similarity: 88.66% - Preview: Product Marketing Manager | Scaling Growth through Data-Driven Strategies Experienced...
Text 6688 similarity: 88.48% - Preview: Product Marketing Manager | Driving Business Growth through Data-Driven Insights...
Text 6405 similarity: 88.27% - Preview: Product Marketing Manager | Scaling SaaS Products for Global Markets...
Text 3439 similarity: 88.11% - Preview: Product Manager | Focused on delivering innovative products that drive...
Text 5958 similarity: 88.00% - Preview: Product Manager Office | Growth Driven by Customer Centricity Highly...
Text 5183 similarity: 87.86% - Preview: Product Marketing Manager | B2B SaaS Experienced Product Marketing Manager...
Text 1329 similarity: 87.81% - Preview: Product Marketing Manager | Scaling Growth for Emerging Tech Startups...
Text 130 similarity: 87.81% - Preview: Product Marketing Manager | Growth Strategies & Launches Results-driven Product...
Text 3423 similarity: 87.78% - Preview: Product Marketing Manager | Scaling B2B SaaS Solutions Experienced Product...
Text 4234 similarity: 87.72% - Preview: Product Manager | Leading Cross-Functional Teams to Drive Business Growth...
mxbai 这个模型——还有不少其他模型也一样——明显能看出它把 product marketing manager 跟 product manager 一起返回了,这不是我们想要的。
看上面 88.11% – Preview: Product Manager 和 88.00% – Preview: Product Manager Office 这两条。
那就上聚类——看看它能不能帮上忙。
聚类
第一步从 profile 的 embedding 里搭聚类——你得先决定要分多少簇。
我选了 10。
embeddings_array = np.array(embeddings_list)
num_clusters = 10 # 这里数字可以换
kmeans = KMeans(n_clusters=num_clusters, random_state=42)
kmeans.fit(embeddings_array)
cluster_labels = kmeans.labels_
pca = PCA(n_components=2)
reduced_embeddings = pca.fit_transform(embeddings_array)
接下来要看 query(岗位描述)落在哪一簇。
# 看 query 落到哪个簇里
query_embedding_array = np.array(query_embedding_vector).reshape(1, -1)
reduced_query_embedding = pca.transform(query_embedding_array)
# 预测 query 应该归到哪一簇
query_cluster_label = kmeans.predict(query_embedding_array)[0]
print(f"The query belongs to cluster {query_cluster_label}")
然后我们可以把簇画在 2 维图上——记得这是降维后的结果,所以有些簇可能叠在一起。

你可以鼠标悬停到不同 embedding 上看 profile 内容。

也可以把 query(图里的 X)单独标出来,看模型把它判到哪一簇。

可以清楚看到,模型把 marketing 相关的人都正确归到同一簇——SEO specialist 和 growth hacker 也在里面,但 Office Product Manager 和 Product Manager 都不在。
漂亮。
到这一步,我们可以把聚类和语义搜索两招拼起来用,效果会更好。
记得换不同模型试试看哪个效果更好——你会发现大模型在"把相似 profile 分到一组"这件事上按理说更强,但有些小模型也能跑得不错。
聚类 + 语义搜索
既然 query 能正确归到对的簇里,我们就可以把两招拼起来用。
# 在正确的簇内做语义搜索
cluster_indices = np.where(cluster_labels == query_cluster_label)[0]
cluster_embeddings = embeddings_array[cluster_indices]
cluster_texts = [texts[i] for i in cluster_indices]
similarities_in_cluster = []
for idx, emb in zip(cluster_indices, cluster_embeddings):
sim = cosine_similarity(query_embedding_vector, emb)
similarities_in_cluster.append((idx, sim))
similarities_in_cluster.sort(key=lambda x: x[1], reverse=True)
top_n = 40 # 想看更多结果就调这个
top_matches = similarities_in_cluster[:top_n]
print(f"\nTop {top_n} similar texts in the same cluster as the query:")
for idx, sim in top_matches:
percentage = (sim + 1) / 2 * 100
text_preview = ' '.join(texts[idx].split()[:10])
print(f"Text {idx+1} similarity: {percentage:.2f}% - Preview: {text_preview}...")
跑完上面这段就能看到——结果里 Product Manager 不见了,换成了 Marketing Manager 这类更对路的人选。
Top 40 similar texts in the same cluster as the query:
Text 3615 similarity: 89.59% - Preview: Product Marketing Manager | Building Go-to-Market Strategies for Growth Results-driven...
Text 3232 similarity: 89.09% - Preview: Product Marketing Manager | Driving Product Growth through Data-Driven Strategies...
Text 5959 similarity: 88.90% - Preview: Product Marketing Manager | Data-Driven Growth Expert Results-driven Product Marketing...
Text 5635 similarity: 88.84% - Preview: Product Marketing Manager | Driving Growth through Data-Driven Marketing Strategies...
Text 5835 similarity: 88.74% - Preview: Product Marketing Manager | Cloud-Based SaaS Results-driven Product Marketing Manager...
Text 139 similarity: 88.66% - Preview: Product Marketing Manager | Scaling Growth through Data-Driven Strategies Experienced...
Text 6688 similarity: 88.48% - Preview: Product Marketing Manager | Driving Business Growth through Data-Driven Insights...
Text 6405 similarity: 88.27% - Preview: Product Marketing Manager | Scaling SaaS Products for Global Markets...
Text 5183 similarity: 87.86% - Preview: Product Marketing Manager | B2B SaaS Experienced Product Marketing Manager...
Text 1329 similarity: 87.81% - Preview: Product Marketing Manager | Scaling Growth for Emerging Tech Startups...
Text 130 similarity: 87.81% - Preview: Product Marketing Manager | Growth Strategies & Launches Results-driven Product...
Text 3423 similarity: 87.78% - Preview: Product Marketing Manager | Scaling B2B SaaS Solutions Experienced Product...
Text 5945 similarity: 87.63% - Preview: Marketing Manager | Driving Growth through Data-Driven Strategies Results-driven marketing...
Text 2664 similarity: 87.59% - Preview: Product Marketing Manager | Driving Growth & Innovation Results-driven Product...
Text 3368 similarity: 87.54% - Preview: Product Marketing Manager | Scaling Growth through Data-Driven Strategies Highly...
Text 5794 similarity: 87.48% - Preview: Product Marketing Manager | Driving Growth through Data-Driven Insights Results-driven...
Text 5685 similarity: 86.71% - Preview: Performance Marketing Manager | Driving Business Growth through Data-Driven Strategies...
Text 5818 similarity: 86.37% - Preview: Digital Marketing Manager | Driving Business Growth through Data-Driven Strategies...
真实生产场景里,最好在做语义搜索之前先对数据做一遍过滤和分类。
这一节的核心就是让你自己对比不同模型——尤其是小模型 vs 大模型——看你愿意为更快更便宜的推理牺牲多少质量。
别只是为了上大模型而上大模型,除非你真的需要。
想继续评估这些模型,可以用 RAGAs 看看不同模型下检索应用的表现。
关于模型表现的几点观察
要测性能总得挑个标准——我选的是看模型能不能把 product manager 和 product marketing manager 分清楚。
大模型在不上聚类的时候,更容易直接给出对的结果——但所有模型一开始都没把这两个职位分干净。
Ada-002 因为体量大不少,不上聚类的裸跑也表现不错;OpenAI 那个更小更新的 text-embed-3-small 反而差一些。

不过有些模型连聚类都聚不对——具体来说,fine-tune 过的 7B Mistral 和 E5 这次没跑好。这可能跟它们怎么训出来有关。
剩下那些在这个具体岗位上表现差不多。
让我意外的是 mxbai——只有 335M 居然这么能打。这恰好说明:简单任务上,大模型可能就是杀鸡用牛刀。
这只是针对这一件小事的评测——你自己的任务该看什么指标得自己定。
不过从这里可以继续往前走——比如再叠一层重排,把最好的结果挑给 LLM 评。
重排(Re-Ranking)
RAG pipeline 里要修正不相关结果的招数有不少,重排是其中一种。
重排说白了就是把结果重新排序,让相关的排到最上面。一种常见做法是 Pairwise Ranking(成对排序)。
具体怎么做:把一对 profile 喂给一个模型(可以是 LLM),让它根据岗位描述判断哪一个更合适。

具体到你的场景,得把几种方法组合起来才能跑得好。
如果你刚接触 embedding,希望这篇有点收获;如果不算新手,希望这次至少给你扒了点小模型 vs 大模型的成本账——开源闭源都聊到了。
至于更大的 LLM——闭源那边确实领先;但 embedding 模型这块不是这样。
就记一句:给小一些、算力更友好的模型一个机会。
❤