手把手教你用 Milvus + LangChain 搭建《天龙八部》RAG 知识库 RAG 检索 生成向量数据库是桥梁LangChain 是胶水而你的业务数据才是灵魂。你是否曾幻想过像问 ChatGPT 一样问一本小说“段誉会什么武功”然后它不仅能给出答案还能告诉你这句话出自第几章这就是 RAG检索增强生成的典型应用——把静态文档变成可交互的知识库。本文将带你从零开始用MilvusLangChain搭建一个“天龙八部问答机器人”。全文实战优先不但给出可运行的代码还会逐函数解析关键 API并用流程图帮你理清整个流程。读完你不仅会跑还会调优顺带带走几个可以直接复用的设计模式。一、项目全景我们到底在做什么RAG 的核心三步离线建库将电子书加载、分块、向量化存入向量数据库。在线检索用户提问时将问题向量化从数据库中检索最相关的几个片段。生成答案将检索到的片段拼接成上下文喂给大模型生成最终回答。整个项目包含两个主要脚本main.mjs负责建库加载 EPUB、分块、生成向量、插入 Milvus。rag.mjs负责问答检索 LLM 生成。下面我们按照流程图的顺序逐一拆解。二、整体流程先看全景图上半部分是离线建库下半部分是在线问答。我们按顺序展开。三、准备工作环境与依赖3.1 安装依赖npm install zilliz/milvus2-sdk-node langchain/openai langchain/community langchain/textsplitters dotenv3.2 环境变量.envMILVUS_ADDRESShttps://your-instance.region.zillizcloud.com:19530 MILVUS_TOKENyour-api-token OPENAI_API_KEYsk-xxx OPENAI_BASE_URLhttps://api.openai.com/v1 # 代理可替换 EMBEDDINGS_MODEL_NAMEtext-embedding-3-small MODEL_NAMEgpt-4o-mini3.3 初始化核心客户端import dotenv/config; import { MilvusClient, DataType, MetricType, IndexType } from zilliz/milvus2-sdk-node; import { OpenAIEmbeddings, ChatOpenAI } from langchain/openai; import { EPubLoader } from langchain/community/document_loaders/fs/epub; import { RecursiveCharacterTextSplitter } from langchain/textsplitters; const COLLECTION_NAME ebook; const VECTOR_DIM 1024; const CHUNK_SIZE 500; const EPUB_FILE ./天龙八部.epub; // Milvus 客户端注意地址处理 const client new MilvusClient({ address: process.env.MILVUS_ADDRESS.replace(/^https?:///, ), token: process.env.MILVUS_TOKEN, ssl: true }); // Embedding 模型指定维度 const embeddings new OpenAIEmbeddings({ apiKey: process.env.OPENAI_API_KEY, model: process.env.EMBEDDINGS_MODEL_NAME, configuration: { baseURL: process.env.OPENAI_BASE_URL }, dimensions: VECTOR_DIM }); // LLM用于问答 const model new ChatOpenAI({ temperature: 0.1, model: process.env.MODEL_NAME, apiKey: process.env.OPENAI_API_KEY, configuration: { baseURL: process.env.OPENAI_BASE_URL } });注意text-embedding-3-small默认输出 1536 维我们通过dimensions: 1024截断减少存储和计算开销同时保持语义质量。四、离线建库核心函数详解4.1 集合管理ensureCollection在插入数据前必须确保 Milvus 集合存在并已创建索引且加载到内存。这个函数承担了“守门员”的角色。async function ensureCollection(bookId) { try { // 1. 检查集合是否已存在幂等性 const hasCollection await client.hasCollection({ collection_name: COLLECTION_NAME }); if (!hasCollection.value) { console.log(创建集合...); // 2. 定义 Schema字段结构 await client.createCollection({ collection_name: COLLECTION_NAME, fields: [ { name: id, data_type: DataType.VarChar, max_length: 100, is_primary_key: true }, { 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 }, { name: content, data_type: DataType.VarChar, max_length: 10000 }, { name: vector, data_type: DataType.FloatVector, dim: VECTOR_DIM } ] }); console.log(集合创建成功); // 3. 创建向量索引搜索加速的关键 console.log(创建索引...); await client.createIndex({ collection_name: COLLECTION_NAME, field_name: vector, index_type: IndexType.IVF_FLAT, metric_type: MetricType.COSINE, params: { nlist: 1024 } }); console.log(索引创建成功); } // 4. 加载集合到内存搜索前必须 try { await client.loadCollection({ collection_name: COLLECTION_NAME }); console.log(集合加载成功); } catch (err) { // 若已加载会报错忽略即可 console.error(集合可能已加载); } } catch (err) { console.error(创建集合时出错:, err); } }API 解析hasCollection检查集合是否存在返回{ value: boolean }。createCollection定义字段is_primary_key必须指定一个字段我们使用字符串组合 ID 确保唯一性。createIndex索引类型IVF_FLAT是倒排索引 扁平量化适合中等规模数据nlist是聚类个数一般取sqrt(数据量)。metric_type选择COSINE因为 OpenAI 的向量已归一化。loadCollection将索引加载到内存只有加载后才能搜索。重复加载会抛异常所以我们用 try-catch 包裹。为什么需要先创建索引再插入数据因为 Milvus 支持动态建索引但如果在插入后再创建需要重建索引会额外耗时。我们建议先建索引再插入这样插入时就会自动构建增量索引。4.2 流式加载与分块loadAndProcessEPubStreaming这个函数负责加载 EPUB、按章节拆分、分块、调用insertChunksBatch入库。我们采用“逐章处理”的策略避免一次性将所有章节内容加载到内存。async function loadAndProcessEPubStreaming(bookId) { try { console.log(加载 EPUB: ${EPUB_FILE}); const loader new EPubLoader(EPUB_FILE, { splitChapters: true // 按目录自动拆分章节 }); const documents await loader.load(); // 每个元素是一个 Document一章节 console.log(加载到 ${documents.length} 个章节); const textSplitter new RecursiveCharacterTextSplitter({ chunkSize: CHUNK_SIZE, chunkOverlap: 50 }); let totalInserted 0; for (let chapterIndex 0; chapterIndex documents.length; chapterIndex) { const chapter documents[chapterIndex]; const chapterContent chapter.pageContent; console.log(处理第 ${chapterIndex 1}/${documents.length} 章...); // 分块 const chunks await textSplitter.splitText(chapterContent); console.log(拆分为 ${chunks.length} 个片段); if (chunks.length 0) continue; // 批量插入 const insertedCount await insertChunksBatch( chunks, bookId, chapterIndex 1 ); totalInserted insertedCount; console.log(已插入 ${totalInserted} 条记录); } console.log(总共插入 ${totalInserted} 条记录); return totalInserted; } catch (err) { console.error(处理 EPUB 时出错:, err); } }设计要点EPubLoader的splitChapters: true会解析目录每个章节生成一个独立的DocumentpageContent存储纯文本。RecursiveCharacterTextSplitter默认分隔符为[\n\n, \n, , ]它会递归尝试保证每个块不超过chunkSize同时chunkOverlap让边界信息得以保留。我们逐章处理处理完一章即释放该章节的引用控制内存峰值。为什么不用loadAndSplit因为我们需要在插入时携带章节号自定义 ID所以手动遍历更灵活。4.3 批量插入与向量化insertChunksBatch这个函数负责将一章内的所有 chunk 转为向量并批量插入 Milvus。它是性能优化的核心。async function insertChunksBatch(chunks, bookId, chapterNum) { try { if (chunks.length 0) return 0; // 并发生成向量注意并发数控制 const insertData await Promise.all( chunks.map(async (chunk, chunkIndex) { const vector await embeddings.embedQuery(chunk); return { id: ${bookId}_${chapterNum}_${chunkIndex}, book_id: bookId, book_name: 天龙八部, chapter_num: chapterNum, index: chunkIndex, content: chunk, vector: vector }; }) ); // 一次性插入整章 const insertResult await client.insert({ collection_name: COLLECTION_NAME, data: insertData }); return Number(insertResult.insert_cnt) || 0; } catch (err) { console.error(插入章节 ${chapterNum} 失败:, err.message); throw err; // 让上层捕获 } }API 解析embedQuery将单个文本转为向量返回number[]。Promise.all并发执行所有 chunk 的向量化大幅缩短总时间。但如果一章有上百个 chunk可能触发 API 限流此时建议引入p-limit控制并发数例如 10。client.insert一次性提交整章数据减少网络往返。insert_cnt返回插入条数我们转为数字返回。错误处理捕获异常后重新抛出让上层loadAndProcessEPubStreaming知晓插入失败从而停止处理或重试。金句向量化是 RAG 中最耗时的环节善用并发和批量别让 I/O 等你。4.4 主函数main调度const main async () { try { console.log(.repeat(80)); console.log(电子书处理程序); console.log(.repeat(80)); await client.connectPromise; // 确保连接 const bookId 1; await ensureCollection(bookId); await loadAndProcessEPubStreaming(bookId); console.log(建库完成); } catch (err) { console.error(主流程出错:, err); } }; main();解析client.connectPromise是一个 Promise在调用任何 Milvus 操作前自动连接但我们显式等待它确保连接成功。流程简洁先确保集合存在再加载数据。错误捕获后打印避免进程崩溃。五、在线问答检索 生成这部分在rag.mjs中实现核心是两个函数检索和生成。5.1 检索相关片段retrieveRelevantContentasync function retrieveRelevantContent(question, k 3) { try { const queryVector await embeddings.embedQuery(question); const searchResult await client.search({ collection_name: COLLECTION_NAME, vector: queryVector, limit: k, metric_type: MetricType.COSINE, output_fields: [id, chapter_num, content], // params: { nprobe: 16 } // 可调提升召回 }); return searchResult.results; } catch (err) { console.error(检索失败:, err); return []; } }API 解析search执行向量检索limit控制返回条数output_fields指定返回的标量字段。params.nprobeIVF 索引专用决定搜索时访问的聚类个数默认 8增大可提升召回但会降低速度。可调优。5.2 组装 Prompt 并调用 LLManswerEbookQuestionasync function answerEbookQuestion(question, k 3) { const results await retrieveRelevantContent(question, k); if (!results.length) return 未找到相关内容。; const context results.map((item, i) [片段 ${i1}] 章节第 ${item.chapter_num} 章 内容${item.content} ).join(\n----\n); const prompt 你是一个专业的《天龙八部》小说助手。请根据以下片段回答问题 ${context} 问题${question} 要求 1. 如果片段中有相关信息请结合小说内容给出详细准确的回答。 2. 可以综合多个片段提供完整的答案。 3. 如果片段中没有相关信息请如实告知。 4. 回答要符合小说情节和人物设定。 5. 可以引用原文内容来支持回答。 AI 助手的回答; const response await model.invoke(prompt); return response.content; }设计要点将检索到的片段按章节和内容格式化注入 Prompt。明确要求 LLM “只根据片段回答”降低幻觉风险。设置temperature: 0.1让答案更确定。六、项目亮点与技术最佳实践流式处理与内存优化逐章处理避免整本书常驻内存若数据量更大可改用lazyLoad或流式读取。批量操作与并发控制按章批量插入减少网络 I/OPromise.all加速向量生成同时预留限流接口。健壮的错误处理集合创建幂等hasCollection。加载集合时捕获“已加载”异常。插入失败抛出异常上层可决策重试。可扩展的架构Loader、Splitter、Embedding、数据库均可替换符合开闭原则。清晰的模块化每个函数职责单一便于单元测试和维护。实践中的调优空间调整chunk_size和overlap。调整nlist/nprobe平衡速度与召回。可引入重排序Rerank进一步提升答案质量。七、避坑指南 常见问题问题解决方案连接 Milvus 超时检查地址、端口、SSL 设置确保网络可达集合加载失败已加载用getLoadState()判断或 try-catch 忽略向量维度不匹配确保 Embedding 模型dimensions与集合字段dim一致检索结果不相关增大chunkSize或nprobe或调整分块策略插入速度慢提高批量大小或增加并发但注意限流大模型回答幻觉在 Prompt 中强调“只根据片段”并降低 temperature八、总结我们完整实现了一个 RAG 应用从 EPUB 加载、分块、向量化、Milvus 建库、检索到 LLM 生成代码总共不到 200 行。通过逐函数解析 API你不仅知道“怎么写”更明白了“为什么这么写”。最后记住一句话RAG 的精度不在模型而在数据分块和检索策略效果不好先调 chunk 和 nprobe别急着换大模型。你可以把“天龙八部”换成技术文档、公司规章制度、法律条文……只需替换 Loader 即可。希望这篇文章能成为你 RAG 实战之路的一块基石。

本月热点