Node.js + LangChain.js + Milvus 实战:从 EPUB 入库到《天龙八部》RAG 问答系统

Node.js + LangChain.js + Milvus 实战:从 EPUB 入库到《天龙八部》RAG 问答系统

  • 前言
  • [1. 项目目标与核心原理](#1. 项目目标与核心原理)
    • [1.1 为什么需要 RAG](#1.1 为什么需要 RAG)
    • [1.2 Embedding、Chunk 与 VectorStore](#1.2 Embedding、Chunk 与 VectorStore)
    • [1.3 项目的两条数据链路](#1.3 项目的两条数据链路)
  • [2. 初始化项目与安装依赖](#2. 初始化项目与安装依赖)
    • [2.1 使用 npm 初始化](#2.1 使用 npm 初始化)
    • [2.2 使用 pnpm 安装全部依赖](#2.2 使用 pnpm 安装全部依赖)
    • [2.3 配置目录与环境变量](#2.3 配置目录与环境变量)
  • [3. `main.mjs`:加载 EPUB 并建立向量知识库](#3. main.mjs:加载 EPUB 并建立向量知识库)
    • [3.1 文件职责与总体流程](#3.1 文件职责与总体流程)
    • [3.2 导入模块、读取配置并初始化 Embedding](#3.2 导入模块、读取配置并初始化 Embedding)
    • [3.3 封装向量函数并创建 Milvus 客户端](#3.3 封装向量函数并创建 Milvus 客户端)
    • [3.4 创建 Collection、字段与索引](#3.4 创建 Collection、字段与索引)
    • [3.5 加载 EPUB 并递归切片](#3.5 加载 EPUB 并递归切片)
    • [3.6 并发生成片段向量并批量插入](#3.6 并发生成片段向量并批量插入)
    • [3.7 入口函数串联全部入库步骤](#3.7 入口函数串联全部入库步骤)
  • [4. `query.mjs`:验证 Milvus 向量检索](#4. query.mjs:验证 Milvus 向量检索)
    • [4.1 文件职责与总体流程](#4.1 文件职责与总体流程)
    • [4.2 导入依赖并初始化查询组件](#4.2 导入依赖并初始化查询组件)
    • [4.3 搜索 Top 3 并打印结果](#4.3 搜索 Top 3 并打印结果)
  • [5. `rag.mjs`:组合检索结果并生成答案](#5. rag.mjs:组合检索结果并生成答案)
    • [5.1 文件职责与总体流程](#5.1 文件职责与总体流程)
    • [5.2 初始化 Embedding、ChatOpenAI 与 Milvus](#5.2 初始化 Embedding、ChatOpenAI 与 Milvus)
    • [5.3 封装 Retriever 检索函数](#5.3 封装 Retriever 检索函数)
    • [5.4 构造上下文与 Prompt](#5.4 构造上下文与 Prompt)
    • [5.5 执行完整 RAG 问答](#5.5 执行完整 RAG 问答)
  • [6. 运行顺序与结果验证](#6. 运行顺序与结果验证)
    • [6.1 第一次运行的正确顺序](#6.1 第一次运行的正确顺序)
    • [6.2 常见运行现象与排查方向](#6.2 常见运行现象与排查方向)
  • [7. 三个文件的完整流程复盘](#7. 三个文件的完整流程复盘)
    • [7.1 从电子书到向量数据库](#7.1 从电子书到向量数据库)
    • [7.2 从问题到相似片段](#7.2 从问题到相似片段)
    • [7.3 从相似片段到自然语言答案](#7.3 从相似片段到自然语言答案)
  • 总结

前言

让大模型回答一部一百多万字的小说,真正的难点并不在"调用一次聊天接口",而在于如何让模型找到与问题有关的原文。整本电子书无法直接塞进一次请求,模型本身也不一定准确记得每一处人物、武功和情节细节。要解决这个问题,需要先把电子书加工成一个可以按语义检索的知识库。

**RAG(Retrieval-Augmented Generation,检索增强生成)**的思路很直接:先从外部知识库检索相关内容,再把检索结果连同用户问题一起交给大模型。这样一来,Milvus 负责"找到原文",大模型负责"结合原文组织答案",两部分职责清晰,也便于分阶段调试。

本项目使用 Node.js、LangChain.js、OpenAI 兼容接口与 Milvus,围绕《天龙八部》EPUB 完成一条完整链路。项目的编写和讲解顺序严格保持为:

  • main.mjs:加载 EPUB、切分章节、生成向量并写入 Milvus;
  • query.mjs:把问题转换为向量,验证 Milvus 的相似度检索结果;
  • rag.mjs:把召回片段组装成上下文,调用聊天模型生成答案。

下面不会一次性堆出三个完整文件,而是按照源码顺序分块讲解。每个 JavaScript 代码块都直接取自项目源码,按顺序拼接后就是对应文件的原始代码。

1. 项目目标与核心原理

1.1 为什么需要 RAG

只把"段誉会什么武功"交给大模型,模型会根据训练阶段形成的参数知识回答。面对小说、企业文档、内部手册等资料时,这种方式存在几个明显限制:

  • 内容容量有限:整本书远远超过一次请求适合承载的文本量;
  • 细节不可控:模型可能知道主要人物,却不一定记得具体情节;
  • 回答难以验证:没有检索片段时,很难判断答案来自原文还是模型推测;
  • 知识更新麻烦:资料变化后,直接更新知识库比重新训练模型更灵活。

RAG 将系统拆成"检索"和"生成"两个阶段。检索阶段先从 Milvus 找到与问题最相近的片段;生成阶段再让聊天模型基于这些片段回答。

方案 匹配依据 能做什么 主要特点
关键词检索 文本中是否出现相同词语 找到字面匹配内容 简单直接,但不擅长理解同义表达
向量检索 文本向量之间的距离 找到语义相近内容 能识别不同说法背后的相近含义
RAG 向量召回 + 大模型生成 检索资料并组织自然语言答案 同时利用外部知识与模型表达能力

1.2 Embedding、Chunk 与 VectorStore

Embedding 是把一段文本转换为浮点数向量的过程。语义相近的文本,通常会在向量空间中拥有更接近的方向或位置。

项目将电子书拆成多个文本片段,再把每个片段转换为向量。用户提问时,问题也会被转换为向量,然后由 Milvus 比较问题向量与文档向量的相似程度。

这里涉及三个基础概念:

  • Chunk:从长文本中切出来的小片段,是检索和入库的基本单位;
  • Embedding:Chunk 或问题经过模型计算后得到的数值向量;
  • VectorStore:保存向量、原文和元数据,并支持相似度搜索的数据库,本项目使用 Milvus。

项目使用 MetricType.COSINE 计算余弦相似度。其核心公式为:

cosine ⁡ ( q , d ) = q ⋅ d ∥ q ∥ ∥ d ∥ \operatorname{cosine}(q,d)=\frac{q\cdot d}{\lVert q\rVert\lVert d\rVert} cosine(q,d)=∥q∥∥d∥q⋅d

其中 q 表示问题向量,d 表示文档片段向量。二者方向越接近,语义通常越相似。搜索结果中的 score 就是排序的重要依据,代码会把它保留四位小数输出。

1.3 项目的两条数据链路

项目分为离线入库和在线问答两条链路。

离线入库由 main.mjs 完成:

EPUB → Document → Chunk → Embedding → Milvus

在线查询与问答由 query.mjsrag.mjs 完成:

用户问题 → 问题向量 → Top K 片段 → Prompt → 大模型答案

三个文件之间不是平行关系,而是存在明确的前置顺序:

文件 输入 核心任务 输出
main.mjs 天龙八部.epub 创建集合、切片、生成向量并入库 Milvus 中的知识库记录
query.mjs "段誉会什么武功?" 执行 Top 3 向量搜索 分数、主键、书籍 ID 和原文
rag.mjs "鸠摩智会什么武功?" 检索 Top 5 片段并调用聊天模型 基于小说片段生成的回答

当前 EPUB 经 EPubLoader 解析后会得到 168 个 Document,总文本量约 124.5 万字符;使用 chunkSize: 500chunkOverlap: 50 切分后,可以形成约 3042 个片段。这里的 168 是 EPUB 内部文档节点数量,其中可能包含内容简介、序言、回目和其他页面,因此代码中的 chapter_num 表示加载后的文档顺序。

2. 初始化项目与安装依赖

2.1 使用 npm 初始化

先创建项目目录并进入该目录:

bash 复制代码
mkdir tlbb
cd tlbb
npm init -y

npm init -y 会快速生成 package.json。三个源码文件使用 .mjs 扩展名,Node.js 会按照 ES Module 解析,因此代码中可以直接使用 import

2.2 使用 pnpm 安装全部依赖

项目需要安装以下全部依赖:

bash 复制代码
pnpm i @langchain/community @langchain/core @langchain/openai @langchain/textsplitters @zilliz/milvus2-sdk-node dotenv epub2 html-to-text

各依赖的职责如下:

依赖 核心作用 在项目中的位置
dotenv .env 加载到 process.env 三个文件顶部的 import 'dotenv/config'
@langchain/core 提供 LangChain 的核心抽象 LangChain 组件运行所依赖的基础能力
@langchain/community 提供社区 Loader EPubLoader 从 EPUB 加载文档
@langchain/textsplitters 提供文本切分器 RecursiveCharacterTextSplitter 拆分章节
@langchain/openai 对接 OpenAI 兼容接口 OpenAIEmbeddingsChatOpenAI
@zilliz/milvus2-sdk-node Node.js 版 Milvus SDK 创建集合、索引、插入与搜索
epub2 解析 EPUB 的内部结构 EPubLoader 的运行时依赖
html-to-text 把章节 HTML 转为纯文本 为切片和 Embedding 提供干净文本

EPubLoader 位于 @langchain/community,但底层还需要 epub2 读取 EPUB、需要 html-to-text 去除 HTML 标签,因此这两个包也必须安装。

2.3 配置目录与环境变量

项目目录保持如下结构:

text 复制代码
tlbb/
├── .env
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readmd.md
├── 天龙八部.epub
└── src/
    ├── main.mjs
    ├── query.mjs
    └── rag.mjs

.env 中需要提供六个变量。真实值由各自使用的模型服务和 Milvus 服务决定:

dotenv 复制代码
MODEL_NAME=聊天模型名称
OPENAI_API_KEY=接口密钥
OPENAI_BASE_URL=OpenAI兼容接口地址
MILVUS_ADDRESS=Milvus服务地址
MILVUS_TOKEN=Milvus访问令牌
EMBEDDING_MODEL_NAME=向量模型名称
环境变量 使用位置 作用
MODEL_NAME rag.mjs 指定生成答案的聊天模型
OPENAI_API_KEY 三个文件 为模型接口提供身份认证
OPENAI_BASE_URL 三个文件 指定 OpenAI 兼容接口地址
MILVUS_ADDRESS 三个文件 指定 Milvus 服务地址
MILVUS_TOKEN 三个文件 为 Milvus 连接提供认证
EMBEDDING_MODEL_NAME 三个文件 指定文本向量模型

3. main.mjs:加载 EPUB 并建立向量知识库

3.1 文件职责与总体流程

main.mjs 是整个项目的入库程序。它先读取配置并初始化 Embedding 模型和 Milvus 客户端,再检查 ebook 集合是否存在;第一次运行时创建字段与向量索引,随后加载 EPUB、按章节切片、为每个切片生成向量,最后将数据批量插入集合。

它的执行顺序可以概括为:

初始化配置 → 连接 Milvus → 确保集合存在 → 加载 EPUB → 按章节切片 → 生成向量 → 批量插入

Milvus 中不仅保存 vector,还保存 contentchapter_numindex 等信息。向量负责匹配语义,原文和元数据则负责在检索命中后还原内容。

3.2 导入模块、读取配置并初始化 Embedding

这一部分完成依赖导入、常量声明、书名解析和向量模型初始化:

javascript 复制代码
import 'dotenv/config'
import { parse } from 'path';// path 解析路径
import {
  MilvusClient,
  DataType,
  MetricType,
  IndexType
} from '@zilliz/milvus2-sdk-node'
import {
  OpenAIEmbeddings
} from '@langchain/openai'
import {
  EPubLoader
} from '@langchain/community/document_loaders/fs/epub'
import {
  RecursiveCharacterTextSplitter
} from '@langchain/textsplitters'

// config
const COLLECTION_NAME = 'ebook';// 编程习惯
const VECTOR_DIM = 1024;
const CHUNK_SIZE = 500;
const EPUB_FILE = './天龙八部.epub'
const ADDRESS = process.env.MILVUS_ADDRESS// 云端地址
const TOKEN = process.env.MILVUS_TOKEN// API Key
const {name:BOOK_NAME} = parse(EPUB_FILE);
console.log(BOOK_NAME);

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL:process.env.OPENAI_BASE_URL,
  },
  dimension: VECTOR_DIM,
});

import 'dotenv/config' 是一个有副作用的导入。模块载入后,根目录 .env 中的内容会进入 process.env,后续代码便能读取 Milvus 地址、令牌和模型配置。

parse(EPUB_FILE) 来自 Node.js 的 path 模块。EPUB_FILE./天龙八部.epub,解构得到的 BOOK_NAME 是不带扩展名的文件名,它会作为每条切片的书名元数据。

这一块中的常量各自控制一个核心环节:

常量 当前值 作用
COLLECTION_NAME ebook Milvus 集合名称
VECTOR_DIM 1024 项目使用的向量维度
CHUNK_SIZE 500 每个切片的目标字符大小
EPUB_FILE ./天龙八部.epub 电子书相对路径
ADDRESS 环境变量 Milvus 服务地址
TOKEN 环境变量 Milvus 访问凭证

OpenAIEmbeddings 接收接口密钥、向量模型名称、兼容服务地址和维度配置。这个实例会被后续 getEmbedding() 统一调用。

3.3 封装向量函数并创建 Milvus 客户端

第二块代码把向量生成封装为单一职责函数,同时创建数据库客户端:

javascript 复制代码
async function getEmbedding(text) {
  const result = await embeddings.embedQuery(text);
  return result;
};

// 向量数据库初始化
const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

embeddings.embedQuery(text) 接收一段字符串并返回一个浮点数数组。虽然函数名中带有 Query,在当前项目中它被统一作为单文本向量生成入口:main.mjs 用它处理文本切片,query.mjsrag.mjs 用它处理用户问题。

MilvusClient 保存服务器地址和认证令牌。客户端实例会在集合创建、数据插入和向量查询环节复用,避免每次操作都重新构造连接对象。

3.4 创建 Collection、字段与索引

ensureCollection(bookId) 负责"没有集合就创建,有集合就直接加载"。这让第一次执行和后续执行可以共用同一个入口:

javascript 复制代码
async function ensureCollection(bookId){
  // 没有就建立
  // 有就忽略
  try {
    // 判断是否已经创建了集合
    const hasCollection = await client.hasCollection({
      collection_name: COLLECTION_NAME,
    });
    console.log(hasCollection.value);
    if(!hasCollection.value){
      console.log('创建集合...');
      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('集合创建完成');
      console.log('创建索引...');
      await client.createIndex({
        collection_name: COLLECTION_NAME,
        field_name:'vector',
        // nlist 是K-Means 聚类的簇数
        index_type:IndexType.IVF_FLAT,
        metric_type:MetricType.COSINE,
        params:{nlist: 1024}
      })
      // COSINE 高维相似度,不慢,数据量大
      console.log('索引创建完成');
    }
    // 细节捕捉错误
    // 每次要做的
    try{
      await client.loadCollection({
        collection_name: COLLECTION_NAME
      });
      console.log('集合加载完成');
    }catch(err){
      console.error('集合已经处于加载状态');
    }
  }catch(err){
    console.error('创建集合时出错');
  }
}

client.hasCollection() 返回集合是否存在,结果保存在 hasCollection.value。只有值为假时,程序才执行 createCollection()createIndex()

Collection Schema 中每个字段都有明确用途:

字段 Milvus 类型 作用
id VarChar 主键,格式为"书籍 ID_章节序号_切片序号"
book_id VarChar 保存书籍业务标识
book_name VarChar 保存从 EPUB 文件名解析出的书名
chapter_num Int32 保存当前文档在加载结果中的顺序
index Int32 保存切片在当前文档中的序号
content VarChar 保存用于展示与 RAG 的原文
vector FloatVector 保存用于相似度搜索的向量

id 被设置为主键,content 的最大长度是 10000,vector 的维度来自 VECTOR_DIM。Schema 的作用是提前约束每条记录的形状,保证后续插入的数据字段一致。

索引部分使用 IndexType.IVF_FLAT。它会通过 K-Means 思想将向量空间划分为多个聚类,params: { nlist: 1024 } 中的 nlist 表示聚类簇数量。距离度量采用 MetricType.COSINE,因此查询时也继续使用 COSINE。

无论集合是刚创建还是已经存在,函数最后都会调用 loadCollection()。加载 Collection 是后续在 Milvus 中执行向量检索的重要准备步骤。内层 try...catch 负责处理集合已经处于加载状态的情况,外层 try...catch 则统一捕获集合创建阶段的异常。

3.5 加载 EPUB 并递归切片

这一块负责把电子书变成可以逐批入库的文本片段:

javascript 复制代码
async function loadAndProcessEPubStreaming(bookId){
  try{
    console.log(`\n 开始加载EPUB文件: ${EPUB_FILE}`);
    const loader = new EPubLoader(EPUB_FILE,{
      // 加载后就会按章节生成多个document
      // 内存需求的必然
      splitChapters: true
    });
    const documents = await loader.load();
    console.log(`\n 加载完成, 共 ${documents.length} 个章节`);

    const textSplitter = new RecursiveCharacterTextSplitter({
      // 没有传separtor 就用默认的 \n
      chunkSize: CHUNK_SIZE,
      chunkOverlap: 50, // 重叠50个字符,保持上下文连贯性
    });
    let totalInserted = 0; // 计数
    const documentLen = documents.length;// 缓存
    for(let chapterIndex = 0; chapterIndex<documentLen;chapterIndex++){
      // document 这一章的
      const chapter = documents[chapterIndex];
      const chapterContent = chapter.pageContent;
      console.log(`处理第 ${chapterIndex+1} / ${documentLen} 章...`);
      const chunks = await textSplitter.splitText(chapterContent);
      console.log(`拆分为 ${chunks.length} 个片段`);
      if(chunks.length === 0){
        console.log(`跳过空章节\n`);
        continue;
      }
      console.log(`生成向量并插入中...`);
      const insertedCount = await insertChunksBatch(chunks,bookId,chapterIndex+1);
      totalInserted += insertedCount;
      console.log(`已插入 ${totalInserted} 条记录`);
    }

    console.log(`\n总共插入 ${totalInserted} 条记录\n`);
    return totalInserted;
  }catch(err){
    console.error('加载EPUB文件时出错:', err.message);
  }
}

new EPubLoader(EPUB_FILE, { splitChapters: true }) 会读取 EPUB 的内部 flow,并为加载到的章节节点生成 Document。每个 Document 的正文位于 pageContent,因此循环中通过 chapter.pageContent 取得待切分文本。

RecursiveCharacterTextSplitter 接收两个关键参数:

参数 当前值 作用
chunkSize 500 控制每个片段的目标长度
chunkOverlap 50 让相邻片段保留一部分重复上下文

递归字符切分器会尝试沿段落、换行和更细的字符边界切分。chunkOverlap 让前一片段末尾与后一片段开头共享部分内容,减少一句话刚好跨越边界后语义被拆散的情况。

外层 for 循环按 documents 顺序处理,每个章节节点完成切片后立即调用 insertChunksBatch()totalInserted 会累计每一次批量写入返回的数量,并持续打印进度。若某个章节切分结果为空,则通过 continue 进入下一轮。

3.6 并发生成片段向量并批量插入

insertChunksBatch() 接收当前章节的全部 chunks,为它们生成向量并一次性提交给 Milvus:

javascript 复制代码
// 将一批chunk 插入向量数据库
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 getEmbedding(chunk);
        return {
          id: `${bookId}_${chapterNum}_${chunkIndex}`,
          book_id: bookId,
          book_name: 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;
  }
}

chunks.map() 会为当前章节的每个片段创建一个异步任务,任务内部调用 getEmbedding(chunk)。由于 map() 返回的是 Promise 数组,所以外层使用 Promise.all() 等待全部向量生成完毕。最终 insertData 是一组结构完整的 Milvus 行记录。

每条记录的主键由三个值拼接:

text 复制代码
bookId_chapterNum_chunkIndex

例如书籍 ID 为 1、文档序号为 5、切片序号为 2 时,主键就是 1_5_2。同一条记录还保留书名、章节位置、切片位置、原文和向量,检索命中后便能直接拿到所需上下文。

client.insert()collection_name 指向 ebookdata 是待写入的记录数组。SDK 返回的 insert_cnt 会通过 Number() 转换成数字;若转换结果不可用,则返回 0,使函数返回值保持统一的数值类型。

3.7 入口函数串联全部入库步骤

main 函数是 main.mjs 的总入口:

javascript 复制代码
const main = async () => {
  try{
    console.log('='.repeat(50));
    console.log('电子书处理程序');
    console.log('='.repeat(50));

    console.log('\n连接Milvus数据库...');
    await client.connectPromise;
    console.log('已连接');
    const bookId = 1;
    // 确保集合建立了
    await ensureCollection(bookId);
    // 加载和处理EPUB文件
    // 一边切割一边embedding,一边存数据库
    await loadAndProcessEPubStreaming(bookId);
  }catch(err){
    console.error('程序执行出错:', err);
  }
}
main().catch(err => console.log(err));

await client.connectPromise 等待 Milvus 连接完成。bookId 设置为 1,随后依次传给 ensureCollection(bookId)loadAndProcessEPubStreaming(bookId),并最终进入每条记录的主键和 book_id 字段。

整个入口的调用关系如下:

main() → ensureCollection() → loadAndProcessEPubStreaming() → insertChunksBatch() → getEmbedding()

main().catch(...) 位于最外层,用于接收入口 Promise 的异常;各业务函数内部也通过 try...catch 输出当前阶段的错误信息。

4. query.mjs:验证 Milvus 向量检索

4.1 文件职责与总体流程

知识库入库完成后,先使用 query.mjs 验证纯检索效果。这个文件不会调用聊天模型,它只把问题转换为向量,然后从 ebook 集合中搜索最相近的三条记录。

流程可以概括为:

连接 Milvus → 加载 Collection → 问题向量化 → Top 3 搜索 → 打印 Score 与原文

把检索独立出来有一个重要价值:如果最终 RAG 回答不理想,可以先观察 Milvus 是否召回了正确内容,从而区分问题出在检索阶段还是生成阶段。

4.2 导入依赖并初始化查询组件

query.mjs 的第一部分创建 Embedding 和 Milvus 客户端,并封装 getEmbedding()

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,    // C/S B/S
  MetricType, // 相似度求方法 
  IndexType, 
  DataType  // 字段数据类型约束
} from '@zilliz/milvus2-sdk-node';
import {
  OpenAIEmbeddings
} from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
// api key
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
})
const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
}

这里继续使用与入库阶段相同的 COLLECTION_NAMEVECTOR_DIMEMBEDDING_MODEL_NAME 和服务配置。文档向量与问题向量只有位于同一个语义空间中,距离比较才有意义。

IndexTypeDataType 一并从 Milvus SDK 导入,使代码中的 SDK 类型来源保持集中;实际执行搜索时使用的是 MilvusClientMetricType

4.3 搜索 Top 3 并打印结果

第二部分进入查询主流程:

javascript 复制代码
async function main() {
  try {
    console.log('Connecting to Milvus');
    await client.connectPromise;
    console.log('connected\n');

    await client.loadCollection({
      collection_name: COLLECTION_NAME
    });

    const query = '段誉会什么武功?';
    const queryVector = await getEmbedding(query);
    const searchResult = await client.search({
      collection_name:COLLECTION_NAME,
      vector: queryVector,
      limit: 3,
      metric_type: MetricType.COSINE,
      output_fields: ["id", "book_id", "chapter_num", 
        "index", "content"]
    });
    searchResult.results.forEach((item, index) => {
      console.log(`
      ${index + 1}.[Score:${item.score.toFixed(4)}]\n
      ID: ${item.id}  \n
      BookId: ${item.book_id}\n
      Content: ${item.content} \n 
      `)
    })
  } catch(err) {
    console.log(err);
  }
}

main()
  .catch(err => {
    console.log(err);
  })

这段代码先等待连接完成,再通过 loadCollection()ebook 集合加载到可搜索状态。问题保存在 query 中,getEmbedding(query) 返回查询向量。

client.search() 的参数含义如下:

参数 当前值 作用
collection_name ebook 指定搜索的集合
vector queryVector 传入问题向量
limit 3 返回相似度最高的三条记录
metric_type MetricType.COSINE 使用余弦相似度排序
output_fields 五个业务字段 返回主键、书籍 ID、章节、序号和原文

搜索完成后,searchResult.results 是结果数组。forEach() 按排名逐条输出,item.score.toFixed(4) 将分数保留四位小数,item.iditem.book_id 用于定位记录,item.content 则显示真正命中的小说片段。

验证时应同时观察分数和原文。分数体现向量相似度,原文则决定结果是否真正回答了问题。Top 1、Top 2、Top 3 的内容越集中,说明这次语义检索越稳定。

5. rag.mjs:组合检索结果并生成答案

5.1 文件职责与总体流程

rag.mjs 在向量检索基础上加入 ChatOpenAI。它先搜索相关小说片段,再把片段拼接为 context,最后用 Prompt 告诉聊天模型应如何使用这些内容。

完整链路为:

问题 → Embedding → Milvus Top K → context → prompt → ChatOpenAI.invoke() → response.content

在这个文件中,OpenAIEmbeddings 负责理解"问题和文档是否相似",ChatOpenAI 负责把多个片段组织成通顺、详细的自然语言答案。

5.2 初始化 Embedding、ChatOpenAI 与 Milvus

第一块代码完成两个模型组件和数据库客户端的初始化:

javascript 复制代码
import 'dotenv/config'
import {
  MilvusClient,   // C/S架构  B/S
  MetricType, // 相似度求方法
} from '@zilliz/milvus2-sdk-node';
import {
  ChatOpenAI,
  OpenAIEmbeddings
} from '@langchain/openai'


// 云端地址
const ADDRESS = process.env.MILVUS_ADDRESS
// API key
const TOKEN = process.env.MILVUS_TOKEN
const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

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
  }
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});
const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
}

OpenAIEmbeddings 继续处理问题向量,配置来自 EMBEDDING_MODEL_NAMEChatOpenAI 则使用 MODEL_NAME 指定聊天模型。

temperature: 0.1 控制生成随机性。较低的 temperature 更适合知识问答:模型会更稳定地围绕现有上下文组织答案,而不是追求开放式创作。

组件 输入 输出 职责
OpenAIEmbeddings 用户问题 数值向量 为 Milvus 搜索提供查询向量
MilvusClient 查询向量 相似片段数组 找到与问题有关的小说内容
ChatOpenAI Prompt 字符串 AIMessage 基于上下文生成最终答案

5.3 封装 Retriever 检索函数

retrieveRelevantContent() 只负责检索,不负责生成答案,体现了源码注释中"一个函数一个功能、只有一个返回值"的思路:

javascript 复制代码
// RAG 图书业务知识库化
// 函数名可读性
// 一个函数一个功能
// 只有一个返回值
async function retrieveRelevantContent(question, k = 3) {
  try {
    const queryVector = await getEmbedding(question);
    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vectors: queryVector,
      limit: k,
      metric_type: MetricType.COSINE,
      output_fields: ['id', 'book_id', 'chapter_num', 'index', 'content']
    });
    return searchResult.results;
  } catch (err) {
    console.error('检索相关内容时出错:', err.message);
    return [];
  }
}

函数接收两个参数:question 是用户问题,k 是返回片段数量,默认值为 3getEmbedding(question) 先生成问题向量,随后 client.search()ebook 集合中进行余弦相似度搜索。

参数 含义
collection_name 指定检索 ebook 集合
vectors 传入当前问题的向量
limit k 决定返回多少条
metric_type 使用 COSINE 保持与索引一致
output_fields 返回构造上下文所需的字段

成功时函数返回 searchResult.results;检索发生异常时输出错误信息并返回空数组。这样调用方始终接收数组,可以直接通过 length 判断是否找到内容。

5.4 构造上下文与 Prompt

answerEbookQuestion() 负责把 Retriever 的结果加工成大模型可以阅读的上下文:

javascript 复制代码
async function answerEbookQuestion(question, k = 3) {
  try {
    const relevantContent = await retrieveRelevantContent(question, k);
    if (relevantContent.length === 0) {
      console.log('没有找到相关的内容');
      return "抱歉,我没有找到相关的《天龙八部》的内容。";
    }
    const context = relevantContent.map((item, i) => `
  [片段${i + 1}]
  章节: 第${item.chapter_num}章
  内容: ${item.content}
  `).join('\n');
    const prompt = `
  你是一个专业的《天龙八部》小说助手。
  基于小说回答问题,用准确、详细的语言。
  请根据以下小说片段内容回答问题
  ${context}
  用户问题: ${question}

  回答要求:
  1.如果片段中有相关信息,请结合小说内容给出详细准确的答案。
  如果没有,请说不知道
  2.如果可以综合多个片段的内容,提供完整的答案。
  3.如果片段中没有相关信息,请如实告诉用户。
  4.回答要准确,符合小说的情节和人物设定。
  5.可以引用原文内容来支持你的回答。
  AI 助手的回答:
  `

    const response = await model.invoke(prompt);
    return response.content;
  } catch (err) {
    console.error('回答问题时出错:', err.message);
  }
}

函数先调用 retrieveRelevantContent(question, k)。如果结果数组为空,就直接返回未找到内容的提示,不再请求聊天模型。

找到内容后,map() 把每条记录转换为一个带编号的文本片段:

  • [片段1][片段2] 用于区分多条召回结果;
  • 章节 来自 Milvus 的 chapter_num
  • 内容 来自 Milvus 的 content

所有片段通过 .join('\n') 合并成 context。Prompt 随后依次写入助手角色、小说片段、用户问题和回答要求。五条规则共同限制答案:

规则 目的
有相关信息时详细回答 充分使用召回内容
无相关信息时说不知道 避免脱离片段猜测
可以综合多个片段 支持跨片段整理信息
符合情节与人物设定 保证答案与小说语境一致
可以引用原文 让回答拥有文本依据

model.invoke(prompt) 返回消息对象,真正的答案位于 response.content。函数最后只返回这一字段,使调用方获得可直接输出的文本。

5.5 执行完整 RAG 问答

最后一块代码加载集合、提出问题并打印答案:

javascript 复制代码
async function main() {
  try {
    await client.loadCollection({
      collection_name: COLLECTION_NAME
    });
    console.log('集合加载成功');
    const result =
      await answerEbookQuestion('鸠摩智会什么武功?', 5);
    console.log(result);
  } catch (err) {

  }
}

main()
  .catch(err => {
    console.log(err)
  })

入口先调用 loadCollection(),再执行 answerEbookQuestion('鸠摩智会什么武功?', 5)。虽然函数默认 k = 3,这里显式传入 5,因此本次问答会取五个相关片段作为上下文。

调用链可以整理为:

main() → answerEbookQuestion() → retrieveRelevantContent() → getEmbedding() → client.search() → model.invoke()

这条链路先检索、后生成。只有在召回数组不为空时才会调用聊天模型,最后通过 console.log(result) 输出回答。

6. 运行顺序与结果验证

6.1 第一次运行的正确顺序

先执行入库程序:

bash 复制代码
node src/main.mjs

main.mjs 会连接 Milvus、创建或加载 ebook 集合,并处理整本 EPUB。控制台会持续输出当前章节、切片数量和累计插入记录数。

入库完成后运行检索验证:

bash 复制代码
node src/query.mjs

如果配置和入库流程正常,控制台会输出三条结果,每条包含 ScoreIDBookIdContent。重点观察 Content 是否与"段誉会什么武功"相关。

最后执行完整 RAG:

bash 复制代码
node src/rag.mjs

rag.mjs 会检索五条相关片段,再由聊天模型围绕这些片段回答"鸠摩智会什么武功?"。

6.2 常见运行现象与排查方向

现象 优先检查内容 原因说明
找不到 EPUB 当前终端所在目录、EPUB_FILE ./天龙八部.epub 是相对项目根目录的路径
模型接口无法访问 OPENAI_API_KEYOPENAI_BASE_URL Embedding 与聊天请求都依赖兼容接口
Milvus 无法连接 MILVUS_ADDRESSMILVUS_TOKEN 地址或认证信息决定客户端能否访问服务
第一次运行等待较久 EPUB 体积、切片数量、接口响应速度 需要为大量片段逐一生成向量
Query 没有理想片段 Score、输出的 Content 需要先从检索结果判断问题与片段的语义匹配情况
RAG 返回未找到内容 Retriever 返回空数组 answerEbookQuestion() 会直接返回预设提示

排查时最好沿着代码实际执行顺序进行:先确认 EPUB 能否加载,再观察切片数量,然后确认向量生成和数据插入,最后检查 query.mjs 的检索结果与 rag.mjs 的生成结果。

7. 三个文件的完整流程复盘

7.1 从电子书到向量数据库

main.mjs 启动后,先通过 client.connectPromise 连接 Milvus。ensureCollection(bookId) 检查 ebook 是否存在;不存在时创建包含主键、书籍信息、章节信息、原文和向量的 Schema,并为 vector 字段创建 IVF_FLAT + COSINE 索引。

随后,EPubLoader.load() 将电子书加载为 Document[]RecursiveCharacterTextSplitter.splitText() 将每个 pageContent 拆成多个 Chunk。insertChunksBatch() 使用 Promise.all() 并发执行 getEmbedding(chunk),生成当前章节的 insertData,再交给 client.insert() 一次写入 Milvus。

7.2 从问题到相似片段

query.mjs 将"段誉会什么武功?"传给 getEmbedding(),得到 queryVectorclient.search()ebook 集合中使用 MetricType.COSINE 排序,limit: 3 决定只返回前三条。

每个结果除了 score,还带有 idbook_idchapter_numindexcontent。这一步证明向量数据库不仅能返回"哪个向量相近",还能同时返回可阅读、可定位的原始片段。

7.3 从相似片段到自然语言答案

rag.mjs 将 Retriever 和聊天模型串联起来。retrieveRelevantContent() 负责返回片段数组,answerEbookQuestion() 负责把数组格式化为 context 并插入 Prompt,ChatOpenAI.invoke() 负责生成答案。

整个项目的数据变化可以用下表复盘:

阶段 核心 API 输入 输出
加载 EPubLoader.load() EPUB 文件 Document[]
切片 splitText() pageContent chunks
文档向量化 embedQuery() 单个 Chunk 向量数组
入库 client.insert() insertData 插入数量
问题向量化 embedQuery() 用户问题 queryVector
召回 client.search() 问题向量 Top K 片段
上下文构造 map().join() 检索结果数组 context
答案生成 model.invoke() Prompt AIMessage

三份代码分别对应 RAG 系统中最重要的三层:数据加工层、语义检索层、答案生成层。先把每一层独立跑通,再通过函数调用把它们串起来,能够让长文本问答项目的结构保持清晰。

总结

这个项目完整跑通了 Loader、Splitter、Embedding、Milvus 与 RAG 的核心链路。main.mjs 负责把 EPUB 加载为文档,通过 500 字符切片与 50 字符重叠保留上下文,再为片段生成向量并写入 ebook 集合;query.mjs 使用同一个向量模型处理问题,以 COSINE 相似度取回 Top 3 原文;rag.mjs 进一步把 Top 5 结果组成 Prompt,由 ChatOpenAI 生成小说问答结果。理解三个文件之间的数据流后,就能清楚看到 RAG 的本质:模型不需要一次读完整本书,而是在每次提问时先找出最相关的内容,再围绕这些内容完成回答。

相关推荐
勇往直前plus2 小时前
Vue3(篇四)单页面应用到路由管理
javascript·typescript·前端框架·vue
爱奥尼欧3 小时前
【LangChain】6.聊天模型-工具调用
langchain
不简说3 小时前
JS 代码技巧 vol.9 — 20 个设计模式在真实项目里的应用
前端·javascript·github
勇往直前plus3 小时前
Vue3(篇三) Element Plus
前端·javascript·typescript·vue
Revolution613 小时前
数组本身没有 map,为什么还能直接调用:原型链怎样查找属性
前端·javascript·面试
cdcdhj3 小时前
vue3中的watchEffect()监听,什么时候监听,什么时候清理,什么时候停止
前端·javascript·vue.js
huabuyu3 小时前
几百 MB 的文件为什么等几分钟才能打开?文件分片下载与渐进预览原理
前端·javascript
虚惊一场3 小时前
麻将桌上的并发控制:扫码进房、乐观版本与零和结算
前端·javascript
天才熊猫君4 小时前
自动给所有 catch 块补上错误上报:从原理到落地
前端·javascript