第一次用向量数据库!手搓《天龙八部》RAG助手,让AI真正“懂”你

摘要 :还在用 LIKE '%段誉%' 做模糊搜索?OUT了!本文带你从零开始,利用 Milvus 向量数据库 + LangChain + OpenAI Embedding,打造一款能理解语义的《天龙八部》电子书 RAG(检索增强生成)助手。无需复杂部署,代码全开源,小白也能轻松上手向量数据库!


🤔 为什么我们需要 RAG?

想象一下,你手里有一本几百兆的《天龙八部》EPUB 电子书。 如果你想问:"段誉到底会哪些武功? "

  • 传统搜索:你只能搜关键词"段誉"、"武功"。如果原文写的是"他施展出六脉神剑",而没出现"武功"二字,传统搜索可能就漏掉了。
  • 语义搜索 (RAG) :它能理解"会哪些武功"和"施展出六脉神剑"在语义上是相关的。即使关键词不匹配,也能精准定位到相关段落,并让 AI 总结给你听。

这就是 RAG (Retrieval-Augmented Generation,检索增强生成) 的魅力。今天,我们就通过三个核心文件,手把手实现这个流程。


🛠️ 技术栈概览

本次 MVP (最小可行性产品) 我们选用以下"黄金组合":

  1. 数据源天龙八部.epub (本地电子书)
  2. 文档加载 & 分割@langchain/community (EPUB Loader) + RecursiveCharacterTextSplitter
  3. 嵌入模型 (Embedding)@langchain/openai (将文本转为向量)
  4. 向量数据库Milvus (高性能向量检索,支持本地或云端)
  5. 大语言模型 (LLM)ChatOpenAI (用于最终的回答生成)

🚀 核心流程解析

RAG 的流程其实就两步:入库 (Indexing)查询 (Querying)

第一步:数据入库 (ebook-writer.mjs)

这是最耗时的一步。我们需要把书"读"进去,切碎,变成向量,存进 Milvus。

1. 初始化 Milvus 集合 (Schema 设计)

向量数据库不像 MySQL 那样随意建表,我们需要定义 Schema 。在 ensureBookCollection 函数中,我们设计了如下字段:

yaml 复制代码
// 核心 Schema 定义
fields: [
    { name: 'id', data_type: DataType.VarChar, is_primary_key: true }, // 主键
    { name: 'book_id', data_type: DataType.VarChar }, // 书籍ID
    { name: 'book_name', data_type: DataType.VarChar }, // 书名
    { 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: 1024 }, // ⭐ 核心:1024维向量
]

💡 注意dim: 1024 必须与你的 Embedding 模型输出维度一致(这里使用的是支持 1024 维的模型)。

2. 加载与切片 (Loading & Splitting)

电子书不能一次性塞进去,太大了。我们使用 LangChain 的 EPubLoader 按章节加载,再用 RecursiveCharacterTextSplitter 按段落切分。

ini 复制代码
const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
const documents = await loader.load();

const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,      // 每个片段500字符
    chunkOverlap: 50,    // 重叠50字符,保持上下文连贯
});

// 遍历每一章,切分后生成向量
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
    const chunks = await textSplitter.splitText(documents[chapterIndex].pageContent);
    // ... 后续生成向量并插入
}

3. 向量化与批量插入

这是"魔法"发生的地方。调用 OpenAI API 将文本转为向量数组,然后批量写入 Milvus。

javascript 复制代码
// 并发生成向量,提升性能
const insertData = await Promise.all(
    chunks.map(async (chunk, chunkIndex) => {
        const vector = await getEmbedding(chunk); // 调用 OpenAI Embedding
        return {
            id: `${bookId}_${chapterNum}_${chunkIndex}`,
            content: chunk,
            vector, // ⭐ 存入向量
            // ...其他元数据
        }
    })
);

await client.insert({ collection_name: COLLECTION_NAME, data: insertData });

第二步:语义检索 (ebook-query.mjs)

数据入库后,我们就可以进行语义搜索了。

用户提问:"段誉会什么武功? "

  1. Query Embedding:先把这个问题也变成向量。
  2. Vector Search :在 Milvus 中计算问题向量与库中所有向量的余弦相似度 (Cosine Similarity)
  3. 返回结果:取出相似度最高的 Top 3 个文本片段。
csharp 复制代码
const query = '段誉会什么武功?';
const queryVector = await getEmbedding(query);

const searchResult = await client.search({
  collection_name: COLLECTION_NAME,
  vector: queryVector,
  limit: 3, // 取前3个最相关的
  metric_type: MetricType.COSINE, // 使用余弦相似度
  output_fields: ['content', 'chapter_num', 'book_name'], // 同时返回原文和元数据
});

此时,控制台会打印出得分最高的几个片段,哪怕原文里没有"武功"这两个字,只要语义相近(比如描述了打斗场景),都能被搜出来!


第三步:RAG 回答生成 (ebook-rag.mjs)

