嵌入与向量表示:让文本可被几何计算


文档摘要

嵌入与向量表示:让文本可被几何计算 本节摘要:文本是离散的,数学是连续的。每次你让 LLM「找相似文档、比较含义、超越关键词搜索」,你都在依赖这两者之间的桥——嵌入(Embedding)。不理解嵌入,就不理解现代 AI,你只是在用它而已。本节带你从零吃透嵌入:为什么它能解决关键词搜索束手无策的「词汇不匹配」问题,从 Word2Vec 到 Sentence-BERT 到指令微调嵌入的演进,三种相似度度量(余弦、点积、欧氏)何时用哪种,HNSW 如何把暴力 O(n) 搜索压到 O(log n),分块策略(定长/句界/递归/语义)的取舍,以及「双编码器检索 + 交叉编码器重排」的生产范式。

嵌入与向量表示:让文本可被几何计算

本节摘要:文本是离散的,数学是连续的。每次你让 LLM「找相似文档、比较含义、超越关键词搜索」,你都在依赖这两者之间的桥——嵌入(Embedding)。不理解嵌入,就不理解现代 AI,你只是在用它而已。本节带你从零吃透嵌入:为什么它能解决关键词搜索束手无策的「词汇不匹配」问题,从 Word2Vec 到 Sentence-BERT 到指令微调嵌入的演进,三种相似度度量(余弦、点积、欧氏)何时用哪种,HNSW 如何把暴力 O(n) 搜索压到 O(log n),分块策略(定长/句界/递归/语义)的取舍,以及「双编码器检索 + 交叉编码器重排」的生产范式。读完本节,你能用纯 Python + numpy 搭一个语义搜索引擎,并理解何时用 Matryoshka 截断、何时用二值量化。

对应原课程:Phase 11 · Lesson 04 · embeddings(原英文 phases/11-llm-engineering/04-embeddings/docs/en.md)。本节聚焦生产管线(向量库、HNSW、相似度数学);模型深挖(稠密/稀疏/多向量、Matryoshka 截断、按轴选模型)见原课程 Phase 5·22。

学习目标

阅读完本节,你应当能够:

  1. 用 API provider 与开源模型生成文本嵌入,并计算它们之间的余弦相似度。
  2. 解释嵌入为何能解决关键词搜索处理不了的词汇不匹配问题。
  3. 搭建一个语义搜索索引,按含义而非精确关键词命中检索文档。
  4. 用检索基准(precision@k、recall)评估嵌入质量,并为任务选对嵌入模型。

一、问题与直觉

你有 1 万张工单。客户写「我的付款没成功」,你要找相似的历史工单。关键词搜索找出含「付款」「没成功」的工单,却漏掉了「交易失败」「扣款被拒」「账单错误」——这些工单描述的是同一件事,用了完全不同的词。

这就是词汇不匹配问题。人类语言里,同一件事有几十种说法。关键词搜索把每个词当成无意义的独立符号,它不知道「被拒」和「没成功」指的是同一个概念。

你需要一种文本表示,让含义而非拼写决定相似度。你需要一种方式,把「我的付款没成功」和「交易被拒」放在某个数学空间里靠得很近,同时把「我的付款按时到账」推得很远——尽管它们共享「付款」这个词。

这种表示就是嵌入。

什么是嵌入

嵌入是表示文本含义的稠密浮点向量。「稠密」很重要——每个维度都承载信息,不像词袋、TF-IDF 这种稀疏表示里大多数维度是零。

「猫坐在垫子上」会变成类似 [0.023, -0.041, 0.087, ..., 0.012]——一串 768 到 3072 个数字(取决于模型)。这些数字编码含义,你从不直接看它们,你比较它们。

Word2Vec 的突破

2013 年,Google 的 Tomas Mikolov 等人发表 Word2Vec。核心洞见:训练一个神经网络,用上下文词预测目标词(或反之),隐藏层的权重就成了有意义的向量表示。

著名结果是:

king - man + woman = queen

词嵌入上的向量算术捕捉了语义关系。「man」到「woman」的方向,大致等于「king」到「queen」的方向。这是整个领域意识到几何可以编码含义的时刻。

