从零实现 Embedding 服务:文本转向量

本文面向:想理解 Embedding 服务原理和实现细节的开发者。 预计阅读时间:12 分钟 最终效果:理解文本转向量的完整流程------构建、分块、API 调用、存储、语义搜索,能独立实现一个最小 Embedding 服务。


什么是 Embedding

在日常对话中,我们靠"语感"判断两段话是否相关。但计算机不理解语义,它只认识数字。Embedding(嵌入) 就是把人类语言转换成机器可以计算的数字表示的过程。

具体来说,Embedding 模型接收一段文本,输出一个固定长度的浮点数组(向量)。例如:

arduino 复制代码
输入: "如何配置 Ollama 本地模型"
输出: [0.023, -0.156, 0.089, 0.412, ..., -0.034]  // 长度取决于模型,通常 384~3072

关键特性是:语义相近的文本,向量也相近。 "配置 Ollama 模型"和"设置本地 LLM 环境"这两句话,虽然用词不同,但它们的向量在空间中的距离会很近。这就是 Embedding 能用于语义搜索的根本原因。

向量的数学意义:余弦相似度

得到向量之后,如何衡量两个向量的"相似程度"?最常用的方法是余弦相似度(Cosine Similarity)

想象两个箭头从原点出发。如果它们指向完全相同的方向,夹角为 0 度,余弦值为 1(完全相似)。如果互相垂直(90 度),余弦值为 0(无关)。如果方向相反(180 度),余弦值为 -1(完全相反)。

数学公式:

css 复制代码
cosine_similarity(A, B) = (A . B) / (|A| * |B|)

其中 A . B 是向量点积,|A| 是向量的模(长度)。实际使用中,Embedding 模型输出的向量通常已经归一化(模为 1),所以余弦相似度就简化为点积。

在 ChatCrystal 的搜索流程中,vectra 向量索引内部就使用余弦相似度来计算查询向量与所有存储向量之间的距离,然后返回得分最高的结果。

ChatCrystal 的 Embedding 架构

ChatCrystal 的 Embedding 服务涉及三个核心步骤:

markdown 复制代码
笔记文本 → 文本预处理(构建 + 分块)→ Embedding API 调用 → vectra 向量存储
                                                                    ↓
查询文本 → Embedding API 调用 → 余弦相似度匹配 ←────────────────┘

对应的代码集中在 server/src/services/ 下的三个文件中:

  • embedding.ts --- 文本构建、分块、Embedding 调用、语义搜索
  • providers.ts --- Embedding 模型工厂(Ollama/OpenAI/Google/Azure/自定义)
  • vector-index.ts --- vectra LocalIndex 的生命周期管理

第一步:构建有意义的 Embedding 文本

不是直接把原始对话丢进 Embedding 模型。ChatCrystal 先用 LLM 对对话生成结构化笔记(标题、摘要、结论、代码片段、标签),然后把这些结构化字段拼接成一段适合 Embedding 的文本。

buildNoteEmbeddingText 函数负责这个拼接过程:

typescript 复制代码
function buildNoteEmbeddingText(input: BuildNoteEmbeddingTextInput): string {
  const parts: string[] = [];

  appendText(parts, input.title);        // 标题
  appendText(parts, input.summary);      // 摘要

  // 关键结论
  for (const conclusion of stringArrayFromJson(input.keyConclusionsJson)) {
    appendText(parts, conclusion);
  }

  appendText(parts, input.tagsText);     // 标签

  // 代码片段的描述(不是代码本身)
  const codeSnippets = safeParseJson(input.codeSnippetsJson);
  if (Array.isArray(codeSnippets)) {
    for (const snippet of codeSnippets) {
      if (isRecord(snippet)) {
        appendText(parts, snippet.description);
      }
    }
  }

  return dedupeExact(parts).join('\n\n');
}

这里有几个值得注意的设计决策:

  1. 拼接的是摘要信息,不是原始对话。 原始对话可能有上万 token,而笔记的标题 + 摘要 + 结论通常只有几百字,信息密度更高。
  2. 代码片段只取描述,不取代码体。 代码的语义很难被通用 Embedding 模型准确捕捉,而"这段代码做了什么"的自然语言描述更有搜索价值。
  3. 去重。 dedupeExact 确保相同内容不会被重复嵌入。
  4. 对于记忆型笔记(agent-writeback 和 manual-note),会额外拼接错误签名、文件路径等元信息, 让搜索时更容易命中。

第二步:分块策略

Embedding 模型有上下文长度限制,太长的文本会被截断。即使模型支持长上下文,过长的文本也会让向量"稀释"------一个向量要表达太多主题,导致搜索精度下降。

