创见博客
LangChain Embedding 实战:把文本和文档转换成向量
七崽爱吃小饼干2026/10/18阅读 0

上一篇(demo-22)讲了如何把长文档切成合适的文本块。切好之后,下一步就是把文本块转换成向量。

这一篇专门讲 Embedding:文本和 Document 如何变成向量、向量之间如何比较、以及如何接入向量库。demo 使用阿里云 MaaS 的 Qwen 文本向量模型。

代码基于 JavaScript 版 langchain v1,Embedding 接口来自 @langchain/core,向量库使用 @langchain/classic 的 MemoryVectorStore。

一、什么是 Embedding

Embedding 模型把一段文本映射成一个固定维度的浮点数向量:

"这件衣服的质量很好"
        ↓
[0.0259, 0.0694, -0.0615, 0.0185, ...]

这个向量不是给人读的,而是用来计算语义相似度。语义越接近的文本,向量在空间中的方向越接近。

常见用途:

  • RAG:文档和问题都转成向量,再做相似度检索;
  • 语义搜索:不依赖关键词完全匹配;
  • 聚类、去重、分类、推荐。

几个关键认知:

  1. 向量维度由模型决定,本 demo 的模型输出 1024 维;
  2. 单个维度的数值没有直观含义,有意义的是向量之间的关系;
  3. 相似度通常用余弦相似度衡量;
  4. 换 embedding 模型后,向量空间会变,旧索引通常要重建。

二、环境准备

2.1 运行环境

  • Node.js >= 20;
  • 原生 fetch;
  • process.loadEnvFile 读取 .env;
  • 一个可用的 embedding API Key。

2.2 配置文件

在 demo-23/.env 中保存配置:

bash
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

代码启动时加载:

js
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 调用:

bash
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": ["衣服的质量杠杠的,很漂亮,不枉我等了这么久啊,喜欢,以后还来这里买"]
    }
  }'

响应结构:

json
{
  "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 依赖

bash
npm install @langchain/core @langchain/classic

本 demo 不需要额外的 SDK,直接用原生 fetch 调用 HTTP 接口。

三、实现 LangChain 的 Embeddings 接口

LangChain 的组件依赖 Embeddings 抽象,只要实现两个方法:

  • embedQuery(text):把查询转成向量;
  • embedDocuments(texts):把一批文档转成向量。

完整实现:

js
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;
  }
}

关键点:

  1. 按 text_index 排序:接口不保证返回顺序,批量时尤其要注意;
  2. 分批:控制单次请求的文本数量,避免超限;
  3. 统一错误处理:非 2xx 时把响应体带出来,方便排查;
  4. 实现标准接口:这样 MemoryVectorStore 等组件可以直接使用。

四、把文本转换成向量

4.1 单条文本:embedQuery

js
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

js
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:

js
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

js
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 分开保存

工程上通常保存成这样一条索引记录:

js
{
  id: "review-1",
  vector: [0.0259, 0.0694, ...],
  text: "这件衣服的质量很好,我很喜欢",
  metadata: { source: "reviews.csv", rating: 5 }
}

不要把这些东西混在一起:

  • 向量交给向量库;
  • 原文用于最终拼上下文;
  • metadata 用于过滤和展示。

把 1024 个浮点数塞进 metadata 会给序列化、日志、过滤带来很大负担。

5.4 只向量化正文就够了吗

默认只向量化 pageContent。如果标题、章节名对语义很重要,可以把它们拼进去:

js
const textForEmbedding = `${doc.metadata.title}\n${doc.pageContent}`;

但要注意:检索时查询和文档的向量化方式要一致,否则相似度会失真。

六、用余弦相似度比较语义

6.1 公式

cos(a, b) = (a · b) / (|a| × |b|)
  • 越接近 1,方向越一致,语义越相似;
  • 越接近 0,越不相关;
  • 余弦相似度只比较方向,不关心向量长度。
js
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

手工算余弦相似度适合理解原理,实际项目直接用向量库。

js
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

const vectorStore = await MemoryVectorStore.fromDocuments(
  documents,
  embeddings
);

const results = await vectorStore.similaritySearchWithScore(
  "衣服质量怎么样?",
  3
);

MemoryVectorStore 自动完成三件事:

  1. 对每个 Document 调 embedDocuments;
  2. 保存向量、原文和 metadata;
  3. 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 等向量数据库:

js
{
  id: "review-1",
  vector: [...],
  pageContent: "...",
  metadata: {...}
}

八、工程实践

8.1 批量与限流

  • 批量调用减少网络往返,但要注意接口的批量上限;
  • 失败时按指数退避重试,避免瞬时 429 / 5xx;
  • 记录请求 ID,方便排查问题。

8.2 维度与模型版本

  • 本 demo 的模型输出 1024 维;
  • 向量库的维度必须和模型一致;
  • 换 embedding 模型或版本后,旧向量通常需要全量重建。

8.3 缓存

相同文本不要重复调用。可以用文本哈希做缓存键:

js
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 接口:

python
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]

使用:

python
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 把「意思」变成「方向」,检索就是在向量空间里找方向最接近的文本。

评论
0/100