MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

向量数据库原理:从 1536 维坐标到语义检索(通俗解释)

上一篇《企业级记忆知识库》里,我把”代码库信息、业务知识怎么写进向量数据库、怎么被检索回来”讲到了代码级。但写完我自己回头看,发现一个更根本的问题被跳过了:

向量数据库内部,到底是怎么回事?

那段话、那篇企业发文,是怎么被算成一串 1536 个数字的?两个没有一个共同词的句子,凭什么说它们”语义相近”?为什么高维空间里树索引会失效?Transformer 那”一坨矩阵乘法”到底在算什么?

这篇文章就是补这一课。它不是那篇的续集,而是那篇的地基——上一篇告诉你”怎么写代码调用向量库”,这一篇告诉你”向量库背后那套原理,以及顺着它引出来的一整条链路”。

写作方式跟以往不一样:这一篇按”你可能会问的问题”来组织,每个问题都是从真实困惑里来的。你可以跳着读,卡在哪就读哪一节。

先立一句话贯穿全文:

向量数据库不是”存文本”的数据库,而是”存高维坐标点”的数据库;它的”索引”是一张导航图或一堆簇;它的”查询”是”找到离我最近的点”。


一、先忘掉”数据库”,它本质只解决一个问题

MySQL 的 B-tree 索引,本质解决的是:在一维有序的键上,O(log N) 找到某个值或范围。

向量数据库,本质解决的是另一个问题:在几百上千维的空间里,找到”离某个点最近”的 K 个点。

就这么一句话。剩下的全是”怎么把这个找近邻的动作做快”。

关键区别在于”维度”:

  • B-tree 的键是一维的(一个 int、一个字符串),天然有序,可以二分。
  • 向量的键是一个坐标,比如 [0.03, -0.11, 0.42, ...] 一共 1536 个数。1536 维空间里,”有序”这个概念不存在了——你没法说 [0.03, 0.1][0.5, -0.2] “大”。

所以向量库没法用 B-tree 那套,它要用几何的办法。


二、它存的是什么:一个点,不是一个词

先纠正一个直觉。向量库里存的不是文本,而是一串数字——一个高维空间里的坐标点

以上一篇的例子:

"OrderFacade#createOrder ... 库存不足抛 InventoryShortageException 需回滚"
        │
        │  调用 embedding 模型
        ▼
[0.03, -0.11, 0.42, 0.017, ... 共 1536 个 float]

这 1536 个数,就是”这句话”在这个空间里的位置

embedding 模型的”神奇”之处(也是整个向量检索成立的前提),只有一条:

语义相近的两段文本,会被映射到空间里距离很近的两个点;语义无关的,距离很远。

“下单超卖怎么兜底” 和 “库存不足抛异常需回滚”,字面没有一个词相同,但它们在空间里是邻居。这就是”语义检索”的全部魔法——它不是什么玄学,就是”把语义变成了几何距离”。

所以:

向量数据库里的每一条记录 = 一个点(向量) + 一堆元数据(tenantId、scope、importance...)

检索时,元数据可以像 MySQL 的 WHERE 一样做过滤(tenantId = 123),而”内容像不像”交给几何距离。这是两套独立的筛选,别混在一起。


三、”找最近的点”为什么是个难题

如果你只有 1000 条记忆,最简单粗暴的办法就够了:

// 暴力 KNN:拿 query 和每一条都算一遍距离,排序取前 K
for (Vec v : allVectors) {
    dist[i] = cosine(query, v);   // 一条一次点积,1536 次乘法
}
sort(dist);  // 取前 K

复杂度 O(N × D):N 是条数,D 是维度。1000 条 × 1536 维,瞬间算完。

但企业记忆库是百万级、千万级。百万 × 1536 = 15 亿次浮点运算,一次查询几百毫秒起,再来点并发就崩了。

于是问题变成:怎么不做全表扫描,也能近似地找到最近的点?

这跟你建 B-tree 索引的动机一模一样——”全表扫太慢,建个索引快速定位”。只是这次要在一个高维空间里建。


四、为什么不能用你熟悉的树结构(问题 1:维度灾难)

4.1 你问的原话

“在 1536 维空间里,空间’体积’爆炸式增长,数据变得极度稀疏,任何’框’都会几乎跟所有区域相交——树结构退化成了近似全表扫描。”这段话没太理解。

4.2 先搞懂”体积”指什么

它不是一个物理体积,而是“你能把这个空间切成多少个小格子”

假设每个维度都在 [0, 1] 之间取值,每个维度切成 10 份:

  • 1 维(一条线段):切成 10 段。总共 10 个格子。
  • 2 维(一个平面):10×10 = 100 个格子。
  • 3 维(一个立方体):10×10×10 = 1000 个格子。
  • 1536 维:10 × 10 × … × 10(乘 1536 次)= 10^1536 个格子

关键就在这:每多一个维度,格子数量不是”加一点”,而是”乘 10”。 这就是”体积爆炸式增长”——空间能容纳的”区域”数量,随维度呈指数级10^维度)爆炸。

10^1536 是什么概念?整个可观测宇宙的原子总数大约是 10^80。哪怕把宇宙里每一个原子都变成一个数据点,也远远填不满这 1536 维空间里的格子。

4.3 格子填不满 → 数据”极度稀疏”

现在你有 100 万条记忆(10^6 个点),要装进上面那个空间。

  • 如果是 2 维,切成 100 个格子,100 万个点 → 平均每个格子 1 万个点。很密
  • 如果是 1536 维,切成 10^1536 个格子,100 万个点 → 平均每个格子 10^6 / 10^15360 个点

