Markdown 向量映射

本章节将系统梳理 Hugo Markdown 内容在向量化流程中的各个关键环节,包括文件解析、分块算法、短 ID 生成、元数据结构、URL 归一化及批量 Upsert 等。通过详细代码示例与设计思路,帮助理解如何高效地将多语言内容转化为可检索的向量数据,并实现批量处理与存储优化。适用于构建 AI 检索、问答等场景的内容管道。

路径与 Front‑matter

目录语言分离:zh/en/ 前缀;博客使用 index.md,列表页 _index.md。解析时:

  1. 读取文件文本 → gray-matter 解析 YAML front‑matter
  2. 跳过 draft: true
  3. 抽取 title / Title 字段
  4. 主体 markdown → 纯文本(剥离标记)供分块

示例结构:

content/
├── zh/blog/{slug}/index.md
├── en/blog/{slug}/index.md
├── zh/about/_index.md
└── en/about/_index.md

示例 front‑matter:

---
title: 示例标题
description: 描述
date: 2024-01-01 10:00:00+08:00
draft: false
tags: [AI]
categories: [示例]
---

分块算法 chunkText()

目标:在保持语义完整的前提下生成长度适中(≤800 字符)的片段,兼容中英文。策略:

  1. 先按标题(^#{1,6})粗切
  2. 对每段做长度判断,短段直接收集
  3. 长段再按句末标点正则 /[。!?!?;;]\s*/ 分句
  4. 逐句累积至 maxLen,超限截断换新块

核心实现(节选):

export function chunkText(input: string, maxLen = 800): string[] {
    const sections = input.split(/^#{1,6}\s+/m).map(s => s.trim()).filter(Boolean);
    const chunks: string[] = [];
    const push = (s: string) => { if (s.trim()) chunks.push(s.trim()); };
    for (const sec of sections.length ? sections : [input]) {
        if (sec.length <= maxLen) { push(sec); continue; }
        let buf = '';
        for (const part of sec.split(/(?<=[。!?!?;;]\s*)/)) {
            if ((buf + part).length > maxLen) { push(buf); buf = part; }
            else { buf += part; }
        }
        push(buf);
    }
    return chunks;
}

短 ID 生成 generateShortId()

需求:唯一但简短;区分同 URL 不同语言/来源路径(如 zh/en 版本);可追溯来源。策略:hash(url|sourcePath) 取前 12 位 + -chunkIndex

import { createHash } from 'node:crypto';

function generateShortId(baseUrl: string, sourcePath: string, chunkIndex: number): string {
    const uniqueKey = `${baseUrl}|${sourcePath}`; // 保证语言差异写入 sourcePath
    const urlHash = createHash('sha256').update(uniqueKey).digest('hex').substring(0, 12);
    return `${urlHash}-${chunkIndex}`; // 示例:a1b2c3d4e5f6-3
}

设计考虑:

  1. 采用 SHA‑256 子串 → 碰撞概率极低
  2. 截断到 12 hex(48 bit)在本规模足够
  3. 附加顺序索引 chunkIndex 保留片段原始相对位置,可用于重构/调试

ID ↔ Chunk Index 关系与示例

为增强可追溯性,下表展示同一文档不同分块与向量 ID 的映射方式,基于 hash(url|sourcePath) 前 12 位 + -chunkIndex

向量 IDChunk IndexSource 文档values 长度metadata 字段
abc123def456-00zh/blog/kubernetes/index.md1024text, source, title, url, language
abc123def456-11zh/blog/kubernetes/index.md1024text, source, title, url, language
def789abc012-00docs/guide/index.md1024text, source, title, url, language
def789abc012-11docs/guide/index.md1024text, source, title, url, language

生成流程回顾:

  1. uniqueKey = ${baseUrl}|${sourcePath}
  2. SHA‑256 → hex 取前 12 字符
  3. 追加 -chunkIndex → 最终 ID

设计 rationale:

  1. 唯一性:URL + 源路径(区分 zh/en)
  2. 紧凑性:12 位 hash + 数字后缀,人眼可读
  3. 可追溯:chunkIndex 直接映射原文顺序

可视化结构流:

向量 ID 生成流程
向量 ID 生成流程

元数据结构与字段

上传到 Vectorize 时 metadata 仅保留检索及答案引用必须字段,控制大小:

字段说明用途
text片段前 500 字符(长截断加 …)语义匹配 + 组装上下文
source相对源路径(含 zh/ en/)调试、回链、区分语言版本
titlefront‑matter 标题(截断 100)提示中展示 / 引用列表
url规范化站点绝对 URL最终回答引用链接
language语言标记 zh/en检索阶段语言过滤与回退

除 text 外都相对较短;避免写入整个正文以控制索引存储成本。

可视化映射表(Visual Mapping Table)

下表将“文件→分块→向量写入”过程中每一阶段的输入、输出与约束统一呈现,便于整体把握:

阶段输入处理输出关键约束失败重试点
读取 & 解析markdown 文件文本gray-matter 解析 front‑matter;过滤 draft{ body, frontMatter }front‑matter 必须含 title文件缺失/解析异常直接跳过
纯文本抽取markdown bodymarkdown→HTML→纯文本(去标签/多余空白)plainText保留语义,剔除代码块可选markdown 转换失败可回退为原文
分块 chunkTextplainText标题优先切分 + 句末标点二级切分chunks[]≤800 字符;去空块无块则终止流程
URL 归一化文件路径index/_index 折叠;zh/blog 去前缀;en 保留{ url, language }语言:en 前缀判断路径异常记录警告继续
批量嵌入chunks[] 分批getBatchEmbeddings 并发 + 维度裁剪/补零vectors[]长度统一 EMBED_DIM嵌入单批失败整批重试
ID 生成baseUrl + sourcePath + chunkIndexSHA‑256 截断 12 + 序号id短且唯一,可追溯哈希失败极低,可忽略
组装 metadatachunk + title + 路径截断 text(500)/title(100)item{...}控制字段规模字段缺失(title 为空可置空串)
上传缓冲items 批次聚合到 UPLOAD_BATCH_SIZEuploadBatch[]减少 HTTP 次数网络失败批次重排队
/admin/upsertuploadBatch校验维度 + 转换 values/metadataVectorize 存储单批 ≤1000返回 count 校验

该表帮助快速审视每个阶段的职责边界与错误处理位置,支撑后续性能与可靠性优化。

截断与容量控制原则

结合数据结构章节,这里强调影响存储与检索性能的核心参数:

参数含义典型值优化方向
EMBED_DIM向量维度768 / 1024兼顾召回 vs 成本;升级需全量重建
平均 chunks/文档分块粒度3–25调整 maxLen 与切分正则
text 截断metadata.text 长度500减少传输体积
title 截断metadata.title 长度100保持引用行整洁
EMBEDDING_BATCH_SIZE单次嵌入条目Qwen 10 / Gemini 1与并发参数协同
UPLOAD_BATCH_SIZE单次 upsert 条数300过大失败放大,过小频繁请求

容量估算:总向量 ≈ 文档数 * 平均 chunk 数;单向量存储近似:(EMBED_DIM * 4 bytes + metadata 平均大小)

删除 / 清空策略摘要

场景操作说明
单文件下线后续扩展精确删除 API现阶段需手工收集 ID 后批删
全量重建/admin/clear-all分页 deleteByIds 统计 totalDeleted
测试数据独立索引避免与生产混杂

增量更新尚未实现,可通过本地维护文件 hash 规避重复 ingest。

风险与缓解摘要

风险表现缓解
维度不匹配upsert 报错预校验向量长度,单测覆盖
语言混淆回退结果多/乱首选 metadata.language 过滤,URL 规则兜底
向量膨胀存储/查询成本上升监控 avg chunk len;优化切分策略
批次失败放大整批重试浪费控制批大小,引入部分重试
过度截断语义缺失影响召回调整截断阈值并对长文 rerank

演进方向速记

  1. 增量 ingest(mtime/hash)
  2. 条件删除(metadata.filter)
  3. 质量评分 / 过短块过滤
  4. Rerank 融合(向量初筛 + 语义重排)
  5. 版本化 ID(支持历史回溯)

以上补充使本章节自洽覆盖从文件到向量的“结构 + 行为 + 运维”三层面。

processFile() 文件处理流水线

核心步骤:

  1. 读取 & gray-matter 解析 → 跳过 draft: true
  2. 提取 title
  3. Markdown → HTML → 纯文本(移除标签与多余空白)
  4. chunkText() 分块(<=800)
  5. 计算 URL + language (toUrlFromPath)
  6. 分批(最多 10)调用嵌入接口(Gemini 单文本;Qwen 批量)
  7. 维度对齐(截断 / 补零)
  8. 组装 item:{ id, vector, text, title, source, url, language }

摘录(省略错误处理/批次节流):

async function processFile(filePath: string): Promise<any[]> {
    const raw = await fs.readFile(filePath, 'utf-8');
    const fm = matter(raw);
    if (fm.data.draft === true) return [];
    const title = (fm.data.title || fm.data.Title || '').toString();
    const plain = markdownToPlain(fm.content);
    const chunks = chunkText(plain, 800);
    if (!chunks.length) return [];
    const { url: baseUrl, language } = toUrlFromPath(filePath);
    const sourcePath = path.relative(CONTENT_DIR, filePath);
    const items: any[] = [];
    for (let i = 0; i < chunks.length; i += EMBEDDING_BATCH_SIZE) {
        const chunkBatch = chunks.slice(i, i + EMBEDDING_BATCH_SIZE);
        const vectors = await getBatchEmbeddings(chunkBatch);
        for (let j = 0; j < chunkBatch.length; j++) {
            let v = vectors[j];
            if (v.length > EMBED_DIM) v = v.slice(0, EMBED_DIM);
            else if (v.length < EMBED_DIM) v = [...v, ...new Array(EMBED_DIM - v.length).fill(0)];
            items.push({
                id: generateShortId(baseUrl, sourcePath, i + j),
                vector: v,
                text: chunkBatch[j].length > 500 ? chunkBatch[j].slice(0, 500) + '...' : chunkBatch[j],
                title: title.slice(0, 100),
                source: sourcePath,
                url: baseUrl,
                language
            });
        }
    }
    return items;
}

URL 归一化 toUrlFromPath()

规则抽象:

  1. 统一把 index.md / _index.md → 目录 URL(末尾 /)再去除尾部多余 /
  2. 中文博客:zh/blog/{slug}/index.md/blog/{slug}/
  3. 英文博客:en/blog/{slug}/index.md/en/blog/{slug}/
  4. 中文静态页(zh/about/ 等)去掉 zh/ 前缀
  5. 其它保持原路径(包含 en/ 前缀)
  6. 语言判定:路径以 en/ 开头即 en,否则 zh

实现节选:

function toUrlFromPath(filePath: string): { url: string; language: 'en' | 'zh' } {
    const rel = path.relative(CONTENT_DIR, filePath).replace(/\\/g, '/');
    const noExt = rel.replace(/\.md$/i, '');
    let cleanPath;
    if (noExt.endsWith('/_index')) cleanPath = noExt.replace('/_index', '/');
    else if (noExt.endsWith('/index')) cleanPath = noExt.replace('/index', '/');
    else cleanPath = noExt + '/';
    const language = cleanPath.startsWith('en/') ? 'en' : 'zh';
    let finalPath;
    if (cleanPath.startsWith('zh/blog/')) finalPath = cleanPath.replace('zh/blog/', 'blog/');
    else if (cleanPath.startsWith('en/blog/')) finalPath = cleanPath;
    else if (cleanPath.startsWith('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 };
}

批量 Upsert /admin/upsert

批量策略:

  1. 预处理所有文件 → 并发切片与向量生成(Gemini 高并发小批;Qwen 10 条批次)
  2. 聚合到一定量(例:300)触发一次 HTTP 上传,减少网络开销
  3. 服务端校验维度,包装为 values + metadata 写入 Vectorize
  4. 失败批次保留在内存队列以便重试(当前实现简单回退)

POST /admin/upsert 请求示例(精简):

{
    "items": [
        {
            "id": "a1b2c3d4e5f6-0",
            "vector": [0.01, 0.02, 0.03],
            "text": "片段...",
            "source": "zh/blog/x/index.md",
            "title": "示例",
            "url": "https://example.com/blog/x",
            "language": "zh"
        }
    ]
}

小结

File → Chunk → Vector 流程关键点回顾:

  1. 语义友好分块:标题 → 句末标点,控制 ≤800 字符
  2. 语言双版本分流 & 去重策略:优先中文,保留缺失语言的英文
  3. URL 归一化 + 语言标记一致性,保障检索过滤与回答引用正确
  4. 短 ID 设计兼顾可读性、唯一性与来源追踪
  5. 嵌入批处理(Gemini 单条 / Qwen 批量)提升吞吐
  6. 元数据裁剪(截断)平衡召回质量与存储成本
  7. 大批量缓冲上传减少请求次数,提高总体速度

后续可扩展方向包括:

  • 增量更新机制:通过对比文件 hash,仅处理有变动的内容,显著提升大规模知识库的同步效率。
  • 向量重排(rerank):结合语义相关性模型,对初步检索结果进行二次排序,提升最终召回质量。
  • 智能语义段落合并:基于上下文和语义边界,自动合并过短或强相关的片段,优化分块粒度。
  • 内容类型自适应切片:针对不同内容(如 FAQ、教程、新闻等)动态调整分块参数,实现更细致的处理策略。
  • 变更监控与自动回滚:集成文件变更监听与异常回退机制,保障数据一致性与系统稳定性。

这些扩展将进一步提升向量化内容管道的智能化与可维护性,为后续大规模知识库和多场景 AI 应用打下坚实基础。

文章导航

独立页面

这是书籍中的独立页面。

书籍首页

评论区