Word2Vec 产出 300 维向量,每个词无论上下文都只有一个向量。「河岸」和「银行账户」里的 bank 嵌入相同——这个局限推动了之后十年的研究。

从词到句

词嵌入表示单个 token,生产系统要嵌入整句、整段、整篇文档。四种做法涌现:

平均:取句子里所有词向量的均值。便宜、有损、短文本意外地还行,但完全丢失词序——「狗咬人」和「人咬狗」嵌入相同。

CLS token:Transformer 模型(BERT,2018)输出一个特殊的 [CLS] token 嵌入表示整个输入,比平均好,但 [CLS] 是为「下一句预测」训练的,不是为相似度。

对比学习:显式训练模型把相似对拉近、不相似对推远。Sentence-BERT(Reimers & Gurevych,2019)用了这套,成为现代嵌入模型的基石。给定「如何重置密码?」和「我需要改密码」,模型学到这两句该有几乎相同的向量。

指令微调嵌入:最新思路。E5、GTE 这类模型接收任务前缀(「search_query:」「search_document:」),告诉模型产出哪种嵌入,让一个模型服务多个任务。

现代嵌入模型

市场已收敛到少数生产级选项(MTEB 分数为 2026 年初,MTEB v2):

模型 provider 维度 MTEB 上下文 每 100 万 token 成本
Gemini Embedding 2 Google 3072(Matryoshka) 67.7(检索) 8192 $0.15
embed-v4 Cohere 1024(Matryoshka) 65.2 128K $0.12
voyage-4 Voyage AI 1024/2048(Matryoshka) 66.8 32K $0.12
text-embedding-3-large OpenAI 3072(Matryoshka) 64.6 8192 $0.13
text-embedding-3-small OpenAI 1536(Matryoshka) 62.3 8192 $0.02
BGE-M3 BAAI 1024(稠密+稀疏+ColBERT) 63.0 多语言 8192 开源权重
Qwen3-Embedding 阿里 4096(Matryoshka) 66.9 32K 开源权重
Nomic-embed-v2 Nomic 768(Matryoshka) 63.1 8192 开源权重

MTEB(Massive Text Embedding Benchmark)v2 覆盖检索、分类、聚类、重排、摘要等 100+ 任务,越高越好。到 2026 年,开源权重模型(Qwen3-Embedding、BGE-M3)在多数轴上已追平甚至超过闭源托管模型。Gemini Embedding 2 在纯检索上领先;Voyage/Cohere 在特定领域(金融、法律、代码)领先。动手前一定要在自己的查询上基准测试

相似度度量

给定两个嵌入向量,三种衡量相似度的方式:

余弦相似度:两向量夹角的余弦,范围 -1(反向)到 1(同向)。忽略模长——10 词句子和 500 词文档只要方向相同就打 1.0。这是 90% 场景的默认选择。

cosine_sim(a, b) = dot(a, b) / (||a|| * ||b||)

点积:两向量的原始内积。向量归一化(单位长度)时与余弦相同,计算更快。OpenAI 嵌入已归一化,所以点积与余弦给出相同排序。

dot(a, b) = sum(a_i * b_i)

欧氏距离(L2):向量空间里的直线距离,越小越相似。对模长差敏感。当空间中的绝对位置(而非方向)重要时用。

L2(a, b) = sqrt(sum((a_i - b_i)^2))
度量 何时用 何时避开
余弦相似度 比较不同长度文本;多数检索任务 模长承载信息时
点积 嵌入已归一化;求最快 向量模长不一
欧氏距离 聚类;空间最近邻 比较长度悬殊的文档

向量库与 HNSW

暴力相似度搜索把查询与每个存储向量比一遍。100 万向量、1536 维时,每次查询要 15 亿次乘加——太慢。

向量库用近似最近邻(ANN)算法解决,主流算法是 HNSW(分层可导航小世界):

  1. 构建多层向量图。
  2. 顶层稀疏——远处簇之间的长程连接。
  3. 底层稠密——近处向量之间的细粒度连接。
  4. 搜索从顶层开始,贪心下降逐层细化。
  5. 在 O(log n) 而非 O(n) 时间内返回近似 top-k。

