
企业内部文档、产品手册、课程资料、小说原文、项目代码说明都可以成为 RAG 的外部知识来源。这些内容不一定存在于模型的训练数据中即使存在也可能不完整、不准确或者不够新。所以我们会先把自己的资料整理成知识库。用户提问时系统从知识库中找出相关内容再把这些内容交给大模型生成答案。听起来很简单把资料切一切生成向量再放进向量数据库不就行了吗真正实现时问题往往就出在这一步。内容被切得太碎、来源信息丢失、原有结构没有保留或者重复入库后新旧数据混在一起都会直接影响后面的检索效果。到了这个阶段再怎么调整 prompt也很难弥补入库时已经丢失的信息。以《逆天邪神.txt》为例一份原始资料会依次经历读取、整理、切分和向量化最后写入数据库中。其他的资料也是一样的只是采用的 loader 不一样。RAG 入库做了什么一个常见的 RAG 流程是用户提出问题系统从向量数据库里检索相关片段再让大模型根据这些片段组织答案。这很容易让人把注意力都放在后半段换一个更强的模型、调整 prompt、增加召回数量或者尝试一种新的检索策略。但如果入库时已经把一段关键情节从中间切断模型就看不到完整语义如果 chunk 没有携带章节信息系统即使命中了原文也无法告诉用户出处如果旧数据没有清理或覆盖同一个问题还可能召回多个版本的内容。模型只能使用检索回来的材料。材料从一开始就不完整后面再聪明也补不回来。因此入库不是直接执行txt - vector而是经过下面这条链路最重要的顺序先理解文档再切分文档最后才考虑数据库。保留文档结构程序拿到 txt 后首先用 LangChain 的TextLoader把它转换成统一的Documentimport { 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 Schemachunk 准备好之后就需要把它们保存到数据库中这里使用向量数据库 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) Promiseobject} buildMilvusRow 单个 chunk 的处理函数。 * returns {Promiseobject[]} 可写入 Milvus 的数据行。 */ const rows await mapWithConcurrency( batch, EMBEDDING_CONCURRENCY, buildMilvusRow, ); /** * withRetry操作失败时短暂等待后重试。 * param {string} label 写入日志的操作名称。 * param {() Promiseobject} 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 知识库不只是数据库里存在一批向量。每个片段还要保留原文、来源和顺序能够被稳定更新也能够通过真实问题验证。只有这样原始资料才真正变成了大模型可以检索和使用的知识。