🥋 深入学习 RAG:手把手带你用 Langchain + Milvus 打造私人《天龙八部》 AI 助手

0 阅读9分钟

哈喽,掘金的同学们大家好!👋 我是你们的 AI 领路人。

今天我们要搞点好玩的!咱们不聊枯燥的理论,直接以此生最爱的武侠小说 《天龙八部》 为例,带大家从零开始,手搓一个 RAG(Retrieval-Augmented Generation,检索增强生成) 应用。

想象一下,你有一个 AI 助手,它不仅懂聊天,还熟读金庸先生的《天龙八部》,无论是“段誉的凌波微步怎么走?”,还是“乔峰的降龙十八掌有几招?”,它都能基于原著给你最精准的回答。是不是很酷?😎

这就是 RAG 的魅力:给大模型外挂一个“知识库”,让它从“一本正经胡说八道”变成“引经据典的博学大师”。

废话不多说,咱们开整!🚀


📚 第一章:心法总纲 —— RAG 的修炼路径

在开始写代码(练招式)之前,我们先要懂心法(原理)。

RAG 的核心流程其实就几步,就像把大象装进冰箱一样简单(误):

  1. 资源获取 (Load Source):首先你得有书。不管是 PDF、Markdown 还是我们今天要用的 EPub 电子书,先得把数据搬进来。
  2. 切分 (Split):大模型胃口有限(上下文窗口),不能一口吃成个胖子。我们需要把长篇小说切成一个个小片段(Chunk)。
  3. 向量化 (Embedding):这是最关键的一步!计算机看不懂中文,它只认数字。我们需要把文字片段转换成一串串数字(向量),存入向量数据库(Milvus)。
  4. 检索 (Retrieve):当用户提问时,把问题也变成向量,去数据库里找最相似的片段。
  5. 生成 (Generate):把找到的片段作为“参考资料”(Context),连同用户的问题一起扔给大模型:“嘿,根据这些资料,回答这个问题!”

听起来是不是很清晰?接下来,我们进入实战环节!🛠️


🛠️ 第二章:铸剑 —— 环境与工具准备

我们要用到的“神兵利器”有:

  • LangChain 🦜🔗:大模型开发的瑞士军刀。
  • Milvus 🦅:高性能的开源向量数据库(存数据的藏经阁)。
  • OpenAI Embedding & Chat 🧠:提供大脑和向量化能力。
  • Node.js 🟢:我们的开发环境。

1. 导入模块:召集各路英雄

我们要创建一个 ebook-writer.mjs 文件,专门负责数据的处理和入库

// ebook-writer.mjs

// 引入环境变量配置,保护你的 API Key 不泄露
import "dotenv/config";
import { parse } from 'path';

// 🦅 1. 引入 Milvus 客户端及相关类型
// MilvusClient: 用于连接和操作数据库
// DataType: 定义字段类型(比如整数、字符串、向量)
// MetricType: 距离计算方式(比如余弦相似度)
// IndexType: 索引类型(决定检索速度和精度)
import {
    MilvusClient,
    DataType,
    MetricType,
    IndexType,
} from '@zilliz/milvus2-sdk-node'

// 🧠 2. 引入 OpenAI 的 Embedding 模型
// 它的作用是把“文字”变成“向量”
import {
    OpenAIEmbeddings
} from '@langchain/openai'

// 📚 3. 引入 LangChain 社区的加载器
// 这里我们专门处理 EPub 格式的电子书
import {
    EPubLoader
} from '@langchain/community/document_loaders/fs/epub'

// 🔪 4. 引入文本切割器
// RecursiveCharacterTextSplitter 是最常用的切割器
// 它会递归地尝试分割文本,尽量保持语义完整
import {
    RecursiveCharacterTextSplitter
} from '@langchain/textsplitters'

2. 初始化配置:排兵布阵

接下来我们要配置一些全局参数,这些参数就像是武功的“内力值”,决定了系统的性能。

// 集合名称,相当于数据库里的表名
const COLLECTION_NAME = 'ebook';

// 向量维度。注意!这个必须和你的 Embedding 模型匹配。
// OpenAI 的 text-embedding-3-small 通常是 1536,但很多老模型或自定义设置是 1024 或 768。
// 这里一定要去查你所用模型的文档,维度错了是存不进去的!
const VECTION_DIM = 1024;

// 切片大小。每个片段包含 500 个字符。
// 太小了语义不完整,太大了检索不精准。500 是个经验值。
const CHUNK_SIZE = 500;

// 切片重叠。让相邻的切片有 50 个字符的重叠。
// 为什么要重叠?为了防止一句话正好被切在两刀中间,导致意思断了。
// 就像接力赛跑,交接棒总得有一段共同跑的距离。
const CHUNK_OVERLAP = 50;

