Markdown 向量映射
本章节将系统梳理 Hugo Markdown 内容在向量化流程中的各个关键环节,包括文件解析、分块算法、短 ID 生成、元数据结构、URL 归一化及批量 Upsert 等。通过详细代码示例与设计思路,帮助理解如何高效地将多语言内容转化为可检索的向量数据,并实现批量处理与存储优化。适用于构建 AI 检索、问答等场景的内容管道。
路径与 Front‑matter
目录语言分离:zh/
与 en/
前缀;博客使用 index.md
,列表页 _index.md
。解析时:
- 读取文件文本 → gray-matter 解析 YAML front‑matter
- 跳过
draft: true
- 抽取
title
/Title
字段 - 主体 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,6}
)粗切 - 对每段做长度判断,短段直接收集
- 长段再按句末标点正则
/[。!?!?;;]\s*/
分句 - 逐句累积至
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
}
设计考虑:
- 采用 SHA‑256 子串 → 碰撞概率极低
- 截断到 12 hex(48 bit)在本规模足够
- 附加顺序索引 chunkIndex 保留片段原始相对位置,可用于重构/调试
ID ↔ Chunk Index 关系与示例
为增强可追溯性,下表展示同一文档不同分块与向量 ID 的映射方式,基于 hash(url|sourcePath)
前 12 位 + -chunkIndex
:
向量 ID | Chunk Index | Source 文档 | values 长度 | metadata 字段 |
---|---|---|---|---|
abc123def456-0 | 0 | zh/blog/kubernetes/index.md | 1024 | text, source, title, url, language |
abc123def456-1 | 1 | zh/blog/kubernetes/index.md | 1024 | text, source, title, url, language |
def789abc012-0 | 0 | docs/guide/index.md | 1024 | text, source, title, url, language |
def789abc012-1 | 1 | docs/guide/index.md | 1024 | text, source, title, url, language |
生成流程回顾:
- uniqueKey =
${baseUrl}|${sourcePath}
- SHA‑256 → hex 取前 12 字符
- 追加
-chunkIndex
→ 最终 ID
设计 rationale:
- 唯一性:URL + 源路径(区分 zh/en)
- 紧凑性:12 位 hash + 数字后缀,人眼可读
- 可追溯:chunkIndex 直接映射原文顺序
可视化结构流:
元数据结构与字段
上传到 Vectorize 时 metadata 仅保留检索及答案引用必须字段,控制大小:
字段 | 说明 | 用途 |
---|---|---|
text | 片段前 500 字符(长截断加 …) | 语义匹配 + 组装上下文 |
source | 相对源路径(含 zh/ en/) | 调试、回链、区分语言版本 |
title | front‑matter 标题(截断 100) | 提示中展示 / 引用列表 |
url | 规范化站点绝对 URL | 最终回答引用链接 |
language | 语言标记 zh/en | 检索阶段语言过滤与回退 |
除 text 外都相对较短;避免写入整个正文以控制索引存储成本。
可视化映射表(Visual Mapping Table)
下表将“文件→分块→向量写入”过程中每一阶段的输入、输出与约束统一呈现,便于整体把握:
阶段 | 输入 | 处理 | 输出 | 关键约束 | 失败重试点 |
---|---|---|---|---|---|
读取 & 解析 | markdown 文件文本 | gray-matter 解析 front‑matter;过滤 draft | { body, frontMatter } | front‑matter 必须含 title | 文件缺失/解析异常直接跳过 |
纯文本抽取 | markdown body | markdown→HTML→纯文本(去标签/多余空白) | plainText | 保留语义,剔除代码块可选 | markdown 转换失败可回退为原文 |
分块 chunkText | plainText | 标题优先切分 + 句末标点二级切分 | chunks[] | ≤800 字符;去空块 | 无块则终止流程 |
URL 归一化 | 文件路径 | index/_index 折叠;zh/blog 去前缀;en 保留 | { url, language } | 语言:en 前缀判断 | 路径异常记录警告继续 |
批量嵌入 | chunks[] 分批 | getBatchEmbeddings 并发 + 维度裁剪/补零 | vectors[] | 长度统一 EMBED_DIM | 嵌入单批失败整批重试 |
ID 生成 | baseUrl + sourcePath + chunkIndex | SHA‑256 截断 12 + 序号 | id | 短且唯一,可追溯 | 哈希失败极低,可忽略 |
组装 metadata | chunk + title + 路径 | 截断 text(500)/title(100) | item{...} | 控制字段规模 | 字段缺失(title 为空可置空串) |
上传缓冲 | items 批次 | 聚合到 UPLOAD_BATCH_SIZE | uploadBatch[] | 减少 HTTP 次数 | 网络失败批次重排队 |
/admin/upsert | uploadBatch | 校验维度 + 转换 values/metadata | Vectorize 存储 | 单批 ≤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 |
演进方向速记
- 增量 ingest(mtime/hash)
- 条件删除(metadata.filter)
- 质量评分 / 过短块过滤
- Rerank 融合(向量初筛 + 语义重排)
- 版本化 ID(支持历史回溯)
以上补充使本章节自洽覆盖从文件到向量的“结构 + 行为 + 运维”三层面。
processFile() 文件处理流水线
核心步骤:
- 读取 & gray-matter 解析 → 跳过
draft: true
- 提取
title
- Markdown → HTML → 纯文本(移除标签与多余空白)
chunkText()
分块(<=800)- 计算 URL + language (
toUrlFromPath
) - 分批(最多 10)调用嵌入接口(Gemini 单文本;Qwen 批量)
- 维度对齐(截断 / 补零)
- 组装 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()
规则抽象:
- 统一把
index.md
/_index.md
→ 目录 URL(末尾/
)再去除尾部多余/
- 中文博客:
zh/blog/{slug}/index.md
→/blog/{slug}/
- 英文博客:
en/blog/{slug}/index.md
→/en/blog/{slug}/
- 中文静态页(
zh/about/
等)去掉zh/
前缀 - 其它保持原路径(包含
en/
前缀) - 语言判定:路径以
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
批量策略:
- 预处理所有文件 → 并发切片与向量生成(Gemini 高并发小批;Qwen 10 条批次)
- 聚合到一定量(例:300)触发一次 HTTP 上传,减少网络开销
- 服务端校验维度,包装为
values + metadata
写入 Vectorize - 失败批次保留在内存队列以便重试(当前实现简单回退)
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 流程关键点回顾:
- 语义友好分块:标题 → 句末标点,控制 ≤800 字符
- 语言双版本分流 & 去重策略:优先中文,保留缺失语言的英文
- URL 归一化 + 语言标记一致性,保障检索过滤与回答引用正确
- 短 ID 设计兼顾可读性、唯一性与来源追踪
- 嵌入批处理(Gemini 单条 / Qwen 批量)提升吞吐
- 元数据裁剪(截断)平衡召回质量与存储成本
- 大批量缓冲上传减少请求次数,提高总体速度
后续可扩展方向包括:
- 增量更新机制:通过对比文件 hash,仅处理有变动的内容,显著提升大规模知识库的同步效率。
- 向量重排(rerank):结合语义相关性模型,对初步检索结果进行二次排序,提升最终召回质量。
- 智能语义段落合并:基于上下文和语义边界,自动合并过短或强相关的片段,优化分块粒度。
- 内容类型自适应切片:针对不同内容(如 FAQ、教程、新闻等)动态调整分块参数,实现更细致的处理策略。
- 变更监控与自动回滚:集成文件变更监听与异常回退机制,保障数据一致性与系统稳定性。
这些扩展将进一步提升向量化内容管道的智能化与可维护性,为后续大规模知识库和多场景 AI 应用打下坚实基础。