这就像一个 HashMap 里塞了 10^1536 个桶,但你只有 100 万条数据——几乎每一个桶都是空的,数据稀稀拉拉地散在无边无际的空间里,彼此离得老远。不是数据少,是空间大到把数据稀释了。

4.4 为什么”任何框都会几乎跟所有区域相交”

这是最反直觉的一步。先看低维有多爽,再看高维有多惨。

低维(2 维地图):你想”找离我最近的 5 家店”,画一个 1 公里见方的框就够了——框很小,框外全部剪掉。高效。

高维(1536 维):你也想画个”框”圈住一部分数据。问题是——在 1536 维里,你想圈住一点点数据,框必须大到几乎覆盖每一个维度。

算一笔账。假设数据均匀分布,你想用一个”框”圈住 1% 的数据,那么框在每一个维度上要覆盖多大范围?结论:每条边必须覆盖 0.01^(1/维度) 的比例。

维度 想圈住 1% 的数据,每条边要覆盖多少
1 维 0.01^(1/1) = 1%
2 维 0.01^(1/2) = 10%
3 维 0.01^(1/3) ≈ 21.5%
10 维 0.01^(1/10) ≈ 63%
100 维 0.01^(1/100) ≈ 95.5%
1536 维 0.01^(1/1536) ≈ 99.7%

看最后一行:在 1536 维里,你只是想圈住 1% 的数据,那个”框”在每一个维度上都必须覆盖 99.7% 的范围。 这个框根本不是一个”小框”,它几乎就是整个空间本身

更狠的是圈住一半(50%)的数据:1536 维下每条边要覆盖 0.5^(1/1536)99.955%连圈一半数据,框都得覆盖 99.955% 的每个维度——这哪里还是”裁剪”,这等于”全要”。

4.5 为什么这会让树退化成全表扫描

树索引(kd-tree、R-tree,MySQL 的 GEOMETRY 空间索引就是 R-tree)的原理,本质就是递归地画框

把空间画一个大框分成两半 → 每个半再画框分成两半 → ... → 叶子节点就是一堆小框
查询时:看 query 落在哪个框里,只沿着那条树枝往下走,其余全剪掉

它在 2D、3D 里特别牛,因为框很小,剪掉的东西很多。但到了 1536 维,你没法画出”小而有效”的框——每个框要么是空的,要么膨胀到覆盖几乎整个维度。结果是:

  • query 进来,发现它落在几乎所有框里(因为所有框都巨大且彼此重叠);
  • 于是”剪枝”形同虚设,你每一条树枝都得走下去
  • 走到最后,几乎所有叶子节点都被访问了

访问几乎所有叶子节点 = 全表扫描。这就是”树结构退化成了近似全表扫描“。

4.6 一句话收束

维度低时,”画框裁剪”是神器;维度高时,空间被撑得太大,数据被稀释得极稀,任何框要圈住数据就不得不膨胀到覆盖整个空间,于是框剪不掉任何东西,树索引就退化成全表扫描了。

这也顺带回答了”那向量数据库怎么办”:既然”画框划分空间”这条路在高维走不通,HNSW 和 IVF 就换了两条完全不同的路——下面两节就是它们。


五、HNSW:用”图”导航(把它想成导航软件)

5.1 核心直觉:小世界网络

你大概听过”六度分隔”:世界上任意两个人,平均通过 6 个人就能连起来。为什么?因为每个人的朋友圈里,既有住楼下的邻居(短边),也有在另一个城市的老同学(长边)。长边让你能”跳”到很远的地方,短边让你在目标附近精确定位。

HNSW 就是把所有向量点建成这样一张图:每个点连向几个”近邻”(短边),也连向几个”远点”(长边)。

5.2 为什么要分层

如果只有一层图,找”最近点”时你要走很多步。HNSW 的做法是分多层

层 2(最稀疏,长边为主):  A ──────────── B      (高速公路:车少、路远、开得快)
层 1(中等):              A ── C ── D ── B      (省道)
层 0(最稠密,短边为主):  a-b-c-d-e-f-...        (街道:精确到门牌)
  • 越往上,点越少、边越长,负责”快速逼近目标区域”;
  • 越往下,点越多、边越短,负责”精确找到最近邻居”。

5.3 一次检索怎么走

query 向量 q 进来:
  1. 从最顶层(层2)的入口点出发,贪心:看周围邻居,谁离 q 近就往谁走,走到走不动(局部最近)
  2. 下到层1,从刚才那个点继续贪心走
  3. 下到层0,在附近做一次更细的搜索,返回离 q 最近的 K 个点

翻译成人话:先上高速开到目标城市附近,再下省道开到街区,最后在小路上精确找到那栋楼。 每一步只看了”沿途几个邻居”,而不是全城每一栋楼——这就是它快的原因,复杂度大约 O(log N)。

代价:因为是贪心 + 局部搜索,偶尔会”没找到绝对最近的那栋楼”(卡在某个局部最优)。这就是它”近似”的部分——用损失的那一点点召回率,换来了从”全表扫描”到”对数级”的飞跃。

5.4 为什么”删除/更新”贵

因为那张图是点与点之间的边连成的。删一个点,它周围的边要重连;改一个点的坐标,它的位置要重新在图里定位。所以 HNSW 是“写贵、读快、删改麻烦”——这正好解释了上一篇里”先写后删、删除 best-effort”的深层原因。


六、IVF:用”聚类”缩小搜索范围(把它想成分区表)

6.1 核心直觉

IVF(Inverted File,倒排文件)的思路更直白:先把所有点分成若干个簇(cluster),查询时只搜”最相关的几个簇”,不搜其余。