// 我们的目标书籍的路径
const EPUB_FILE = './天龙八部.epub';

// Milvus 的地址和 Token
const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;

// 获取书名,后面存元数据要用
const BOOK_NAME = parse(EPUB_FILE).name;
console.log('当前处理书籍:', BOOK_NAME);

// 🧠 初始化 Embedding 模型
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, // 再次强调,维度要对齐!
})

// 🦅 初始化 Milvus 客户端
const client = new MilvusClient({
    address: ADDRESS,
    token: TOKEN,
})

// 封装一个简单的获取向量的函数
async function getEmbedding(text) {
    // embedQuery 是专门用来把查询文本或文档片段变成向量的方法
    const result = await embeddings.embedQuery(text);
    return result;
}

🏗️ 第三章:藏经阁建设 —— 数据库初始化

数据入库前,我们得先建好“仓库”。在 Milvus 中,这个仓库叫 Collection(集合)

我们来看看 main 函数和 ensureBookCollection 函数的设计。

1. 主流程控制

async function main() {
    try{
        console.log('📖 电子书处理程序启动...');
        console.log('🔌 正在连接 Milvus...');
        
        // ⚡ 重点:等待连接成功
        // connectPromise 是 Milvus SDK 提供的一个属性,await 它能确保连接建立后再进行后续操作
        await client.connectPromise;
        console.log('✅ Milvus 连接成功');
        
        const bookId = 1; // 假设这本书 ID 为 1
        
        // 第一步:确保存放数据的集合(表)已经建好了
        await ensureBookCollection(bookId);
        
        // 第二步:开始流式加载并处理电子书
        await loadAndProcessEPubStreaming(bookId);
        
    } catch(err) {
        console.error('❌ 程序执行出错:', err);
    }
}

2. 确保集合存在(建表)

这一步非常关键,相当于 MySQL 里的 CREATE TABLE IF NOT EXISTS。但向量数据库有它独特的 Schema(结构)。

async function ensureBookCollection(bookId) {
    try {
        // 🔍 检查集合是否已经存在
        const hasCollection = await client.hasCollection({
            collection_name: COLLECTION_NAME,
        })
        console.log('集合是否存在状态:', hasCollection);
        
        // ⚠️ 注意:hasCollection 返回的是一个对象,.value 才是布尔值
        if (!hasCollection.value) {
            console.log(`🚧 ${COLLECTION_NAME} 集合不存在,开始创建...`);
            
            // 🏗️ 创建集合与定义 Schema
            await client.createCollection({
                collection_name: COLLECTION_NAME,
                // schema 定义字段
                fields: [
                    // id: 主键,VarChar 类型
                    { name: 'id', data_type: DataType.VarChar, max_length: 100, is_primary_key: true },
                    // book_id: 书籍 ID,方便后续按书筛选
                    { name: 'book_id', data_type: DataType.VarChar, max_length: 100 },
                    // book_name: 书名
                    { name: 'book_name', data_type: DataType.VarChar, max_length: 100 },
                    // chapter_num: 章节号
                    { name: 'chapter_num', data_type: DataType.Int32 },
                    // index: 片段在章节中的序号,用于排序
                    { name: 'index', data_type: DataType.Int32 },
                    // content: 具体的文本内容,最大长度设大点,防止溢出
                    { name: 'content', data_type: DataType.VarChar, max_length: 10000 },
                    // vector: 最核心的向量字段,FloatVector 类型,维度必须匹配
                    { name: 'vector', data_type: DataType.FloatVector, dim: VECTION_DIM },
                ]
            });
            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('✅ 索引创建成功')
        }
        
        // 📥 加载集合
        // Milvus 的特性:集合创建后必须 Load 进内存才能被检索
        try {
            await client.loadCollection({
                collection_name: COLLECTION_NAME // 注意代码里的 typo 修正:collection_name
            });
            console.log('✅ 集合加载成功,准备就绪');
        } catch(err) {
            // 如果已经加载过,可能会报错,忽略即可
            console.log('ℹ️ 集合可能已处于加载状态')
        }
    } catch(err) {
        console.error('❌ 创建集合失败:', err.message);
        throw err; // 抛出错误,中断流程
    }
}

云端数据库查看表结构: image.png

🔪 第四章:庖丁解牛 —— 文本处理与向量化

这里是整个数据处理的核心。我们需要把 EPUB 文件变成一条条可以入库的记录。

1. 加载与切割