HNSW 用少量精度损失(通常 95~99% 召回)换巨大速度提升。1000 万向量,暴力要几秒,HNSW 只要毫秒。

生产选项:

数据库 类型 最适合 最大规模
Pinecone 托管 SaaS 零运维生产 十亿级
Weaviate 开源 自托管,混合搜索 1 亿+
Qdrant 开源 高性能,过滤 1 亿+
ChromaDB 嵌入式 原型,本地开发 100 万
pgvector Postgres 扩展 已在用 Postgres 1000 万
FAISS 进程内,研究 10 亿+

分块策略

文档太长,无法作为单个向量嵌入。50 页 PDF 涵盖几十个主题,它的嵌入变成一切的平均,跟什么都不特别像。你把文档切成块,每块单独嵌入。

定长分块:每 N 个 token 切一刀,带 M 个 token 重叠。简单可预测,文档无清晰结构时好用。512 token 块、50 token 重叠:块 1 是 token 0-511,块 2 是 462-973。

句界分块:在句子边界切,按句子累加到 token 上限。每块至少一个完整句子,比定长好——不会把一个想法切两半。

递归分块:先按最大边界(章节标题)切,还太大就按段落,再按句子,再按字符。这是 LangChain 的 RecursiveCharacterTextSplitter,对混合格式语料好用。

语义分块:每句嵌入,把嵌入相似的连续句子归一组,相似度跌破阈值就开新块。贵(每句都要嵌入)但产出的块最连贯。

策略 复杂度 质量 最适合
定长 尚可 非结构化文本、日志
句界 文章、邮件
递归 Markdown、HTML、混合文档
语义 最佳 检索质量至关重要

多数系统的甜区是 256~512 token 块、50 token 重叠

双编码器 vs 交叉编码器

双编码器(Bi-Encoder)独立嵌入查询和文档,再比向量。快——查询嵌入一次,与预算好的文档向量比。这是检索用的。

交叉编码器(Cross-Encoder)把查询和文档作为单一输入,输出相关度分数。慢——每个查询-文档对都要过完整模型,但准确得多,因为它能同时注意到查询与文档的 token。

生产范式:双编码器检索 top-100 候选,交叉编码器把它们重排到 top-10。这就是先检索后重排管线。

重排模型:Cohere Rerank 3.5(每 1000 查询 $2)、BGE-reranker-v2(免费开源)、Jina Reranker v2(免费开源)。

Matryoshka 嵌入

传统嵌入是全有或全无:1536 维向量用 1536 个浮点,不能截断到 256 维,除非重训。

Matryoshka 表示学习(Kusupati 等,2022)解决了这点。模型被训练成「前 N 维承载最重要信息」,像俄罗斯套娃。把 1536 维 Matryoshka 嵌入截到 256 维,损失一些精度但仍可用。

OpenAI 的 text-embedding-3-small/large 通过 dimensions 参数支持 Matryoshka 截断。要 256 维而非 1536 维,存储省 6 倍,MTEB 上大约掉 3~5%。

二值量化

1536 维嵌入存成 float32 用 6144 字节。乘 1000 万文档:光是向量就 61 GB。

二值量化把每个浮点转成一位:正值变 1,负值变 0。存储从 6144 字节降到 192 字节——32 倍压缩。相似度用汉明距离(数不同的位数)算,CPU 单条指令就能完成。

召回大约掉 5~10%。常见范式:百万级向量第一遍用二值量化搜,top-1000 再用全精度向量重排。这样在 1/32 内存下拿到 95%+ 的全精度准确率。

二、从零实现

我们从零搭一个语义搜索引擎,不用向量库,不用外部嵌入 API,纯 Python + numpy 做数学。

步骤 1:文本分块

def chunk_text(text, chunk_size=200, overlap=50): words = text.split() chunks, start = [], 0 while start < len(words): chunk = " ".join(words[start:start + chunk_size]) chunks.append(chunk) start += chunk_size - overlap return chunks def chunk_by_sentences(text, max_chunk_tokens=200): sentences = [s.strip() + "." for s in text.replace("\n"," ").split(".") if s.strip()] chunks, cur, cur_len = [], [], 0 for sent in sentences: slen = len(sent.split()) if cur_len + slen > max_chunk_tokens and cur: chunks.append(" ".join(cur)); cur, cur_len = [], 0 cur.append(sent); cur_len += slen if cur: chunks.append(" ".join(cur)) return chunks

