RAG 入库实战:一份原始资料,如何变成可检索的知识

RAG 最常见的用途,是让大模型回答它原本不知道,或者不能只靠自身记忆准确回答的问题。

企业内部文档、产品手册、课程资料、小说原文、项目代码说明,都可以成为 RAG 的外部知识来源。这些内容不一定存在于模型的训练数据中,即使存在,也可能不完整、不准确或者不够新。

所以,我们会先把自己的资料整理成知识库。用户提问时,系统从知识库中找出相关内容,再把这些内容交给大模型生成答案。

听起来很简单:把资料切一切,生成向量,再放进向量数据库不就行了吗?

真正实现时,问题往往就出在这一步。内容被切得太碎、来源信息丢失、原有结构没有保留,或者重复入库后新旧数据混在一起,都会直接影响后面的检索效果。到了这个阶段,再怎么调整 prompt,也很难弥补入库时已经丢失的信息。

以《逆天邪神.txt》为例,一份原始资料会依次经历读取、整理、切分和向量化,最后写入数据库中。

其他的资料也是一样的,只是采用的 loader 不一样。

RAG 入库做了什么

一个常见的 RAG 流程是:用户提出问题,系统从向量数据库里检索相关片段,再让大模型根据这些片段组织答案。

这很容易让人把注意力都放在后半段:换一个更强的模型、调整 prompt、增加召回数量,或者尝试一种新的检索策略。

但如果入库时已经把一段关键情节从中间切断,模型就看不到完整语义;如果 chunk 没有携带章节信息,系统即使命中了原文,也无法告诉用户出处;如果旧数据没有清理或覆盖,同一个问题还可能召回多个版本的内容。

模型只能使用检索回来的材料。材料从一开始就不完整,后面再聪明也补不回来。

因此,入库不是直接执行 txt -> vector,而是经过下面这条链路:

最重要的顺序:先理解文档,再切分文档,最后才考虑数据库。

保留文档结构

程序拿到 txt 后,首先用 LangChain 的 TextLoader 把它转换成统一的 Document

js 复制代码
import { fileURLToPath } from "node:url";
import { Document } from "@langchain/core/documents";
import { TextLoader } from "@langchain/classic/document_loaders/fs/text";

// 当前示例只处理一本书,用固定 ID 关联这本书的所有知识片段。
const BOOK_ID = "1";

// 写入 metadata 的展示名称,不保存本机文件路径。
const BOOK_NAME = "逆天邪神";

export async function loadRawDocuments(fileUrl) {
  // Loader 只负责把外部文件转换成统一的 Document。
  const filePath = fileURLToPath(fileUrl);
  const docs = await new TextLoader(filePath).load();

  return docs.map(
    (doc) =>
      new Document({
        pageContent: doc.pageContent,
        metadata: {
          ...doc.metadata,
          // 书籍信息从入口开始携带,后续章节和 chunk 会自动继承。
          source: doc.metadata?.source ?? filePath,
          book_id: BOOK_ID,
          book_name: BOOK_NAME,
        },
      }),
  );
}

pageContent 保存正文,metadata 保存书名、来源和业务标识。Loader 到这里就应该停下,它的任务只是把外部数据源变成统一格式,而不是决定文本应该怎样切。

为什么不立刻把整本书交给文本切分器?

因为小说不是一块没有结构的长文本。它有序章、章节、番外,章节之前还可能有书名和作者信息。如果直接按固定长度切分,正文仍然存在,但"这段内容属于哪一章"这个关系消失了。

一旦这个关系丢失,后面想展示出处、补充相邻上下文,或者按原文顺序重排检索结果,就只能重新猜。

更稳妥的做法是先识别章节边界,把整本书拆成章节级 Document

js 复制代码
// 章节标题的匹配规则单独定义,方便适配其他文档格式。
const CHAPTER_PATTERN = /(^|\n)(序章|第\d+章[^\n]*)/g;