建索引时(k-means 聚类):
  所有向量 → 聚成 C 个簇,每个簇一个"质心"(centroid)
  倒排表: 质心1 → [向量a, 向量b, ...]
           质心2 → [向量c, ...]
           ...

查询时:
  1. 算 query 到 C 个质心的距离
  2. 只挑最近的 nprobe 个簇
  3. 在这 nprobe 个簇内部的向量里做精确搜索

这跟数据库分区表 + 分区裁剪是一个思路:查询只扫”相关分区”,跳过无关分区。

代价:如果真实最近的点落在你没探(probe)的那个簇里,你就漏了。所以 nprobe(探几个簇)是”准”和”快”之间的旋钮——探得越多越准越慢,探得越少越快越可能漏。上一篇里得物的 mode=fast,大概率就是这类旋钮的一个快捷档位。

6.2 PQ(乘积量化):再压一把内存

IVF 可以再叠加 PQ 做压缩:

一个 1536 维向量,切成 m=48 段,每段 32 维
每段用 256 个码本(codebook)近似 → 每段只需 1 个字节(0~255 的索引)
原来:1536 个 float ≈ 6KB
现在:48 个字节

内存直接降两个数量级,代价是距离算得没那么准(有量化误差)。这就是为什么有些向量库能”内存很小地”存海量向量。


七、所以”近似”到底损失了什么

现在你能把两条路线和各自代价对齐了:

索引 快在哪 损失什么 像你熟悉的什么
暴力 KNN 无(最慢) 零损失(精确) 全表扫描
HNSW 图导航,O(log N) 可能卡局部最优 邻接表 + 贪心
IVF(-PQ) 只搜 nprobe 个簇 真实近邻若在未探簇就漏 分区表 + 分区裁剪

工程上衡量”损失多少”的指标叫 recall(召回率):真最近邻的前 K 里,被 ANN 找回来的比例。99% 的 recall 意味着”100 次有 1 次没找到最该找的那条”——对记忆检索来说通常完全可接受,但你必须知道有这 1%,尤其在做投毒/漏检排查时。


八、一段话是怎么算出 1536 个数的(问题 2 前半)

8.1 你问的原话

一段话、一段企业内部的发文,是怎么算出 1536 维数据的?

8.2 先破除误区

这 1536 个数不是”查字典查出来的”,也不是”人定的规则算出来的”,而是一个训练好的神经网络”算”出来的。就像你的订单金额不是查表来的,是走了一遍 createOrder 的业务逻辑算出来的。整条流水线分四步。

8.3 第 1 步:分词(Tokenization)

模型不认汉字,只认数字。先把一段话拆成一个个 token,每个 token 换成字典里的一个 ID。

"库存不足会抛什么异常" → ["库存", "不足", "会", "抛", "什么", "异常"]
                        → [10234, 4581, 77, 2003, 918, 5562]

(示意,真实中文 BERT 用更细的子词拆分。)分词不产生任何”语义”,只是把文本变成机器能吃的整数序列,跟你把订单号转成 long 一个道理。

8.4 第 2 步:过神经网络——每个 token 变成初始向量

这串整数进到一个叫 Transformer 编码器 的神经网络里。它的第一层,把每个 token ID 查一个嵌入表(embedding table),变成一个 1536 维的向量。粗暴理解:给每个词碎片一个 1536 维的初始坐标。

8.5 第 3 步:多层”注意力”——让每个词”看见”它前后的词

这是最关键的一步。网络不是独立处理每个词的,它有十几层注意力机制(Attention),每一层都在做一件事:

让每个词去”看”这句话里的其他词,然后根据”和谁在一起”,调整自己的向量。

第一层的”不足”最初只是个泛泛的词向量;但经过注意力之后,它”看见”了自己前面是”库存”,于是它的向量被往”库存相关”的方向挪了挪。同理”异常”看见”抛”,被往”程序异常”的方向挪。经过十几层反复”互相看、互相调整”,每个 token 的向量都不再是”这个词的通用意思”,而是“这个词在这句话里、结合上下文之后的意思”

8.6 第 4 步:池化(Pooling)——把”一串向量”压成”一个向量”

但我们最后要的是一句话 = 一个 1536 维向量,不是一串。于是做一步池化,最常见的是平均池化(mean pooling)

最终向量[第i维] = (向量1[i] + 向量2[i] + ... + 向量n[i]) / n

平均完,还是一个 1536 维的向量。这个向量,就是”这段话”在 1536 维空间里的坐标。 它就是写进向量库的那条记录。

8.7 小结

文本 → 分词 → token ID 序列
     → Transformer 编码器(注意力让词与词互相"染色")
     → 每个 token 一个 1536 维向量
     → 平均池化 → 整句话的 1536 维向量

一句话:1536 维不是”填”出来的,是”算”出来的——是几亿个参数的网络,把”这句话里每个词结合上下文的意思”,压缩成的一个坐标。

8.8 补充:不管多长都变成一个点,但长文档要先切块

这里补一个很容易接着往下问的点:“我输入 1000 字的话和 10 字的话,最后都是 1536 维一个点吗?”

答案:都是,因为平均池化不 care 长度。

10 个字符的话:  "库存不足"
  → 2 个 token → 2×1536 矩阵 → 平均池化 → 1×1536 ✅

1000 个字符的话:
  → 500 个 token → 500×1536 矩阵 → 平均池化 → 1×1536 ✅

平均池化是”逐维度求平均”,跟 token 个数无关——100 个向量求平均、还是 500 个求平均,结果都还是 1536 个数。所以无论输入多长,最后都是一个 1536 维的点。这就是它能”一句话、一段话、一篇文”统一处理的原因:不管输入长度,出口维度恒定。