步骤 2:从零造嵌入

我们用 TF-IDF + L2 归一化实现一个简单稠密嵌入。这不是神经嵌入,但遵循同样的契约:文本进、定长向量出、相似文本产相似向量。

import math import numpy as np from collections import Counter class SimpleEmbedder: def __init__(self): self.vocab, self.idf, self.word_to_idx = [], [], {} def fit(self, documents): vocab_set = set() for doc in documents: vocab_set.update(doc.lower().split()) self.vocab = sorted(vocab_set) self.word_to_idx = {w: i for i, w in enumerate(self.vocab)} n = len(documents) self.idf = np.zeros(len(self.vocab)) for i, word in enumerate(self.vocab): doc_count = sum(1 for doc in documents if word in doc.lower().split()) self.idf[i] = math.log((n + 1) / (doc_count + 1)) + 1 def embed(self, text): words = text.lower().split() count = Counter(words) total = len(words) or 1 vec = np.zeros(len(self.vocab)) for word, freq in count.items(): if word in self.word_to_idx: tf = freq / total vec[self.word_to_idx[word]] = tf * self.idf[self.word_to_idx[word]] norm = np.linalg.norm(vec) return vec / norm if norm > 0 else vec

步骤 3:相似度函数

def cosine_similarity(a, b): na, nb = np.linalg.norm(a), np.linalg.norm(b) return float(np.dot(a, b) / (na * nb)) if na and nb else 0.0 def dot_product(a, b): return float(np.dot(a, b)) def euclidean_distance(a, b): return float(np.linalg.norm(a - b))

步骤 4:暴力搜索的向量索引

class VectorIndex: def __init__(self): self.vectors, self.texts, self.metadata = [], [], [] def add(self, vector, text, meta=None): self.vectors.append(vector); self.texts.append(text) self.metadata.append(meta or {}) def search(self, query_vector, top_k=5, metric="cosine"): scores = [] for i, vec in enumerate(self.vectors): if metric == "cosine": score = cosine_similarity(query_vector, vec) elif metric == "dot": score = dot_product(query_vector, vec) elif metric == "euclidean": score = -euclidean_distance(query_vector, vec) else: raise ValueError(f"未知度量: {metric}") scores.append((i, score)) scores.sort(key=lambda x: x[1], reverse=True) return [{"text": self.texts[i], "score": s, "metadata": self.metadata[i], "index": i} for i, s in scores[:top_k]]

步骤 5:语义搜索引擎

class SemanticSearchEngine: def __init__(self, chunk_size=200, overlap=50): self.embedder = SimpleEmbedder() self.index = VectorIndex() self.chunk_size, self.overlap = chunk_size, overlap def index_documents(self, documents, source_names=None): all_chunks, all_sources = [], [] for i, doc in enumerate(documents): chunks = chunk_text(doc, self.chunk_size, self.overlap) all_chunks.extend(chunks) name = source_names[i] if source_names else f"doc_{i}" all_sources.extend([name] * len(chunks)) self.embedder.fit(all_chunks) for chunk, source in zip(all_chunks, all_sources): self.index.add(self.embedder.embed(chunk), chunk, {"source": source}) return len(all_chunks) def search(self, query, top_k=5, metric="cosine"): return self.index.search(self.embedder.embed(query), top_k, metric)

💡 这套架构与生产嵌入 API 完全同构——只要把 SimpleEmbedder 换成真实 API 调用,VectorIndex 换成 Qdrant/Pinecone,搜索逻辑无需改动。从零实现的价值在于让你看见每个零件的内部。

三、框架对比

接生产嵌入 API,架构不变,只换 embedder:

# from openai import OpenAI # client = OpenAI() # def openai_embed(texts, model="text-embedding-3-small", dimensions=None): # kwargs = {"model": model, "input": texts} # if dimensions: kwargs["dimensions"] = dimensions # response = client.embeddings.create(**kwargs) # return [item.embedding for item in response.data]