export function parseOneDocumentIntoChapters(doc) {
  // 先统一换行符,避免同一份文本在不同系统上得到不同匹配结果。
  const text = doc.pageContent.replace(/\r\n/g, "\n").trim();

  // 匹配"序章"或"第 N 章",标题所在位置就是章节边界。
  const matches = Array.from(text.matchAll(CHAPTER_PATTERN));

  // 没有匹配到章节时,将全文作为一个章节,保证流程仍可继续。
  if (matches.length === 0) {
    return [createChapterDocument(/* 全文及原始 metadata */)];
  }

  // 第一章之前的书籍信息会单独保存,空章节会被过滤。
  return matches.map((match, index) => {
    // 当前标题是起点,下一个标题是终点;最后一章读到文件结尾。
    const start = match.index + match[1].length;
    const end = matches[index + 1]?.index ?? text.length;

    // createChapterDocument 把章节正文和来源信息组装成新的 Document。
    return createChapterDocument({
      sourceDoc: doc,
      // 章节顺序和标题进入 metadata,后续切片时不会丢失。
      chapterOrder: index,
      // parseChapterNumber 从标题提取章节号,提取失败时使用章节顺序
      chapterNum: parseChapterNumber(match[2], index),
      chapterTitle: match[2].trim(),
      chapterText: text.slice(start, end).trim(),
    });
  });
}

正则表达式并不是这里的重点。不同小说的标题格式不同,真实项目里当然需要调整匹配规则。真正值得保留的是这个原则:先按内容本身的业务结构分层,再按模型和数据库需要的长度切片。

就比如,每个章节都会继承原始文档的 metadata,并补上自己的章节信息:

js 复制代码
{
  book_id: "1",
  book_name: "逆天邪神",
  source: "逆天邪神.txt",
  chapter_num: 1,
  chapter_title: "第1章 云澈、萧澈",
  chapter_order: 0
}

这些字段会继续跟随章节进入下一步。文本会越切越小,但它来自哪里,不应该随着切分一起丢掉。

控制切片粒度

章节保住了结构,却仍然太长,不适合直接生成一个向量。

用户通常只问一个人物、一次对话或一段情节。如果一整章只有一个向量,语义会被大量无关内容稀释;命中后把整章交给模型,也会浪费上下文窗口。

但 chunk 也不是越小越好。切得过小,一句话可能被拦腰分开,人物和行为落入两个片段,检索到其中一个仍然无法回答问题。

这里采用 RecursiveCharacterTextSplitter,让切分器优先寻找段落、换行等自然边界,同时保留少量重叠:

js 复制代码
import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// 单个 chunk 最多 900 字节,为 Milvus 的 content 字段预留空间。
const CHUNK_SIZE_BYTES = 900;

// 相邻 chunk 重叠 90 字节,减少边界处的上下文丢失。
const CHUNK_OVERLAP_BYTES = 90;

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE_BYTES,
  chunkOverlap: CHUNK_OVERLAP_BYTES,
  // content 字段按 UTF-8 字节限制,因此切片也按字节计数。
  lengthFunction: (text) => Buffer.byteLength(text, "utf8"),
});

export async function splitChapterDocuments(chapterDocuments) {
  const chunks = await textSplitter.splitDocuments(chapterDocuments);

  return chunks.map(
    (doc, chunkOrder) =>
      new Document({
        pageContent: doc.pageContent.trim(),
        // splitDocuments 会继承章节 metadata,这里只补全书顺序。
        metadata: { ...doc.metadata, chunk_order: chunkOrder },
      }),
  );
}

这段配置里有两个容易忽略的细节。

第一个是 overlap。这里保留 90 字节的重叠,不是为了制造重复内容,而是给切分边界留一小段缓冲。答案刚好跨过边界时,相邻 chunk 仍有机会保留完整语义。

第二个是长度单位 。JavaScript 的 string.length 和数据库字段限制不是一回事,中文字符在 UTF-8 编码下通常占多个字节。Milvus 的 VarChar 长度限制按字节计算,因此 splitter 也必须使用 Buffer.byteLength()。否则程序以为片段没有超长,写入时却可能被数据库拒绝。

当然,90090 也不是适用于所有资料的标准答案。它们只是当前 schema 和文本类型下的起点。真正需要观察的是:片段是否保留完整语义、是否超过存储限制,以及实际问题能否召回足够的上下文。

