从零搭建私人 RAG 实战:技术文档与业务文档向量化
本文是「从零搭建私人 RAG 实战」专栏的第三篇。在前两篇完成决策文档规范和本地环境准备后,我们将正式实现索引链路,把 Markdown 中的 BADR、FADR 转换成可以被语义检索的向量知识库。
前言:从 Markdown 文档到向量知识库
在第一篇文章中,我们使用 Markdown 结构化记录技术决策和业务决策,并通过 BADR、FADR 模板让每条知识具备相对完整的语义。
在第二篇文章中,我们完成了 Node.js、Ollama、nomic-embed-text 和项目依赖的准备。
现在,原始文档和运行环境都已经具备,可以开始构建 RAG 的索引链路:
Markdown 决策文档
↓ 加载并提取元数据
LangChain Document 数组
↓ 文本分块
Document Chunk 数组
↓ Ollama + nomic-embed-text
文本向量
↓
LanceDB 数据库和数据表
这条链路的目标不是生成自然语言回答,而是先完成一件更基础的事情:
把技术决策和业务决策转换成可以按照语义进行搜索的数据。
本文将结合 CorpRAG 项目的 src/cli/index-cli.ts 及其依赖,逐步说明:
- LangChain 在索引链路中负责什么;
- 如何扫描 BADR、FADR 文档并生成标准
Document; - 如何使用
RecursiveCharacterTextSplitter分割长文档; - 如何通过
OllamaEmbeddings调用本地 Embedding 模型; - 如何使用
LanceDB.fromDocuments创建向量表; - 如何按照系统和决策类型组织多个知识库。
一、LangChain 如何组织 RAG 数据流?
LangChain 不是 Embedding 模型,也不是向量数据库。
在 CorpRAG 中,它更像是一层统一的数据流抽象,把文档、分块器、Embedding 服务和向量数据库连接起来。
对应关系如下:
| 环节 | 项目使用的能力 | 作用 |
|---|---|---|
| 文档表示 | Document | 统一保存正文和元数据 |
| 文本分块 | RecursiveCharacterTextSplitter | 把长文档切成较小的语义片段 |
| 向量生成 | OllamaEmbeddings | 调用本地 Ollama Embedding 服务 |
| 向量存储 | LanceDB | 保存向量、正文和元数据 |
如果不使用统一抽象,我们需要分别处理每个组件的数据格式:
Markdown 解析结果
↓ 手动转换
分块器输入格式
↓ 手动转换
Embedding 请求格式
↓ 手动转换
数据库记录格式
使用 LangChain 后,各环节主要围绕 Document 传递数据:
Document[]
↓ splitter.splitDocuments
Document[]
↓ LanceDB.fromDocuments
向量表
这让索引代码可以把注意力放在数据流和业务边界上,而不必在每一步重复设计中间格式。
不过,LangChain 只是组织链路。真正生成向量的是 Ollama 中运行的 nomic-embed-text,真正保存向量的是 LanceDB。
二、索引程序从哪里启动?
项目通过下面的命令启动索引:
npm run index
package.json 中对应的脚本是:
{
"scripts": {
"index": "tsx src/cli/index-cli.ts"
}
}
tsx 可以在开发阶段直接运行 TypeScript,因此不需要先执行构建。
src/cli/index-cli.ts 是索引命令的入口。它本身不负责解析 Markdown、生成向量或操作数据库,而是调用完整索引流程:
import { indexAllSystems } from "../rag/indexer.js";
indexAllSystems().catch((error) => {
console.error("[ERROR] failed to build vector knowledge bases:", error);
process.exitCode = 1;
});
这里体现了一个重要的职责划分:
- CLI 入口负责启动任务和处理顶层错误;
- Indexer 负责编排索引步骤;
- Infrastructure 层负责文档、分块、Embedding 和数据库等具体能力。
完整调用关系可以概括为:
index-cli.ts
↓
indexAllSystems()
↓
loadSystemDocuments()
↓
createSplitter().splitDocuments()
↓
buildVectorStore()
↓
createEmbeddings()
↓
LanceDB.fromDocuments()
接下来按照这条调用链逐步展开。
三、扫描技术决策和业务决策
1. 按系统和决策类型组织文件
CorpRAG 当前将原始决策文档放在 data/ 目录中:
data/
├── EXT-BADR.md
├── EXT-FADR.md
├── FR-BADR.md
├── FR-FADR.md
├── GA-BADR.md
└── GA-FADR.md
文件名同时表达两个维度:
EXT、FR、GA表示不同系统;BADR、FADR表示业务决策和技术决策。
程序可以由此推导文档路径:
{DATA_DIR}/{SYSTEM}-{DOCTYPE}.md
例如:
data/EXT-BADR.md
data/EXT-FADR.md
这种约定让新增文档不需要额外维护一份复杂映射表,也为后续生成 system 和 docType 元数据提供了稳定来源。
2. 解析 Markdown 和 Frontmatter
项目使用 gray-matter 读取 Markdown:
const raw = await fs.readFile(filePath, "utf-8");
const parsed = matter(raw);
假设原始文件是:
---
title: 外部渠道技术决策方案
date: 2026-07-26
tags: [FADR, 外部渠道]
---
# 技术决策方案
## FADR #001 - React 组件模式
正文内容……
解析后可以得到两部分:
parsed.content:Markdown 正文;parsed.data:Frontmatter 元数据。
正文用于分块和生成向量,Frontmatter 用于过滤、隔离和溯源。
3. 转换成 LangChain Document
LangChain 的 Document 主要包含两个字段:
new Document({
pageContent: "文档正文",
metadata: {
source: "文档来源"
}
});
CorpRAG 在加载文档时加入系统、决策类型、来源和 Frontmatter:
docs.push(
new Document({
pageContent: parsed.content.trim(),
metadata: {
system,
docType,
source: path.relative(process.cwd(), filePath),
...(parsed.data
? { frontmatter: serializeFrontmatter(parsed.data) }
: {})
}
})
);
最终生成的数据类似:
{
pageContent: "# 技术决策方案\n\n## FADR #001 ...",
metadata: {
system: "ext",
docType: "FADR",
source: "data/EXT-FADR.md",
frontmatter: {
title: "外部渠道技术决策方案",
date: "2026-07-26T00:00:00.000Z",
tags: ["FADR", "外部渠道"]
}
}
}
这里有几个值得注意的细节。
正文需要去除首尾空白
pageContent: parsed.content.trim()
这可以避免空行成为分块内容的一部分,也可以识别只有 Frontmatter、没有有效正文的空文档。
来源应保存相对路径
source: path.relative(process.cwd(), filePath)
相对路径比本机绝对路径更适合存入知识库。它不会暴露不同开发者的目录结构,也更容易在项目内定位原文件。
日期需要序列化
YAML 中的日期可能被解析成 JavaScript Date 对象,但向量数据库的 metadata 更适合保存可序列化值。因此项目会把日期转换成 ISO 字符串。
这一步看起来很小,却可以避免写入 LanceDB 时出现不兼容的数据类型。
缺少单个文件时不应终止全部索引
一个系统可能暂时只有 BADR,没有 FADR。加载器遇到不存在的文件时会跳过,而不是让整个索引任务失败。
这样可以在知识库逐步建设的过程中保持索引命令可用。
四、为什么不能直接对整份 Markdown 生成向量?
加载完成后,每份 Markdown 仍然是一个较长的 Document。
如果直接为整份文档生成一个向量,会出现两个问题:
- 一份文档可能包含多条不同主题的决策,单个向量难以准确表达全部语义;
- 检索命中后只能返回整份文档,既浪费上下文,也会引入大量无关内容。
例如,EXT-FADR.md 可能同时包含:
FADR #001 - React 组件模式
FADR #002 - 页面状态管理
FADR #003 - 网络请求封装
FADR #004 - 多渠道适配
如果它们共用一个向量,用户询问“业务组件能否直接访问 Store”时,状态管理信息会和组件模式、网络请求、多渠道适配混在一起。
更合理的流程是:
一份 Markdown Document
↓ 分块
多个 Chunk Document
↓ 分别生成向量
多条可独立检索的数据库记录
五、使用 createSplitter 分割文档
1. 创建 RecursiveCharacterTextSplitter
项目对分块器进行了一层简单封装:
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { config } from "../config.js";
export function createSplitter() {
return new RecursiveCharacterTextSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap
});
}
调用时直接处理 Document[]:
const splitter = createSplitter();
const chunks = await splitter.splitDocuments(documents);
分块后的每一项仍然是标准 Document:
{
pageContent: "某一段决策内容",
metadata: {
system: "ext",
docType: "FADR",
source: "data/EXT-FADR.md",
frontmatter: { /* ... */ }
}
}
也就是说,正文被拆分了,但原文的 metadata 会继续保留。后续无论召回哪个 Chunk,都可以知道它属于哪个系统、哪类决策以及哪份原始文件。
2. chunkSize 是什么?
chunkSize 控制每个片段允许包含的大致字符数量。项目默认配置为:
chunkSize = 500
也可以通过环境变量调整:
CHUNK_SIZE=800 npm run index
chunkSize 太大会导致一个 Chunk 混入多个主题;太小则可能把一条完整决策拆散。
例如:
Chunk 1:Feature + Assessment 前半部分
Chunk 2:Assessment 后半部分 + Decision
Chunk 3:Rationale
如果 Decision 和 Rationale 被完全拆开,检索系统可能只返回结论,却没有返回选择原因。
这也是第一篇文章强调固定 Markdown 结构的原因:清晰的标题、段落和换行可以为递归分块器提供更自然的切分位置。
3. chunkOverlap 是什么?
项目默认配置:
chunkOverlap = 50
它表示相邻 Chunk 之间重复保留一部分内容:
Chunk 1:AAAA BBBB CCCC
Chunk 2: CCCC DDDD EEEE
重叠内容可以减少切分边界造成的语义丢失。例如,某段理由刚好跨越两个 Chunk 时,适当重叠可以让前后片段都保留一部分上下文。
同样可以通过环境变量调整:
CHUNK_OVERLAP=100 npm run index
需要注意,Overlap 并不是越大越好。重叠过大会:
- 生成更多重复内容;
- 增加 Embedding 计算量;
- 占用更多向量存储空间;
- 让多个高度相似的 Chunk 同时进入 Top-K。
因此,chunkSize: 500、chunkOverlap: 50 只是当前项目的起始参数,不是适用于所有文档的固定答案。后续应通过真实查询结果继续调整。
六、使用 Ollama 和 nomic-embed-text 生成向量
1. Ollama 与 Embedding 模型的分工
这里再次区分两个概念:
- Ollama:负责在本地启动和管理模型服务;
nomic-embed-text:负责把文本转换成向量。
运行索引前,需要确保模型已经下载:
ollama pull nomic-embed-text
并确认本地可以看到该模型:
ollama list
默认情况下,Ollama 服务地址是:
http://localhost:11434
2. 创建 createEmbeddings
项目使用 LangChain 的 OllamaEmbeddings 封装 Ollama API:
import { OllamaEmbeddings } from "@langchain/ollama";
import { config } from "../config.js";
export function createEmbeddings() {
return new OllamaEmbeddings({
model: config.embeddingModel,
baseUrl: config.ollamaBaseUrl
});
}
默认配置为:
{
embeddingModel: "nomic-embed-text",
ollamaBaseUrl: "http://localhost:11434"
}
这层封装的价值在于,后续代码只依赖 LangChain 的 Embeddings 接口,不需要手动拼接 HTTP 请求。
在概念上,它完成的是:
文档 Chunk
↓ OllamaEmbeddings
请求本地 Ollama API
↓ nomic-embed-text
返回数值向量
例如,一段文本最终会被表示为一组浮点数:
[0.013, -0.027, 0.041, /* ... */]
向量本身不适合直接阅读,但语义相近的文本通常会在向量空间中更加接近。
3. 为什么文档和查询必须使用同一个模型?
索引阶段使用 nomic-embed-text 生成文档向量,后续查询也必须使用相同模型生成问题向量。
文档文本 ──nomic-embed-text──> 文档向量
用户问题 ──nomic-embed-text──> 查询向量
只有两者位于同一个向量空间,相似度比较才有意义。
如果后续把 EMBEDDING_MODEL 更换成其他模型,不能继续使用旧索引,必须重新运行索引命令生成全部向量。
七、使用 LanceDB.fromDocuments 创建向量数据库
1. 为什么选择 LanceDB?
CorpRAG 使用 LanceDB 保存:
- 文档 Chunk 正文;
nomic-embed-text生成的向量;- 系统、决策类型、来源等 metadata。
LanceDB 可以直接使用本地目录,不需要额外部署数据库服务,适合私人知识库和本地开发场景。
默认向量数据库目录由配置提供,例如:
data/vector-db/
2. 创建向量表
项目通过 LanceDB.fromDocuments 完成 Embedding 和数据写入:
import { LanceDB } from "@langchain/community/vectorstores/lancedb";
export async function buildVectorStore(
system: System,
docType: "BADR" | "FADR",
docs: Document[]
) {
const embeddings = createEmbeddings();
return LanceDB.fromDocuments(docs, embeddings, {
tableName: tableNameFor(system, docType),
uri: config.vectorDbPath,
mode: "overwrite"
});
}
fromDocuments 内部会依次完成:
接收 Document[]
↓
读取每个 Document.pageContent
↓
调用 OllamaEmbeddings 生成向量
↓
把向量、正文和 metadata 写入 LanceDB
因此,我们不需要先手动调用 embedDocuments(),再逐条组装数据库记录。
3. tableName 的命名规则
项目按照“系统 + 决策类型”创建独立表:
`${system}_${docType.toLowerCase()}_kb`
最终可能生成:
ext_badr_kb
ext_fadr_kb
fr_badr_kb
fr_fadr_kb
ga_badr_kb
ga_fadr_kb
这种设计同时提供两层隔离:
- 不同系统的知识不会混入同一张表;
- 业务决策和技术决策可以分别检索。
例如,查询外部渠道的技术规范时,可以直接访问:
ext_fadr_kb
而不需要先从所有系统、所有文档类型中检索,再依赖 metadata 排除大量无关结果。
4. 为什么使用 overwrite 模式?
当前索引使用:
mode: "overwrite"
每次重新索引时,会用当前 Markdown 内容重建对应的数据表。
这种方式的优点是实现简单、结果确定:
- 已删除的文档片段不会残留;
- 修改后的内容不会和旧版本并存;
- 调整分块参数或 Embedding 模型后可以完整重建。
代价是每次都会重新生成该表的全部向量。
对于当前规模的私人决策知识库,全量重建比过早实现增量更新更容易维护。后续文档量明显增长后,再考虑根据文件哈希、更新时间或稳定 Chunk ID 实现增量索引。
八、按照系统和文档类型构建知识库
前面的加载、分块和存储能力最终由 Indexer 串联起来。
单个系统的处理过程可以概括为:
async function indexSystem(system: System) {
const documents = await loadSystemDocuments(system);
const splitter = createSplitter();
const chunks = await splitter.splitDocuments(documents);
for (const docType of ["BADR", "FADR"] as const) {
const typedChunks = chunks.filter(
(chunk) => chunk.metadata.docType === docType
);
if (typedChunks.length > 0) {
await buildVectorStore(system, docType, typedChunks);
}
}
}
完整数据流如下:
EXT-BADR.md ─┐
├─> loadSystemDocuments("ext")
EXT-FADR.md ─┘ ↓
Document[]
↓
splitDocuments()
↓
Chunk Document[]
↙ ↘
BADR Chunk FADR Chunk
↓ ↓
ext_badr_kb ext_fadr_kb
然后 indexAllSystems() 依次处理所有系统:
export async function indexAllSystems(): Promise<void> {
await cleanLegacyTables();
for (const system of SYSTEMS) {
try {
await indexSystem(system);
} catch (error) {
console.error(
`[ERROR] failed to index system "${system}":`,
error
);
}
}
}
这里还补充了两个工程细节。
1. 清理旧表
项目早期可能使用过不包含文档类型的表名:
ext_kb
fr_kb
ga_kb
现在已经拆分成:
ext_badr_kb
ext_fadr_kb
索引前清理旧格式表,可以避免后续查询误用历史数据。
2. 单个系统失败时继续处理其他系统
每个系统的索引都位于独立的 try...catch 中。
如果 fr 因文档格式或模型调用失败,程序仍然可以继续处理 ga,而不是让全部系统都无法更新。
这并不意味着错误可以被忽略。失败信息仍然需要输出到日志,只是故障范围被限制在当前系统。
九、集中管理索引配置
CorpRAG 将索引相关参数集中放在配置模块中,并允许通过环境变量覆盖:
| 配置 | 默认值 | 环境变量 | 作用 |
|---|---|---|---|
| Ollama 地址 | http://localhost:11434 | OLLAMA_BASE_URL | 本地模型服务地址 |
| Embedding 模型 | nomic-embed-text | EMBEDDING_MODEL | 文本向量模型 |
| Chunk 大小 | 500 | CHUNK_SIZE | 单个分块大小 |
| Chunk 重叠 | 50 | CHUNK_OVERLAP | 相邻分块重叠大小 |
| Top-K | 3 | TOP_K | 查询默认返回数量 |
| 向量库目录 | data/vector-db | VECTOR_DB_PATH | LanceDB 数据目录 |
| 原始文档目录 | data | DATA_DIR | Markdown 文件目录 |
例如,可以临时调整分块参数和数据库目录:
CHUNK_SIZE=800 \
CHUNK_OVERLAP=100 \
VECTOR_DB_PATH=./tmp/vector-db \
npm run index
集中配置有两个作用:
- 避免加载器、分块器和数据库模块分别硬编码参数;
- 便于后续通过真实检索效果比较不同配置。
但参数可配置不等于需要频繁修改。尤其是 Embedding 模型发生变化时,必须同步重建索引,不能让同一张表混入不同模型生成的向量。
十、运行并检查索引结果
1. 启动 Ollama
先确认 Ollama 服务和模型可用:
ollama list
列表中应包含:
nomic-embed-text
2. 执行索引命令
npm run index
程序会依次:
- 清理旧格式向量表;
- 扫描各系统的 BADR、FADR;
- 解析 Markdown 和 Frontmatter;
- 生成 LangChain
Document[]; - 分割文档;
- 调用本地 Ollama 生成向量;
- 创建或覆盖 LanceDB 表。
3. 重点检查什么?
仅仅看到命令执行成功还不够,至少应该检查:
- 是否加载了预期的 Markdown 文件;
- 每个系统生成了多少原始 Document;
- 分块后生成了多少 Chunk;
- BADR 和 FADR 是否进入不同数据表;
- metadata 中是否保留
system、docType和source; - 修改文档后重新索引,旧内容是否被正确替换;
- Ollama 不可用时,日志是否明确指出 Embedding 阶段失败。
后续实现查询链路后,还需要使用一组已知答案的问题验证召回效果。例如:
为什么业务组件不能直接访问 MobX Store?
纯业务逻辑应该放在哪一层?
项目统一使用哪种 React 组件模式?
如果正确决策没有进入 Top-K,应该依次检查:
- 原始文档是否包含足够明确的问题和关键词;
- 分块是否拆散了 Decision 与 Rationale;
- 系统和文档类型是否选择正确;
- 文档与查询是否使用同一个 Embedding 模型;
chunkSize、chunkOverlap和 Top-K 是否合理。
不要在检索尚未可靠时急着接入大语言模型。否则回答错误时,很难判断问题来自索引、检索还是生成阶段。
十一、本篇补充的几个关键点
除了“加载、分块、向量化、写入数据库”四个主要步骤,一个可维护的索引链路还需要注意下面这些内容。
1. metadata 与正文同样重要
向量主要用于查找语义相近的正文,metadata 则负责隔离、过滤和溯源。
如果只保存正文,不保存来源,即使召回了正确内容,也很难让开发者回到原始决策进行复核。
2. 文档结构直接影响向量质量
Embedding 模型不会自动理解混乱文档中的隐含边界。
固定的 BADR、FADR 模板,可以让一个 Chunk 更可能同时保留:
决策点 + 候选方案 + 最终决定 + 选择理由
因此,向量化不是独立于文档写作的后处理步骤,而是整个知识治理流程的一部分。
3. 表隔离与 metadata 过滤是两种不同策略
当前项目直接按照系统和文档类型拆表,适合系统数量较少、边界明确的场景。
如果未来系统数量显著增加,也可以考虑使用较少的数据表,再通过 metadata 过滤系统和文档类型。
两种方式没有绝对优劣:
- 拆表的边界直观,查询目标明确;
- 统一表更容易跨系统检索,但更依赖过滤条件。
当前实现选择拆表,是为了优先保证知识隔离和实现清晰。
4. 全量重建是当前阶段的合理选择
增量索引通常需要解决:
- 如何识别新增、修改和删除的文档;
- 如何生成稳定 Chunk ID;
- 文档重新分块后如何删除旧 Chunk;
- 更换 Embedding 模型后如何强制全量重建。
在文档量较小时,overwrite 全量重建更简单,也更不容易残留脏数据。
5. 索引成功不代表检索质量合格
数据库中存在向量,只能说明链路可以运行。
真正的验收标准应该是:针对真实问题,正确的决策是否稳定出现在 Top-K 中,并且结果是否保留完整语义和可追溯来源。
十二、本章完成了什么?
这一篇完成了 CorpRAG 的索引链路:
- 使用 LangChain
Document统一表示正文和元数据; - 扫描不同系统的 BADR、FADR Markdown 文件;
- 使用
gray-matter分离正文与 YAML Frontmatter; - 使用
RecursiveCharacterTextSplitter对文档进行分块; - 通过
chunkSize和chunkOverlap控制片段大小及上下文重叠; - 使用
OllamaEmbeddings调用本地nomic-embed-text模型; - 使用
LanceDB.fromDocuments生成向量并写入数据库; - 按照系统和决策类型创建独立向量表;
- 使用
overwrite模式保证重建后的索引与当前文档一致; - 补充了 metadata、错误隔离、旧表清理和索引验证等工程细节。
现在,技术决策和业务决策已经完成了从 Markdown 到向量知识库的转换:
Markdown
↓
Document
↓
Chunk
↓
Embedding
↓
LanceDB
总结
文档向量化并不是简单地“把 Markdown 写入向量数据库”。
一条完整的索引链路需要先正确读取正文和元数据,再把长文档切成语义相对完整的 Chunk,随后调用统一的 Embedding 模型生成向量,最后将正文、向量和来源一起保存到向量数据库。
在 CorpRAG 中:
- LangChain 负责组织数据流;
Document负责统一正文与 metadata;RecursiveCharacterTextSplitter负责文本分块;- Ollama 负责运行本地模型服务;
nomic-embed-text负责生成向量;- LanceDB 负责保存并管理可检索数据。
至此,我们已经完成 RAG 的“写入”部分。下一篇将进入查询链路:把用户问题转换成查询向量,从 LanceDB 中执行相似度搜索,并返回最相关的技术决策、业务决策及其来源。