然后就是拿这个点去找离它最近的前 K 个点(再过阈值过滤 + 元数据过滤)。

但有一个”但是”,尤其 1000 字符那个场景:把 1000 个字符平均池化成一个点,信息会被”稀释”。 想象一下:

  • 1000 字的发文里,讲了 5 件互不相关的事(人事、预算、排期、风险、结论);
  • 你把 500 个 token 的向量求平均成一个点,等于把这 5 件事”搅成一锅糊”;
  • 这个点可能跟任何一件事都不够近——因为它的坐标是 5 个主题的”平均位置”,落在一个谁也不挨着的地方。

所以工程上的正确做法是:长的文档,先切块,再每块单独 embedding 成一个点

1000 字的发文
  → 切成 5 个 ~200 字的 chunk
  → 每个 chunk 各自 embed 成一个 1536 维点(5 个点)
  → 5 个点分别写进向量库(各带元数据,指向"同一篇发文")

查询时,用户问”这次预算砍了多少”,命中的是那篇发文里”预算”那一块的点,而不是整篇的”大杂烩”点。这就是”检索粒度 = chunk 粒度”——粒度越细,检索越准;粒度越粗,越省存储和计算。

一对比就很清楚:

  长度 处理方式
查询(query) 短(一句话) 直接 embed 成 1 个点
被检索的文档/记忆 可能很长 先 chunk,再每个 chunk 各成 1 个点

两边都在同一个 1536 维空间里,这样”用户的一句短问题”才能和”文档里切出来的某一块”在空间里比距离。这也正好是上一篇《企业级记忆知识库》第 5.1 节”切分(chunking)”存在的真正原因:切块不是为了向量库能存,而是为了不让”一个点装太多事”。


九、没有共同词,为什么能识别出语义相近(问题 2 后半)

9.1 你问的原话

两段没有同一个词的话,怎么能识别出语义是相近的呢?

9.2 先立一个语言学前提

语言学家有个著名论断(分布假说,Firth):

一个词的语义,由它经常和谁一起出现决定。 “库存不足”和”超卖”虽然不是同一个词,但它们总是出现在相同的上下文里——都出现在”下单、扣库存、回滚、兜底、缺货”这些词的旁边。

于是,如果一台机器见过足够多的文本,它不需要”读懂”这两个词,也能发现:这俩词在上下文分布上高度相似。 这个”上下文分布相似”,就是机器眼里的”语义相近”。

9.3 训练:强制”相近的靠拢、无关的远离”

embedding 模型的训练,大致干这么一件事(以对比学习为例)。给模型成对的文本:

  • 正样本对(该近)("库存不足怎么办", "下单超卖怎么兜底") —— 语义相近
  • 负样本对(该远)("库存不足怎么办", "今天的天气怎么样") —— 语义无关

训练目标就一句话:让所有正样本对的向量距离变小,让所有负样本对的向量距离变大。

一遍遍喂几亿对这样的数据,模型参数被反复调整,最后收敛成一个稳定的规律:语义相近的句子,会被自动”安排”到空间里相近的位置。

两个关键点:

  1. 这不是”死记硬背”每对句子——训练数据里根本没有你那句”下单超卖怎么兜底”。模型学到的是“词与词之间的相对关系”这种通用模式,所以对没见过的句子也能泛化。
  2. “相近”是分布式的,不落在某一个维度上——没有”第 37 维 = 库存”这种对应关系。语义是整个 1536 维坐标的方向/位置共同表达的,单独看任何一个维度都没意义。

9.4 一个贴合的类比:地图上的坐标

  • 一句话的语义 = 地图上一个城市的位置。
  • 1536 维向量 = 这个城市的经纬度坐标(只不过不是 2 个数,是 1536 个数)。
  • “库存不足”和”超卖” = 两个不同的地名,但指代的是同一片区域,所以坐标天然挨得近。

你不会问”为什么’北京’和’首都’这两个词没有共同字,却指同一个地方”——因为它们的坐标一样。embedding 干的就是这件事:它不在乎你用了哪个词,它只在乎这个词被安放在空间的哪个位置。位置近,就是语义近。

9.5 必须补一句边界

向量检索的”语义相近”,准确说其实是“分布相似”——是”这俩东西经常和同一批东西一起出现”,不是人类意义上的”理解”。所以它有三个典型翻车场景:

  1. 精确值会漂"订单号 20260901001" 这种精确编号,向量不一定记得准——所以生产要上混合检索(关键词 + 向量)。
  2. 相近不等于等价"库存不足""库存充足" 只有一字之差,但向量可能非常近(因为出现在几乎相同的上下文里)。方向性、否定、程度这些,纯向量是弱项。
  3. embedding 是有损压缩:一句话压成 1536 个数字,细节必然丢。所以向量检索是”召回候选”,要精确的场合后面还要接 rerank 或回原文核对。

十、”一坨矩阵乘法、几亿参数”是什么意思(问题 3)

10.1 你问的原话

“这串整数进到一个叫 Transformer 编码器的神经网络里。这个网络你可以先理解成一坨矩阵乘法(就像 W × x,只不过 W 是几亿个训练出来的参数)。”解释下这段话。

10.2 先拆句

神经网络 = 一个函数:输入一个向量,输出一个向量。 “一坨矩阵乘法” = 这个函数的内部实现,就是一遍遍做 y = W·x + b。 “几亿个参数” = 这些 Wb 里面的数字,是”训练”学出来的,不是人写的。

10.3 矩阵乘法到底在算什么

别被”矩阵”吓到。矩阵乘法拆开看,就是一堆点积。假设输入 x 是 3 个数,想变成输出 y 是 4 个数,需要一个 4×3 的矩阵 W 和一个 4 维偏移 b。用 Java 写:

double[] y = new double[4];
for (int i = 0; i < 4; i++) {          // 输出的每个维度
    double sum = 0;
    for (int j = 0; j < 3; j++) {      // 输入的每个维度
        sum += W[i][j] * x[j];          // 加权累加
    }
    y[i] = sum + b[i];
}

“矩阵乘法”的全部含义:输出的每一个维度,都是”输入所有维度的加权和”。 它干的事,本质是一种信息重新组合

10.4 为什么是”一坨”而不是”一次”

因为中间要夹”非线性”。多个矩阵乘法连起来,等价于一个矩阵乘法(W3·W2·W1·x = W总·x),那堆再多层不都等于一层?所以真正的网络是“矩阵乘法 + 掰弯”交替叠,这个”掰弯”叫激活函数,最常用的是 ReLU:

ReLU(v) = max(0, v)     // 负数全部变成 0,正数原样保留

真实的一层是:

y = ReLU(W·x + b)

ReLU 这个”负数归零”的动作,打破了”线性叠加”。有了它,100 层就不再等价于 1 层了——它能表达出任何复杂、弯弯曲曲的映射(这叫”万能逼近定理”)。

所以原文说”一坨矩阵乘法”是口语化简化,准确说法是:

一坨【矩阵乘法 → 掰弯(ReLU) → 矩阵乘法 → 掰弯 → …】交替叠十几层。

10.5 “几亿个参数”到底是什么

“参数”就是一个矩阵里的一个格子里的一个数字。 几亿个参数 = 所有层的所有矩阵、所有偏移,加起来有几亿个数字。拿向量维度 1536 的一层编码器感受数量级:

部件 矩阵形状 数字个数
注意力:Q 投影 1536 × 1536 236 万
注意力:K 投影 1536 × 1536 236 万
注意力:V 投影 1536 × 1536 236 万
注意力:输出投影 1536 × 1536 236 万
前馈网络第一层 1536 × 4096 629 万
前馈网络第二层 4096 × 1536 629 万

光这 6 个矩阵,一层就 2200 万个数字。真实网络叠 12~24 层,再算上开头的词嵌入表(几万词 × 1536 维),轻轻松松几亿

10.6 “训练出来的”是什么意思

那几亿个数字没人手动填过。它们一开始是随机数,然后靠”训练”调成最终值:

  1. 把参数初始化成随机数;
  2. 喂进一条数据,让它算出输出;
  3. 拿输出和”正确答案”比,算一个误差(loss)
  4. 用微积分算出”每个数字该往哪个方向、挪多少,误差会变小”(梯度下降/反向传播);
  5. 把几亿个数字都微调一点点
  6. 重复 2~5,喂几亿条数据,调几十万轮。

最终,那几亿个数字被”调”成了一个稳定的状态——这个状态,就是”把语义相近的句子,算到空间里相近位置”的那个规律。

这跟你平时理解的”代码里的魔法数”正好相反:代码里的常量是人拍脑袋写的;模型参数是机器拿海量数据反复调出来的,没人看得懂单个数字是什么意思,但整体效果是对的。

10.7 一个帮你锚定的类比

把 Transformer 想成你写过的”评分函数”:

// 你手写的:信用评分 = 收入×0.4 + 负债×(-0.3) + 年龄×0.1 + ...
double score = income * 0.4 + debt * (-0.3) + age * 0.1 + ...;

这里 0.4、-0.3、0.1你凭经验手写的参数。神经网络做的事一模一样——也是”输入 × 权重 求和”,只不过:

  1. 输入不是 3 个字段,是 1536 维;
  2. 权重不是 3 个,是几亿个;
  3. 权重不是你凭经验填的,是机器拿数据自动学出来的
  4. 而且它把这个”评分”过程叠了十几层,每层中间还加了”负数归零”的掰弯动作。

除此之外,没有别的魔法。


十一、嵌入表:非 1536 维是怎么变 1536 维的(问题 4)

11.1 你问的原话

分词后,给每个词换成字典里的一个 ID,那这段话的 [数字, 数字, ...] 大概率不是 1536 维的。下面这段话的意思就是把这段非 1536 维的向量,转换为 1536 维的数据吗?(指嵌入表那一段)

11.2 先纠正:那串 ID 严格说不是”向量”,是”一串索引”

"库存不足"  →  分词  →  ["库存", "不足"]  →  查字典  →  [10234, 4581]

这个 [10234, 4581] 不是一个”有维度”的向量,它就是一串索引(下标),像 List<Integer>

  • 长度 = 这句话有多少个 token(可变);
  • 每个数字本身只是个”编号”,10234 这个数字的大小没有任何数学意义,它只是”字典里第 10234 号词”的门牌号。

所以进入网络之前的输入是:一串长度可变、每个元素是”字典下标”的整数序列,它压根没有”维度”这回事。

11.3 嵌入表干的事:把”每个 ID”单独换成 1536 维向量

嵌入表本质就是一张大查找表,一个普通的”数组 + 下标取值”:

嵌入表 = 二维数组: embeddingTable[字典大小][1536]

    字典里有 50000 个词,这张表就是 50000 行,每行 1536 个数字:

    第 0 行:   [0.02, -0.11, 0.43, ..., 0.07]   ← 1536 个数
    ...
    第 10234 行:[0.11, -0.03, 0.29, ..., 0.15]  ← "库存"的初始向量
    ...
    第 4581 行:[-0.07, 0.22, -0.14, ..., 0.08] ← "不足"的初始向量

用 Java 写:

int[] tokenIds = {10234, 4581};                    // 这串 ID
float[][] sentenceVectors = new float[tokenIds.length][1536];
for (int i = 0; i < tokenIds.length; i++) {
    sentenceVectors[i] = embeddingTable[tokenIds[i]];  // 每个 ID → 一行 1536 维
}
// 结果:2 个向量,每个 1536 维

所以回答你的问题:

是的,嵌入表的作用就是做”非 1536 维 → 1536 维”的转换。但它是”一个 token ID 换成一行 1536 维”,所以一段话(N 个 token)会变成 N 个 1536 维向量,而不是 1 个。

经过嵌入表之后,[10234, 4581] 变成了一个 2 × 1536 的矩阵

11.4 把”维度”这条线完整走一遍

阶段 数据是什么 形状
0. 原始文本 "库存不足" 字符串
1. 分词 ["库存", "不足"] 2 个词
2. 查字典 [10234, 4581] 2 个整数 ID(无维度概念)
3. 查嵌入表 每个 ID → 一行 1536 维 2 × 1536 的矩阵
4. Transformer 编码器 每个词的向量被”上下文调整” 仍是 2 × 1536
5. 池化(求平均) N 个向量压成 1 个 1 × 1536 ← 这句话最终的向量

你之前的疑惑,正是卡在”第 3 步到第 5 步之间”:1536 维不是在第 3 步一步到位变成”整段话”的,而是第 3 步给”每个词”一个 1536 维,第 5 步才把”N 个词”压成”1 个” 1536 维。

11.5 补一个点:这其实也还是”矩阵乘法”

把 token ID 10234 写成一个独热向量(one-hot):长度 50000 的向量,只有第 10234 位是 1,其余全是 0。把这个 one-hot 向量去乘嵌入表矩阵(50000 × 1536),结果恰好就是嵌入表的第 10234 行

onehot(10234) · 嵌入表  =  第 10234 行  =  "库存"的 1536 维向量

(因为其他位都是 0,只有第 10234 位是 1,加权和只剩这一行。)

所以:“查表” = “one-hot 向量 × 矩阵”,它还是矩阵乘法,只是输入是个”只有一个 1”的极稀疏向量。这也就解释了为什么上一条里可以说”整张网络是一坨矩阵乘法”——连开头这个”查表”动作,本质上也是。

11.6 嵌入表里的数字也是训练出来的

嵌入表那 50000 行 × 1536 维的数字,也不是人填的,是训练时和其他参数一起”调”出来的。训练后,”库存”和”超卖”这两个词的 1536 维初始向量,会比”库存”和”天气”的向量更接近——这是”语义相近”的种子。后面的 Transformer 编码器再在这个基础上让每个词”结合上下文”做进一步调整,最后池化出整句话的坐标。


十二、上下文调整(注意力)到底在做什么(问题 5)

12.1 你问的原话

查嵌入表得到 2×1536 的矩阵后,”Transformer 编码器把每个词的向量做上下文调整,仍是 2×1536”,是在做什么?

12.2 这一步到底在干什么

进这步之前,”库存”和”不足”是两个孤立的词向量,都是它们字典里的通用意思(不管在哪个句子里都一样)。但”库存不足”四个字连在一起,是个特定含义(”缺货”),光看两个孤立向量,机器还”不知道”它俩在这一句里是连着的。

“上下文调整”就是:让”库存”去”看见”旁边的”不足”,把”不足”的信息吸收一部分到自己身上;反过来”不足”也吸收”库存”。 调整完之后:

  • “库存”的向量不再是”通用库存”,而是”和’不足’在一起的那个库存“;
  • “不足”的向量不再是”通用不足”,而是”和’库存’在一起的那个不足“。

一句话:每个词从”查字典的孤立意思”,变成”结合了上下文的具体意思”。

12.3 机制:注意力——每个词都去”问”其他词

这个”吸收别人信息”的动作,是一套叫注意力的机制。对每个词,先用三个矩阵各乘一次,得到三个向量:

Q = Wq · x    (Query:我想找什么 / 我在关注什么)
K = Wk · x    (Key:  我身上有什么 / 我能提供什么)
V = Wv · x    (Value:我实际传递什么信息)

然后,注意力 = 相关性加权求和

第 1 步:算"库存"和所有词(包括自己)的相关性
        score(库存→库存) = Q_库存 · K_库存
        score(库存→不足) = Q_库存 · K_不足      ← 点积越大越相关

第 2 步:softmax 归一化,把分数变成"权重"(加起来等于 1)
        权重(库存→库存) = 0.6
        权重(库存→不足) = 0.4

第 3 步:按权重,把所有人的 V 加权求和,得到"库存"的新向量
        新_库存 = 0.6 × V_库存 + 0.4 × V_不足

看最后一行——“库存”的新向量,不再只是它自己,而是 60% 的自己 + 40% 的”不足”。 它把”不足”的信息,按相关性”抄”了一部分进来。

12.4 用具体数字走一遍

把维度缩到极小来演示(真实是 1536 维,逻辑一模一样):

初始向量(从嵌入表来,只画 3 维示意):
  x_库存 = [1.0, 0.2, 0.1]
  x_不足 = [0.3, 1.1, 0.8]

① 算 Q、K、V(乘以学好的矩阵,数字是编的,只为讲清逻辑):

  Q_库存 = [0.5, 0.1, 0.3]    K_库存 = [0.4, 0.2, 0.1]    V_库存 = [1.0, 0.2, 0.1]
  Q_不足 = [0.1, 0.6, 0.2]    K_不足 = [0.3, 0.5, 0.4]    V_不足 = [0.3, 1.1, 0.8]

② 算”库存”对每个词的注意力分数(点积):

