
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()。否则程序以为片段没有超长,写入时却可能被数据库拒绝。
当然,900 和 90 也不是适用于所有资料的标准答案。它们只是当前 schema 和文本类型下的起点。真正需要观察的是:片段是否保留完整语义、是否超过存储限制,以及实际问题能否召回足够的上下文。
设计 Milvus Schema
chunk 准备好之后,就需要把它们保存到数据库中,这里使用向量数据库 Milvus。
如果只从"向量检索"四个字理解向量数据库,很容易只设计 id + vector 两个字段。但一个向量本身无法直接展示给用户,也无法说明它来自哪里。检索命中后,系统真正要使用的是向量背后的原文和上下文。
当前 collection 保存的是下面这些信息:
| 字段 | 用途 |
|---|---|
id |
标识一个稳定的知识片段 |
vector |
进行相似度检索 |
content |
命中后交给大模型的原文 |
book_id、book_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 入库链路:
- Loader 读取原始文件,把不同数据源转换成统一的
Document。 - 章节解析保留原文结构,并把书籍、章节和顺序写入
metadata。 - Splitter 在章节内部切出适合检索的 chunk,同时控制字节大小和上下文重叠。
- Embedding 模型把
pageContent转换成 vector,原文和来源信息继续保留。 - 稳定主键、分批 upsert、并发限制和重试,让大量知识片段能够重复、可靠地写入 Milvus。
- 真实问题经过向量检索后交给大模型回答,再用命中原文核对答案是否有依据。
小说只是这条链路中的一种数据源。换成产品手册、课程资料或企业文档时,需要调整的是文件读取方式和结构解析规则;从 chunk 切分、向量生成到检索验证,整体思路仍然成立。
一个可用的 RAG 知识库,不只是数据库里存在一批向量。每个片段还要保留原文、来源和顺序,能够被稳定更新,也能够通过真实问题验证。只有这样,原始资料才真正变成了大模型可以检索和使用的知识。