async function loadAndProcessEPubStreaming(bookId) {
    try {
        console.log('📂 开始加载 EPUB 文件...');
        
        // 1. 初始化 Loader
        // splitChapters: true 表示它会尝试按章节帮你把书拆开,这非常有用!
        const loader = new EPubLoader(
            EPUB_FILE,
            {
                splitChapters: true
            }
        );
        
        // load() 返回的是 Document 对象数组
        const documents = await loader.load();
        console.log(`📚 书籍加载完毕,共识别到 ${documents.length} 个章节`);

        // 2. 初始化 Splitter (切割器)
        const textSplitter = new RecursiveCharacterTextSplitter({
            chunkSize: CHUNK_SIZE,
            chunkOverlap: CHUNK_OVERLAP,
            // separators: 默认不需要填
            // 默认分隔符优先级:["\n\n", "\n", " ", ""]
            // 它会优先在段落间切,段落太长就在换行切,还不行就在空格切...
            // 这样能最大程度保留语义完整性
        });

        let totalInserted = 0;
        
        // 3. 逐章处理
        // 为什么不用 Promise.all 并发处理所有章节?
        // 因为书可能很大,一次性全部读入内存并处理可能会爆内存 (OOM)。
        // 逐章处理比较稳妥。
        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) {
                console.log(`   ⚠️ 跳过空章节`);
                continue;
            }
            
            console.log('   ⚡ 生成向量并插入中...');
            
            // 调用批量插入函数
            const insertedCount = await insertChunksBatch(chunks, bookId, chapterIndex + 1);
            totalInserted += insertedCount;
            console.log(`   ✅ 成功插入 ${insertedCount} 个片段 (累计: ${totalInserted})`);
        }
        
        console.log(`\n🎉 全部处理完成! 共插入 ${totalInserted} 个知识片段`);
        return totalInserted;
    } catch(err) {
        console.error('❌ 加载 EPUB 文件失败:', err.message);
        throw err;
    }
}

2. 批量向量化与入库(性能优化的关键点)

这里有一个非常重要的性能优化技巧:并发 Embedding

如果我们一个一个片段去调 OpenAI 的接口,那速度会慢到让你怀疑人生。利用 Promise.all 可以并发请求,速度提升几十倍!

async function insertChunksBatch(chunks, bookId, chapterNum) {
    try {
        if (chunks.length === 0) {
            return 0;
        }
        
        // 🚀 性能优化核心:Embedding 并发
        // 我们利用 map 生成一组 async 任务,然后用 Promise.all 等待它们全部完成
        const insertData = await Promise.all(
            chunks.map(async (chunk, chunkIndex) => {
                // 每一小块文本都要变成向量
                const vector = await getEmbedding(chunk);
                
                // 返回符合 Milvus Schema 的数据结构
                return {
                    // 构造一个唯一的 ID,方便排查问题
                    id: `${bookId}_${chapterNum}_${chunkIndex}`,
                    book_id: bookId,
                    book_name: BOOK_NAME,
                    chapter_num: chapterNum,
                    index: chunkIndex,
                    content: chunk, // 原文内容也要存,不然检索出来只有向量没法看
                    vector // 向量数据
                }
            })
        )
        
        // 📥 批量插入数据库
        // insert 接口支持一次插入多条数据,效率远高于逐条插入
        const insertResult = await client.insert({
            collection_name: COLLECTION_NAME,
            data: insertData,
        })
        
        return Number(insertResult.insert_cnt) || 0;
    } catch(err) {
        console.error('❌ 插入片段失败:', err.message);
        throw err;
    }
}

🔍 第五章:小试牛刀 —— 向量检索测试

数据入库后,我们怎么知道对不对呢?写个 ebook-query.mjs 测试一下。

这个脚本不需要大模型参与,只是单纯测试“找得准不准”。

// ebook-query.mjs
// ... (引入模块和初始化的代码与 writer 类似,略过) ...

async function main() {
    try{
        console.log('Connection to Milvus...');
        await client.connectPromise;
        
        // 必须先 Load 集合
        await client.loadCollection({
            collection_name: COLLECTION_NAME
        });
        
        // ❓ 提出一个测试问题
        const query = '段誉会什么武功?';
        console.log(`❓ 问题: ${query}`);
        
        // 1. 把问题变成向量
        const queryVector = await getEmbedding(query);
        
        // 2. 在数据库中搜索最相似的向量
        const searchResult = await client.search({
            collection_name: COLLECTION_NAME,
            vector: queryVector, // 问题的向量
            limit: 3, // 只取前 3 个最相似的结果
            metric_type: MetricType.COSINE, // 使用余弦相似度
            // 指定要返回哪些字段,content 最重要
            output_fields: ['id','content','book_id','chapter_num','index','book_name'],
        })
        
        // 3. 打印结果
        searchResult.results.forEach((item,index) => {
            console.log(`\n🏆 第 ${index+1} 个结果 (相似度 Score: ${item.score.toFixed(2)})`);
            console.log(`📄 内容片段: ${item.content}`);
            console.log(`📍 来源: 第 ${item.chapter_num} 章`);
        })
        
    } catch(err) {
        console.error('Test failed:', err.message);
    }
} 

