第一次用向量数据库!手搓《天龙八部》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 助手?欢迎在评论区留言!

相关推荐
GetcharZp5 小时前
玩转 Linux 机器视觉:手把手带你搞定 Ubuntu 下海康工业相机 C++ SDK
后端
橙子家6 小时前
浏览器缓存之【基础键值存储】:Local storage 和 Session storage
前端
星星在线8 小时前
MusicFree:一个「All in One」的个人音乐服务器,让听歌回归简单
前端·后端
IT_陈寒9 小时前
Redis的SETNX并发问题让我加了三天班
前端·人工智能·后端
demo007x9 小时前
Docling 文档转换以及技术架构分析
前端·后端·程序员
京东云开发者10 小时前
京东市民服务又“上新”!这次是黑龙江“龙易办”
前端
袋鱼不重10 小时前
我的神奇同事,AI 用多了居然写了个 Open In Codex
前端·后端·ai编程
用户83562907805111 小时前
使用 Python 操作 Word 内容控件
后端·python
像我这样帅的人丶你还11 小时前
啥? 前端也要会干Java?🛵🛵🛵
后端
Hommy8811 小时前
【剪映小助手】添加贴纸接口(Add Sticker)
后端·github·剪映小助手·视频剪辑自动化·剪映api