ChatCrystal 的策略是按 500 字符分块,优先在段落边界切割

typescript 复制代码
const CHUNK_SIZE = 500;

function chunkText(text: string): string[] {
  if (text.length <= CHUNK_SIZE) return [text];

  const chunks: string[] = [];
  const paragraphs = text.split(/\n\n+/);  // 按双换行分割段落
  let current = '';

  for (const para of paragraphs) {
    if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) {
      chunks.push(current.trim());
      current = para;
    } else {
      current += (current ? '\n\n' : '') + para;
    }
  }
  if (current.trim()) chunks.push(current.trim());

  return chunks;
}

为什么选择 500 字符?这是一个经验值------对于中英文混合的技术文本,500 字符大约对应一个完整的知识点或一段结论。太小会丢失上下文,太大则降低搜索精度。

段落边界切割的意义在于:段落通常是语义完整单元。在段落中间硬切可能导致一句话被拆到两个 chunk 里,两半都变得难以理解。

第三步:调用 Embedding API

ChatCrystal 使用 Vercel AI SDK 的 embed 函数,统一了不同提供商的调用接口。模型工厂根据配置创建对应的 Embedding 模型:

typescript 复制代码
function getEmbeddingModel() {
  const { provider, ...config } = appConfig.embedding;
  const entry = getProvider(provider);
  if (!entry.createEmbeddingModel) {
    throw new Error(`Provider "${provider}" does not support embeddings.`);
  }
  return entry.createEmbeddingModel(config);
}

支持的提供商和它们的实现方式:

提供商 SDK Embedding 端点
Ollama @ai-sdk/openai(兼容层) localhost:11434/v1/embeddings
OpenAI @ai-sdk/openai api.openai.com/v1/embeddings
Google @ai-sdk/google Gemini embedding API
Azure @ai-sdk/azure Azure OpenAI embeddings
自定义 @ai-sdk/openai(兼容层) 任何 OpenAI 兼容端点

以 Ollama 为例,它实际上通过 OpenAI 兼容接口调用:

typescript 复制代码
createEmbeddingModel({ baseURL, model }) {
  const url = baseURL || 'http://localhost:11434';
  const ollama = createOpenAI({
    baseURL: `${url}/v1`,
    apiKey: 'ollama',
    name: 'ollama'
  });
  return ollama.textEmbeddingModel(model);
}

实际调用非常简洁:

typescript 复制代码
const model = getEmbeddingModel();
const { embedding } = await embed({ model, value: chunkText });
// embedding 是 number[],例如 [0.023, -0.156, 0.089, ...]

对于一篇笔记的多个 chunk,逐个调用 embed 并收集所有向量:

typescript 复制代码
const vectors: { chunkIndex: number; chunkText: string; vector: number[] }[] = [];
for (const chunk of embeddings) {
  const { embedding } = await embed({ model, value: chunk.chunkText });
  vectors.push({
    chunkIndex: chunk.chunkIndex,
    chunkText: chunk.chunkText,
    vector: embedding,
  });
}

第四步:存储向量到 vectra

生成的向量需要持久化存储。ChatCrystal 使用 vectra,一个轻量级的本地向量索引库。它基于 HNSW(Hierarchical Navigable Small World)算法,能在百万级向量中实现毫秒级的近似最近邻搜索。

vectra 的索引存储在数据目录下的 vectra-index/ 文件夹中:

typescript 复制代码
const INDEX_PATH = resolve(appConfig.dataDir, 'vectra-index');

export async function getIndex(): Promise<LocalIndex> {
  if (_index) return _index;
  _index = new LocalIndex(INDEX_PATH);
  if (!(await _index.isIndexCreated())) {
    await _index.createIndex();
  }
  return _index;
}

写入向量时,每个 chunk 作为一条记录存入 vectra,同时附带元数据:

typescript 复制代码
const item = await index.insertItem({
  vector: chunk.vector,
  metadata: {
    noteId: id,
    chunkIndex: chunk.chunkIndex,
    conversationId,
    title,
    projectName,
  },
});

这些 metadata 不参与相似度计算,但在返回搜索结果时用于关联到原始笔记。

同时,chunk 文本本身和 vectra 的 item ID 会写入 SQLite 数据库,作为备份和文本检索的来源:

typescript 复制代码
db.run(
  `INSERT INTO embeddings (note_id, chunk_index, chunk_text, vectra_id)
   VALUES (?, ?, ?, ?)`,
  [noteId, item.chunkIndex, item.chunkText, item.id],
);

这是一个双写策略------vectra 负责向量检索,SQLite 负责文本存储和元数据查询。两者通过 vectra_id 关联。

查询向量:语义搜索的完整流程