score(库存→库存) = Q_库存 · K_库存 = 0.5×0.4 + 0.1×0.2 + 0.3×0.1 = 0.25
score(库存→不足) = Q_库存 · K_不足 = 0.5×0.3 + 0.1×0.5 + 0.3×0.4 = 0.32

③ softmax 归一化成权重(0.25→约 0.48,0.32→约 0.52):

权重(库存→库存) ≈ 0.48
权重(库存→不足) ≈ 0.52

④ 加权求和,得到”库存”的新向量:

新_库存 = 0.48 × V_库存 + 0.52 × V_不足
        = 0.48×[1.0, 0.2, 0.1] + 0.52×[0.3, 1.1, 0.8]
        = [0.636, 0.668, 0.464]

对比:

调整前 x_库存 = [1.0, 0.2, 0.1]        ← 纯"库存"的意思
调整后 新_库存 = [0.636, 0.668, 0.464]  ← 混进了"不足"的意思

“库存”的向量被”不足”拉过去了一部分——这就是”上下文调整”的本质:每个词的新向量 = 所有词(包括自己)的 V 按注意力权重的加权平均。

12.5 为什么形状还是 2 × 1536

因为注意力是逐词做的:给”库存”算出一个新的 1536 维,给”不足”算出一个新的 1536 维;输出还是 2 个词、每个 1536 维 → 2×1536

改变的是”每个向量里的数值”,不是”向量的个数和维度”。 就像你给 List<Point> 里每个 Pointx,y 重新赋值,list 的长度没变,但每个点的坐标变了。

12.6 为什么说”多层”

上面只是一层。真实的 Transformer 编码器会叠 12~24 层:

第 1 层:每个词看"紧挨着"的上下文("库存"看见"不足")
第 2 层:基于第 1 层的结果,看得更远、组合更复杂的语义
第 12 层:每个词的向量已经融合了整句话的丰富信息

每一层都在上一层的”半成品”上继续”互相吸收”。越到深层,每个词向量里携带的”上下文信息”就越完整、越抽象。这就是为什么它能捕捉”库存不足”作为一个整体概念,而不只是两个词的简单拼接。

12.7 补全:一层编码器里还夹了什么

一层 Transformer 编码器,严格说是这几步:

输入 x(2×1536)
  │
  ├─ ① 多头注意力(通常拆 12 个头各算一份再拼起来)
  ├─ ② 残差连接:y = x + 注意力结果    (原样保留 + 增量调整,防梯度消失)
  ├─ ③ LayerNorm:把数值归一化          (稳定训练)
  ├─ ④ 前馈网络 FFN:ReLU(W·y + b)     (就是"矩阵乘法+掰弯")
  ├─ ⑤ 残差 + LayerNorm 再来一次
  ▼
输出(还是 2×1536)

其中注意力是”词与词之间”的交互(横向:让词看别的词),FFN 是”单个词内部”的非线性变换(纵向:把信息搅一搅)。两者交替,就是”上下文调整”的完整配方。


十三、Transformer 编码器怎么用在大模型里(问题 6)

13.1 你问的原话

Transformer 编码器怎么像大模型里面使用到的呢?

13.2 先记住:Transformer 是”两半边”的通用架构

“Transformer”不是某一个模型,而是一种网络架构,天然分成两半:

        ┌─────────────────────────────────────────┐
        │              Transformer 架构            │
        │   ┌──────────────┐   ┌──────────────┐   │
        │   │   编码器      │   │   解码器      │   │
        │   │  Encoder     │   │  Decoder     │   │
        │   │  (理解/压缩) │   │ (生成/预测) │   │
        │   └──────────────┘   └──────────────┘   │
        └─────────────────────────────────────────┘

两半的核心零件一模一样——都是”矩阵乘法 + 注意力 + 掰弯”。真正的区别只有一个:Encoder 负责”读懂”,Decoder 负责”写出来”。

于是业界有三种用法:

用法 用哪半 代表模型 干什么
只用 Encoder 编码器 BERT、BGE、text-embedding 系列 把一句话”读”成一个向量(就是 embedding)
只用 Decoder 解码器 GPT、DeepSeek、Claude、Qwen、LLaMA 一个字一个字地”生成”(就是你在调的大模型)
两半都用 编码器+解码器 早期 T5、翻译模型 先读进一句、再吐出另一句(翻译/摘要)

关键结论:你博客里的 embedding 模型是”只用 Encoder”;你天天调 chat 接口的 GPT/DeepSeek 是”只用 Decoder”,它根本不用 Encoder。这是两套不同的模型,不是同一个模型的两个模式。

13.3 Encoder:每个词能”看见”整句话

你前面学的全是 Encoder 的活儿。注意一个之前没点破的细节:Encoder 的注意力是双向的

  • “库存”能看”不足”,也能看自己;
  • 在一句长话里,第 1 个词能直接看第 20 个词。

为什么能双向?因为 Encoder 的任务是“理解整句话”,它拿到的是完整的一句话,当然可以随便看前后。Encoder 的输出不是词,是一个”浓缩了整句话语义”的向量。它只”读”,不”写”。

13.4 Decoder(大模型用的):每个词只能”看见”它前面的词

大模型用的是 Decoder,任务完全不同:预测下一个词。 它和 Encoder 的注意力只有一个致命区别:

Decoder 的注意力是”单向的、带掩码的”(causal/masked)——每个词只能看见它”前面”的词,永远看不见”后面”的词。

为什么必须单向?因为大模型要生成,生成的时候”后面的词”根本还不存在。

举个生成例子,你问它:”库存不足会抛什么?”模型实际是一个词一个词往外吐:

第 1 步:看到 [库存, 不足, 会, 抛, 什么]  → 预测下一个 = "异"
第 2 步:看到 [库存, 不足, 会, 抛, 什么, 异] → 预测下一个 = "常"
第 3 步:看到 [..., 异, 常] → 预测下一个 = "?"

每一步模型都只能看”已经生出来的前半截”,绝不能偷看还没生成的后半截。所以它的注意力要带一个”三角形掩码”(mask),把后面的词挡住:

        库存  不足  会  抛  什么  (当前要预测"异常"时)
库存    ✓
不足    ✓    ✓
会      ✓    ✓    ✓
抛      ✓    ✓    ✓    ✓
什么    ✓    ✓    ✓    ✓    ✓      ← 每个词只能看自己及左边(前面)
异常    ✗    ✗    ✗    ✗    ✗      ← 未来词全部 ✗,被挡住

这个”✓ 只看左边、✗ 挡住右边”的三角,就叫因果掩码(causal mask)。它就是 Decoder 和 Encoder 唯一本质上的区别。

Decoder 的输出:不是向量,是一个词(更准确说,是”下一个词是字典里每个词的概率分布”,取最大/采样一个)。它只”写”,”读”是为了”写”。

13.5 一句话对比

  Encoder(embedding 用) Decoder(大模型用)
任务 理解/压缩一句话 预测/生成下一个词
注意力方向 双向(看全部) 单向 + 掩码(只看前面)
输入 完整一句话 已生成的前半截
输出 一个语义向量 下一个 token
代表 BERT、BGE、embedding GPT、DeepSeek、Qwen

一句话:同一个”注意力”零件,Encoder 让它”看全句”于是能读懂,Decoder 让它”只看前面”于是能接着写。方向一改,用途天差地别。

13.6 大模型到底怎么”跑”起来:自回归

你现在调的 POST /v1/chat/completions,服务器端干的事,就是让 Decoder 一遍遍重复”预测下一个词”:

你的输入:"库存不足会抛什么"
   ▼
Decoder 第 1 次前向:预测 → "异"
   ▼
把 "异" 拼回输入 → "库存不足会抛什么异"
   ▼
Decoder 第 2 次前向:预测 → "常"
   ▼
... 直到吐出结束符 <eos> 或达到 max_tokens

这个”每生成一个词,就把它拼回输入,再生成下一个词“的循环,叫自回归(autoregressive)

这也正好解释了《LLM 应用后端接入工程》里讲的两件事:

  1. 为什么大模型”流式输出”(SSE)是一个字一个字蹦出来的——因为 Decoder 本来就是一个 token 一个 token 生成,服务器只是实时转发;
  2. 为什么”输入 + 输出”共享窗口、输出越长越贵——因为每生成一个词,都要把”前面全部内容”重新做一次前向计算。

十四、把整条链路串起来

到这里,你其实已经完整掌握了一整条链:

Embedding 模型(Encoder-only,双向注意力)
   → 把文本变成 1536 维向量 → 喂给向量库(HNSW/IVF 索引)做检索
大模型(Decoder-only,单向掩码)
   → 把检索到的记忆 + 用户问题 → 自回归生成回答

而上一篇《企业级记忆知识库》干的,恰恰是把这两半串起来:Embedding 负责”记忆怎么写进库、怎么搜出来”,大模型负责”搜出来之后怎么用”。这也是为什么那篇里”向量检索”和”LLM 判断/生成”是两条相互配合、但底层机制不同的链路。


十五、收束

最后把这篇文章所有问题收成一张”一句话版”:

你的疑问 一句话答案
向量库到底存什么 存”文本算出来的高维坐标点”,不是存文本
为什么树索引会失效 高维空间太大,数据太稀疏,任何”框”都剪不掉东西 → 退化成全表扫描
HNSW/IVF 怎么快 一个靠”图导航”(高速+街道),一个靠”聚类”(分区裁剪),都牺牲一点点召回
1536 维怎么算出来 分词 → 嵌入表 → 多层注意力结合上下文 → 池化求平均
没共同词为什么相近 训练时强制”语义相近靠拢、无关远离”,语义=分布相似
矩阵乘法/几亿参数是啥 就是”加权求和 + 负数归零”叠十几层,参数是机器练出来的数字
嵌入表干嘛 把每个 ID 换成一行 1536 维(本质是 one-hot × 矩阵)
上下文调整干嘛 注意力:每个词按相关性,把其他词的 V 加权平均到自己身上
编码器怎么用在大模型 大模型用的是另一半(Decoder),注意力单向带掩码,自回归生成

向量数据库不是”存文本”的数据库,而是”存高维坐标点”的数据库;它的”索引”是一张导航图(HNSW)或一堆簇(IVF);它的”查询”是”找到离我最近的点”;它的”近似”是”用 1% 的召回损失换 100 倍以上的提速”。而这一切的地基,是那个把”语义”压成”坐标”的 embedding 模型——一个 Encoder-only 的 Transformer。

如果你之前”不完全理解”,很可能是卡在两个地方:

  1. 以为它像 MySQL 一样存”内容”,按内容匹配 —— 其实它存的是”内容的坐标”,按几何距离匹配;
  2. 用一维 B-tree 的直觉去套高维 —— 高维空间里”有序/二分/树划分”全部失效,所以才需要图导航和聚类这两种专门的”高维索引”。

下一篇如果继续往下挖,可以讲 KV Cache——大模型生成时,为什么不能每步都把整段历史重新算一遍,而是把算过的结果缓存起来。这正好把”上下文窗口 / 输出越贵”那点彻底收口。


本文是上一篇《企业级记忆知识库》的原理补课篇,以”你可能会问的问题”组织,所有数字与代码均为示意,用于讲清机制。

本文阅读量