Embedding 与语义匹配:从概念到 pgvector 落地
适合谁读:听说过 ChatGPT,想给产品加「语义搜索」或「语义匹配」,但还没搞懂 Embedding、pgvector、以及 Chat 和 Embedding 为什么要分开配的人。
读完你能做什么:理解原理、会调 Embedding API、会算相似度、知道怎么用 PostgreSQL + pgvector 存向量,并在现有业务里加一条语义相似度链路。
1. 先从一个尴尬的场景说起
你做了一个帮助中心,用户搜:「怎么取消订单」。
系统用关键词匹配,去文档标题里找有没有「取消」「订单」这几个字。
结果排第一的是:《订单 状态有哪些?》
真正有用的《如何取消订单并退款》反而排在后面。
问题不在服务器慢,而在于:关键词一样,意思可以差很远;关键词不一样,意思却可以是一回事。
| 用户说的 | 文档标题 | 关键词重叠 | 意思像不像 |
|---|---|---|---|
| 怎么取消订单 | 订单状态有哪些 | 有「订单」 | 不太像 |
| 怎么取消订单 | 如何办理退货退款 | 几乎没有 | 很像 |
| 苹果手机 | iPhone 15 | 没有 | 很像 |
| Go 后端 | Golang 服务端 | 很少 | 很像 |
语义匹配要解决的问题:让计算机判断「像不像」,而不是「有没有同一个词」。
Embedding,就是实现语义匹配的常用手段。
2. Embedding 是什么?
2.1 一句话定义
Embedding(嵌入)= 把一段文字,变成固定长度的一串数字(向量)。
输入:
text
"如何取消订单并退款"
输出(示意,真实向量很长):
text
[0.012, -0.034, 0.089, 0.021, ...] ← 通常 768 / 1024 / 1536 维
这串数字人类读不懂,但有一个关键性质:
意思相近的文本,向量在数学空间里更接近。
2.2 和 Chat 大模型有什么区别?
很多人第一次接触 AI 就是 Chat。Embedding 和 Chat 是两类不同的 API:
| 对比项 | Chat(对话 / 补全) | Embedding(嵌入) |
|---|---|---|
| 输入 | 提示词、对话历史 | 一段或多段文本 |
| 输出 | 人话、JSON、代码 | 只有数字向量 |
| 模型在干什么 | 「理解 + 生成」 | 「编码成坐标」 |
| 典型用途 | 写文案、分析、问答 | 搜索、推荐、聚类、算相似度 |
| 成本与速度 | 相对慢、相对贵 | 相对快、相对便宜 |
口诀:
Chat 负责「说出来」;Embedding 负责「标位置」。
那如果不标位置,只靠 Chat 直接说出来,会怎样?
可以,但通常只适合少量、一次性的任务。一旦涉及「从很多候选里找最像的」或「大规模对比」,就会吃力:
| 场景 | 只有 Chat(不标位置) | 先 Embedding 标位置 |
|---|---|---|
| 用户搜「怎么取消订单」 | 往往退回关键词匹配,或让 LLM 临时读几篇文档再答------换说法容易漏 | 离线给每篇文档标好坐标,在线算距离,Top-K 检索,「退货退款」也能命中 |
| 库里有 1 万条文档,找最相关的 5 条 | 没法把 1 万条都塞进 Prompt 让 LLM 逐条比 | 1 万次坐标已存好,毫秒级算最近邻 |
| 两段话像不像 | 可以问 LLM「打 1--100 分」------慢、贵,同样输入多次问可能分数波动 | 两次 Embedding + 算距离,快、稳,适合批量对比 |
| 「Go 后端」和「Golang 服务端」 | LLM 多数能判断像,但没有统一坐标系,难和库里其他 JD 一起排序 | 都在同一语义空间里,相对远近可比 |
换句话说:
- Chat 直接说 :像请一位专家当场口头点评 ------适合分析、解释、写文案,但不擅长在成千上万条数据里快速找相似项。
- Embedding 标位置 :像先给每句话在地图上钉一枚图钉------平时看不见理由,但一搜「离 query 最近的图钉是谁」就很快、很便宜。
很多产品里的尴尬(第 1 节的帮助中心搜不准),根因就是:只有「说出来」的能力,没有「标位置」的索引。RAG、语义搜索、推荐,底层都要先标位置,再根据需要让 Chat 出来说人话。
先区分两个词:
- 标量(scalar):只有一个数,没有方向。体温 37°C、匹配分 85、价格 99 元,都是标量。
- 向量(vector) :多个数排成一排 ,而且顺序不能乱。
例如 [0.1, 0.5, -0.2] 是一个 3 维向量------3 个数,对应 3 个方向上的坐标:
| 位置 | 值 | 可以粗浅理解为 |
|---|---|---|
| 第 1 维 | 0.1 | 在某个抽象方向上的分量 |
| 第 2 维 | 0.5 | 在另一个方向上的分量 |
| 第 3 维 | -0.2 | 在第三个方向上的分量(可为负) |
几个要点:
- 有序 :
[0.1, 0.5]和[0.5, 0.1]是不同的向量,不能交换位置。 - 一起才有意义:单独看 0.5 没意义,整串数字合起来才表示「这句话在语义空间里的位置」。
- Embedding 的向量很长 :真实场景常见 768、1024、1536 维------不是 3 个数,而是一千多个 float 排成一排;维数由模型决定,你只需整段文本调一次 API,拿回完整数组。
- 可以是负数:Embedding 里正负都有,模型内部学出来的,不必强行解释每一维代表什么。
「向量」和「矢量」英文都是 vector (物理课里的力、速度也是矢量)。和向量相对的概念通常是标量。
Embedding 里,每个 float 对应高维空间里的一个坐标轴;你不需要读懂每一维,只要知道:两个向量离得近 = 两段话意思像。
2.4 生活化类比
想象每一句话都被贴上图书馆里的坐标:
- 「如何取消订单」→ 坐标 A
- 「退货退款流程」→ 坐标 B,离 A 很近
- 「公司年假制度」→ 坐标 C,离 A 很远
Embedding 模型就是那个自动贴坐标的机器。
3. 相似度怎么算?
3.1 余弦相似度
两段话各变成一个向量,就是空间里的两个点:
- 点离得近 → 语义像
- 点离得远 → 语义不像
文本场景最常用余弦相似度:
- 值约在 0 ~ 1(越接近 1 越像)
- 1 ≈ 方向几乎相同
- 0 ≈ 无关
3.2 Python 手算
python
import math
def cosine_similarity(v1, v2):
dot = sum(a * b for a, b in zip(v1, v2))
norm1 = math.sqrt(sum(a * a for a in v1))
norm2 = math.sqrt(sum(b * b for b in v2))
return dot / (norm1 * norm2)
a = embed("如何取消订单") # 假设 embed() 已返回向量
b = embed("退货退款怎么办")
c = embed("员工年假有几天")
print(cosine_similarity(a, b)) # 通常较高,如 0.85+
print(cosine_similarity(a, c)) # 通常较低,如 0.3x
3.3 变成 0--100 分
python
def to_percent(sim: float) -> int:
return round(max(0.0, min(1.0, sim)) * 100)
注意:绝对分数因模型而异 。对比时更可靠的是同一模型下、同一批文本之间的相对高低,而不是执着于 82 还是 85。
3.4 余弦相似度 vs 余弦距离
pgvector 用 余弦距离 运算符 <=>:
text
余弦距离 = 1 - 余弦相似度 (向量归一化等常见设定下)
- 距离越小 → 越相似
- 相似度越大 → 越相似
别搞反。
4. 调用 Embedding API
下面用最常见的 OpenAI 兼容 /embeddings 接口举例(多数云厂商格式类似:传文本,拿回 embedding 数组)。
4.1 HTTP 请求
bash
curl https://api.openai.com/v1/embeddings \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "text-embedding-3-small",
"input": "如何取消订单并退款"
}'
国产云厂商(如阿里云百炼、智谱等)通常也提供 OpenAI 兼容地址,只需改 base_url 和 model:
bash
curl "$EMBEDDING_BASE_URL/embeddings" \
-H "Authorization: Bearer $EMBEDDING_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "text-embedding-v4",
"input": "如何取消订单并退款",
"dimensions": 1536
}'
4.2 响应里你关心的部分
json
{
"data": [
{ "embedding": [0.012, -0.034, 0.089, "..."] }
],
"usage": { "prompt_tokens": 8 }
}
要点:
embedding长度由模型决定(如 1536 维)- 按 token 计费,通常比 Chat 便宜
- 一次可传多条
input(批量嵌入)
4.3 Python 最小示例
python
from openai import OpenAI
client = OpenAI() # 或 OpenAI(api_key=..., base_url=...)
def embed(text: str) -> list[float]:
resp = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return resp.data[0].embedding
a = embed("如何取消订单")
print(len(a)) # 1536
5. Chat 和 Embedding 为什么要分开配?
这是落地时最常见的认知误区。
很多团队只开通了一个大模型的 Chat API (如各类对话模型),就想顺便拿它做 Embedding。通常行不通:
- Chat 模型走
/chat/completions,输出是人话或 JSON - Embedding 走
/embeddings,输出是数字向量 - 多数 Chat 提供商并不提供 Embedding 端点;即使有,也是另一个模型、另一个计费
工程上的标准做法:
| 用途 | 典型选择 | 配置思路 |
|---|---|---|
| 对话 / 分析 / 生成 | Chat 模型 A | CHAT_API_KEY + Chat base URL |
| 语义向量 | Embedding 模型 B | EMBEDDING_API_KEY + Embedding base URL |
两者可以来自同一家云 (如都用 OpenAI 兼容接口),也可以是不同家 (Chat 用 A 厂、Embedding 用 B 厂)。关键是:各调各的 API,各用各的模型。
环境变量示例(命名仅作参考):
bash
# Chat
CHAT_API_KEY=sk-...
CHAT_BASE_URL=https://api.example.com/v1
CHAT_MODEL=some-chat-model
# Embedding
EMBEDDING_API_KEY=sk-...
EMBEDDING_BASE_URL=https://api.example.com/v1
EMBEDDING_MODEL=text-embedding-3-small
EMBEDDING_DIMENSIONS=1536
6. LLM 打分 vs Embedding 打分:可以并存
除了 Embedding,你也可以让 Chat 模型读两段话,直接输出「匹配度 1--100」并解释原因。
两条路线各有优劣:
| 维度 | Embedding + 向量距离 | Chat 直接打分 |
|---|---|---|
| 速度 | 快 | 慢 |
| 成本 | 低 | 高 |
| 稳定性 | 同输入结果稳定 | 受 Prompt 影响大 |
| 可解释性 | 弱(只有分数) | 强(可写理由) |
| 大规模检索 | 擅长(百万条 Top-K) | 不擅长 |
| 同义词 / 换说法 | 语义上自然接近 | 不一定稳定 |
工程上常见做法:两者并存。
- Embedding 分:客观、可复现的语义相似度(适合排序、对比)
- LLM 分:带 breakdown、建议、摘要的可解释分析
产品上可以并排展示两个指标,各自标注来源,避免用户混淆。
7. 业务里怎么用?
7.1 语义搜索(最常见)
text
离线:文档 → Embedding → 存入向量库
在线:用户 query → Embedding → Top-K 最近邻 → 返回结果
7.2 一对一匹配(如简历 vs 岗位)
text
文本 A → 向量 a
文本 B → 向量 b
余弦相似度(a, b) → 匹配分
不需要检索库,算一次距离即可。
7.3 RAG(检索增强生成)
text
用户问题 → Embedding → 检索相关段落 → 拼进 Prompt → Chat 生成答案
Embedding 负责找材料 ;Chat 负责组织语言。企业知识库问答底层多是这条链。
7.4 完整迷你 Demo
目标:验证「退票」和「取消订单」比「年假」更像。
python
from openai import OpenAI
import math
client = OpenAI()
texts = [
"如何取消订单并退款",
"退货流程说明",
"公司员工年假有几天",
]
query = "我想退票"
def embed(text):
r = client.embeddings.create(model="text-embedding-3-small", input=text)
return r.data[0].embedding
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
q = embed(query)
for t in texts:
print(f"{cosine(q, embed(t)):.3f} {t}")
期望:前两条明显高于「年假」。跑通这一刻,API → 相似度 → 应用的闭环就完成了。
8. 向量存哪里?
| 方案 | 适合阶段 | 优点 | 注意 |
|---|---|---|---|
| 内存 / JSON | Demo、几千条 | 零依赖 | 难扩展 |
| PostgreSQL + pgvector | 已有 PG、百万级以内 | 向量与业务同库 | 要装扩展,维度写对 |
| Redis / ES 向量字段 | 已有这些设施 | 运维统一 | 能力因版本而异 |
| Milvus / Qdrant / Pinecone | 大规模高 QPS | 检索优化 | 多一套组件 |
早期原则:别为了「将来千万级」过早上专用向量库。 几千、几万条,pgvector 或内存都够用。
9. pgvector 落地指南
9.1 换 Docker 镜像
普通 postgres:16 没有 vector 扩展。要用:
yaml
# docker-compose.yml 示例
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpass
POSTGRES_DB: myapp
ports:
- "5432:5432"
三件事的顺序不要搞混:
text
1. 换镜像(让 PostgreSQL 能装 vector 扩展)
2. 写 migration
3. 执行 migrate
9.2 Migration:第一条 SQL
必须先启用扩展:
sql
CREATE EXTENSION IF NOT EXISTS vector;
否则后面 vector(1536) 会报类型不存在。
建表示例:
sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536) -- 维度必须和 Embedding 模型一致
);
给已有表加列:
sql
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE articles
ADD COLUMN embedding vector(1536),
ADD COLUMN semantic_score INT;
9.3 用 SQL 做 Top-K 检索
sql
-- $1 是 query 的 embedding 向量
SELECT id, content, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
<=> 是余弦距离:越小越相似。
数据量上来后,可加 HNSW / IVFFlat 索引;早期几千条暴力算即可。
9.4 应用层写入向量
各语言驱动做法不同,核心是:把 []float 转成 pgvector 接受的格式(如 '[0.1,0.2,...]'::vector)。
伪代码:
text
vector_str = "[" + join(embeddings, ",") + "]"
INSERT INTO documents (content, embedding)
VALUES ($content, $vector_str::vector)
一对一相似度也可以在应用层算余弦,不必走 SQL------两种都行,看数据量和架构偏好。
9.5 在现有 API 里加语义分(通用模式)
假设你已有同步接口 POST /analyze,接收两段文本,返回 LLM 分析结果。加语义分的最小改动是加一条旁路,而不是重写服务:
text
收到 text_a, text_b
│
├─并行─→ Embed(text_a) ─┐
├─并行─→ Embed(text_b) ─┼─→ 余弦 → semantic_score
└─并行─→ Chat 分析 ─→ llm_score + 文字说明
│
▼
持久化(文本 + 向量 + 双分)→ 返回 JSON
Embedding 和 Chat 输入相同、输出互不依赖,可以并行以缩短总耗时。
响应 JSON 示例(字段名自定):
json
{
"llm_score": 72,
"semantic_score": 85,
"analysis": { "...": "..." }
}
10. 验收思路
以「简历 vs 三个 JD」为例(换成任何两段文本对比场景都一样):
| 文本 B | 期望 semantic_score |
|---|---|
| Go 后端开发 | 高 |
| Golang 服务端工程师 | 高(接近上一行) |
| 产品经理 | 明显更低 |
验证三件事:
- API 返回语义分,且排序符合直觉
- 向量已写入数据库(
embedding IS NOT NULL) - 前端或客户端能展示语义分(可与 LLM 分并排)
11. 常见坑
| 坑 | 说明 |
|---|---|
| 维度对不上 | 模型 1536 维,表写 vector(768) → 插入失败 |
| 混用不同模型 | A 模型算的向量不能和 B 模型的比距离;换模型要全量重算 |
| 用 Chat 当 Embedding | Chat 模型不会给你可靠的语义坐标 |
| 文本过长 | 超 max token 要截断或分块 |
| 指标搞反 | 余弦距离越小越像;相似度越大越像 |
| 忽视成本 | Embedding 便宜但不是免费;内容没变别重算 |
| 只配 Chat Key | 忘了单独配 Embedding API,上线后 analyze 报错 |
12. 小结
图 1:Embedding 在系统中的位置
text
文本 ──→ [Embedding 模型] ──→ 向量 ──→ [距离/相似度] ──→ 分数
↑
另一条文本的向量
图 2:Chat 与 Embedding 的分工
text
Embedding:「这两句话像不像?」(数学,稳定)
Chat: 「为什么像?帮我写一段分析。」(语言,可解释)
图 3:从 Demo 到生产
text
内存手算 → PostgreSQL + pgvector →(规模上来)索引优化或专用向量库
写在最后
Embedding 并不神秘:它只是一种把文字变成坐标的 API。
落地时记住三件事:
- Chat 和 Embedding 是两类 API,通常要分开配 Key、分开选模型
- LLM 分负责解释,Embedding 分负责相似度------可以并存,别互相替代
- pgvector 让向量和业务数据同库 ------换镜像 →
CREATE EXTENSION→vector(n)→ 写入