造一个“读过《天龙八部》“的 AI 助手 —— advance-rag

大模型没读过你的书?没关系------我们把整本《天龙八部》拆成碎片、变成向量存进 Milvus,再用 LangGraph 编排一条"先检索、后生成"的智能流水线。本文带你逐行拆解这个进阶 RAG 项目的三个核心脚本。

技术栈:LangChain.js · LangGraph · Milvus 向量库 · OpenAI 兼容接口 · Zod 结构化输出 · EPUB 电子书


📑 目录

  1. [🎯 为什么需要 RAG?](#🎯 为什么需要 RAG?)
  2. [🗺️ 整体架构:三步走](#🗺️ 整体架构:三步走)
  3. [🗂️ 第一步:知识库写入(ebook-writter.mjs)](#🗂️ 第一步:知识库写入(ebook-writter.mjs))
  4. [🔍 第二步:基础 RAG 检索问答(naive-rag.mjs)](#🔍 第二步:基础 RAG 检索问答(naive-rag.mjs))
  5. [🧭 第三步:智能查询路由(rag-query-router.mjs)](#🧭 第三步:智能查询路由(rag-query-router.mjs))
  6. [💡 关键设计要点速查](#💡 关键设计要点速查)
  7. [🚀 动手跑起来](#🚀 动手跑起来)
  8. [🌱 结语与改进方向](#🌱 结语与改进方向)

🎯 一、为什么需要 RAG?

直接问大模型"阿朱的结局是什么?",它多半也能答个八九不离十------但你敢信吗?模型可能把电视剧和原著记混,甚至一本正经地编造情节。这就是典型的**幻觉(Hallucination)**问题。

RAG(Retrieval-Augmented Generation,检索增强生成)的思路非常朴素:先查资料,再答题 。把私有知识(这里是一整本《天龙八部》电子书)切成小块、转成向量存进专门的向量数据库;用户提问时,先检索出最相关的几个片段,连同问题一起交给大模型,让它基于证据回答,而不是凭记忆发挥。

📌 本项目要解决的问题:以《天龙八部.epub》为唯一知识源,构建一个能准确回答小说情节、人物关系、原文细节的问答助手,并在此基础上演示"进阶"技巧------查询路由。

项目由三个脚本组成,恰好对应 RAG 系统的三个演进阶段:

脚本 角色 一句话职责
ebook-writter.mjs 🧱 索引工程师(离线) 解析 EPUB → 分块 → 向量化 → 写入 Milvus
naive-rag.mjs 🔍 问答引擎(在线) LangGraph 状态图编排:检索 Top-5 片段 → 流式生成回答
rag-query-router.mjs 🧭 智能调度(进阶) 让 LLM 判断问题类型,决定要不要走检索

🗺️ 二、整体架构:三步走

整个系统分离线 和在线两条链路。离线链路负责"把书读懂存起来",在线链路负责"接得住用户的提问"。先看全景图:
#mermaid-svg-WoxkJ2qCmWW6qs6E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WoxkJ2qCmWW6qs6E .error-icon{fill:#552222;}#mermaid-svg-WoxkJ2qCmWW6qs6E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WoxkJ2qCmWW6qs6E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .marker.cross{stroke:#333333;}#mermaid-svg-WoxkJ2qCmWW6qs6E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WoxkJ2qCmWW6qs6E p{margin:0;}#mermaid-svg-WoxkJ2qCmWW6qs6E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .cluster-label text{fill:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .cluster-label span{color:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .cluster-label span p{background-color:transparent;}#mermaid-svg-WoxkJ2qCmWW6qs6E .label text,#mermaid-svg-WoxkJ2qCmWW6qs6E span{fill:#333;color:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .node rect,#mermaid-svg-WoxkJ2qCmWW6qs6E .node circle,#mermaid-svg-WoxkJ2qCmWW6qs6E .node ellipse,#mermaid-svg-WoxkJ2qCmWW6qs6E .node polygon,#mermaid-svg-WoxkJ2qCmWW6qs6E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .rough-node .label text,#mermaid-svg-WoxkJ2qCmWW6qs6E .node .label text,#mermaid-svg-WoxkJ2qCmWW6qs6E .image-shape .label,#mermaid-svg-WoxkJ2qCmWW6qs6E .icon-shape .label{text-anchor:middle;}#mermaid-svg-WoxkJ2qCmWW6qs6E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .rough-node .label,#mermaid-svg-WoxkJ2qCmWW6qs6E .node .label,#mermaid-svg-WoxkJ2qCmWW6qs6E .image-shape .label,#mermaid-svg-WoxkJ2qCmWW6qs6E .icon-shape .label{text-align:center;}#mermaid-svg-WoxkJ2qCmWW6qs6E .node.clickable{cursor:pointer;}#mermaid-svg-WoxkJ2qCmWW6qs6E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .arrowheadPath{fill:#333333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WoxkJ2qCmWW6qs6E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WoxkJ2qCmWW6qs6E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WoxkJ2qCmWW6qs6E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WoxkJ2qCmWW6qs6E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .cluster text{fill:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E .cluster span{color:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WoxkJ2qCmWW6qs6E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WoxkJ2qCmWW6qs6E rect.text{fill:none;stroke-width:0;}#mermaid-svg-WoxkJ2qCmWW6qs6E .icon-shape,#mermaid-svg-WoxkJ2qCmWW6qs6E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WoxkJ2qCmWW6qs6E .icon-shape p,#mermaid-svg-WoxkJ2qCmWW6qs6E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WoxkJ2qCmWW6qs6E .icon-shape .label rect,#mermaid-svg-WoxkJ2qCmWW6qs6E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WoxkJ2qCmWW6qs6E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WoxkJ2qCmWW6qs6E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WoxkJ2qCmWW6qs6E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 💬 在线问答 · naive-rag + router
⚙️ 离线写入 · ebook-writter
simple 常识
complex 情节细节
向量检索
📕 天龙八部.epub
EPubLoader

按章节拆分
RecursiveCharacterTextSplitter

500 字符 / 重叠 50
OpenAIEmbeddings

text-embedding-v3 · 1024 维
🗄️ Milvus

ebook_collection
🙋 用户提问
🧭 查询路由
LLM 直接回答
retrieve 节点

相似度检索 Top-5
generate 节点

LLM 流式生成
✅ 回答

图 1:advance-rag 全景架构------左边离线建库,在线链路按需检索

理解这张图只需要抓住一句话:写入端决定检索的下限,编排层决定体验的上限。下面我们按数据流的顺序,从写入端开始拆。

🗂️ 三、第一步:知识库写入(ebook-writter.mjs)

这是整个系统的地基,做的事可以概括为一条流水线:加载 → 分块 → 向量化 → 入库。

3.1 📕 加载 EPUB:按章节切第一刀

项目用 LangChain 社区的 EPubLoader 直接解析电子书,并开启 splitChapters------第一刀由"章节"来切,天然保留了小说的结构信息:

js 复制代码
const loader = new EPubLoader(EPUB_FILE, {
  splitChapters: true, // 按章节拆分,每章一个 Document
});
const documents = await loader.load();
// ✓ 加载完成,共 N 个章节

3.2 ✂️ 二次分块:500 字符 + 50 重叠

一章几千字,直接整章向量化太"钝"------语义被稀释,检索也定位不准。所以要用 RecursiveCharacterTextSplitter 做第二刀。两个关键参数:

  • chunkSize = 500:每块 500 个字符,粒度足够细,能精准定位到具体情节;
  • chunkOverlap = 50:相邻两块重叠 50 字符,防止一句话被硬生生切成两半,导致两边都"说不完整"。
text 复制代码
片段 1  [==========================================]  第 0 -- 500 字符
片段 2    [==========================================]  第 450 -- 950 字符
片段 3      [==========================================]  第 900 -- 1400 字符
          └── 重叠 50 ──┘

🔀 重叠区让被切断的句子在前后两个片段中都完整出现,检索时不会"断章取义"。

js 复制代码
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE,   // 500 字符
  chunkOverlap: 50,        // 重叠 50,保持上下文连贯
  // 未指定 separators,默认 ["\n\n", "\n", " ", ""]
  // ------ 递归地优先按段落、再按行、再按词切,尽量不切断语义
});

3.3 🗄️ 设计 Milvus 集合:字段即"书签"

向量不是孤立存在的,每一块文本都挂上了完整的"出处坐标"------书 ID、章节号、块序号。这些元数据后面在生成回答时会直接展示给用户("片段来自第 X 章"),是可追溯性的关键:

字段 类型 作用
id VarChar(100),主键 手工构造:bookId_章节号_块序号,天然唯一、可重跑
book_id / book_name VarChar 支持多本书共存一个集合
chapter_num / index Int32 章节与块位置,回答时可引用"第 X 章"
content VarChar(10000) 原文片段,生成时作为上下文
vector FloatVector(1024) 片段的向量表示,检索的依据

主键 ID 的构造很有代表性------不依赖数据库自增,而是用业务坐标手工拼出来:

js 复制代码
const insertData = await Promise.all(
  chunks.map(async (chunk, chunkIndex) => {
    const vector = await getEmbedding(chunk); // 1024 维向量
    return {
      id: `${bookId}_${chapterNum}_${chunkIndex}`, // 手动 ID:可重跑、不重复
      book_id: bookId, book_name: BOOK_NAME,
      chapter_num: chapterNum, index: chunkIndex,
      content: chunk, vector,
    };
  })
);
await client.insert({ collection_name: COLLECTION_NAME, data: insertData });

3.4 ⚡ 边处理边插入的流式写入

整本书一次性加载、一次性向量化,内存和失败重来的代价都很大。这个脚本选择了更工程化的做法:逐章处理,处理完一章立即入库。中断了?已经写入的章节不用重来;内存?始终只保留一章的片段。

🧠 为什么向量索引用 IVF_FLAT? 写入端建索引时选择了 IVF_FLAT(nlist=1024)------先用聚类把向量空间分成 1024 个"桶",检索时只在最相关的少数桶里精细比对。它是建库速度、内存、召回的经典平衡点,对一次性导入全量书籍的场景非常合适。

🔍 四、第二步:基础 RAG 检索问答(naive-rag.mjs)

书已入库,接下来是在线问答。这个脚本的核心不是"调两次 API",而是用 LangGraph 把问答过程建模成一张状态图(State Graph)------这正是通往 Agentic RAG 的门票。

4.1 🧩 用状态图建模问答流程

LangGraph 的世界观:程序 = 状态 + 节点 + 边。状态(State)是在节点间流转的共享数据;节点是干活的函数;边定义流转顺序。先声明状态,四个字段简单明了:

js 复制代码
const GraphState = Annotation.Root({
  question:   Annotation, // 问题
  k:          Annotation, // 检索数量
  document:   Annotation, // 检索到的文档
  generation: Annotation, // 生成的回复
})

然后把两个节点串成一条最简流水线 retrieve → generate:

js 复制代码
const graph = new StateGraph(GraphState)
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END)
  .compile(); // 编译后才能 invoke

这段代码的产出就是项目根目录 a.md 里那张图(脚本启动时会自动打印 mermaid 定义):
#mermaid-svg-KtSP5t1rXv8hkxa3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KtSP5t1rXv8hkxa3 .error-icon{fill:#552222;}#mermaid-svg-KtSP5t1rXv8hkxa3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KtSP5t1rXv8hkxa3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .marker.cross{stroke:#333333;}#mermaid-svg-KtSP5t1rXv8hkxa3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KtSP5t1rXv8hkxa3 p{margin:0;}#mermaid-svg-KtSP5t1rXv8hkxa3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .cluster-label text{fill:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .cluster-label span{color:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .cluster-label span p{background-color:transparent;}#mermaid-svg-KtSP5t1rXv8hkxa3 .label text,#mermaid-svg-KtSP5t1rXv8hkxa3 span{fill:#333;color:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .node rect,#mermaid-svg-KtSP5t1rXv8hkxa3 .node circle,#mermaid-svg-KtSP5t1rXv8hkxa3 .node ellipse,#mermaid-svg-KtSP5t1rXv8hkxa3 .node polygon,#mermaid-svg-KtSP5t1rXv8hkxa3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .rough-node .label text,#mermaid-svg-KtSP5t1rXv8hkxa3 .node .label text,#mermaid-svg-KtSP5t1rXv8hkxa3 .image-shape .label,#mermaid-svg-KtSP5t1rXv8hkxa3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-KtSP5t1rXv8hkxa3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .rough-node .label,#mermaid-svg-KtSP5t1rXv8hkxa3 .node .label,#mermaid-svg-KtSP5t1rXv8hkxa3 .image-shape .label,#mermaid-svg-KtSP5t1rXv8hkxa3 .icon-shape .label{text-align:center;}#mermaid-svg-KtSP5t1rXv8hkxa3 .node.clickable{cursor:pointer;}#mermaid-svg-KtSP5t1rXv8hkxa3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .arrowheadPath{fill:#333333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KtSP5t1rXv8hkxa3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KtSP5t1rXv8hkxa3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KtSP5t1rXv8hkxa3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KtSP5t1rXv8hkxa3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .cluster text{fill:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 .cluster span{color:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KtSP5t1rXv8hkxa3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KtSP5t1rXv8hkxa3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-KtSP5t1rXv8hkxa3 .icon-shape,#mermaid-svg-KtSP5t1rXv8hkxa3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KtSP5t1rXv8hkxa3 .icon-shape p,#mermaid-svg-KtSP5t1rXv8hkxa3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KtSP5t1rXv8hkxa3 .icon-shape .label rect,#mermaid-svg-KtSP5t1rXv8hkxa3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KtSP5t1rXv8hkxa3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KtSP5t1rXv8hkxa3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KtSP5t1rXv8hkxa3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 🎯 start
🔍 retrieve

相似度检索
🧠 generate

流式生成回答
🏁 end

图 2:naive-rag 的 LangGraph 状态图(复现自 a.md)------最经典的线性 RAG 链路

4.2 📡 retrieve 节点:带着分数的检索

检索用的是 similaritySearchWithScore------不只拿回片段,还带上相似度分数,方便后续判断"这些片段到底够不够像":

js 复制代码
const docsWithScores = await vectorStore.similaritySearchWithScore(question, k);
return docsWithScores.map(([doc, score]) => ({
  document: doc.pageContent,
  score,                                // 相似度分数
  book_id:  doc.metadata?.book_id,      // 出处坐标一路带在身上
  chapter_num: doc.metadata?.chapter_num,
  ...
}))

注意向量库的连接方式是 Milvus.fromExistingCollection------不建新集合,直接挂上写入端已建好的 ebook_collection。这正是两个脚本之间的契约。

4.3 💬 generate 节点:有纪律的提示词 + 流式输出

检索到的片段先组装成带编号、带章节出处的上下文,再配合一套"回答纪律"送进模型:

js 复制代码
const prompt = `
你是一个专业的《天龙八部》小说助手。基于小说内容回答问题,用准确,详细的语言。
请根据以下《天龙八部》小说片段内容回答问题:
${context}
用户问题:${state.question}

回答要求:
1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答
2. 可以综合多个片段的内容,提供完整的答案
3. 如果片段中没有相关信息,请如实告知用户    // ← 抑制幻觉的关键一条
4. 回答要准确,符合小说的情节和人物设定
5. 可以引用原文内容来支持你的回答`;

生成过程采用流式输出 (model.stream),边收边打印,体验接近打字机效果:

js 复制代码
const stream = await model.stream(prompt);
for await (const chunk of stream) {
  const text = typeof chunk === "string" ? chunk : chunk.content;
  if (!text) continue;
  generation += text;
  process.stdout.write(text); // 逐字上屏
}

⚖️ 一个值得注意的细节:两端索引配置不同。 写入端真正建索引时用的是 IVF_FLAT;而查询端在 fromExistingCollection 里声明了 HNSW(M=16, efConstruction=200)并指定 ef=64。实际生效的索引以 Milvus 集合中已创建的为准,查询端声明主要影响的是检索参数。IVF_FLAT 与 HNSW 的取舍见下表:

维度 IVF_FLAT(写入端配置) HNSW(查询端声明)
原理 聚类分桶,只搜最相关的几个桶 多层邻接图,贪心逐层导航
优势 建索引快、内存省、可控扫描范围 召回率更高、延迟更稳定
代价 召回受 nlist / nprobe 影响 建索引慢、内存占用更高
调参 nlist / nprobe M / efConstruction / ef
适合 批量导入、资源敏感 高召回在线检索

🧭 五、第三步:智能查询路由(rag-query-router.mjs)

朴素 RAG 有个浪费:不管用户问什么,一律检索一遍。可"乔峰是谁?"这种常识问题根本不需要翻书。进阶的第一步,是给流水线装上大脑------一个决定"走哪条路"的路由节点。

5.1 🎯 用 Zod 给 LLM 的判断"上锁"

让 LLM 输出结构化结果,靠的是 withStructuredOutput + Zod schema。模型被约束成只能在 simple / complex 里二选一,并且必须给出理由:

js 复制代码
const RouteSchema = z.object({
  strategy: z.enum(["simple", "complex"]), // 枚举,只能二选一
  reason: z.string(),                      // 决策理由,可解释
});

const router = model.withStructuredOutput(RouteSchema);
const route = await router.invoke(`
  你是一个问答路由器,请判断用户问题是否需要外部检索。
  规则:
  - simple  : 常识问答、简短定义、无需特定小说细节即可回答
  - complex : 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持
  用户问题:${state.question}`);

相比让模型输出 JSON 再手工 JSON.parse,withStructuredOutput 的价值在于:Schema 校验失败会自动重试修正,拿到手的一定是合法对象。

5.2 🔀 路由之后的两条路

#mermaid-svg-eqLbfhRN8tryjExV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eqLbfhRN8tryjExV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eqLbfhRN8tryjExV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eqLbfhRN8tryjExV .error-icon{fill:#552222;}#mermaid-svg-eqLbfhRN8tryjExV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eqLbfhRN8tryjExV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eqLbfhRN8tryjExV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eqLbfhRN8tryjExV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eqLbfhRN8tryjExV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eqLbfhRN8tryjExV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eqLbfhRN8tryjExV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eqLbfhRN8tryjExV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eqLbfhRN8tryjExV .marker.cross{stroke:#333333;}#mermaid-svg-eqLbfhRN8tryjExV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eqLbfhRN8tryjExV p{margin:0;}#mermaid-svg-eqLbfhRN8tryjExV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eqLbfhRN8tryjExV .cluster-label text{fill:#333;}#mermaid-svg-eqLbfhRN8tryjExV .cluster-label span{color:#333;}#mermaid-svg-eqLbfhRN8tryjExV .cluster-label span p{background-color:transparent;}#mermaid-svg-eqLbfhRN8tryjExV .label text,#mermaid-svg-eqLbfhRN8tryjExV span{fill:#333;color:#333;}#mermaid-svg-eqLbfhRN8tryjExV .node rect,#mermaid-svg-eqLbfhRN8tryjExV .node circle,#mermaid-svg-eqLbfhRN8tryjExV .node ellipse,#mermaid-svg-eqLbfhRN8tryjExV .node polygon,#mermaid-svg-eqLbfhRN8tryjExV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eqLbfhRN8tryjExV .rough-node .label text,#mermaid-svg-eqLbfhRN8tryjExV .node .label text,#mermaid-svg-eqLbfhRN8tryjExV .image-shape .label,#mermaid-svg-eqLbfhRN8tryjExV .icon-shape .label{text-anchor:middle;}#mermaid-svg-eqLbfhRN8tryjExV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eqLbfhRN8tryjExV .rough-node .label,#mermaid-svg-eqLbfhRN8tryjExV .node .label,#mermaid-svg-eqLbfhRN8tryjExV .image-shape .label,#mermaid-svg-eqLbfhRN8tryjExV .icon-shape .label{text-align:center;}#mermaid-svg-eqLbfhRN8tryjExV .node.clickable{cursor:pointer;}#mermaid-svg-eqLbfhRN8tryjExV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eqLbfhRN8tryjExV .arrowheadPath{fill:#333333;}#mermaid-svg-eqLbfhRN8tryjExV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eqLbfhRN8tryjExV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eqLbfhRN8tryjExV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eqLbfhRN8tryjExV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eqLbfhRN8tryjExV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eqLbfhRN8tryjExV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eqLbfhRN8tryjExV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eqLbfhRN8tryjExV .cluster text{fill:#333;}#mermaid-svg-eqLbfhRN8tryjExV .cluster span{color:#333;}#mermaid-svg-eqLbfhRN8tryjExV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eqLbfhRN8tryjExV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eqLbfhRN8tryjExV rect.text{fill:none;stroke-width:0;}#mermaid-svg-eqLbfhRN8tryjExV .icon-shape,#mermaid-svg-eqLbfhRN8tryjExV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eqLbfhRN8tryjExV .icon-shape p,#mermaid-svg-eqLbfhRN8tryjExV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eqLbfhRN8tryjExV .icon-shape .label rect,#mermaid-svg-eqLbfhRN8tryjExV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eqLbfhRN8tryjExV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eqLbfhRN8tryjExV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eqLbfhRN8tryjExV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} simple 常识/定义
complex 情节/人物/原文
🙋 用户问题
🧭 routeQuestion

LLM + Zod 结构化输出
⚡ 直接回答,跳过检索
🔍 检索 Milvus → 生成回答

图 3:查询路由的决策逻辑------简单问题省一次检索,复杂问题保证有据可查

当前仓库里,路由脚本是一个独立的验证程序 :它单独跑一次决策并打印 { strategy, reason },帮你确认路由提示词的准确率。它的下一步归宿,是长进状态图里,变成一条条件边(Conditional Edge):

js 复制代码
// 展望:把路由接入 LangGraph ------ retrieve 不再是必经之路
const graph = new StateGraph(GraphState)
  .addNode("route", routeQuestionNode)
  .addNode("retrieve", retrieveNode)
  .addNode("generate", generateNode)
  .addEdge(START, "route")
  .addConditionalEdges("route", (s) =>
    s.strategy === "simple" ? "generate" : "retrieve") // 按状态分流
  .addEdge("retrieve", "generate")
  .addEdge("generate", END);

🚩 从这里开始,就是 Agentic RAG 的世界。 一旦图里有了条件边,你就可以继续加节点:查询改写(Query Rewriting)、多路召回、结果重排(Rerank)、自我反思(拿到低分片段就换个问法再查一次)......每一步都只是图上的一个新节点。

💡 六、关键设计要点速查

设计点 代码位置 为什么这么做
chunkOverlap = 50 ebook-writter.mjs 跨块边界的信息两边都能看到,避免"断章"
手工主键 book_chapter_index ebook-writter.mjs 幂等可重跑,重复导入不产生脏数据
IVF_FLAT 建索引 ebook-writter.mjs 全量导入场景下建库快、内存省
similaritySearchWithScore naive-rag.mjs 带分数返回,为"结果不够好就重试"留了钩子
temperature = 0 naive-rag.mjs 问答要的是事实复述,不是创意发挥
withStructuredOutput rag-query-router.mjs 枚举约束 + 自动重试,杜绝"自由发挥的 JSON"

六句话总结:

  • ✂️ 分块带重叠------500 字符切块 + 50 重叠,句子不被腰斩,语义不被稀释;
  • 🏷️ 元数据贯穿始终------book_id / chapter_num 从写入带到检索结果,回答可引用出处;
  • 🔄 流式处理与输出------写入端逐章入库,生成端逐字上屏,省内存、体验好;
  • 🗺️ 状态图编排------LangGraph 把流程变成可扩展的图,为 Agentic 演进铺路;
  • 🔐 结构化输出------Zod Schema + withStructuredOutput,路由决策合法且可解释;
  • 🛡️ 提示词抑制幻觉------"没有相关信息请如实告知",让模型学会说"书里没写"。

🚀 七、动手跑起来

复现这个项目需要三样东西:本地 Milvus、环境变量、Node.js 环境。

① 准备 Milvus(Docker 本地单机版,端口 19530)

bash 复制代码
docker run -d --name milvus -p 19530:19530 -p 19091:19091 milvusdb/milvus:latest

② 配置 .env(项目使用 OpenAI 兼容接口,可指向任意兼容网关)

变量 说明
OPENAI_API_KEY API 密钥
OPENAI_BASE_URL OpenAI 兼容接口地址
OPENAI_MODEL_NAME 对话模型名
EMBEDDINGS_MODEL_NAME 嵌入模型名(如 text-embedding-v3)

③ 按顺序执行三个脚本

bash 复制代码
pnpm install

# 1️⃣ 建库:解析《天龙八部.epub》并写入 Milvus(耗时最长)
node src/ebook-writter.mjs

# 2️⃣ 问答:问它"阿朱的结局是什么?",观察检索片段与流式回答
node src/naive-rag.mjs

# 3️⃣ 路由:验证路由器对不同问题的 simple / complex 判断
node src/rag-query-router.mjs

🧪 试玩建议 :把 naive-rag.mjs 里的问题换成"段誉的六脉神剑是怎么学会的?"、"萧峰和乔峰是什么关系?";再拿"1 + 等于几?"去测试路由器------你会看到它正确地给出 simple。

🌱 八、结语与改进方向

回头看,这个项目麻雀虽小、五脏俱全:它完整走过了 RAG 的经典链路------索引(Indexing)→ 检索(Retrieval)→ 生成(Generation),并在最后递出了通向 Agentic RAG 的第一张名片:查询路由。三个脚本各自独立、职责单一,读起来就是三堂循序渐进的实验课。

如果继续往下打磨,这里有几个自然的演进方向:

  • 🚚 路由入图:按第 5 节的 Conditional Edge 方案,把路由器长进状态图,形成真正的分支流程;
  • 🔁 查询改写:检索前先让 LLM 把口语化问题改写成"检索友好"的关键词或多个子问题;
  • 🏆 结果重排(Rerank):粗召回 Top-20,再用重排模型精选 Top-5,精度再上一个台阶;
  • 🪞 自我反思:检索分数太低或回答与原文矛盾时,自动换一种问法重查;
  • 📊 评估体系:准备一批标准问答对,给每次改动一个客观的分数。

RAG 的世界并不神秘------把书撕成碎片、给碎片编上坐标、用相似度找回来、再让模型照着证据说话。每一步,你都在替模型的"记忆"修一条更可靠的路。


📚 延伸阅读

相关推荐
MobotStone1 小时前
那些藏在AI应用工具里的「专家」,到底能帮我们干什么?
人工智能
OxYGC1 小时前
玩转 Spring AI 第二篇:RAG 落地——Redis 向量库、按空行切分的自定义 Splitter,和 pom 里两个“引而未用“的依赖
向量检索·rag·spring ai·redis stack
项目題供诗1 小时前
ai大模型(二)
人工智能
CCblandy1 小时前
向量检索 ANN 参数实测:换一种数据分布,recall 掉了 51 个百分点
人工智能·python·算法·自然语言处理·性能优化
白雪讲堂1 小时前
企业如何获得AI长期调用权?《GEO实战:AI时代的增长密码》提出系统经营路径
人工智能·geo·geo从入门到精通
码上观世界1 小时前
Agent工具调用实现原理
人工智能·智能体
AI视频工具指南1 小时前
怎么用 AI 做一两分钟的小故事视频?
人工智能·aigc·音视频
深圳市快瞳科技有限公司2 小时前
面向端侧部署的多帧宠物身份识别:完整性评分机制与工程约束
人工智能·算法·计算机视觉·端侧ai·宠物识别·宠物ai·宠物身份识别
szxinmai主板定制专家2 小时前
软硬协同!ZYNQ 高速多通道同步采集,变电站 / 工厂设备状态监测方案详解
人工智能·深度学习·机器学习·fpga开发·zynq·rk3576