设计 Milvus Schema

chunk 准备好之后,就需要把它们保存到数据库中,这里使用向量数据库 Milvus。

如果只从"向量检索"四个字理解向量数据库,很容易只设计 id + vector 两个字段。但一个向量本身无法直接展示给用户,也无法说明它来自哪里。检索命中后,系统真正要使用的是向量背后的原文和上下文。

当前 collection 保存的是下面这些信息:

字段 用途
id 标识一个稳定的知识片段
vector 进行相似度检索
content 命中后交给大模型的原文
book_idbook_name 区分和过滤不同书籍
chapter_num 标记章节来源
index 恢复片段在全书中的顺序

collection 的核心 schema 可以概括为:

js 复制代码
import {
  MilvusClient,
  DataType,
  MetricType,
  IndexType,
} from "@zilliz/milvus2-sdk-node";

// 必须与 embedding 模型的实际输出维度保持一致。
const VECTOR_DIM = 1024;

// 小说知识片段统一写入这个 collection,查询时也必须使用相同名称。
const COLLECTION_NAME = "story";

const client = new MilvusClient({
  address: process.env.MILVUS_ADDRESS,
});

await client.connectPromise;

await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    // 稳定主键让同一片段能够通过 upsert 被更新。
    {
      name: "id",
      data_type: DataType.VarChar,
      is_primary_key: true,
      max_length: 100,
    },
    // dim 必须与 embedding 模型的实际输出维度一致。
    { name: "vector", data_type: DataType.FloatVector, dim: VECTOR_DIM },
    // 下面的字段用于过滤、定位来源和恢复原文顺序。
    { name: "book_id", data_type: DataType.VarChar, max_length: 100 },
    { name: "book_name", data_type: DataType.VarChar, max_length: 200 },
    { name: "chapter_num", data_type: DataType.Int32 },
    { name: "index", data_type: DataType.Int32 },
    // content 是检索命中后真正交给大模型的文本。
    { name: "content", data_type: DataType.VarChar, max_length: 1000 },
  ],
});

// 为 vector 字段创建余弦相似度索引。
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: "vector",
  metric_type: MetricType.COSINE,
  index_type: IndexType.IVF_FLAT,
  params: { nlist: 1024 },
});

向量字段的 dim 必须与 embedding 模型的实际输出维度完全一致。这里使用 1024 维,所以模型配置和 Milvus schema 都共享同一个 VECTOR_DIM,避免两处配置悄悄发生偏差。

如果产品需要在答案中展示章节标题,还可以继续保存 chapter_title。schema 不是为了把现有 metadata 原样复制一遍,而是要反过来问:命中片段后,我需要怎样过滤、排序、展示和排查它?

生成稳定主键

第一次入库成功并不难。更麻烦的是第二次。

调试 RAG 时,我们很可能重新调整 chunk 大小、修正章节解析,甚至更换 embedding 模型。如果每次都只做 insert,旧数据和新数据就会同时存在。同一段原文被召回多次,看上去像检索很有把握,实际只是知识库重复了。

这次使用 book_id + chunk_order 生成固定宽度的主键:

js 复制代码
// chunk 顺序补齐到 6 位,让字符串 ID 仍能按原文顺序排列。
const CHUNK_ID_WIDTH = 6;

function buildChunkId(bookId, chunkOrder) {
  // 固定宽度既保证主键稳定,也让字符串排序接近原文顺序。
  return `${bookId}_${String(chunkOrder).padStart(CHUNK_ID_WIDTH, "0")}`;
}

// 1_000000
// 1_000001
// 1_000002

章节号更符合人的阅读习惯,却不适合直接当主键。小说里可能有序章、番外、重复章节号或格式异常的标题;全书 chunk 顺序反而更容易形成确定的标识。

写入时再使用 upsert:主键不存在就插入,已经存在就更新。

js 复制代码
await client.upsert({
  collection_name: COLLECTION_NAME,
  data: rows,
});

