向量数据库原理:从 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^1536≈ 0 个点。
这就像一个 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 模型的训练,大致干这么一件事(以对比学习为例)。给模型成对的文本:
- 正样本对(该近):
("库存不足怎么办", "下单超卖怎么兜底")—— 语义相近 - 负样本对(该远):
("库存不足怎么办", "今天的天气怎么样")—— 语义无关
训练目标就一句话:让所有正样本对的向量距离变小,让所有负样本对的向量距离变大。
一遍遍喂几亿对这样的数据,模型参数被反复调整,最后收敛成一个稳定的规律:语义相近的句子,会被自动”安排”到空间里相近的位置。
两个关键点:
- 这不是”死记硬背”每对句子——训练数据里根本没有你那句”下单超卖怎么兜底”。模型学到的是“词与词之间的相对关系”这种通用模式,所以对没见过的句子也能泛化。
- “相近”是分布式的,不落在某一个维度上——没有”第 37 维 = 库存”这种对应关系。语义是整个 1536 维坐标的方向/位置共同表达的,单独看任何一个维度都没意义。
9.4 一个贴合的类比:地图上的坐标
- 一句话的语义 = 地图上一个城市的位置。
- 1536 维向量 = 这个城市的经纬度坐标(只不过不是 2 个数,是 1536 个数)。
- “库存不足”和”超卖” = 两个不同的地名,但指代的是同一片区域,所以坐标天然挨得近。
你不会问”为什么’北京’和’首都’这两个词没有共同字,却指同一个地方”——因为它们的坐标一样。embedding 干的就是这件事:它不在乎你用了哪个词,它只在乎这个词被安放在空间的哪个位置。位置近,就是语义近。
9.5 必须补一句边界
向量检索的”语义相近”,准确说其实是“分布相似”——是”这俩东西经常和同一批东西一起出现”,不是人类意义上的”理解”。所以它有三个典型翻车场景:
- 精确值会漂:
"订单号 20260901001"这种精确编号,向量不一定记得准——所以生产要上混合检索(关键词 + 向量)。 - 相近不等于等价:
"库存不足"和"库存充足"只有一字之差,但向量可能非常近(因为出现在几乎相同的上下文里)。方向性、否定、程度这些,纯向量是弱项。 - embedding 是有损压缩:一句话压成 1536 个数字,细节必然丢。所以向量检索是”召回候选”,要精确的场合后面还要接 rerank 或回原文核对。
十、”一坨矩阵乘法、几亿参数”是什么意思(问题 3)
10.1 你问的原话
“这串整数进到一个叫 Transformer 编码器的神经网络里。这个网络你可以先理解成一坨矩阵乘法(就像 W × x,只不过 W 是几亿个训练出来的参数)。”解释下这段话。
10.2 先拆句
神经网络 = 一个函数:输入一个向量,输出一个向量。 “一坨矩阵乘法” = 这个函数的内部实现,就是一遍遍做
y = W·x + b。 “几亿个参数” = 这些W、b里面的数字,是”训练”学出来的,不是人写的。
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 “训练出来的”是什么意思
那几亿个数字没人手动填过。它们一开始是随机数,然后靠”训练”调成最终值:
- 把参数初始化成随机数;
- 喂进一条数据,让它算出输出;
- 拿输出和”正确答案”比,算一个误差(loss);
- 用微积分算出”每个数字该往哪个方向、挪多少,误差会变小”(梯度下降/反向传播);
- 把几亿个数字都微调一点点;
- 重复 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 是你凭经验手写的参数。神经网络做的事一模一样——也是”输入 × 权重 求和”,只不过:
- 输入不是 3 个字段,是 1536 维;
- 权重不是 3 个,是几亿个;
- 权重不是你凭经验填的,是机器拿数据自动学出来的;
- 而且它把这个”评分”过程叠了十几层,每层中间还加了”负数归零”的掰弯动作。
除此之外,没有别的魔法。
十一、嵌入表:非 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> 里每个 Point 的 x,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 应用后端接入工程》里讲的两件事:
- 为什么大模型”流式输出”(SSE)是一个字一个字蹦出来的——因为 Decoder 本来就是一个 token 一个 token 生成,服务器只是实时转发;
- 为什么”输入 + 输出”共享窗口、输出越长越贵——因为每生成一个词,都要把”前面全部内容”重新做一次前向计算。
十四、把整条链路串起来
到这里,你其实已经完整掌握了一整条链:
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。
如果你之前”不完全理解”,很可能是卡在两个地方:
- 以为它像 MySQL 一样存”内容”,按内容匹配 —— 其实它存的是”内容的坐标”,按几何距离匹配;
- 用一维 B-tree 的直觉去套高维 —— 高维空间里”有序/二分/树划分”全部失效,所以才需要图导航和聚类这两种专门的”高维索引”。
下一篇如果继续往下挖,可以讲 KV Cache——大模型生成时,为什么不能每步都把整段历史重新算一遍,而是把算过的结果缓存起来。这正好把”上下文窗口 / 输出越贵”那点彻底收口。
本文是上一篇《企业级记忆知识库》的原理补课篇,以”你可能会问的问题”组织,所有数字与代码均为示意,用于讲清机制。