Matryoshka 截断——同模型,更少维度,更低存储:

# full = openai_embed(["语义搜索查询"], dimensions=1536) # compact = openai_embed(["语义搜索查询"], dimensions=256)

256 维向量省 6 倍存储,1000 万文档是 10 GB vs 61 GB,准确率约掉 3~5%。

Cohere 重排:

# import cohere # co = cohere.ClientV2() # results = co.rerank(model="rerank-v3.5", # query="退款政策是什么?", # documents=["30 天内全额退款...", "90 天后不退款..."], # top_n=3)

本地嵌入,无 API 依赖:

# from sentence_transformers import SentenceTransformer # model = SentenceTransformer("BAAI/bge-small-en-v1.5") # embeddings = model.encode(["语义搜索查询", "另一篇文档"])

我们手写的 VectorIndex 与上述任何一个都兼容——换嵌入函数,搜索逻辑照旧。

维度 从零(本节) OpenAI sentence-transformers
嵌入质量 TF-IDF 级,教学用 SOTA 闭源 SOTA 开源
离线
成本 免费 按 token 免费(需 GPU)
适合 学习原理 生产托管 生产自托管

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-embedding-advisor.md:一个提示,为特定用例选嵌入模型与策略。
  • skill-embedding-patterns.md:一个技能,教 Agent 如何在生产中有效使用嵌入。

Python 代码(code/embeddings.py)是独立语义搜索引擎,把 SimpleEmbedder 换成真实 API、VectorIndex 换成 Qdrant 即可投产。

五、练习

  1. 度量对比:对相同样本文档跑同样 5 个查询,分别用余弦、点积、欧氏,记录每种的 top-3。哪些查询上三种度量分歧?为什么?

  2. 分块大小实验:用 50、100、200、500 词的分块索引样本,各跑 5 个查询记录 top-1 相似度,画出分块大小与检索质量的关系,找出块太大开始伤害质量的拐点。

  3. Matryoshka 模拟:造一个产 500 维向量的 SimpleEmbedder,分别截到 50、100、200、500 维,测每级截断下召回如何衰减(无需真实训练技巧即可模拟行为)。

  4. 二值量化:把搜索引擎的嵌入转成二值(正为 1,负为 0),实现汉明距离搜索,把 top-10 与全精度余弦对比,测重叠百分比。

  5. 句界分块:把定长分块换成 chunk_by_sentences,跑同样查询对比检索分数,尊重句子边界是否提升结果?

本节要点回顾

  1. 嵌入是文本到稠密向量的桥:让含义而非拼写决定相似度,解决关键词搜索的词汇不匹配问题。
  2. 从 Word2Vec 到指令微调:Word2Vec 证几何编码含义(king-man+woman=queen),Sentence-BERT 用对比学习做句级,指令微调让一个模型服务多任务。
  3. 三种相似度:余弦(默认,忽略模长)、点积(归一化时同余弦,更快)、欧氏(聚类/空间最近邻)。
  4. HNSW 把 O(n) 压到 O(log n):多层图,顶层稀疏长跳、底层稠密细粒度,用 95~99% 召回换毫秒级响应。
  5. 分块甜区 256~512 token、50 重叠:定长最简、句界不切想法、递归适合混合格式、语义最连贯最贵。
  6. 双编码器检索 + 交叉编码器重排是生产范式:bi 快召回 top-100,cross 精排 top-10。
  7. Matryoshka 截断省存储:前 N 维最重要,1536 截到 256 省 6 倍存储掉 3~5%。
  8. 二值量化省 32 倍内存:正负转 0/1,汉明距离搜索,百万级第一遍用它,top-1000 全精度重排。
  9. 2026 开源追平闭源:Qwen3-Embedding、BGE-M3 多数轴上不输 OpenAI/Voyage,动手前一定自测。
  10. 从零架构同构生产:换 embedder、换向量库,搜索逻辑不变——这是从零实现的最大价值。

下一节,我们将进入「上下文工程」——管的是进入上下文窗口的所有东西:窗口预算、记忆、检索策略,把嵌入用作更大学科的一块砖。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U