---
title: 高性能 Embedding 服务设计：文本分块与批量写入
description: 对照 ChatCrystal 的真实 Embedding 流程，区分文本选择、软分块、向量校验与提交，并用离线反例验证边界。
image: "https://zengliangyi.com/og/blog/embedding-service-design.png"
---

Embedding 服务接收哪些文本、怎样分块、何时替换旧索引，会直接影响检索结果和失败后的状态。本文对照 ChatCrystal 的固定实现拆开这些责任，再用一个不调用模型的练习验证分块与向量边界。

## 先确定被检索的文本

本文对应 [embedding.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/embedding.ts)。服务将标题、摘要、关键结论、标签和代码片段描述去重后拼接。普通导入对话的笔记不会因此把原始对话和全部代码正文都放进向量输入。

`agent-writeback` 和 `manual-note` 两种记忆来源还有补充规则：包含代码正文，以及根因、处理方式、易错点、可复用模式、决策、错误特征和文件路径等已有字段。每段代码正文先压缩连续空白，再截取最多 1000 个 JavaScript 字符单位。这个区别决定“搜索代码内容”在哪些笔记上可能成立；不能把所有代码正文一概视为不参与向量输入。

先列出一份具体的输入清单，再讨论模型选择。如果关键信息没有进入输入，换一个 Embedding 模型也不能直接找回被删掉的内容。模型配置与供应商能力见[Embedding 配置](/blog/embedding-model-configuration/)。

## 500 是合并目标，不是硬上限

`CHUNK_SIZE = 500` 的分块器按空行分段，尝试把相邻段落合并。它使用 JavaScript 的 `.length`，计算的是 UTF-16 代码单位，不是 token 数，也不保证每个块都小于 500。一个没有空行的 700 单位段落仍然成为一个块；这个算法也没有重叠窗口。

下面是当前分块逻辑的独立 JavaScript 版本，变量名简化但保留判断顺序。保存为临时目录中的 `lesson.mjs`，用 Node 24+ 运行；无需安装依赖，不读写知识库。



```js
import   assert   from   'node:assert/strict'  ;  
  
  function   chunkText  (  text  ) {  
    const   size   =   500  ;  
    if   (text.  length   <=   size)   return   [text];  
    const   chunks   =   [];  
    let   current   =   ''  ;  
    for   (  const   paragraph   of   text.  split  (  /  \n\n  +  /  )) {  
      if   (current.  length   +   paragraph.  length   +   2   >   size   &&   current.  length   >   0  ) {  
        chunks.  push  (current.  trim  ());  
        current   =   paragraph;  
      }   else   {  
        current   +=   (current   ?   '  \n\n  '   :   ''  )   +   paragraph;  
      }  
    }  
    if   (current.  trim  ()) chunks.  push  (current.  trim  ());  
    return   chunks;  
  }  
  
  assert.  deepEqual  (  
    chunkText  (  'a'  .  repeat  (  700  )).  map  ((  s  )   =>   s.  length  ),  
    [  700  ]  
  );  
  assert.  deepEqual  (  
    chunkText  (  'a'  .  repeat  (  300  )   +   '  \n\n  '   +   'b'  .  repeat  (  300  )).  map  ((  s  )   =>   s.  length  ),  
    [  300  ,   300  ]  
  );  
  assert.  equal  (  '😀'  .  length  ,   2  );  
  console.  log  (  'chunk boundaries pass'  );
```



把第一条断言改为期望 `[500, 200]`，程序会失败；恢复 `[700]` 后通过。这证明当前实现没有硬切长段落，不能证明它符合任意模型的 token 上限。若要设计严格上限，需要针对选定模型增加计数、长段落拆分和对应反例；这是改进方向，不是已经实现的功能。

## 先生成全部向量，再进入更新

当前服务按顺序调用 AI SDK 的 `embed`，把本次所有块的结果准备好后，才开始 Vectra 更新。任一模型调用提前失败，就不会进入这次索引替换。供应商工厂在 [providers.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/providers.ts) 中通过 `embeddingModel(model)` 创建模型；生成模型配置与向量模型配置分开。

设计自己的服务时，可以在提交之前增加明确的向量契约。下面继续追加到同一个文件：它是**教学设计，不是当前应用已有的向量校验器**。`embedOne` 是注入的假模型，`commit` 是提交回调。