运行这个脚本,如果能在控制台看到关于“六脉神剑”、“凌波微步”的小说原文片段,说明我们的数据库已经构建成功了!🎉


🤖 第六章:神功大成 —— RAG 完整实现

最后,我们将所有模块串联起来,实现真正的问答。创建 ebook-rag.mjs

1. 引入 Chat 模型

这次我们需要引入 ChatOpenAI,因为它才是负责说话的那个“人”。

// ebook-rag.mjs
import {
    ChatOpenAI
} from '@langchain/openai'

// ... (Milvus 和 Embedding 初始化同上) ...

// 🤖 初始化大语言模型
const model = new ChatOpenAI({
    temperature: 0.7, // 0.7 比较平衡,既有创造性又不会太发散
    model: process.env.MODEL_NAME, // 比如 gpt-4o 或 gpt-3.5-turbo
    apiKey: process.env.OPENAI_API_KEY,
    configuration:{
        baseURL: process.env.OPENAI_BASE_URL,
    },
});

2. 检索函数封装

async function retrieveContent(question, k=3) {
    try {
        // 问题 -> 向量
        const queryVector = await getEmbedding(question);

        // 向量 -> 搜索 -> 结果
        const searchResult = await client.search({
            collection_name: COLLECTION_NAME,
            vector: queryVector,
            limit: k,
            metric_type: MetricType.COSINE,
            output_fields: ['content', 'chapter_num'],
        })
        return searchResult.results;
    } catch(err) {
        console.error('检索内容失败:', err.message);
        return [];
    }
}

3. 核心问答逻辑:Prompt 工程

这是 RAG 的灵魂。我们需要构建一个 Prompt,告诉 AI:“你不要瞎编,要根据我给你的这些资料来回答”。

async function answerEbookQuestion(question, k=3) {
    try {
        console.log('🤔 正在思考问题:', question);

        // 1. 检索相关内容
        const retrievedContent = await retrieveContent(question, k);
        console.log(`📚 检索到 ${retrievedContent.length} 条相关资料`);

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

        // 2. 构建上下文 (Context)
        // 把检索到的多个片段拼接成一个字符串
        const context = retrievedContent
        .map((item, i) => 
        `[资料片段 ${i+1}] 
        章节:第 ${item.chapter_num} 章
        内容:${item.content}`
        ).join('\n\n-----\n\n');

        // 3. 构建 Prompt (提示词)
        // 这里的提示词设计很重要,要强调“基于内容回答”
        const prompt = `
        你是一个专业的《天龙八部》小说专家助手。
        请严格根据以下提供的小说片段回答用户问题。如果片段中没有答案,请直接说明,不要编造。

        === 参考资料 ===
        ${context}
        === 资料结束 ===

        用户问题:${question}

        回答要求:
        1. 结合小说情节,语言风格可以生动一些。
        2. 引用原文中的关键描述来支持你的观点。
        3. 答案要准确、完整。

        AI 专家的回答:
        `

        // 4. 调用大模型
        const response = await model.invoke(prompt);
        
        // 5. 返回结果
        return response.content;
        
    } catch(err) {
        console.error('回答出错:', err);
        return '抱歉,修炼走火入魔了,暂时无法回答。';
    }
}

4. 主函数调用

async function main() {
    try {
        await client.connectPromise;
        await client.loadCollection({ collection_name: COLLECTION_NAME });

        // 🎤 提问时间!
        const answer = await answerEbookQuestion('段誉第一次遇见王语嫣是在哪里?发生了什么?');
        console.log('\n💬 AI 回答:\n', answer);
        
    } catch(err) {
        console.error('系统错误:', err.message);
    } 
}

main();

🎉 总结

恭喜你!到这里,你已经亲手打造了一个基于《天龙八部》的 RAG 系统。

我们回顾一下今天的知识点:

  1. 数据清洗:利用 EPubLoaderRecursiveCharacterTextSplitter 把书拆成小块。
  2. 向量存储:利用 OpenAI EmbeddingMilvus 构建了私有知识库。
  3. RAG 生成:通过 Search + Prompt + LLM 的组合拳,实现了有理有据的问答。

现在的 AI 就像是一个刚学会认字的孩童,而 RAG 就是我们塞给他的《百科全书》。只要你的知识库足够丰富(比如塞进去几百本编程书、医学书),他就能成为任何领域的专家!

还在等什么?快去把你硬盘里的电子书都喂给它吧!💪

Happy Coding! 👩‍💻👨‍💻


希望这篇文章能帮你深入理解 RAG 的实现流程。如果有任何问题,欢迎在评论区交流讨论!