这样至少能保证相同主键不会因为脚本重跑而无限累积。需要注意的是,如果新的切片策略让 chunk 总数变少,旧版本末尾那些不再出现的主键不会自动消失。生产环境还需要增加版本字段,或者在整本书重建前按 book_id 清理旧数据。

这也是入库系统和一次性导入脚本的区别:前者必须知道数据下一次更新时会发生什么。

从文本到向量

切分完成后,每个 chunk 仍然是一个 Document:正文保存在 pageContent,书籍和章节信息保存在 metadata。写入 Milvus 之前,需要调用 embedding 模型,把 pageContent 转换成一组数字。

js 复制代码
import { OpenAIEmbeddings } from "@langchain/openai";

// embedding 输出维度必须与 Milvus 的 vector 字段维度一致。
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  model: process.env.AI_EMBEDDING_MODEL,
  dimensions: VECTOR_DIM,
  apiKey: process.env.AI_EMBEDDING_KEY,
  configuration: {
    baseURL: process.env.AI_EMBEDDING_BASE_URL,
  },
});

async function buildMilvusRow(doc) {
  const chunkOrder = Number(doc.metadata.chunk_order);
  const bookId = String(doc.metadata.book_id);
  const id = buildChunkId(bookId, chunkOrder);

  // 核心转换:把 chunk 正文交给 embedding 模型,得到 number[] 向量。
  const vector = await embeddings.embedQuery(doc.pageContent);

  // 模型输出维度不正确时立即停止,避免写入阶段才发现 schema 不匹配。
  if (vector.length !== VECTOR_DIM) {
    throw new Error(`向量维度错误:期望 ${VECTOR_DIM},实际 ${vector.length}`);
  }

  return {
    id,
    vector,
    // 原文仍然需要保存,检索命中后会把它交给大模型。
    content: doc.pageContent,
    // metadata 转成 Milvus 中用于过滤、定位和排序的普通字段。
    book_id: bookId,
    book_name: String(doc.metadata.book_name),
    chapter_num: Number(doc.metadata.chapter_num),
    index: chunkOrder,
  };
}

embedQuery() 的输入是一段字符串,返回值是 number[]。同一个 chunk 会以两种形式保存:vector 表示它的语义,用于相似度检索;content 保留原文,用于生成答案。书籍、章节和顺序信息则从 metadata 映射到 Milvus 的普通字段中。

控制并发与重试

一本小说可能产生几千个 chunk。每个 chunk 都要请求 embedding 服务,随后还要写入 Milvus。

最直接的写法是:

js 复制代码
// 反例:chunk 较多时会瞬间发出全部 embedding 请求。
await Promise.all(
  chunks.map((chunk) => embeddings.embedQuery(chunk.pageContent)),
);

它很短,但会把几千个请求几乎同时发出去。模型服务通常会限制单位时间内的请求数量,请求过多时,后面的请求可能直接失败。即使只有一条失败,我们也很难确认哪些片段已经处理,哪些片段还需要重新处理。

实际实现采用了三层保护:

  • 同一时间最多生成 4 个片段的向量,处理完再继续取下一批;
  • 每 32 个知识片段写入一次 Milvus,避免一次提交的数据太多;
  • 请求偶尔失败时自动再试几次,仍然失败才停止脚本。

每个批次都会依次生成向量、写入 Milvus,再检查这一批是否全部写入成功:

js 复制代码
// 同时最多生成 4 个向量,避免短时间内发出过多请求。
const EMBEDDING_CONCURRENCY = 4;

// 每次向 Milvus 写入 32 个片段,平衡请求次数和失败重试成本。
const UPSERT_BATCH_SIZE = 32;

