企业内部文档、产品手册、课程资料、小说原文、项目代码说明,都可以成为 RAG 的外部知识来源。这些内容不一定存在于模型的训练数据中,即使存在,也可能不完整、不准确或者不够新。
所以,我们会先把自己的资料整理成知识库。用户提问时,系统从知识库中找出相关内容,再把这些内容交给大模型生成答案。
听起来很简单:把资料切一切,生成向量,再放进向量数据库不就行了吗?
真正实现时,问题往往就出在这一步。内容被切得太碎、来源信息丢失、原有结构没有保留,或者重复入库后新旧数据混在一起,都会直接影响后面的检索效果。到了这个阶段,再怎么调整 prompt,也很难弥补入库时已经丢失的信息。
以《逆天邪神.txt》为例,一份原始资料会依次经历读取、整理、切分和向量化,最后写入数据库中。
其他的资料也是一样的,只是采用的 loader 不一样。
RAG 入库做了什么
一个常见的 RAG 流程是:用户提出问题,系统从向量数据库里检索相关片段,再让大模型根据这些片段组织答案。
这很容易让人把注意力都放在后半段:换一个更强的模型、调整 prompt、增加召回数量,或者尝试一种新的检索策略。
但如果入库时已经把一段关键情节从中间切断,模型就看不到完整语义;如果 chunk 没有携带章节信息,系统即使命中了原文,也无法告诉用户出处;如果旧数据没有清理或覆盖,同一个问题还可能召回多个版本的内容。
模型只能使用检索回来的材料。材料从一开始就不完整,后面再聪明也补不回来。
因此,入库不是直接执行 txt -> vector,而是经过下面这条链路:

最重要的顺序:先理解文档,再切分文档,最后才考虑数据库。
保留文档结构
程序拿到 txt 后,首先用 LangChain 的 TextLoader 把它转换成统一的 Document:
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:
// 章节标题的匹配规则单独定义,方便适配其他文档格式。 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,并补上自己的章节信息:
{ book_id: "1", book_name: "逆天邪神", source: "逆天邪神.txt", chapter_num: 1, chapter_title: "第1章 云澈、萧澈", chapter_order: 0 }
这些字段会继续跟随章节进入下一步。文本会越切越小,但它来自哪里,不应该随着切分一起丢掉。
控制切片粒度
章节保住了结构,却仍然太长,不适合直接生成一个向量。
用户通常只问一个人物、一次对话或一段情节。如果一整章只有一个向量,语义会被大量无关内容稀释;命中后把整章交给模型,也会浪费上下文窗口。
但 chunk 也不是越小越好。切得过小,一句话可能被拦腰分开,人物和行为落入两个片段,检索到其中一个仍然无法回答问题。
这里采用 RecursiveCharacterTextSplitter,让切分器优先寻找段落、换行等自然边界,同时保留少量重叠:
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()。否则程序以为片段没有超长,写入时却可能被数据库拒绝。
当然,900 和 90 也不是适用于所有资料的标准答案。它们只是当前 schema 和文本类型下的起点。真正需要观察的是:片段是否保留完整语义、是否超过存储限制,以及实际问题能否召回足够的上下文。
设计 Milvus Schema
chunk 准备好之后,就需要把它们保存到数据库中,这里使用向量数据库 Milvus。
如果只从"向量检索"四个字理解向量数据库,很容易只设计 id + vector 两个字段。但一个向量本身无法直接展示给用户,也无法说明它来自哪里。检索命中后,系统真正要使用的是向量背后的原文和上下文。
当前 collection 保存的是下面这些信息:
| 字段 | 用途 |
|---|---|
id |
标识一个稳定的知识片段 |
vector |
进行相似度检索 |
content |
命中后交给大模型的原文 |
book_id、book_name |
区分和过滤不同书籍 |
chapter_num |
标记章节来源 |
index |
恢复片段在全书中的顺序 |
collection 的核心 schema 可以概括为:
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 生成固定宽度的主键:
// 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:主键不存在就插入,已经存在就更新。
await client.upsert({ collection_name: COLLECTION_NAME, data: rows, });
这样至少能保证相同主键不会因为脚本重跑而无限累积。需要注意的是,如果新的切片策略让 chunk 总数变少,旧版本末尾那些不再出现的主键不会自动消失。生产环境还需要增加版本字段,或者在整本书重建前按 book_id 清理旧数据。
这也是入库系统和一次性导入脚本的区别:前者必须知道数据下一次更新时会发生什么。
从文本到向量
切分完成后,每个 chunk 仍然是一个 Document:正文保存在 pageContent,书籍和章节信息保存在 metadata。写入 Milvus 之前,需要调用 embedding 模型,把 pageContent 转换成一组数字。
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。
最直接的写法是:
// 反例:chunk 较多时会瞬间发出全部 embedding 请求。 await Promise.all( chunks.map((chunk) => embeddings.embedQuery(chunk.pageContent)), );
它很短,但会把几千个请求几乎同时发出去。模型服务通常会限制单位时间内的请求数量,请求过多时,后面的请求可能直接失败。即使只有一条失败,我们也很难确认哪些片段已经处理,哪些片段还需要重新处理。
实际实现采用了三层保护:
- 同一时间最多生成 4 个片段的向量,处理完再继续取下一批;
- 每 32 个知识片段写入一次 Milvus,避免一次提交的数据太多;
- 请求偶尔失败时自动再试几次,仍然失败才停止脚本。
每个批次都会依次生成向量、写入 Milvus,再检查这一批是否全部写入成功:
// 同时最多生成 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; }
这些措施不会让某个片段的检索结果变得更准确,但能避免入库停在半路,或者脚本显示成功、数据库里却缺少一部分内容。
串联入库流程
当读取、章节解析、切片和写入各自完成自己的职责后,整条链路应该非常短:
// 使用脚本相对路径定位文件,避免依赖命令运行时所在的目录。 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 检索与问题最接近的原文片段,再把这些片段交给大模型生成答案。
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 入库链路:
- Loader 读取原始文件,把不同数据源转换成统一的
Document。 - 章节解析保留原文结构,并把书籍、章节和顺序写入
metadata。 - Splitter 在章节内部切出适合检索的 chunk,同时控制字节大小和上下文重叠。
- Embedding 模型把
pageContent转换成 vector,原文和来源信息继续保留。 - 稳定主键、分批 upsert、并发限制和重试,让大量知识片段能够重复、可靠地写入 Milvus。
- 真实问题经过向量检索后交给大模型回答,再用命中原文核对答案是否有依据。
小说只是这条链路中的一种数据源。换成产品手册、课程资料或企业文档时,需要调整的是文件读取方式和结构解析规则;从 chunk 切分、向量生成到检索验证,整体思路仍然成立。
一个可用的 RAG 知识库,不只是数据库里存在一批向量。每个片段还要保留原文、来源和顺序,能够被稳定更新,也能够通过真实问题验证。只有这样,原始资料才真正变成了大模型可以检索和使用的知识。