当用户输入一个搜索查询时,完整流程如下:

  1. 查询文本 Embedding: 将查询文本送入同一个 Embedding 模型,得到查询向量。
  2. vectra 向量检索: 用查询向量在 vectra 索引中查找最相似的 topK 个 chunk。
  3. 文本物化: 从 SQLite 中取出 chunk 的原始文本,按 noteId 去重(保留最高分)。
  4. 关系扩展(可选): 沿知识图谱的关系边扩展,找到关联笔记,分数打 7 折。
typescript 复制代码
async function semanticSearch(query: string, topK = 10) {
  const index = await getIndex();
  const embedding = await embedSearchQuery(query);     // 查询文本 → 向量
  const results = await index.queryItems(embedding, query, topK);  // 向量检索
  return await materializeDirectSearchHits(db, results);            // 文本物化
}

queryItems 内部就是计算查询向量与所有存储向量的余弦相似度,返回得分最高的 topK 个结果。

动手实现:最小 Embedding 服务

理解了原理之后,我们来实现一个最小的 Embedding 服务。以下代码展示核心流程,去掉了 ChatCrystal 中的业务逻辑和错误处理:

typescript 复制代码
import { embed } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';

// 1. 创建 Embedding 模型(以 OpenAI 为例)
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const model = openai.textEmbeddingModel('text-embedding-3-small');

// 2. 文本分块
function chunkText(text: string, chunkSize = 500): string[] {
  if (text.length <= chunkSize) return [text];
  const paragraphs = text.split(/\n\n+/);
  const chunks: string[] = [];
  let current = '';
  for (const para of paragraphs) {
    if (current.length + para.length > chunkSize && current.length > 0) {
      chunks.push(current.trim());
      current = para;
    } else {
      current += (current ? '\n\n' : '') + para;
    }
  }
  if (current.trim()) chunks.push(current.trim());
  return chunks;
}

// 3. 生成 Embedding
async function generateEmbeddings(text: string) {
  const chunks = chunkText(text);
  const results = [];
  for (const chunk of chunks) {
    const { embedding } = await embed({ model, value: chunk });
    results.push({ text: chunk, vector: embedding });
  }
  return results;
}

// 4. 计算余弦相似度
function cosineSimilarity(a: number[], b: number[]): number {
  let dotProduct = 0, normA = 0, normB = 0;
  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

// 5. 搜索
async function search(query: string, documents: { text: string; vector: number[] }[]) {
  const { embedding: queryVector } = await embed({ model, value: query });
  return documents
    .map(doc => ({ text: doc.text, score: cosineSimilarity(queryVector, doc.vector) }))
    .sort((a, b) => b.score - a.score);
}

这 50 行代码覆盖了 Embedding 服务的核心:模型调用、文本分块、向量生成、相似度计算。ChatCrystal 在此基础上增加了多提供商支持、vectra 持久化存储、双写策略、关系扩展等工程化能力,但核心原理完全一致。

总结

Embedding 服务的本质并不复杂:把文本变成向量,用余弦相似度比较向量。但要做好一个生产级的 Embedding 服务,需要关注很多细节:

  • 文本预处理决定了向量的质量。垃圾进,垃圾出。
  • 分块策略影响搜索精度。太大会稀释语义,太小会丢失上下文。
  • 存储方案决定了查询性能。vectra 的 HNSW 算法让百万级向量的毫秒级查询成为可能。
  • 多提供商抽象让系统不被单一供应商锁定。

下一步


项目地址:github.com/ZengLiangYi...

相关推荐
ishangy18 小时前
AI视觉赋能智慧矿山:新一代安全防控体系解决方案
人工智能·边缘计算·ai视觉·智慧矿山·ai视觉监测·智能防控
CeshirenTester18 小时前
大厂校招变了:AI 能力正在进入笔试和面试
人工智能·面试·职场和发展
hughnz18 小时前
AI驱动自动化和智能体AI-加速钻头创新
运维·人工智能·自动化
薛定猫AI18 小时前
【深度解析】AI Coding 模型竞速:从 Claude Mythos 安全编码到 GPT-5.6 传闻,如何落地代码审查智能体
人工智能·gpt·安全
智驭未来掌门人18 小时前
我靠三份Markdown文件,把AI从“胡编乱造”训成了“工程监理”
人工智能·ai编程
星栈18 小时前
订单状态机别写散:我在 Rust CRM 里把 6 个状态收进领域模型
后端·rust·全栈
一起聊电气18 小时前
不止事后断电!AI安全用电开启照明主动防御新时代
人工智能·安全
速易达网络18 小时前
智慧三层停车场系统
人工智能
Y敲键盘的地方18 小时前
第7章 响应式终端UI
人工智能·ai编程