// client 和 COLLECTION_NAME 在创建 collection 时已经初始化。
export async function upsertAllDocuments(chunkDocuments) {
  let totalWritten = 0;

  // 将整本书拆成小批次,缩小单次失败的影响范围。
  for (
    let start = 0;
    start < chunkDocuments.length;
    start += UPSERT_BATCH_SIZE
  ) {
    const batch = chunkDocuments.slice(start, start + UPSERT_BATCH_SIZE);

    /**
     * mapWithConcurrency:在指定的并发上限内处理数组。
     * @param {Document[]} batch 待生成向量的 chunk。
     * @param {number} EMBEDDING_CONCURRENCY 同时执行的最大任务数。
     * @param {(doc: Document) => Promise<object>} buildMilvusRow 单个 chunk 的处理函数。
     * @returns {Promise<object[]>} 可写入 Milvus 的数据行。
     */
    const rows = await mapWithConcurrency(
      batch,
      EMBEDDING_CONCURRENCY,
      buildMilvusRow,
    );

    /**
     * withRetry:操作失败时短暂等待后重试。
     * @param {string} label 写入日志的操作名称。
     * @param {() => Promise<object>} action 需要重试的异步操作。
     */
    const result = await withRetry("写入 Milvus", () =>
      client.upsert({ collection_name: COLLECTION_NAME, data: rows }),
    );

    /**
     * assertMutationSuccess:检查是否存在整体失败或部分数据写入失败。
     * @param {object} result Milvus 返回的写入结果。
     * @param {string} label 用于错误信息的操作名称。
     */
    assertMutationSuccess(result, "写入 Milvus");

    totalWritten += rows.length;
  }

  // 所有批次完成后,要求 Milvus 保存尚在缓冲区中的数据。
  await client.flush({ collection_names: [COLLECTION_NAME] });
  return totalWritten;
}

这些措施不会让某个片段的检索结果变得更准确,但能避免入库停在半路,或者脚本显示成功、数据库里却缺少一部分内容。

串联入库流程

当读取、章节解析、切片和写入各自完成自己的职责后,整条链路应该非常短:

js 复制代码
// 使用脚本相对路径定位文件,避免依赖命令运行时所在的目录。
const BOOK_FILE_URL = new URL("./逆天邪神.txt", import.meta.url);

// 下面五个函数分别对应流程图中的五个处理阶段。
export async function loadAndProcessTxt() {
  // 1. 将外部文件转换成统一的原始 Document。
  const rawDocuments = await loadRawDocuments(BOOK_FILE_URL);

  // 2. 先恢复小说章节结构,把章节信息写入 metadata。
  const chapterDocuments = parseChapterDocuments(rawDocuments);

  // 3. 再把章节切成适合向量检索的 chunk。
  const chunkDocuments = await splitChapterDocuments(chapterDocuments);

  // 4. 生成向量并分批写入 Milvus。
  const totalWritten = await upsertAllDocuments(chunkDocuments);

  // 5. 查询最终行数,用于核对本次写入结果。
  const rowCount = await getCollectionRowCount();

  return {
    raw: rawDocuments.length,
    chapters: chapterDocuments.length,
    chunks: chunkDocuments.length,
    written: totalWritten,
    rowCount,
  };
}

那么执行上面的 loadAndProcessTxt 函数,就可以完成小说的入库。

用真实问题验证

数据写入 Milvus,只能证明入库脚本没有中断。知识片段能不能被正确找到、章节信息有没有保留、大模型能不能根据原文回答,还需要用一个真实问题验证。

可以随便问一个问题,比如以小说为例:

云澈拥有的第一部功法是什么?

验证过程分成两步:先从 Milvus 检索与问题最接近的原文片段,再把这些片段交给大模型生成答案。

js 复制代码
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Milvus } from "@langchain/community/vectorstores/milvus";

// 必须与入库时使用的 collection 名称一致。
const COLLECTION_NAME = "story";

// 必须与入库时的 embedding 模型和 Milvus vector 维度一致。
const VECTOR_DIM = 1024;

// 取最相关的 5 个片段,给模型提供必要的上下文。
const TOP_K = 5;

// 使用答案能够在原文中明确核对的问题。
const QUESTION = "云澈拥有的第一部功法是什么?";

const embeddings = new OpenAIEmbeddings({
  model: process.env.AI_EMBEDDING_MODEL,
  dimensions: VECTOR_DIM,
  apiKey: process.env.AI_EMBEDDING_KEY,
  configuration: {
    baseURL: process.env.AI_EMBEDDING_BASE_URL,
  },
});