光搜出来还不够,我们要让 AI 基于搜到的内容回答问题。这就是 RAG 的完整形态。

构建 Prompt

我们将检索到的上下文(Context)注入到 Prompt 中,告诉 LLM:"别瞎编,只根据下面提供的小说片段回答"。

javascript 复制代码
const context = retrievedContent.map((item, i) => {
  return `
  [片段${i + 1}]
  章节:第${item.chapter_num}章
  内容:${item.content}
  `;
}).join('\n\n---\n\n');

const prompt = `
  你是一个专业的《天龙八部》小说助手。
  
  请根据以下《天龙八部》小说片段内容回答问题:
  ${context}
  
  用户问题:${question}

  回答要求:
  1. 如果片段中有相关信息,请结合小说内容给出详情
  2. 如果片段中没有相关的信息,请如实告知用户
  3. 可以引用原文内容来支持你的回答
`;

调用 LLM 生成答案

arduino 复制代码
const model = new ChatOpenAI({ temperature: 0.7, model: process.env.MODEL_NAME });
const response = await model.invoke(prompt);
console.log(response.content); 
// 输出:段誉主要学会了北冥神功、凌波微步以及六脉神剑...

💻 完整源码展示

为了方便大家复现,以下是三个核心文件的完整代码。请确保你已经安装了依赖: npm install @zilliz/milvus2-sdk-node @langchain/openai @langchain/community @langchain/textsplitters dotenv

并且配置好 .env 文件:

ini 复制代码
MILVUS_ADDRESS=your_milvus_address
MILVUS_TOKEN=your_milvus_token
OPENAI_API_KEY=your_sk_key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1  # 或其他代理地址
EMBEDDING_MODEL_NAME=text-embedding-v3
MODEL_NAME=qwen-plus

1. 数据入库脚本:ebook-writer.mjs

php 复制代码
import "dotenv/config";
import { parse } from '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'

const COLLECTION_NAME = 'ebook';
const VECTION_DIM = 1024;
const CHUNK_SIZE = 500;
const CHUNK_OVERLAP = 50;
const EPUB_FILE = './天龙八部.epub'; // 请确保当前目录下有此文件

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;

const BOOK_NAME = parse(EPUB_FILE).name;

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

const client = new MilvusClient({
    address: ADDRESS,
    token: TOKEN,
})

async function getEmbedding(text) {
    const result = await embeddings.embedQuery(text);
    return result;
}

async function ensureBookCollection(bookId) {
    try {
        const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
        if (!hasCollection.value) {
            console.log(`${COLLECTION_NAME} 集合不存在,创建集合`);
            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: 100 },
                    { 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: VECTION_DIM },
                ]
            });
            await client.createIndex({
                collection_name: COLLECTION_NAME,
                field_name: 'vector',
                index_type: IndexType.IVF_FLAT,
                metric_type: MetricType.COSINE,
                params: { nlist: VECTION_DIM },
            });
            console.log('集合与索引创建成功');
        }
        try {
            await client.loadCollection({ collection_name: COLLECTION_NAME });
        } catch(err) {
            console.log('集合已处于加载状态');
        }
    } catch(err) {
        console.error('创建集合失败:', err.message);
        throw err;
    }
}

async function insertChunksBatch(chunks, bookId, chapterNum) {
    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: String(bookId),
                book_name: BOOK_NAME,
                chapter_num: chapterNum,
                index: chunkIndex,
                content: chunk,
                vector
            }
        })
    );
    
    const insertResult = await client.insert({
        collection_name: COLLECTION_NAME,
        data: insertData,
    });
    return Number(insertResult.insert_cnt) || 0;
}

async function loadAndProcessEPubStreaming(bookId) {
    console.log('开始加载 EPUB 文件');
    const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
    const documents = await loader.load();
    
    const textSplitter = new RecursiveCharacterTextSplitter({
        chunkSize: CHUNK_SIZE,
        chunkOverlap: CHUNK_OVERLAP,
    });
    
    let totalInserted = 0;
    for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
        const chapter = documents[chapterIndex];
        console.log(`处理第 ${chapterIndex + 1}/${documents.length} 章`);
        
        const chunks = await textSplitter.splitText(chapter.pageContent);
        if (chunks.length === 0) continue;
        
        const insertedCount = await insertChunksBatch(chunks, bookId, chapterIndex + 1);
        totalInserted += insertedCount;
        console.log(`插入成功 ${insertedCount} 个片段, 累计 ${totalInserted}`);
    }
    console.log(`\n处理完成, 共插入 ${totalInserted} 个片段`);
}

async function main() {
    try{
        await client.connectPromise;
        console.log('连接 Milvus 成功');
        const bookId = 1;
        await ensureBookCollection(bookId);
        await loadAndProcessEPubStreaming(bookId);
    } catch(err) {
        console.error('主流程错误:', err);
    }
}

main();

2. 纯向量检索测试:ebook-query.mjs

javascript 复制代码
import 'dotenv/config';
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ebook';
const VECTION_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: VECTION_DIM,
});

