本文面向想理解 Embedding 服务原理和实现细节的开发者。预计阅读时间12 分钟最终效果理解文本转向量的完整流程——构建、分块、API 调用、存储、语义搜索能独立实现一个最小 Embedding 服务。什么是 Embedding在日常对话中我们靠语感判断两段话是否相关。但计算机不理解语义它只认识数字。Embedding嵌入就是把人类语言转换成机器可以计算的数字表示的过程。具体来说Embedding 模型接收一段文本输出一个固定长度的浮点数组向量。例如输入: 如何配置 Ollama 本地模型 输出: [0.023, -0.156, 0.089, 0.412, ..., -0.034] // 长度取决于模型通常 384~3072关键特性是语义相近的文本向量也相近。配置 Ollama 模型和设置本地 LLM 环境这两句话虽然用词不同但它们的向量在空间中的距离会很近。这就是 Embedding 能用于语义搜索的根本原因。向量的数学意义余弦相似度得到向量之后如何衡量两个向量的相似程度最常用的方法是余弦相似度Cosine Similarity。想象两个箭头从原点出发。如果它们指向完全相同的方向夹角为 0 度余弦值为 1完全相似。如果互相垂直90 度余弦值为 0无关。如果方向相反180 度余弦值为 -1完全相反。数学公式cosine_similarity(A, B) (A . B) / (|A| * |B|)其中A . B是向量点积|A|是向量的模长度。实际使用中Embedding 模型输出的向量通常已经归一化模为 1所以余弦相似度就简化为点积。在 ChatCrystal 的搜索流程中vectra 向量索引内部就使用余弦相似度来计算查询向量与所有存储向量之间的距离然后返回得分最高的结果。ChatCrystal 的 Embedding 架构ChatCrystal 的 Embedding 服务涉及三个核心步骤笔记文本 → 文本预处理构建 分块→ 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函数负责这个拼接过程functionbuildNoteEmbeddingText(input:BuildNoteEmbeddingTextInput):string{constparts:string[][];appendText(parts,input.title);// 标题appendText(parts,input.summary);// 摘要// 关键结论for(constconclusionofstringArrayFromJson(input.keyConclusionsJson)){appendText(parts,conclusion);}appendText(parts,input.tagsText);// 标签// 代码片段的描述不是代码本身constcodeSnippetssafeParseJson(input.codeSnippetsJson);if(Array.isArray(codeSnippets)){for(constsnippetofcodeSnippets){if(isRecord(snippet)){appendText(parts,snippet.description);}}}returndedupeExact(parts).join(\n\n);}这里有几个值得注意的设计决策拼接的是摘要信息不是原始对话。原始对话可能有上万 token而笔记的标题 摘要 结论通常只有几百字信息密度更高。代码片段只取描述不取代码体。代码的语义很难被通用 Embedding 模型准确捕捉而这段代码做了什么的自然语言描述更有搜索价值。去重。dedupeExact确保相同内容不会被重复嵌入。对于记忆型笔记agent-writeback 和 manual-note会额外拼接错误签名、文件路径等元信息让搜索时更容易命中。第二步分块策略Embedding 模型有上下文长度限制太长的文本会被截断。即使模型支持长上下文过长的文本也会让向量稀释——一个向量要表达太多主题导致搜索精度下降。ChatCrystal 的策略是按 500 字符分块优先在段落边界切割constCHUNK_SIZE500;functionchunkText(text:string):string[]{if(text.lengthCHUNK_SIZE)return[text];constchunks:string[][];constparagraphstext.split(/\n\n/);// 按双换行分割段落letcurrent;for(constparaofparagraphs){if(current.lengthpara.length2CHUNK_SIZEcurrent.length0){chunks.push(current.trim());currentpara;}else{current(current?\n\n:)para;}}if(current.trim())chunks.push(current.trim());returnchunks;}为什么选择 500 字符这是一个经验值——对于中英文混合的技术文本500 字符大约对应一个完整的知识点或一段结论。太小会丢失上下文太大则降低搜索精度。段落边界切割的意义在于段落通常是语义完整单元。在段落中间硬切可能导致一句话被拆到两个 chunk 里两半都变得难以理解。第三步调用 Embedding APIChatCrystal 使用 Vercel AI SDK 的embed函数统一了不同提供商的调用接口。模型工厂根据配置创建对应的 Embedding 模型functiongetEmbeddingModel(){const{provider,...config}appConfig.embedding;constentrygetProvider(provider);if(!entry.createEmbeddingModel){thrownewError(Provider ${provider} does not support embeddings.);}returnentry.createEmbeddingModel(config);}支持的提供商和它们的实现方式提供商SDKEmbedding 端点Ollamaai-sdk/openai兼容层localhost:11434/v1/embeddingsOpenAIai-sdk/openaiapi.openai.com/v1/embeddingsGoogleai-sdk/googleGemini embedding APIAzureai-sdk/azureAzure OpenAI embeddings自定义ai-sdk/openai兼容层任何 OpenAI 兼容端点以 Ollama 为例它实际上通过 OpenAI 兼容接口调用createEmbeddingModel({baseURL,model}){consturlbaseURL||http://localhost:11434;constollamacreateOpenAI({baseURL:${url}/v1,apiKey:ollama,name:ollama});returnollama.textEmbeddingModel(model);}实际调用非常简洁constmodelgetEmbeddingModel();const{embedding}awaitembed({model,value:chunkText});// embedding 是 number[]例如 [0.023, -0.156, 0.089, ...]对于一篇笔记的多个 chunk逐个调用embed并收集所有向量constvectors:{chunkIndex:number;chunkText:string;vector:number[]}[][];for(constchunkofembeddings){const{embedding}awaitembed({model,value:chunk.chunkText});vectors.push({chunkIndex:chunk.chunkIndex,chunkText:chunk.chunkText,vector:embedding,});}第四步存储向量到 vectra生成的向量需要持久化存储。ChatCrystal 使用vectra一个轻量级的本地向量索引库。它基于 HNSWHierarchical Navigable Small World算法能在百万级向量中实现毫秒级的近似最近邻搜索。vectra 的索引存储在数据目录下的vectra-index/文件夹中constINDEX_PATHresolve(appConfig.dataDir,vectra-index);exportasyncfunctiongetIndex():PromiseLocalIndex{if(_index)return_index;_indexnewLocalIndex(INDEX_PATH);if(!(await_index.isIndexCreated())){await_index.createIndex();}return_index;}写入向量时每个 chunk 作为一条记录存入 vectra同时附带元数据constitemawaitindex.insertItem({vector:chunk.vector,metadata:{noteId:id,chunkIndex:chunk.chunkIndex,conversationId,title,projectName,},});这些 metadata 不参与相似度计算但在返回搜索结果时用于关联到原始笔记。同时chunk 文本本身和 vectra 的 item ID 会写入 SQLite 数据库作为备份和文本检索的来源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关联。查询向量语义搜索的完整流程当用户输入一个搜索查询时完整流程如下查询文本 Embedding将查询文本送入同一个 Embedding 模型得到查询向量。vectra 向量检索用查询向量在 vectra 索引中查找最相似的 topK 个 chunk。文本物化从 SQLite 中取出 chunk 的原始文本按 noteId 去重保留最高分。关系扩展可选沿知识图谱的关系边扩展找到关联笔记分数打 7 折。asyncfunctionsemanticSearch(query:string,topK10){constindexawaitgetIndex();constembeddingawaitembedSearchQuery(query);// 查询文本 → 向量constresultsawaitindex.queryItems(embedding,query,topK);// 向量检索returnawaitmaterializeDirectSearchHits(db,results);// 文本物化}queryItems内部就是计算查询向量与所有存储向量的余弦相似度返回得分最高的 topK 个结果。动手实现最小 Embedding 服务理解了原理之后我们来实现一个最小的 Embedding 服务。以下代码展示核心流程去掉了 ChatCrystal 中的业务逻辑和错误处理import{embed}fromai;import{createOpenAI}fromai-sdk/openai;// 1. 创建 Embedding 模型以 OpenAI 为例constopenaicreateOpenAI({apiKey:process.env.OPENAI_API_KEY});constmodelopenai.textEmbeddingModel(text-embedding-3-small);// 2. 文本分块functionchunkText(text:string,chunkSize500):string[]{if(text.lengthchunkSize)return[text];constparagraphstext.split(/\n\n/);constchunks:string[][];letcurrent;for(constparaofparagraphs){if(current.lengthpara.lengthchunkSizecurrent.length0){chunks.push(current.trim());currentpara;}else{current(current?\n\n:)para;}}if(current.trim())chunks.push(current.trim());returnchunks;}// 3. 生成 EmbeddingasyncfunctiongenerateEmbeddings(text:string){constchunkschunkText(text);constresults[];for(constchunkofchunks){const{embedding}awaitembed({model,value:chunk});results.push({text:chunk,vector:embedding});}returnresults;}// 4. 计算余弦相似度functioncosineSimilarity(a:number[],b:number[]):number{letdotProduct0,normA0,normB0;for(leti0;ia.length;i){dotProducta[i]*b[i];normAa[i]*a[i];normBb[i]*b[i];}returndotProduct/(Math.sqrt(normA)*Math.sqrt(normB));}// 5. 搜索asyncfunctionsearch(query:string,documents:{text:string;vector:number[]}[]){const{embedding:queryVector}awaitembed({model,value:query});returndocuments.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 算法让百万级向量的毫秒级查询成为可能。多提供商抽象让系统不被单一供应商锁定。下一步nomic vs openai embedding 横评 — 本地与云端 Embedding 模型的性能对比LLM 和 Embedding 不能混用 — 配置时的常见踩坑点Ollama 本地部署零成本跑通全流程 — 本地模型部署指南项目地址github.com/ZengLiangYi/ChatCrystal