上一篇(demo-22)讲了如何把长文档切成合适的文本块。切好之后,下一步就是把文本块转换成向量。
这一篇专门讲 Embedding:文本和 Document 如何变成向量、向量之间如何比较、以及如何接入向量库。demo 使用阿里云 MaaS 的 Qwen 文本向量模型。
代码基于 JavaScript 版
langchainv1,Embedding 接口来自@langchain/core,向量库使用@langchain/classic的MemoryVectorStore。
一、什么是 Embedding
Embedding 模型把一段文本映射成一个固定维度的浮点数向量:
"这件衣服的质量很好"
↓
[0.0259, 0.0694, -0.0615, 0.0185, ...]
这个向量不是给人读的,而是用来计算语义相似度。语义越接近的文本,向量在空间中的方向越接近。
常见用途:
- RAG:文档和问题都转成向量,再做相似度检索;
- 语义搜索:不依赖关键词完全匹配;
- 聚类、去重、分类、推荐。
几个关键认知:
- 向量维度由模型决定,本 demo 的模型输出 1024 维;
- 单个维度的数值没有直观含义,有意义的是向量之间的关系;
- 相似度通常用余弦相似度衡量;
- 换 embedding 模型后,向量空间会变,旧索引通常要重建。
二、环境准备
2.1 运行环境
- Node.js >= 20;
- 原生
fetch; process.loadEnvFile读取.env;- 一个可用的 embedding API Key。
2.2 配置文件
在 demo-23/.env 中保存配置:
API_KEY=sk-...
EMBEDDING_MODEL=qwen3.7-text-embedding
EMBEDDING_BASE_URL=https://llm-q00qu1ahp6gnapvj.cn-beijing.maas.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding
代码启动时加载:
import { existsSync } from "node:fs";
import { join } from "node:path";
const ENV_PATH = join(import.meta.dirname, ".env");
if (existsSync(ENV_PATH)) process.loadEnvFile(ENV_PATH);
const apiKey = process.env.DASHSCOPE_API_KEY ?? process.env.API_KEY;
注意:.env 不要提交到仓库,日志里也不要打印完整 Key。
2.3 接口调用
等价的 curl 调用:
curl --location 'https://llm-q00qu1ahp6gnapvj.cn-beijing.maas.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding' \
--header "Authorization: Bearer $API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"model": "qwen3.7-text-embedding",
"input": {
"texts": ["衣服的质量杠杠的,很漂亮,不枉我等了这么久啊,喜欢,以后还来这里买"]
}
}'
响应结构:
{
"output": {
"embeddings": [
{
"text_index": 0,
"embedding": [0.025892, 0.069366, -0.061488, "..."]
}
]
},
"usage": {
"total_tokens": 37
},
"request_id": "..."
}
几个要点:
- 返回的每一项带
text_index,批量请求时要用它恢复输入顺序; usage.total_tokens可以用于成本监控;- 这个接口不是 OpenAI 兼容格式,不能直接套
OpenAIEmbeddings。
2.4 依赖
npm install @langchain/core @langchain/classic
本 demo 不需要额外的 SDK,直接用原生 fetch 调用 HTTP 接口。
三、实现 LangChain 的 Embeddings 接口
LangChain 的组件依赖 Embeddings 抽象,只要实现两个方法:
embedQuery(text):把查询转成向量;embedDocuments(texts):把一批文档转成向量。
完整实现:
import { Embeddings } from "@langchain/core/embeddings";
class QwenEmbeddings extends Embeddings {
constructor({ apiKey, baseUrl, model, batchSize = 10 }) {
super({});
this.apiKey = apiKey;
this.baseUrl = baseUrl;
this.model = model;
this.batchSize = batchSize;
this.lastUsage = null;
}
async #request(texts) {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: this.model,
input: { texts },
}),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`Embedding API ${response.status}: ${detail}`);
}
const data = await response.json();
this.lastUsage = data.usage ?? null;
return data.output.embeddings
.sort((a, b) => a.text_index - b.text_index)
.map((item) => item.embedding);
}
async embedDocuments(texts) {
const vectors = [];
for (let i = 0; i < texts.length; i += this.batchSize) {
const batch = texts.slice(i, i + this.batchSize);
vectors.push(...(await this.#request(batch)));
}
return vectors;
}
async embedQuery(text) {
const [vector] = await this.#request([text]);
return vector;
}
}
关键点:
- 按
text_index排序:接口不保证返回顺序,批量时尤其要注意; - 分批:控制单次请求的文本数量,避免超限;
- 统一错误处理:非 2xx 时把响应体带出来,方便排查;
- 实现标准接口:这样
MemoryVectorStore等组件可以直接使用。
四、把文本转换成向量
4.1 单条文本:embedQuery
const vector = await embeddings.embedQuery(
"衣服的质量杠杠的,很漂亮,不枉我等了这么久啊,喜欢,以后还来这里买"
);
console.log(vector.length); // 1024
console.log(vector.slice(0, 5)); // [0.02596, 0.06946, -0.06154, ...]
本 demo 的实际输出:
维度:1024
前 5 维:[0.02596, 0.06946, -0.06154, 0.01846, 0.04561, ...]
向量长度(L2 范数):1.000000
Token 用量:{"total_tokens":37}
这个模型的输出已经归一化,L2 范数为 1。归一化后,余弦相似度和内积是等价的。
4.2 批量文本:embedDocuments
const texts = [
"这件衣服的质量很好,我很喜欢",
"衣服做工不错,穿着也舒服",
"今天北京天气晴朗,适合出门",
"如何申请退款?",
"这件衣服颜色好看,就是有点贵",
];
const vectors = await embeddings.embedDocuments(texts);
console.log(vectors.length); // 5
console.log(vectors[0].length); // 1024
本 demo 的输出:
输入条数:5
输出条数:5
每条维度:1024
Token 用量:{"total_tokens":112}
批量结果必须和输入一一对应。接口返回的 text_index 就是用来做这件事的。
4.3 embedQuery 和 embedDocuments 的区别
两个方法底层调用的是同一个接口,区别主要在语义和使用场景:
embedQuery:用于查询,一次一条;embedDocuments:用于入库,一次一批。
有些模型会对「查询」和「文档」使用不同的前缀或指令,这时两个方法的行为可能不同。本 demo 的模型没有这个要求,所以底层实现一致。
五、把 Document 转换成向量
5.1 Document 不会自动向量化
Document 只是一个数据容器,创建它不会触发 embedding:
import { Document } from "@langchain/core/documents";
const doc = new Document({
pageContent: "这件衣服的质量很好,我很喜欢",
metadata: { id: "review-1", source: "reviews.csv", rating: 5 },
});
const [vector] = await embeddings.embedDocuments([doc.pageContent]);
需要自己取出 pageContent,再调用 embedDocuments。
5.2 批量处理 Document
const documents = reviewRecords.map(
(record) =>
new Document({
pageContent: record.text,
metadata: {
id: record.id,
source: record.source,
rating: record.rating,
},
})
);
const documentVectors = await embeddings.embedDocuments(
documents.map((doc) => doc.pageContent)
);
本 demo 的映射关系:
[review-1] 这件衣服的质量很好,我很喜欢
-> [0.02371, 0.05204, -0.04342, ...]
[review-2] 衣服做工不错,穿着也舒服
-> [0.02100, 0.05129, -0.05639, ...]
[review-5] 衣服的质量杠杠的,很漂亮……
-> [0.02596, 0.06946, -0.06154, ...]
5.3 向量、原文、metadata 分开保存
工程上通常保存成这样一条索引记录:
{
id: "review-1",
vector: [0.0259, 0.0694, ...],
text: "这件衣服的质量很好,我很喜欢",
metadata: { source: "reviews.csv", rating: 5 }
}
不要把这些东西混在一起:
- 向量交给向量库;
- 原文用于最终拼上下文;
- metadata 用于过滤和展示。
把 1024 个浮点数塞进 metadata 会给序列化、日志、过滤带来很大负担。
5.4 只向量化正文就够了吗
默认只向量化 pageContent。如果标题、章节名对语义很重要,可以把它们拼进去:
const textForEmbedding = `${doc.metadata.title}\n${doc.pageContent}`;
但要注意:检索时查询和文档的向量化方式要一致,否则相似度会失真。
六、用余弦相似度比较语义
6.1 公式
cos(a, b) = (a · b) / (|a| × |b|)
- 越接近 1,方向越一致,语义越相似;
- 越接近 0,越不相关;
- 余弦相似度只比较方向,不关心向量长度。
const cosineSimilarity = (a, b) => {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
};
6.2 实际效果
问题:衣服质量怎么样?
本 demo 的排序结果:
1. 0.7473 [review-2] 衣服做工不错,穿着也舒服
2. 0.7463 [review-5] 衣服的质量杠杠的,很漂亮……
3. 0.7284 [review-1] 这件衣服的质量很好,我很喜欢
4. 0.4283 [review-4] 如何申请退款?
5. 0.3249 [review-3] 今天北京天气晴朗,适合出门
再换一个问题:退款怎么操作?
Top-1:0.9013 如何申请退款?
这说明:
- 不需要关键词完全匹配,模型也能理解「衣服质量」和「退款」的语义;
- 三条衣服评价的分数明显高于天气和退款;
- 不同主题之间的分数差距是有效的排序信号。
6.3 分数是相对的
不要直接拿一个固定阈值判断「相关」:
- 不同模型的分数分布不同;
- 不同语料的分数分布也不同;
- 有的模型分数整体偏高,有的整体偏低。
正确做法是在自己的数据上标注一批相关问题,观察 Recall@K 和分数分布,再决定阈值。
七、接上 MemoryVectorStore
手工算余弦相似度适合理解原理,实际项目直接用向量库。
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
const vectorStore = await MemoryVectorStore.fromDocuments(
documents,
embeddings
);
const results = await vectorStore.similaritySearchWithScore(
"衣服质量怎么样?",
3
);
MemoryVectorStore 自动完成三件事:
- 对每个
Document调embedDocuments; - 保存向量、原文和 metadata;
similaritySearch时对查询调embedQuery,再算相似度。
本 demo 的检索结果:
1. score=0.7474 [review-2] 衣服做工不错,穿着也舒服
2. score=0.7463 [review-5] 衣服的质量杠杠的,很漂亮……
3. score=0.7284 [review-1] 这件衣服的质量很好,我很喜欢
MemoryVectorStore 只适合开发和演示。生产环境把同样的向量写入 pgvector、Milvus、Qdrant、Chroma 等向量数据库:
{
id: "review-1",
vector: [...],
pageContent: "...",
metadata: {...}
}
八、工程实践
8.1 批量与限流
- 批量调用减少网络往返,但要注意接口的批量上限;
- 失败时按指数退避重试,避免瞬时 429 / 5xx;
- 记录请求 ID,方便排查问题。
8.2 维度与模型版本
- 本 demo 的模型输出 1024 维;
- 向量库的维度必须和模型一致;
- 换 embedding 模型或版本后,旧向量通常需要全量重建。
8.3 缓存
相同文本不要重复调用。可以用文本哈希做缓存键:
const key = createHash("sha256").update(text).digest("hex");
缓存能显著降低成本和索引时间。
8.4 归一化
- 余弦相似度本身会处理长度;
- 如果向量库使用内积(IP)检索,入库前通常要先做 L2 归一化;
- 本 demo 的模型输出已经归一化,但不要假设所有模型都如此。
8.5 数据与隐私
- 向量化会把文本发送到外部服务,注意数据合规;
- API Key 不要提交到仓库;
- 敏感数据要考虑脱敏、私有化部署或本地 embedding 模型。
8.6 文本预处理
- 去掉页眉页脚、重复空行、导航栏等噪声;
- 文档切分策略会影响向量质量,先做好切分再向量化;
- 过短或空的文本块没有检索价值,可以过滤掉。
8.7 成本
计费通常按输入 Token 计算。响应里的 usage.total_tokens 可以用于监控成本,也可以按批次统计。
九、Python 对应写法
Python 侧同样是实现 Embeddings 接口:
import requests
from langchain_core.embeddings import Embeddings
class QwenEmbeddings(Embeddings):
def __init__(self, api_key: str, base_url: str, model: str):
self.api_key = api_key
self.base_url = base_url
self.model = model
def _embed(self, texts: list[str]) -> list[list[float]]:
response = requests.post(
self.base_url,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"input": {"texts": texts},
},
timeout=60,
)
response.raise_for_status()
data = response.json()
items = sorted(
data["output"]["embeddings"],
key=lambda item: item["text_index"],
)
return [item["embedding"] for item in items]
def embed_documents(self, texts: list[str]) -> list[list[float]]:
return self._embed(texts)
def embed_query(self, text: str) -> list[float]:
return self._embed([text])[0]
使用:
import os
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
embedding = QwenEmbeddings(
api_key=os.environ["API_KEY"],
base_url=os.environ["EMBEDDING_BASE_URL"],
model="qwen3.7-text-embedding",
)
documents = [
Document(
page_content="这件衣服的质量很好,我很喜欢",
metadata={"id": "review-1", "source": "reviews.csv"},
),
Document(
page_content="如何申请退款?",
metadata={"id": "review-4", "source": "faq.md"},
),
]
store = InMemoryVectorStore(embedding=embedding)
store.add_documents(documents)
results = store.similarity_search_with_score("衣服质量怎么样?", k=3)
for doc, score in results:
print(score, doc.page_content)
十、小结
- Embedding:把文本映射成固定维度向量,用相似度表达语义接近程度。
- 文本向量化:
embedQuery(text):单条向量,适合查询;embedDocuments(texts):批量向量,适合文档入库。
- Document 向量化:
- 取出
pageContent,调用embedDocuments; - 原文、向量、metadata 分开保存,向量交给向量库。
- 取出
- 相似度:
- 余弦相似度越接近 1 越相似;
- 分数是相对的,要在自己的数据上评估阈值。
- 接入检索:
MemoryVectorStore.fromDocuments(documents, embeddings);similaritySearchWithScore(query, k)。
- 生产注意:批量、重试、缓存、维度一致、模型版本、数据合规。
一句话:Embedding 把「意思」变成「方向」,检索就是在向量空间里找方向最接近的文本。