const client = new MilvusClient({ address: ADDRESS, token: TOKEN });

async function getEmbedding(text) {
    return await embeddings.embedQuery(text);
}

async function main() {
  try {
    await client.connectPromise;
    await client.loadCollection({ collection_name: COLLECTION_NAME }).catch(() => {});
    
    const query = '段誉会什么武功?';
    console.log(`正在搜索:"${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','content','book_id','chapter_num','book_name'],
    });
    
    searchResult.results.forEach((item, index) => {
      console.log(`\n--- 第${index + 1}个结果 (Score: ${item.score.toFixed(4)}) ---`);
      console.log(`章节:${item.book_name} 第${item.chapter_num}章`);
      console.log(`内容:${item.content.substring(0, 100)}...`);
    });
  } catch(err){
    console.log('执行失败', err);
  }
}
main();

3. RAG 智能问答:ebook-rag.mjs

javascript 复制代码
import 'dotenv/config';
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings, ChatOpenAI } from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ebook';
const VECTION_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: VECTION_DIM,
});

const model = new ChatOpenAI({
  temperature: 0.7,
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.MODEL_NAME,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

const client = new MilvusClient({ address: ADDRESS, token: TOKEN });

async function getEmbedding(text) {
  return await embeddings.embedQuery(text);
}

async function retrieveRelevantContent(question, k = 3) {
  const queryVector = await getEmbedding(question);
  const searchResult = await client.search({
    collection_name: COLLECTION_NAME,
    vector: queryVector,
    limit: k,
    metric_type: MetricType.COSINE,
    output_fields: ['book_name', 'chapter_num', 'content']
  });
  return searchResult.results;
}

async function answerEbookQuestion(question, k = 3) {
  console.log(`\n🤖 用户提问:${question}`);
  const retrievedContent = await retrieveRelevantContent(question, k);

  if (retrievedContent.length === 0) {
    return '抱歉,没有在书中找到相关内容。';
  }

  const context = retrievedContent.map((item, i) => {
    return `[片段${i + 1} - 第${item.chapter_num}章]: ${item.content}`;
  }).join('\n\n');

  const prompt = `
  你是一个专业的《天龙八部》小说助手。请严格基于以下提供的小说片段内容回答问题。
  
  【参考片段】:
  ${context}
  
  【用户问题】:${question}
  
  【回答要求】:
  1. 准确、详细,符合原著情节。
  2. 如果片段信息不足,请说明"根据现有片段无法完全确认",不要胡编乱造。
  3. 适当引用原文。
  
  AI 回答:
  `;

  const response = await model.invoke(prompt);
  console.log(`\n✅ AI 回答:\n${response.content}`);
  return response.content;
}

async function main() {
  try {
    await client.connectPromise;
    await client.loadCollection({ collection_name: COLLECTION_NAME }).catch(() => {});
    await answerEbookQuestion('谁的武功最厉害?');
  } catch (err) {
    console.log('运行出错', err.message);
  }
}

main();

🎯 总结与展望

通过这三个文件,我们完成了一个完整的 RAG 闭环

  1. 非结构化数据 (EPUB) -> 结构化向量 (Milvus)。
  2. 自然语言提问 -> 语义匹配 -> 精准上下文
  3. 上下文 + LLM -> 高质量回答

这仅仅是 MVP。在正式的商业级开发中,我们还可以加入:

  • 混合检索:关键词匹配 (BM25) + 向量检索,效果更稳。
  • 重排序 (Rerank) :对检索回来的结果再次打分排序。
  • 多模态:不仅搜文字,还能搜书里的插图。
  • 权限管理:不同用户只能搜自己上传的书。

向量数据库不再是黑科技,它已经成为了 AI 应用的标配基础设施。赶紧动手试试,把你的知识库"喂"给 AI 吧!

互动话题:你想把哪本书做成这样的 AI 助手?欢迎在评论区留言!

相关推荐
苏三说技术1 小时前
阿里又开源了一个顶级Java项目!
后端
忆江南1 小时前
# Flutter Engine、Dart VM、Runner、iOS 进程与线程 —— 深度解析
前端
小码哥_常1 小时前
Android 开发秘籍:用Tint为Icon动态变色
前端
小码哥_常1 小时前
从0到1手把手封装Android基类Activity/Fragment,告别重复代码,开发效率直接拉满!
前端
ChoriaKiinweill1 小时前
不会有人现在还不了解BOM的知识吧? 关于它的一切都在这里!!!
前端
ChoriaKiinweill1 小时前
我们最爱操纵的DOM是个什么玩意? 关于DOM的知识快速一览!
前端
毛骗导演1 小时前
万字解析 OpenClaw 源码架构-代理系统(二)
前端·架构
im_AMBER1 小时前
从0到1实现块级编辑器的文件导入
前端·架构
不可能的是1 小时前
彻底搞懂 Module Federation(中中):MF 模块加载(上)
前端·webpack