Skip to content

Markdown 向量映射

本文介绍单个 Markdown 文件如何经过分块处理并转换为 RAG 系统所需的向量。

单个 Markdown 文件路径与 Front-matter

文件路径结构

系统从结构化内容目录中处理 Markdown 文件,按语言区分路径:

content/
├── zh/blog/{slug}/index.md     # 中文博客文章
├── en/blog/{slug}/index.md     # 英文博客文章
├── zh/about/_index.md          # 中文静态页面
└── en/about/_index.md          # 英文静态页面

Front-matter 格式

每个 Markdown 文件包含 YAML 格式的 front-matter 元数据:

---
title: "AIGC 工具推荐:利用 AI 提高工作效率的利器"
description: "本篇博客旨在为大家推荐几款实用且易于上手的 AI 工具"
date: 2023-03-15 21:40:40+08:00
draft: false
tags:
  - AI
  - AIGC
categories:
  - 工程实践
type: post
keywords:
  - ai
  - chatgpt
  - claude
---

chunkText() 分块规则

算法概述

chunk.ts 中的分块函数实现了适合中文的分块策略:

/**
 * 简单中文友好分块器:
 * - 先按标题(##、### 等)分割
 * - 再按最大长度 maxLen 字符分块,尽量保持句子边界
 */
