NumPy 入门实战(二):向量化与余弦相似度——从 ndarray 到 Embedding

目录

前言

一、向量化到底快在哪

二、从矩阵乘法到点积,再到余弦相似度

核心公式:一步步推

[第 1 步:展开 ‖a-b‖²](#第 1 步:展开 ‖a-b‖²)

[第 2 步:代入归一化条件](#第 2 步:代入归一化条件)

[第 3 步:把点积换成余弦](#第 3 步:把点积换成余弦)

[三、一对多:一次矩阵乘,算完 100 个文档](#三、一对多:一次矩阵乘,算完 100 个文档)

[ANN 是什么](#ANN 是什么)

[ANN 怎么做到"不扫全库"的](#ANN 怎么做到“不扫全库”的)

[HNSW(Hierarchical Navigable Small World)](#HNSW(Hierarchical Navigable Small World))

[其他常见 ANN 索引](#其他常见 ANN 索引)

用生活化比喻理解

和上述代码的关系

[四、ndarray 变成 Embedding:语义相似度实验](#四、ndarray 变成 Embedding:语义相似度实验)

解释:

[1. tokenize:把文本切成 token](#1. tokenize:把文本切成 token)

[2. hash_embed:用哈希把 token 变成向量(核心)](#2. hash_embed:用哈希把 token 变成向量(核心))

[① token → 维度索引](#① token → 维度索引)

[② 符号:+1 或 -1](#② 符号:+1 或 -1)

[③ 归一化](#③ 归一化)

[3. 两组对比:精心设计的"陷阱"](#3. 两组对比:精心设计的“陷阱”)

[第 1 对:同义但字面不重叠](#第 1 对:同义但字面不重叠)

[第 2 对:字面重叠但语义不同](#第 2 对:字面重叠但语义不同)

这段代码到底想说明什么

五、踩坑记录

[六、面试高频问题(AI 应用开发方向)](#六、面试高频问题(AI 应用开发方向))

小结

参考链接

系列上一篇:NumPy 入门实战(一):环境搭建与数组核心操作-CSDN博客

前言

我最近在学 RAG(检索增强生成),写向量检索 demo 的时候发现:所谓「语义检索」,剥开 Chroma、Milvus 这些向量库的包装,数学核心就是上一篇第七节讲的那个 np.dot------两个向量算一个相似度,排序,取前几名,完事。顺藤摸瓜回头一看,NumPy 里真正支撑这一切的,就是向量化运算。

所以干脆先把 NumPy 这条线收完,再进 Pandas------反正 Pandas 底层就是 NumPy,这篇的内容后面全都用得上。

本篇路线:向量化为什么快 → 从点积推出余弦相似度 → 一对多批量计算(向量库的真实原理)→ Embedding 是什么。环境沿用上一篇的 Anaconda + Jupyter,代码逐格粘贴就能跑,每个例子后面我都附了自己跑出来的输出。


一、向量化到底快在哪

上一篇 5.1 节演示过向量化运算:数组与标量、数组与数组对位运算,不用写循环。但当时只演示了「能这么写」,没说清楚它凭什么值得这么写。这里补一个对比实验:

python 复制代码
import time
import numpy as np

n = 1_000_000              # 一百万。别贪大:平方和 ≈ 3.3×10^17,int64 上限是 9.2×10^18,
                            # n 到千万级平方和就溢出了(NumPy 整数溢出不报错,见下方补充)
data = list(range(n))      # 普通 Python 列表
arr = np.arange(n)         # NumPy 数组

# 写法1:原生 for 循环(以前写 Java 时我的第一反应)
start = time.perf_counter()
total = 0
for x in data:
    total += x * x
t1 = time.perf_counter() - start

# 写法2:NumPy 向量化
start = time.perf_counter()
total2 = int((arr ** 2).sum())
t2 = time.perf_counter() - start

print(f"for 循环: {t1:.3f}s   NumPy: {t2:.3f}s   加速比: {t1 / t2:.0f} 倍")
print(f"结果一致: {total == total2}")

运行结果:

复制代码

⚠️ 你机器上跑出来的具体数字会跟我的不一样(取决于 CPU 和内存),但量级关系是一致的,无须纠结具体倍数。

为什么快?两个原因:

  1. 循环在 C 层执行,避免了解释器逐条调度;点积、矩阵乘这类重操作,底层还会直接调用 BLAS 优化库(OpenBLAS/MKL,各家发行版默认不同),并吃上 CPU 的 SIMD 指令;
  2. ndarray 是一块连续内存(上一篇 2.1 节讲过的本质区别),而 Python 列表存的是一串对象指针,遍历时 CPU 缓存友好度完全不是一个量级。

补充 :(arr ** 2).sum() 会先创建一个跟原数组一样大的临时数组(内存直接翻倍),而 np.dot(arr, arr) 同样是平方和,却不在内存里生成中间结果,数据量大时值得换。

顺手提醒一个整数陷阱:NumPy 的 int64 溢出不会报错 ,结果直接 wrap around(可能算出负数),跟踩坑 4 的静默截断是一路货色。数据量更大时用 dtype=np.float64------但 float 也有边界:超过 2 的 53 次方(约 9×10^15)整数就存不精确了,超大整数和别指望 == 严格相等。所以上面实验的 n 取一百万是算过的,不是随手写的。
网上有人拿这个数字吹「Python 比 Java 快」,别信。Java 经 JIT 编译后也是接近原生的速度,这个实验的本质是「解释型语言的解释层很慢,绕开它就快」,跟语言信仰没关系。
为什么 benchmark 偏偏选"平方和"? 性能对比有条铁律:两种写法除了"循环方式",其他一切必须相同。所以这个计算得满足四条:纯计算无副作用(不碰 I/O、不建对象)、每个元素独立(没有跨元素依赖,向量化才能一一对应)、结果可断言(最后能用一个 `==` 证明快的那个没算错)、运算量足够重(纯加法会被 `sum()` 的 C 层优化掩盖,乘加两次运算才能把解释器开销暴露出来)。平方和刚好全中。

而且它不是随便凑的:`x*x` 求和就是 `np.dot(arr, arr)`,也就是向量和自己的点积 = 模长的平方。上一篇 `np.linalg.norm` 算模长,底层就是"平方和开根号";这一篇讲点积、余弦,第一节补充技巧里又出现 `np.dot(arr, arr)`------选它做 benchmark,等于顺手把全文主线(模长 → 点积 → 余弦)的第一块砖砌进去了。换成"求和""求平均"也能测出加速比,但就白瞎了一次把抽象数学和具体代码对上的机会。


二、从矩阵乘法到点积,再到余弦相似度

上一篇第七节手算过矩阵乘法:(2,3) @ (3,2),行乘列、加起来。点积(内积)就是它的特例------一行乘一列,得到一个数:

A · B = a₁×b₁ + a₂×b₂ + ... + aₙ×bₙ

把点积和上一篇 5.3 节的模长概念拼起来,就是余弦相似度:

cos(θ) = (A · B) / (‖A‖ × ‖B‖)

直觉上把向量想成方向 :夹角越小越像。1 分同向(语义一致)、0 分垂直(毫无关系)、-1 分反向。模长用 np.linalg.norm 算(默认算的是 2 范数,也就是欧氏模长,想换别的范数用 ord 参数)。代码就几行:

python 复制代码
import numpy as np

def cosine_sim(a, b):
    """余弦相似度:夹角越小越像。1=同向,0=无关,-1=反向
    【工程细节】分母加 1e-8 防零向量除零------demo 可以不加,生产代码必须加(见踩坑3)"""
    return np.dot(a, b) / ((np.linalg.norm(a) + 1e-8) * (np.linalg.norm(b) + 1e-8))

q  = np.array([1, 2, 3], dtype=float)     # 假装这是「用户问题」的向量
d1 = np.array([1, 2, 2.9], dtype=float)   # 「相关文档」:方向几乎一致
d2 = np.array([9, 0, 1], dtype=float)     # 「无关文档」:方向差得远

print(f"相关文档相似度: {cosine_sim(q, d1):.4f}")
print(f"无关文档相似度: {cosine_sim(q, d2):.4f}")

运行结果:

复制代码

相关文档的分数应该明显更接近 1。就这么多------向量检索里「比相似度」这一步的全部数学就这一个公式。余弦相似度 = 两个向量的点积 ÷ 两个向量模长的乘积。 点积是"对应位置相乘再求和",模长是"平方和开根号",分母是两个标量相乘。

⚠️ 补充:为什么不用上一篇 5.2 节那种直接的距离(欧氏距离)?因为文本向量里,「长度」经常被信息量、句长干扰,我们真正关心的是「方向像不像」。两句意思相同的话,向量应该近乎同向,跟各自长短无关;欧氏距离会被长短差异带偏。
⚠️ 补充:归一化之后,欧氏距离和余弦其实是一回事。刚说完欧氏不如余弦,这里有个工程上的重要特例:如果两个向量都归一化了(模长都是 1),那么

‖a − b‖² = ‖a‖² + ‖b‖² − 2A·B = 2 − 2cos(θ) = 2(1 − cosθ)

也就是「L2 距离越小 ⇔ 余弦越大」,排序结果完全一致。这解释了一个新手常见困惑:Chroma、Milvus 建库时都让你选检索度量:cosine / L2 / IP(内积)三种,选哪个?只要向量都归一化过,三种的排序一模一样,不用纠结,后面 RAG 系列会再遇到它。
归一化 = 把向量除以它自己的模长,让新向量的长度变成 1。

复制代码

比如 a = [3, 4],模长 ‖a‖ = √(9+16) = 5,归一化后:

验证长度:。方向没变,长度变成 1 了。

所以归一化的作用:抹掉长度信息,只保留方向。

核心公式:一步步推

现在假设 a 和 b 都已经归一化 ,即 ‖a‖ = 1、‖b‖ = 1。推导从距离平方开始:

第 1 步:展开 ‖a-b‖²

这是向量模长平方的定义,展开(类似 (x-y)² = x²+y²-2xy):

这里 a·b 是点积(对应位置相乘再求和),‖a‖² 是 a 和自己的点积 a·a。

第 2 步:代入归一化条件

因为 ‖a‖=1、‖b‖=1,所以 ‖a‖²=1、‖b‖²=1:

第 3 步:把点积换成余弦

余弦的定义是:

归一化后 ‖a‖‖b‖ = 1×1 = 1,所以 a·b = cos(θ)(文章说的"归一化后点积=余弦"):

推导完成。 每一步都只用了一个事实:归一化让模长=1。


三、一对多:一次矩阵乘,算完 100 个文档

上面是一对一。真实检索是一个 query 对库里几万条文档,难道 for 一万次?

不用。把所有文档向量按行堆成矩阵 ,一次矩阵乘全算完------形状就是 (n, d) @ (d,):

python 复制代码
import numpy as np

rng = np.random.default_rng(42)

docs  = rng.random((100, 64))   # 模拟 100 个文档向量,每个 64 维
query = rng.random(64)          # 1 个查询向量

# 写法1:for 循环逐个算(能跑,但这是向量库没发明之前的写法)
sims_loop = [np.dot(query, d) / (np.linalg.norm(query) * np.linalg.norm(d))
             for d in docs]

# 写法2:矩阵一次算完
norms = np.linalg.norm(docs, axis=1)                        # 每行模长,shape (100,)
sims_vec = docs @ query / (norms * np.linalg.norm(query))   # (100,64)@(64,) → (100,)

print("两种写法结果一致:", np.allclose(sims_loop, sims_vec))

# 检索的最后一步:按相似度排序,取 top3
#np.argsort(sims_vec)    # 返回"从小到大排序后的索引"。比如 [3, 0, 2, 1] 表示最小的是第3个,然后是第0个...
#[::-1]                  # 反转 → 变成从大到小排序的索引
#[:3]                    # 取前 3 个 → top3 的索引
top3 = np.argsort(sims_vec)[::-1][:3]
for i in top3:
    print(f"文档{i:>3}  相似度 {sims_vec[i]:.4f}")

运行结果:

复制代码

两处对照上一篇的地方:

  • axis=1 用的是上一篇 5.3 节那句话------沿着哪个轴,哪个轴就被压缩掉 :(100, 64) 沿 axis=1 压缩,得到 100 个模长。⚠️ 写成 axis=0 得到的是每一列的模长,结果错了但不会报错,新手很容易在这里卡很久;
  • docs @ query 的形状 (100, 64) @ (64,),按矩阵乘法规则自动对齐成 (100,),不用自己复制 query 一百遍------矩阵乘法的形状规则(上一篇第七节)在实际检索里的样子。
  • np.allclose 判断两个数组在浮点误差范围内 是否相等。因为向量化的计算顺序不同,浮点结果可能有极小差异,所以用 allclose 而不是 ==。

⚠️ 注意这里说的是矩阵乘法规则 ,别和广播混了:广播是加、乘这类元素级运算的形状对齐规则(上一篇第六节),@ 是矩阵乘法,各有各的对齐逻辑。

走到这一步可以揭底了:这就是向量数据库检索的全部数学。Chroma、Milvus 干的是同一件事,只是叠了两层工程优化:

  1. 入库前先把向量归一化(模长变 1),这样点积=余弦,连除法都省了------很多向量库 API 里那个 normalize_embeddings=True 参数不是洁癖,是提速;
  2. 文档到百万级以后,不再傻傻全算一遍,改用 ANN 近似最近邻索引(如 HNSW)加速,数学目标不变。

⚠️ 补充:百万级为什么要 ANN? 以 100 万条 768 维 float32 向量为例:内存占用 ≈ 100 万 × 768 × 4 字节 ≈ 3 GB。每来一个查询,暴力检索都要把这 3 GB 从内存完整扫一遍------单条查询零点几秒也许还能忍,但线上是每秒几百个查询并发进来,内存带宽先扛不住,延迟也压不住。所以要用 ANN 索引(如 HNSW)把「扫全库」降到近似对数级,牺牲一点点召回率,换几十倍的延迟优势。

ANN 是什么

ANN = Approximate Nearest Neighbor,近似最近邻搜索。

核心思想一句话:不保证找到"绝对最相似"的那几个,但保证找到"足够相似"的那几个,用一点点准确率换巨大的速度提升。

对比一下:

精确检索(Flat) 近似检索(ANN)
做法 和每个向量都算一遍 只算"可能相似"的一小部分
召回率 100%(一定找到真 top-k) 比如 95%~99%(偶尔漏掉个别)
速度 O(N),百万级就慢 近似 O(log N),快几十倍
内存 原始向量全存 额外存索引结构,可能更省或略多
适用 几千~几万条 百万、千万、亿级

"近似"的意思是:你问"最相似的 10 个是谁",它可能返回"第 2、3、5、7、8、11、12、13、15、16 名",而不是严格的 1~10 名。但排在前面的几个基本都对,对搜索场景完全够用。

ANN 怎么做到"不扫全库"的

关键是用索引结构提前把向量组织好,查询时只访问一小部分。常见的有:

这是目前最主流的 ANN 算法之一,思路类似跳表 + 图:

  • 把向量组织成一个多层图,上层稀疏、下层密集。

  • 查询时从最上层开始,像走高速一样快速接近目标区域,然后逐层下降,在最底层做小范围精细搜索。

  • 只访问图里的一小部分节点,就能找到近似最近邻。

效果:从"扫 100 万个"变成"访问几千个",速度提升几十倍到上百倍,召回率通常还能保持 95% 以上。

其他常见 ANN 索引
算法 特点 代表库
HNSW 图索引,查询快、召回高,内存占用较大 FAISS、hnswlib、Milvus
IVF(倒排文件) 先聚类分桶,只查最近的几个桶 FAISS
PQ(乘积量化) 把向量压缩存储,省内存,略损精度 FAISS
ScaNN Google 出品,量化+搜索优化 ScaNN

实际系统里经常组合使用,比如 IVF + PQ 、HNSW + PQ。


用生活化比喻理解

假设你要在一个100 万人的城市里找"和你口味最像的 10 个人":

  • 暴力检索:挨家挨户敲门,问每个人口味,记下来,最后排序。100 万次。

  • ANN:先按"川菜 / 粤菜 / 日料 / 西餐"分区(聚类),你只去自己最可能属于的几个区,在区内再细找。可能漏掉个别"住在别的区但口味很像"的人,但找到的 10 个基本都很像,而且快几十倍。


和上述代码的关系

前面那段 docs @ query 的代码就是暴力检索的教科书版,用来理解原理。真实生产里:

  • 几万条以内:docs @ query 或 FAISS 的 IndexFlat 够用。

  • 百万级以上:必须上 ANN 索引 ,比如 FAISS 的 IndexHNSWFlat、IndexIVFPQ,或者直接用向量数据库(Milvus、Qdrant、Weaviate、pgvector 等),它们底层都内置了 ANN。

一句话总结:ANN 是一种"用少量召回率换大量速度"的向量检索技术,HNSW 是其中最常用的索引算法,百万级向量检索离不开它。

⚠️ 补充:top-k 的更快写法 。np.argsort 是全排序,复杂度 O(n log n);检索其实不需要排好全部,只要前 k 个,可以用 np.argpartition(O(n)):np.argpartition(sims_vec, -k)[-k:] 先把最大的 k 个「圈」出来,再只对这 k 个排序。100 条数据没区别,百万条就是天壤之别------这个坑 RAG 系列会再遇到。

再补一个更贴近真实场景的形状:实际系统里查询往往是一批一批来的(比如同时检索 3 个用户的问题),这时就是矩阵乘矩阵:

python 复制代码
queries = rng.random((3, 64))    # 3 个查询,每个 64 维
sims = queries @ docs.T           # (3,64) @ (64,100) → (3,100) 的相似度矩阵
print(sims.shape)                 # 行=查询,列=文档,一票全算完
# 【注意】这里只演示形状,没做归一化,算的是纯点积;
# 实际检索要先归一化(或除以 norms),才是余弦相似度

⚠️ 记一个就够:@ 和 np.matmul 完全等价,且前面多出来的维度自动当「批量维」处理;np.dot 在三维以上语义略有不同,检索代码里认准 @ 写就行。
一句话记住:向量检索 = 向量归一化 + 矩阵乘算余弦 + 排序取 top-k;数据量大了,把「扫全库+全排序」换成 ANN 索引 + argpartition,其余数学一字不变。


四、ndarray 变成 Embedding:语义相似度实验

前三节的向量都是我编的。真实世界里,谁赋予向量意义?------Embedding 模型。

一句话版本:Embedding 模型就是一个函数,文本进、ndarray 出(比如 512 维),训练目标就一条:让语义相近的文本,向量方向也相近。有了这个前提,余弦相似度才有资格当「语义相似度」的代理。

它靠什么做到的?主要靠**对比学习(contrastive learning)**的训练方式:把海量「意思相近的句子对」在向量空间里互相拉近,把不相近的推远------「退货≈退款」这种等价关系,就是被这么「拉」出来的,不是人写在规则里的。

空口无凭,做个实验。没有 Embedding 模型也能先跑------先用一个 20 行的「Hash 伪 Embedding」模拟词面匹配(它不懂语义,只认字面重叠):

python 复制代码
import hashlib, math, re
import numpy as np

def tokenize(text):
    # 中文按单字+相邻二元组切,英文按单词切(够用了,别纠结合分词)
    cn = re.findall(r"[\u4e00-\u9fff]", text)
    bigrams = [cn[i] + cn[i + 1] for i in range(len(cn) - 1)]
    en = re.findall(r"[a-zA-Z]{2,}", text.lower())
    return cn + bigrams + en

def hash_embed(text, dim=256):
    # 每个 token 哈希到某一维,带正负号累加,最后归一化
    vec = [0.0] * dim
    for token in tokenize(text):
        h = hashlib.md5(token.encode("utf-8")).digest()
        idx = int.from_bytes(h[:4], "little") % dim
        vec[idx] += 1 if h[4] % 2 == 0 else -1
    norm = math.sqrt(sum(x * x for x in vec)) or 1.0
    return [x / norm for x in vec]

pairs = [
    ("我要退货", "我想申请退款"),      # 人看是同一件事,但几乎没有共用的词
    ("我要退货", "今天退货通道拥挤"),  # 只是共了「退货」两个字
]
for a, b in pairs:
    va, vb = np.array(hash_embed(a)), np.array(hash_embed(b))
    print(f"「{a}」 vs 「{b}」 相似度 = {np.dot(va, vb):.4f}")

解释:

1. tokenize:把文本切成 token

tokenize(text) 方法对中文做了个简单策略:单字 + 相邻二字组合。英文按 2 个字母以上的单词切。

举例:

python 复制代码
tokenize("我要退货")
# cn      = ['我','要','退','货']
# bigrams = ['我要','要退','退货']
# 返回 ['我','要','退','货','我要','要退','退货']  共 7 个 token

tokenize("我想申请退款")
# 返回 ['我','想','申','请','退','款','我想','想申','申请','请退','退款']  共 11 个

注释里说"别纠结合分词",意思是这里故意用最粗糙的切法,重点是演示后面的哈希逻辑,不是做一个好分词器。

2. hash_embed:用哈希把 token 变成向量(核心)
python 复制代码
def hash_embed(text, dim=256):
    vec = [0.0] * dim
    for token in tokenize(text):
        h = hashlib.md5(token.encode("utf-8")).digest()   # token → 16 字节摘要
        idx = int.from_bytes(h[:4], "little") % dim        # 取前 4 字节 → 索引
        vec[idx] += 1 if h[4] % 2 == 0 else -1             # 第 5 字节奇偶 → 符号
    norm = math.sqrt(sum(x * x for x in vec)) or 1.0
    return [x / norm for x in vec]

这是经典的 signed feature hashing,三步:

① token → 维度索引
python 复制代码
h = hashlib.md5(token.encode()).digest()
idx = int.from_bytes(h[:4], "little") % dim

每个 token 算 MD5(16 字节),取前 4 字节转成整数,对 dim=256 取模,得到一个 0~255 的维度下标。

效果:任何 token 都能被映射到 256 维中的某一维,不需要提前维护词表。这就是"哈希"的妙处------省掉了词典,代价是可能碰撞。

② 符号:+1 或 -1
python 复制代码
vec[idx] += 1 if h[4] % 2 == 0 else -1

用 MD5 第 5 个字节的奇偶性决定这个 token 是 +1 还是 -1。

为什么要正负号? 因为不同 token 可能哈希到同一维(碰撞)。如果全是 +1,碰撞会让这一维的值虚高,产生系统性偏差。加上随机正负号,碰撞的期望贡献相互抵消,是无偏估计。这是 Weinberger 等人在 2009 年 Feature Hashing 论文里的标准做法。

③ 归一化
python 复制代码
norm = math.sqrt(sum(x * x for x in vec)) or 1.0
return [x / norm for x in vec]

L2 归一化,让向量长度为 1。这样后面两个向量做点积就等于余弦相似度,省去再除模长。

or 1.0 是防除零:如果所有 token 都碰撞抵消成 0,norm 就是 0,此时用 1.0 兜底

3. 两组对比:精心设计的"陷阱"
python 复制代码
pairs = [
    ("我要退货", "我想申请退款"),      # 人看是同一件事,但几乎没有共用的词
    ("我要退货", "今天退货通道拥挤"),  # 只是共了「退货」两个字
]

这两组是故意选的,对应两种典型失败模式:

共享 token 人类语义 字面重合度
第1对:我要退货 vs 我想申请退款 我、退 非常相似(都是退款诉求) 很低
第2对:我要退货 vs 今天退货通道拥挤 退、货、退货 不相似(一个是诉求,一个是状态描述) 较高
第 1 对:同义但字面不重叠

| 共享 token | 人类语义 | 字面重合度 |
|--------------------------|--------------|------------------------|----|
| 第1对:我要退货 vs 我想申请退款 | 我、退 | 非常相似(都是退款诉求) | 很低 |
| 第2对:我要退货 vs 今天退货通道拥挤 | 退、货、退货 | 不相似(一个是诉求,一个是状态描述) | 较高 |

真正共用的只有 我 和 退 两个。退货 和 退款 是两个不同的 token,哈希到不同维度。所以相似度会很低,可能只有 0.1~0.3,甚至因为符号碰撞接近 0。

但人类一看就知道这俩是同一件事。 这就是字面方法的假阴性:意思一样,向量却不像。

第 2 对:字面重叠但语义不同

今天退货通道拥挤 的 token 里包含 退、货、退货,和 我要退货 共享 3 个 token。所以相似度会比第 1 对高,可能 0.3~0.5。

但这两句语义上并不是一回事 :一句是用户诉求,一句是状态广播。这就是假阳性:字面像,意思不像。

这段代码到底想说明什么

一句话:哈希词袋能跑、很便宜、不用训练,但它只认"字面",不认"语义"。

  • 同义不同词 → 它认不出来(假阴性)

  • 同词不同义 → 它反而认为很像(假阳性)

所以真实项目里,文本向量化不能靠这种手工哈希 ,而要用在大规模语料上训练过的语义模型 (比如前面下载的 bge-small-zh-v1.5)。BGE 这类模型把"退货"和"退款"映射到相近的向量位置,同时把"退货诉求"和"退货通道拥挤"区分开------这才是"语义相似度"。

哈希词袋(这段代码) 语义模型(BGE)
依赖 字面 token 重叠 训练学到的语义
同义词 ❌ 认不出 ✅ 能识别
一词多义/同词不同义 ❌ 容易误判 ✅ 能区分
成本 零,纯 CPU,微秒级 需要模型推理,毫秒级
适用 演示、极简 baseline、超大规模粗筛 真实检索/RAG/聚类

运行结果:

复制代码

预期(重点在这):第一对分数很低 「退货」和「退款」在 Hash 眼里毫无关系;第二对反而会高不少 ------毕竟共了「退」「货」「退货」三个字面。注意这个排序跟人的判断是反的:明明第一对才是同一个意思。Hash 给出的是「字面重叠排序」,不是「语义排序」------这正是下面要换真模型的原因。

现在换真模型。装一个库,同样的两对文本再跑一遍(首次运行会下载约 100MB 的中文小模型,建议配好国内镜像):

bash 复制代码
# pip install sentence-transformers
from modelscope import snapshot_download
from sentence_transformers import SentenceTransformer
# 使用 ModelScope 的 snapshot_download 下载模型,它会自动处理缓存和路径
# 注意仓库 ID 通常是 'AI-ModelScope/bge-small-zh-v1.5'
model_dir = snapshot_download('AI-ModelScope/bge-small-zh-v1.5')

model = SentenceTransformer(model_dir)
pairs = [
    ("我要退货", "我想申请退款"),
    ("我要退货", "今天退货通道拥挤"),
]
for a, b in pairs:
    va, vb = model.encode([a, b], normalize_embeddings=True)
    # normalize_embeddings=True:输出向量模长全部归一化成 1。
    # ① 模长本身跟语义没关系,比较方向就够了;② 归一化后点积=余弦,省一次除法
    print(f"「{a}」 vs 「{b}」 相似度 = {np.dot(va, vb):.4f}")

运行结果:

复制代码

预期规律:真模型下,「我要退货」和「我想申请退款」分数明显变高------模型学到了「退货≈退款」这种语义等价 ,这就是所谓的「语义泛化」;同时第二对不会跟着涨------字面重叠骗不了它。跟上面 Hash 的排序正好反转过来:排序对了,Embedding 的价值就体现在这一反一正之间。

⚠️ 补充:Hash 版是 256 维、BGE 是 512 维,维度不一样没关系------每个模型在自己空间内玩,比较前都归一化了,余弦只看方向。

这也是生产环境必须用真 Embedding 模型的原因:论字面匹配,BM25 这类关键词检索就能做,还更便宜;Embedding 的价值就是跨过「用词不同、意思相同」这条沟。

但也别迷信它。Embedding 翻车的典型场景:领域外内容(医疗、法律黑话)、否定句(「我要退货」和「我不退货」向量可能很接近)、精确数字区分(「满1年5天」vs「满3年10天」)。所以生产级 RAG 常把向量检索和 BM25 关键词检索混合使用(hybrid search),语义和字面各补各的短板------这块后面 RAG 系列单独展开。


五、踩坑记录

都是我自己踩过(不止一次)的:

  1. 切片是视图不是拷贝 。b = a[:2] 之后改 b 会改到 a,要独立副本得 .copy()。NumPy 这个设计是为了省内存,但坑新手;
  2. (n,)、(n, 1)、(1, n) 是三个不同的东西 。(n,1) 在矩阵乘里是列向量、(1,n) 是行向量,跟 (n,) 混着写,形状会意外对上或对不上,错了还常常不报错。发现 shape 不对劲时,先 reshape(-1) 压平或 squeeze() 去掉长度为 1 的维度,再理形状;
  3. 零向量除零 。文本为空或模型输出全 0 时,cosine_sim 会除以 0。工程写法:(np.linalg.norm(a) + 1e-8),本文示例代码已经带上;
  4. dtype 截断 。整数数组塞浮点会被静默截断(1.9 → 1),涉及计算一律显式 dtype=float------上一篇 2.3 节的坑,这里再踩一次;
  5. 归一化后点积=余弦 。想批量提速,先把所有向量 / norms 归一化,之后相似度只算 @,一个除法都不留------向量库内部就是这么处理的(第二节末尾那个公式还保证了 L2 距离排序不变)。

六、面试高频问题(AI 应用开发方向)

我最近在拆 AI Agent 岗位的 JD,向量和 Embedding 几乎是必考项,高频的五个问题:

  1. 为什么文本相似度用余弦不用欧氏? 方向 vs 长度:语义相似性体现在方向上,欧氏距离会被向量模长(信息量/句长)干扰;
  2. Embedding 是什么? 文本→定长向量的映射,训练目标(对比学习)保证「语义近→向量近」,于是几何距离能代理语义距离;
  3. 向量库的检索原理? 归一化 + 点积 + ANN 索引(HNSW)近似加速,数学本质就是第三节那段代码;
  4. 向量化为什么快? 循环在 C/BLAS 层执行 + 连续内存 + SIMD,绕开解释器逐条调度;
  5. 归一化之后,L2 距离和余弦相似度什么关系? ,单调等价、排序一致------所以向量库的 cosine/L2/IP 三种度量,对归一化向量随便选。

能对着自己跑过的实验结果讲,比背八股有说服力得多------这也是我边学边写这个系列的原因之一。


小结

本篇把 NumPy 收了尾:向量化 解释了为什么 NumPy 是整个科学计算栈的底层;余弦相似度把上一篇的矩阵乘法变成了「语义检索」的数学核心;最后用 Hash 对比 BGE 的实验,把 ndarray 和 Embedding 之间的桥走通了。

一句话总结:Embedding 一点都不神秘,它就是把一段文字变成一个 ndarray;向量检索一点也不神秘,它就是把每对向量算一遍余弦,排序取前几名。

下一篇按原计划进 Pandas:DataFrame 与数据清洗,数据处理这条线打通后,就可以开始搭 RAG 的完整链路了。


参考链接

相关推荐
forest_0931 分钟前
多 Agent 协作第二课:交叉验证≠独立验证
人工智能·python·算法·机器学习·prompt·agi
databook4 小时前
时间序列数据,先拆规律还是先查异常
python·数据挖掘·数据分析
师夷长技以自愈4 小时前
手搓 AI Agent 第 8 篇:从手写循环到画流程图——LangGraph 封装
人工智能·python
aifannihao5 小时前
Rust 1.100 的 DropGuard:用 Python 实测失败回滚与吞异常
python·rust·异常处理·标准库·contextlib
用户019027581617 小时前
天勤 tqsdk 能做 A 股行情吗?期货强项与股票数据选型
python
2601_967979128 小时前
pip在Python中的9个必须知道的用法
python·pip·版本控制·包管理工具·requirements.txt
曲径海盗9 小时前
【Python 学习第 58 天】列表方法
python·学习笔记·编程入门
DeepVisionary9 小时前
Runway开源机器人大模型Praxis-1,推演延迟压到62毫秒
python·自动化
Cooper2510 小时前
DDC 命令成功但屏幕不动:用 gamma 表重做亮度映射
python·mac