从零搭建私人 RAG 实战:技术文档与业务文档向量化

108 阅读18分钟

从零搭建私人 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 及其依赖,逐步说明:

  1. LangChain 在索引链路中负责什么;
  2. 如何扫描 BADR、FADR 文档并生成标准 Document;
  3. 如何使用 RecursiveCharacterTextSplitter 分割长文档;
  4. 如何通过 OllamaEmbeddings 调用本地 Embedding 模型;
  5. 如何使用 LanceDB.fromDocuments 创建向量表;
  6. 如何按照系统和决策类型组织多个知识库。

一、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。

如果直接为整份文档生成一个向量,会出现两个问题:

  1. 一份文档可能包含多条不同主题的决策,单个向量难以准确表达全部语义;
  2. 检索命中后只能返回整份文档,既浪费上下文,也会引入大量无关内容。

例如,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

这种设计同时提供两层隔离:

  1. 不同系统的知识不会混入同一张表;
  2. 业务决策和技术决策可以分别检索。

例如,查询外部渠道的技术规范时,可以直接访问:

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:11434OLLAMA_BASE_URL本地模型服务地址
Embedding 模型nomic-embed-textEMBEDDING_MODEL文本向量模型
Chunk 大小500CHUNK_SIZE单个分块大小
Chunk 重叠50CHUNK_OVERLAP相邻分块重叠大小
Top-K3TOP_K查询默认返回数量
向量库目录data/vector-dbVECTOR_DB_PATHLanceDB 数据目录
原始文档目录dataDATA_DIRMarkdown 文件目录

例如,可以临时调整分块参数和数据库目录:

CHUNK_SIZE=800 \
CHUNK_OVERLAP=100 \
VECTOR_DB_PATH=./tmp/vector-db \
npm run index

集中配置有两个作用:

  1. 避免加载器、分块器和数据库模块分别硬编码参数;
  2. 便于后续通过真实检索效果比较不同配置。

但参数可配置不等于需要频繁修改。尤其是 Embedding 模型发生变化时,必须同步重建索引,不能让同一张表混入不同模型生成的向量。


十、运行并检查索引结果

1. 启动 Ollama

先确认 Ollama 服务和模型可用:

ollama list

列表中应包含:

nomic-embed-text

2. 执行索引命令

npm run index

程序会依次:

  1. 清理旧格式向量表;
  2. 扫描各系统的 BADR、FADR;
  3. 解析 Markdown 和 Frontmatter;
  4. 生成 LangChain Document[];
  5. 分割文档;
  6. 调用本地 Ollama 生成向量;
  7. 创建或覆盖 LanceDB 表。

3. 重点检查什么?

仅仅看到命令执行成功还不够,至少应该检查:

  • 是否加载了预期的 Markdown 文件;
  • 每个系统生成了多少原始 Document;
  • 分块后生成了多少 Chunk;
  • BADR 和 FADR 是否进入不同数据表;
  • metadata 中是否保留 system、docType 和 source;
  • 修改文档后重新索引,旧内容是否被正确替换;
  • Ollama 不可用时,日志是否明确指出 Embedding 阶段失败。

后续实现查询链路后,还需要使用一组已知答案的问题验证召回效果。例如:

为什么业务组件不能直接访问 MobX Store?
纯业务逻辑应该放在哪一层?
项目统一使用哪种 React 组件模式?

如果正确决策没有进入 Top-K,应该依次检查:

  1. 原始文档是否包含足够明确的问题和关键词;
  2. 分块是否拆散了 Decision 与 Rationale;
  3. 系统和文档类型是否选择正确;
  4. 文档与查询是否使用同一个 Embedding 模型;
  5. 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 的索引链路:

  1. 使用 LangChain Document 统一表示正文和元数据;
  2. 扫描不同系统的 BADR、FADR Markdown 文件;
  3. 使用 gray-matter 分离正文与 YAML Frontmatter;
  4. 使用 RecursiveCharacterTextSplitter 对文档进行分块;
  5. 通过 chunkSize 和 chunkOverlap 控制片段大小及上下文重叠;
  6. 使用 OllamaEmbeddings 调用本地 nomic-embed-text 模型;
  7. 使用 LanceDB.fromDocuments 生成向量并写入数据库;
  8. 按照系统和决策类型创建独立向量表;
  9. 使用 overwrite 模式保证重建后的索引与当前文档一致;
  10. 补充了 metadata、错误隔离、旧表清理和索引验证等工程细节。

现在,技术决策和业务决策已经完成了从 Markdown 到向量知识库的转换:

Markdown
    ↓
Document
    ↓
Chunk
    ↓
Embedding
    ↓
LanceDB

总结

文档向量化并不是简单地“把 Markdown 写入向量数据库”。

一条完整的索引链路需要先正确读取正文和元数据,再把长文档切成语义相对完整的 Chunk,随后调用统一的 Embedding 模型生成向量,最后将正文、向量和来源一起保存到向量数据库。

在 CorpRAG 中:

  • LangChain 负责组织数据流;
  • Document 负责统一正文与 metadata;
  • RecursiveCharacterTextSplitter 负责文本分块;
  • Ollama 负责运行本地模型服务;
  • nomic-embed-text 负责生成向量;
  • LanceDB 负责保存并管理可检索数据。

至此,我们已经完成 RAG 的“写入”部分。下一篇将进入查询链路:把用户问题转换成查询向量,从 LanceDB 中执行相似度搜索,并返回最相关的技术决策、业务决策及其来源。