const model = new ChatOpenAI({
  model: process.env.AI_MODEL,
  temperature: 0,
  apiKey: process.env.AI_KEY,
  configuration: {
    baseURL: process.env.AI_BASE_URL,
  },
});

// 连接入库阶段已经创建好的 collection,不重新写入数据。
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
  collectionName: COLLECTION_NAME,
  url: process.env.MILVUS_ADDRESS,
  textField: "content",
  primaryField: "id",
  vectorField: "vector",
});

// Milvus 会先把问题转换成向量,再找出语义最接近的 5 个片段。
const results = await vectorStore.similaritySearchWithScore(QUESTION, TOP_K);

if (results.length === 0) {
  throw new Error("没有检索到相关片段,请检查 collection 和 embedding 配置");
}

// 把命中的原文和章节信息整理成模型能够读取的上下文。
const context = results
  .map(
    ([doc], index) => `
[片段 ${index + 1}]
章节:第 ${doc.metadata.chapter_num} 章
原文:${doc.pageContent}
`,
  )
  .join("\n");

const response = await model.invoke(`
你只能根据下面提供的小说原文回答问题。
如果原文中没有答案,请直接回答"根据现有片段无法确定",不要补充或猜测。

${context}

问题:${QUESTION}
`);

console.log("模型回答:", response.content);

// 同时打印检索来源,核对答案是否真的来自命中的原文。
for (const [doc, score] of results) {
  console.log({
    score,
    chapter: doc.metadata.chapter_num,
    content: doc.pageContent,
  });
}

这里需要同时检查模型回答和检索原文。可以自己动手试试。

只有"检索到正确原文"和"答案能够被原文支持"同时成立,才能说明这批知识片段已经真正进入 RAG 链路。

总结

到这里,一份 txt 已经走完了完整的 RAG 入库链路:

  1. Loader 读取原始文件,把不同数据源转换成统一的 Document
  2. 章节解析保留原文结构,并把书籍、章节和顺序写入 metadata
  3. Splitter 在章节内部切出适合检索的 chunk,同时控制字节大小和上下文重叠。
  4. Embedding 模型把 pageContent 转换成 vector,原文和来源信息继续保留。
  5. 稳定主键、分批 upsert、并发限制和重试,让大量知识片段能够重复、可靠地写入 Milvus。
  6. 真实问题经过向量检索后交给大模型回答,再用命中原文核对答案是否有依据。

小说只是这条链路中的一种数据源。换成产品手册、课程资料或企业文档时,需要调整的是文件读取方式和结构解析规则;从 chunk 切分、向量生成到检索验证,整体思路仍然成立。

一个可用的 RAG 知识库,不只是数据库里存在一批向量。每个片段还要保留原文、来源和顺序,能够被稳定更新,也能够通过真实问题验证。只有这样,原始资料才真正变成了大模型可以检索和使用的知识。

相关推荐
coderklaus4 小时前
RAG 与 LLM Wiki
agent
codingWhat4 小时前
从“模型会说话”到“Agent 可管理”:一套 AI 资产平台的前端工程实践分享
agent
花千树_0104 小时前
插件加载方式选不好,迟早被同事吐槽——四种姿势全解析
langchain·agent
疯狂学习GIS4 小时前
AI Agent 操作 QGIS 实测:Codex 与 Claude Code 能自己出图吗
人工智能·ai·大模型·gis·agent·qgis·codex
大模型真好玩7 小时前
LangChain DeepAgents 速通指南(十一)—— DeepAgents Code 记忆与状态管理
人工智能·langchain·agent
名不经传的养虾人8 小时前
从0到1:企业级AI项目迭代日记 Vol.67|能运行,和能商用
人工智能·agent·ai编程·企业ai·多agent协作
杠杠的blog8 小时前
企业级提示词管理
python·agent·提示词·生产级实践
武子康8 小时前
LingBot 四线全拆:Video / World / VLA / VA 按最终输出选型 + 开源许可证避坑表
人工智能·llm·agent
为你学会写情书9 小时前
RAG 性能总卡在检索?先搞懂 Milvus 向量数据库再说
llm·agent