嵌入与向量表示:让文本可被几何计算 本节摘要:文本是离散的,数学是连续的。每次你让 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 万张工单。客户写「我的付款没成功」,你要找相似的历史工单。关键词搜索找出含「付款」「没成功」的工单,却漏掉了「交易失败」「扣款被拒」「账单错误」——这些工单描述的是同一件事,用了完全不同的词。
这就是词汇不匹配问题。人类语言里,同一件事有几十种说法。关键词搜索把每个词当成无意义的独立符号,它不知道「被拒」和「没成功」指的是同一个概念。
你需要一种文本表示,让含义而非拼写决定相似度。你需要一种方式,把「我的付款没成功」和「交易被拒」放在某个数学空间里靠得很近,同时把「我的付款按时到账」推得很远——尽管它们共享「付款」这个词。
这种表示就是嵌入。
嵌入是表示文本含义的稠密浮点向量。「稠密」很重要——每个维度都承载信息,不像词袋、TF-IDF 这种稀疏表示里大多数维度是零。
「猫坐在垫子上」会变成类似 [0.023, -0.041, 0.087, ..., 0.012]——一串 768 到 3072 个数字(取决于模型)。这些数字编码含义,你从不直接看它们,你比较它们。
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 | 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))
| 度量 | 何时用 | 何时避开 |
|---|---|---|
| 余弦相似度 | 比较不同长度文本;多数检索任务 | 模长承载信息时 |
| 点积 | 嵌入已归一化;求最快 | 向量模长不一 |
| 欧氏距离 | 聚类;空间最近邻 | 比较长度悬殊的文档 |
暴力相似度搜索把查询与每个存储向量比一遍。100 万向量、1536 维时,每次查询要 15 亿次乘加——太慢。
向量库用近似最近邻(ANN)算法解决,主流算法是 HNSW(分层可导航小世界):
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 重叠。
双编码器(Bi-Encoder)独立嵌入查询和文档,再比向量。快——查询嵌入一次,与预算好的文档向量比。这是检索用的。
交叉编码器(Cross-Encoder)把查询和文档作为单一输入,输出相关度分数。慢——每个查询-文档对都要过完整模型,但准确得多,因为它能同时注意到查询与文档的 token。
生产范式:双编码器检索 top-100 候选,交叉编码器把它们重排到 top-10。这就是先检索后重排管线。
重排模型:Cohere Rerank 3.5(每 1000 查询 $2)、BGE-reranker-v2(免费开源)、Jina Reranker v2(免费开源)。
传统嵌入是全有或全无: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 做数学。
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
我们用 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
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))
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]]
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 即可投产。
度量对比:对相同样本文档跑同样 5 个查询,分别用余弦、点积、欧氏,记录每种的 top-3。哪些查询上三种度量分歧?为什么?
分块大小实验:用 50、100、200、500 词的分块索引样本,各跑 5 个查询记录 top-1 相似度,画出分块大小与检索质量的关系,找出块太大开始伤害质量的拐点。
Matryoshka 模拟:造一个产 500 维向量的 SimpleEmbedder,分别截到 50、100、200、500 维,测每级截断下召回如何衰减(无需真实训练技巧即可模拟行为)。
二值量化:把搜索引擎的嵌入转成二值(正为 1,负为 0),实现汉明距离搜索,把 top-10 与全精度余弦对比,测重叠百分比。
句界分块:把定长分块换成 chunk_by_sentences,跑同样查询对比检索分数,尊重句子边界是否提升结果?
下一节,我们将进入「上下文工程」——管的是进入上下文窗口的所有东西:窗口预算、记忆、检索策略,把嵌入用作更大学科的一块砖。