```js
async   function   stageVectors  (  texts  ,   embedOne  ,   commit  ) {  
    const   vectors   =   [];  
    let   dimension;  
    for   (  const   text   of   texts) {  
      const   vector   =   await   embedOne  (text);  
      const   norm   =   Array.  isArray  (vector)   ?   Math.  hypot  (  ...  vector)   :   NaN  ;  
      if   (  
        !  Array.  isArray  (vector)   ||  
        vector.  length   ===   0   ||  
        !  vector.  every  (Number.isFinite)   ||  
        !  Number.  isFinite  (norm)   ||  
        norm   ===   0  
      ) {  
        throw   new   Error  (  'invalid vector'  );  
      }  
      dimension   ??=   vector.  length  ;  
      if   (vector.  length   !==   dimension)   throw   new   Error  (  'dimension mismatch'  );  
      vectors.  push  (vector);  
    }  
    await   commit  (vectors);  
  }  
  
  let   commits   =   0  ;  
  await   assert.  rejects  (  
    stageVectors  (  
      [  'first'  ,   'second'  ],  
      async   (  text  )   =>   (text   ===   'first'   ?   [  1  ,   0  ]   :   [  1  ,   0  ,   0  ]),  
      async   ()   =>   {  
        commits  ++  ;  
      }  
    ),  
    /  dimension mismatch  /  
  );  
  assert.  equal  (commits,   0  );  
  await   assert.  rejects  (  
    stageVectors  (  
      [  'zero'  ],  
      async   ()   =>   [  0  ,   0  ],  
      async   ()   =>   {  
        commits  ++  ;  
      }  
    ),  
    /  invalid vector  /  
  );  
  await   stageVectors  (  
    [  'valid'  ],  
    async   ()   =>   [  1  ,   0  ],  
    async   ()   =>   {  
      commits  ++  ;  
    }  
  );  
  assert.  equal  (commits,   1  );  
  console.  log  (  'staging boundaries pass'  );
```



这组固定二维、三维向量只验证提交回调和校验顺序，不代表真实语言语义。练习还没有定义空输入、模型身份记录、并发写入或跨文件事务；真实服务需要补齐这些契约。

## 维度一致仍然不够

即使两个模型输出相同维度，也不能直接混用它们的向量空间。当前实现从配置读取模型，却没有在每条索引记录中保存可用于验证兼容性的完整模型指纹。切换模型时需要把旧索引兼容性作为单独问题处理，不能只比较数组长度。

实际提交跨越 SQLite 状态和 Vectra 文件：先在数据库事务中替换块映射并标记 `syncing`，保存数据库，再删除旧向量、结束 Vectra 更新，最后标记 `done` 并保存。它不是覆盖两个存储的原子事务。失败清理和恢复路径见[索引恢复](/blog/vectra-index-recovery/)；本文的“先准备再提交”只说明模型调用阶段的位置。

接下来可用[固定向量的本地搜索练习](/blog/vectra-local-search/)观察持久化、过滤和分数，或查阅官方 [embed 接口](https://ai-sdk.dev/docs/reference/ai-sdk-core/embed)。验收服务时分别检查输入覆盖、分块边界、向量有效性、模型兼容性与提交失败，避免用一次搜索成功代替整条链路的证据。

分享：

##  相关文章

[##  可靠的结构化 JSON Prompt 设计：约束与证据溯源

以 ChatCrystal 摘要和关系发现为例，设计字段契约、可见输入与反例样本，并用离线 Zod 练习区分格式验证和证据验证。          曾亮懿       2026年10月6日      阅读约 4 分钟

#ChatCrystal

#AI

#Node.js](/blog/structured-json-prompts)    [##  Vectra 本地向量搜索实战：元数据过滤与索引持久化

在临时目录运行 Vectra 0.15.0 的确定性练习，检查余弦分数、元数据过滤、重新打开索引和取消更新。          曾亮懿       2026年10月6日      阅读约 4 分钟

#ChatCrystal

#AI

#Node.js](/blog/vectra-local-search)    [##  使用 Vercel AI SDK 提取结构化笔记：Schema 校验

对照 ChatCrystal 的 AI SDK 7 调用，分清 provider 适配、输出类型与业务事实，用离线 Zod 练习验证提示描述的边界。          曾亮懿       2026年10月5日      阅读约 4 分钟

#ChatCrystal

#AI

#Node.js](/blog/ai-sdk-structured-notes)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "高性能 Embedding 服务设计：文本分块与批量写入",
  "description": "对照 ChatCrystal 的真实 Embedding 流程，区分文本选择、软分块、向量校验与提交，并用离线反例验证边界。",
  "url": "https://zengliangyi.com/blog/embedding-service-design/",
  "image": "https://zengliangyi.com/images/blog/embedding-service-design.svg",
  "datePublished": "2026-10-06T00:00:00.000Z",
  "dateModified": "2026-10-23T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/embedding-service-design/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "高性能 Embedding 服务设计：文本分块与批量写入",
      "item": "https://zengliangyi.com/blog/embedding-service-design/"
    }
  ]
}
```