export function chunkText(input: string, maxLen = 800): string[] {
  const sections = input
    .split(/^#{1,6}\s+/m) // 按 markdown 标题分割
    .map(s => s.trim())
    .filter(Boolean);

  const chunks: string[] = [];
  const pushChunk = (s: string) => { if (s.trim()) chunks.push(s.trim()); };

  for (const sec of sections.length ? sections : [input]) {
    if (sec.length <= maxLen) { pushChunk(sec); continue; }
    let buf = '';
    for (const part of sec.split(/(?<=[。!?!?;;]\s*)/)) {
      if ((buf + part).length > maxLen) {
        pushChunk(buf); buf = part;
      } else {
        buf += part;
      }
    }
    pushChunk(buf);
  }
  return chunks;
}

分块规则与示例

**输入示例:**
# AI 工具集合

人工智能正在改变我们的生活,使我们的工作更加高效。在未来熟练使用各种 AI 工具优化你的工作流并提高工作效率将是每个人的必备技能!

## ChatGPT

ChatGPT 是一个基于 GPT 技术的智能聊天机器人。它使用深度学习技术和大规模训练的语言模型来理解用户的问题并提供有用的答案。ChatGPT 可以回答各种问题,用户可以直接在网站上输入问题或话题,并获得快速和准确的答案。

分块流程:

  1. 标题分割:按 markdown 标题(### 等)分割
  2. 长度检查:如果分段 ≤ 800 字符,直接作为一个块
  3. 句子边界分割:大段内容按中英文句子边界 [。!?!?;;] 分割
  4. 缓冲累积:累积句子直到达到最大长度(800 字符)

输出分块:

[
  "人工智能正在改变我们的生活,使我们的工作更加高效。在未来熟练使用各种 AI 工具优化你的工作流并提高工作效率将是每个人的必备技能!",

  "ChatGPT 是一个基于 GPT 技术的智能聊天机器人。它使用深度学习技术和大规模训练的语言模型来理解用户的问题并提供有用的答案。ChatGPT 可以回答各种问题,用户可以直接在网站上输入问题或话题,并获得快速和准确的答案。"
]

generateShortId() 向量 ID 生成算法

算法实现

// 根据 URL、源路径和分块索引生成短唯一 ID
function generateShortId(baseUrl: string, sourcePath: string, chunkIndex: number): string {
  // 包含源路径以区分 zh 和 en 版本
  const uniqueKey = `${baseUrl}|${sourcePath}`;
  const urlHash = createHash('sha256').update(uniqueKey).digest('hex').substring(0, 12);
  return `${urlHash}-${chunkIndex}`;
}

ID 生成流程

  1. 唯一键创建:组合 baseUrlsourcePath
  2. 示例:https://jimmysong.io/blog/ai-tools/|zh/blog/ai-tools-collection/index.md
  3. SHA-256 哈希:生成哈希并取前 12 位
  4. 示例:a1b2c3d4e5f6
  5. 分块索引后缀:追加零起始分块索引
  6. 示例:a1b2c3d4e5f6-0, a1b2c3d4e5f6-1

示例输出

// 文件:zh/blog/ai-tools-collection/index.md
// URL: https://jimmysong.io/blog/ai-tools-collection/
// 共 3 个分块

"a1b2c3d4e5f6-0"  // 第一个分块
"a1b2c3d4e5f6-1"  // 第二个分块
"a1b2c3d4e5f6-2"  // 第三个分块
## 元数据结构与 JSON 形状

元数据结构

每个向量项包含如下元数据字段:

interface VectorItem {
  id: string;           // 生成的短 ID
  vector: number[];     // 嵌入向量(768 维)
  text: string;         // 分块文本(展示时截断为 500 字符)
  title: string;        // 文档标题(截断为 100 字符)
  source: string;       // 相对文件路径
  url: string;          // 内容最终 URL
  language: 'en' | 'zh'; // 检测到的语言
}
### JSON 示例
{
  "id": "a1b2c3d4e5f6-0",
  "vector": [0.1234, -0.5678, 0.9012, ...], // 768 维数组
  "text": "人工智能正在改变我们的生活,使我们的工作更加高效。在未来熟练使用各种 AI 工具优化你的工作流并提高工作效率将是每个人的必备技能!",
  "title": "AIGC 工具推荐:利用 AI 提高工作效率的利器",
  "source": "zh/blog/ai-tools-collection/index.md",
  "url": "https://jimmysong.io/blog/ai-tools-collection/",
  "language": "zh"
}

代码注释示例

文件处理流程(fast-ingest.ts

// 处理单个文件并返回所有向量项
export async function processFile(filePath: string): Promise<any[]> {
  const raw = await fs.readFile(filePath, 'utf-8');
  const fm = matter(raw);  // 使用 gray-matter 解析 front-matter

  // 跳过草稿文件
  if (fm.data.draft === true) {
    return [];
  }

  // 从 front-matter 提取标题
  const title = (fm.data && (fm.data.title || fm.data.Title)) || '';

  // 将 markdown 内容转为纯文本
  const plain = markdownToPlain(fm.content);

  // 对纯文本内容分块
  const chunks = chunkText(plain, 800);

  if (chunks.length === 0) {
    return [];
  }

  // 根据文件路径生成 URL 并检测语言
  const { url: baseUrl, language } = toUrlFromPath(filePath);
  const sourcePath = path.relative(CONTENT_DIR, filePath);

  // 批量处理分块嵌入
  const items = [];
  for (let i = 0; i < chunks.length; i += EMBEDDING_BATCH_SIZE) {
    const chunkBatch = chunks.slice(i, i + EMBEDDING_BATCH_SIZE);

    try {
      // 一次获取所有分块的嵌入向量
      const vectors = await getBatchEmbeddings(chunkBatch);

      // 为每个分块创建向量项
      for (let j = 0; j < chunkBatch.length; j++) {
        const text = chunkBatch[j];
        let finalVector = vectors[j];

        // 保证维度正确(768)
        if (finalVector.length > EMBED_DIM) {
          finalVector = finalVector.slice(0, EMBED_DIM);
        } else if (finalVector.length < EMBED_DIM) {
          finalVector = [...finalVector, ...new Array(EMBED_DIM - finalVector.length).fill(0)];
        }

        // 创建最终向量项
        items.push({
          id: generateShortId(baseUrl, sourcePath, i + j),
          vector: finalVector,
          text: text.length > 500 ? text.substring(0, 500) + '...' : text,
          title: title.length > 100 ? title.substring(0, 100) : title,
          source: sourcePath,
          url: baseUrl,
          language: language
        });
      }
    } catch (error) {
      console.error(`分块嵌入失败:${sourcePath}:`, error.message);
    }
  }

  return items;
}

URL 生成逻辑

function toUrlFromPath(filePath: string): { url: string; language: 'en' | 'zh' } {
  const rel = path.relative(CONTENT_DIR, filePath).replace(/\\/g, '/');
  const noExt = rel.replace(/\.md$/i, '');

  // 处理 index 文件
  let cleanPath;
  if (noExt.endsWith('/_index')) {
    cleanPath = noExt.replace('/_index', '/').replace(/^\//, '');
  } else if (noExt.endsWith('/index')) {
    cleanPath = noExt.replace('/index', '/').replace(/^\//, '');
  } else {
    cleanPath = noExt.replace(/^\//, '') + '/';
  }

  // 根据原始路径判断语言
  const language: 'en' | 'zh' = cleanPath.startsWith('en/') ? 'en' : 'zh';

  // 构建语言相关 URL
  let finalPath;
  if (cleanPath.startsWith('zh/blog/')) {
    // 中文博客:/blog/{slug}/(无前缀)
    finalPath = cleanPath.replace('zh/blog/', 'blog/');
  } else if (cleanPath.startsWith('en/blog/')) {
    // 英文博客:/en/blog/{slug}/(保留前缀)
    finalPath = cleanPath;
  } else if (cleanPath.startsWith('zh/')) {
    // 中文静态页面:去掉 zh 前缀
    finalPath = cleanPath.replace('zh/', '');
  } else {
    // 英文静态页面及其他:保持原样
    finalPath = cleanPath;
  }

  const urlPath = ('/' + finalPath).replace(/\/+/g, '/').replace(/\/$/, '') || '/';
  const url = new URL(urlPath, BASE_URL).toString();

  return { url, language };
}

该映射系统确保 Markdown 内容高效转换为可检索的向量,同时保留重要元数据,并为中英文内容维护正确的 URL 结构。

向量批量 upsert 操作说明

RAG worker 的 /admin/upsert 处理器为 Cloudflare Vectorize 数据库提供安全的批量插入/更新接口。下面介绍处理器实现流程及实际 cURL 示例。

处理器实现流程

/admin/upsert 处理器(worker.ts 第 89-127 行)主要步骤如下:

1. 鉴权检查

const auth = req.headers.get('authorization') || '';
if (auth !== `Bearer ${env.ADMIN_TOKEN}`) return new Response('Unauthorized', { status: 401 });
首先校验请求头中的 Bearer token 是否与环境变量 `ADMIN_TOKEN` 匹配

2. 请求载荷处理

const payload = await req.json() as {
  items: { id: string; vector: number[]; text: string; source?: string; title?: string; url?: string }[]
};
处理器要求 JSON 载荷包含 `items` 数组每项必须有
- `id`向量唯一标识
- `vector`嵌入向量数组
- `text`实际文本内容
- `source``title``url`可选元数据字段

3. 构建 Vectorize 数组

const vectors = payload.items.map(it => {
  if (!Array.isArray(it.vector) || it.vector.length !== Number(env.EMBED_DIM)) {
    throw new Error(`Invalid vector dimension: expected ${env.EMBED_DIM}, got ${it.vector?.length || 'undefined'}`);
  }
  return {
    id: it.id,
    values: it.vector,
    metadata: { text: it.text, source: it.source, title: it.title, url: it.url }
  };
});

此步骤:

  • 校验向量维度,确保与配置的 EMBED_DIM(通常为 1024)一致
  • 格式转换,适配 Vectorize 所需格式
  • 元数据打包,便于后续检索

4. 维度检查细节

维度校验至关重要:

  • Vectorize 索引创建时维度固定(如 Qwen 的 text-embedding-v4 为 1024)
  • 维度不匹配会导致插入失败
  • 错误信息明确反馈:"Invalid vector dimension: expected 1024, got 512"

5. 存储向量

await env.VECTORIZE.upsert(vectors); upsert 操作:

  • 插入新向量(ID 不存在时)
  • 更新已有向量(ID 已存在时)
  • 批量处理(单次最多 1000 向量)

cURL 示例

以下为两个实际 cURL 用法,演示如何向 /admin/upsert 上传向量:

示例 1:单向量上传

curl -X POST "https://your-worker.workers.dev/admin/upsert" \
  -H "Authorization: Bearer your-admin-token" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{
      "id": "blog-post-001-chunk-1",
      "vector": [0.123, -0.456, 0.789, 0.234, -0.567, 0.891, 0.345, -0.678, 0.912, 0.456, -0.789, 0.123, 0.567, -0.890, 0.234, 0.678, -0.123, 0.456, 0.789, -0.234, 0.567, -0.891, 0.345, 0.678, -0.912, 0.456, 0.789, -0.123, 0.567, 0.890, -0.234, 0.678],
      "text": "Cloudflare Vectorize 是一个全球分布式的向量数据库服务,专为 AI 应用设计。它支持在 Cloudflare 全球网络中存储和查询向量嵌入,适用于语义搜索、推荐系统、分类和异常检测等任务。",
      "source": "content/zh/blog/vectorize-introduction/index.md",
      "title": "Cloudflare Vectorize 入门指南",
      "url": "https://example.com/blog/vectorize-introduction/"
    }]
  }'

示例 2:多向量批量上传

curl -X POST "https://your-worker.workers.dev/admin/upsert" \
  -H "Authorization: Bearer your-admin-token" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "id": "blog-post-002-chunk-1",
        "vector": [0.234, -0.567, 0.890, 0.345, -0.678, 0.123, 0.456, -0.789, 0.234, 0.567, -0.890, 0.345, 0.678, -0.123, 0.456, 0.789, -0.234, 0.567, 0.890, -0.345, 0.678, -0.123, 0.456, 0.789, -0.234, 0.567, 0.890, -0.345, 0.678, 0.123, -0.456, 0.789],
        "text": "在 RAG 系统中,向量数据库扮演着关键角色:存储由嵌入模型生成的文档向量,根据用户查询向量快速检索最相关的文档片段,管理与向量关联的元数据。",
        "source": "content/zh/blog/rag-vector-database/index.md",
        "title": "RAG 系统中的向量数据库",
        "url": "https://example.com/blog/rag-vector-database/"
      },
      {
        "id": "blog-post-002-chunk-2",
        "vector": [0.345, -0.678, 0.123, 0.456, -0.789, 0.234, 0.567, -0.890, 0.345, 0.678, -0.123, 0.456, 0.789, -0.234, 0.567, 0.890, -0.345, 0.678, 0.123, -0.456, 0.789, -0.234, 0.567, 0.890, -0.345, 0.678, 0.123, -0.456, 0.789, 0.234, -0.567, 0.890],
        "text": "Vectorize 支持三种距离度量方式:cosine(余弦相似度)测量向量间夹角的余弦值,euclidean(欧氏距离)测量向量间的直线距离,dot-product(点积)计算负点积相似度。对于文本相似度搜索,通常推荐使用余弦相似度。",
        "source": "content/zh/blog/rag-vector-database/index.md",
        "title": "RAG 系统中的向量数据库",
        "url": "https://example.com/blog/rag-vector-database/"
      }
    ]
  }'

成功响应

批量 upsert 成功时返回:

{
  "ok": true,
  "count": 2
}

错误响应

处理器会针对常见问题返回详细错误信息:

维度不匹配错误

{
  "error": "Invalid vector dimension: expected 1024, got 512",
  "details": "Error stack trace..."
}

鉴权错误

HTTP 401 Unauthorized

关键实现细节

  1. 批量处理:单次最多处理 1000 个向量(Vectorize 限制)
  2. 维度校验:严格检查,保证与索引兼容
  3. 元数据保留:所有元数据字段均可检索和过滤
  4. Upsert 语义:ID 不存在则插入,已存在则更新
  5. 错误处理:详细错误报告,便于调试