RAG实战-基于 Milvus 和 LangChain 实现的天龙八部阅读助手

5 阅读7分钟

基于 Milvus 和 LangChain 实现的天龙八部阅读助手

1. 什么是 RAG?

RAGRetrieval-Augmented Generation(检索增强生成) 的缩写。

简单来说,它是一种让大语言模型(LLM)在回答问题前,先去“查资料”的技术架构。通过结合外部知识库的检索能力和大模型的生成能力,来解决大模型存在的以下问题:

  • 知识滞后:无法获取训练数据之后的新信息。
  • 幻觉(胡说八道) :生成看似合理但事实错误的内容。
  • 私有数据缺失:不知道用户特定的内部数据。

2. RAG 的核心流程

从命名来看,RAG 分为两部分:先检索 (Retrieval)后增强 (Generation/Augmentation)

  • 检索:从知识库中检索出与用户问题相关的文档片段。
  • 增强:将检索到的文档加入到 Prompt(提示词)中,增强上下文,让大模型能够基于这些具体文档生成更准确、更有依据的回答。

为了实现这一流程,RAG 系统通常被划分为两个主要阶段:

阶段一:数据准备阶段

将公司文档或项目相关文档进行切分向量化,并存储到向量数据库中作为知识库。

阶段二:推理问答阶段

  1. 接收用户问题。
  2. 对问题进行向量化。
  3. 从向量数据库中检索出与问题相关的文档。
  4. 将检索到的文档加入到 Prompt 中。
  5. 调用大模型生成回答。

本文以《天龙八部》电子书为例,介绍如何基于 Milvus(向量数据库)和 LangChain(应用框架)实现一个简单的 RAG 阅读助手。


3. 数据准备阶段

3.1 在 Milvus 中创建集合

Milvus 是一个向量数据库。类比 MySQL,在插入数据之前,我们需要先“建库建表”。
(注:开始前需申请免费的 Milvus 账号并创建集群)

初始化 Milvus 客户端实例
// 初始化一个 Milvus客户端实例
// 通过client对数据库建立连接进行操作
// ADDRESS 是 Milvus 集群的地址
// TOKEN 是 Milvus 集群的访问令牌
const client = new MilvusClient({
    address: ADDRESS,
    token: TOKEN,
})
定义集合结构

此步骤主要经历两个阶段:建表添加索引(为后续查询优化做准备)。

async function ensureBookCollection(bookId) {
    try {
        // 判断集合是否存在 类比到mysql 中的 就是在这一步判断表是否已经创建过
        const hasCollection = await client.hasCollection({
            collection_name: COLLECTION_NAME,
        })
        if (!hasCollection.value) {
            console.log(`${COLLECTION_NAME} 集合不存在,创建集合`);

            // 创建集合
            await client.createCollection({
                collection_name: COLLECTION_NAME, // 集合名称,即表名
                // schema 定义了集合的字段和数据类型
                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 },
                ]
            });
            console.log('集合创建成功');

            // 为集合添加索引 性能优化 提高查询速率
            await client.createIndex({
                collection_name: COLLECTION_NAME, // 集合名称,即表名
                field_name: 'vector', // 为 vector 字段创建索引
                index_type: IndexType.IVF_FLAT, //
                metric_type: MetricType.COSINE,
                params: {
                    nlist: VECTION_DIM, // 将向量分为多少维,一般与选择的模型有关
                }
            });
            console.log('索引创建成功')
        }
    } catch(err) {
        console.error('创建集合失败:', err.message);
        throw err;
    }
}

3.2 对文档进行向量化和切分

A. 加载文档

本例使用 .epub 格式文档,因此选用 LangChain 提供的 EPubLoader

try{
        console.log('开始加载EPUB文件');
        // 实例化一个加载器
        const loader =new EPubLoader(
            EPUB_FILE,
            {
                splitChapters: true, // 是否将章节切分 我这里设置为true 是因为我想将每一章作为一个文档
            }
        );
        const documents =await loader.load(); // 加载文档 并切分章节 这里做一个初步切分
}
B. 对文档进行精细切分

使用 RecursiveCharacterTextSplitter(递归字符文本切分器)进行二次切分。它会优先按段落切分,若长度仍超标,则按句子、字符递归切分。

// 实例化一个切分器
// RecursiveCharacterTextSplitter 递归字符文本切分器
// 递归地将文本切分,优先切分段落,然后切分句子,最后切分字符
const textSplitter = new RecursiveCharacterTextSplitter({
            chunkSize: CHUNK_SIZE, // 每个字段的最大字符数
            chunkOverlap: CHUNK_OVERLAP, // 每个字段之间的重叠字符数
            // 默认的sparators \n\n 段落换行 \n 换行符。 ,
        });


        let totalInserted=0;
        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(`跳过空章节\n`);
            continue;
           }
           console.log('生成向量并插入中...');
           // 功能剥离,保证每个函数不会过于复杂 将向量化操作进行封装
           const insertedCount =await insertChunksBatch(chunks,bookId,chapterIndex+1);
           totalInserted+=insertedCount;
           console.log(`插入成功${insertedCount}个片段,累计插入${totalInserted}个片段`);
           
        }
        console.log(`\n处理完成 共插入${totalInserted}`);
        return totalInserted;

切分器核心参数解析:

  • chunkSize: 规定按多少字符数大小切割。
  • chunkOverlap: 在下一次切割时,保留上一次切割最后的 chunkOverlap 个字符(保持上下文连贯性)。
  • separators: 规定切分时的分隔符,默认是 \n\n (段落), \n (换行), ,
C. 文档向量化与入库

主要步骤:创建向量化模型实例 -> 封装向量化函数 -> 并发执行向量化并插入 Milvus。

// 实例化一个向量化模型
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, // 模型对应的向量维度,通常与选择的模型有关,有些模型能够接受比自己指定的维度更高的向量,我们这里选的是text-embedding-v3,1024维
})

// 封装向量化函数
async function getEmbedding(text) {
    const result = await embeddings.embedQuery(text); // 对目标文本进行向量化操作
    return result;
}

// 并发执行向量化操作
async function insertChunksBatch(chunks,bookId,chapterIndex){
    try {
        if(chunks.length===0){
            return 0;
        }
        // 性能优化 embedding 并发
        // 返回结果是符合schema 的数组
        const insertData =await Promise.all(
            chunks.map(async (chunk,chunkIndex)=>{

                // 对每个字段进行向量化操作
                const vector = await getEmbedding(chunk);


                // 返回字段与集合中的字段一致
                return {
                    id:`${bookId}_${chapterIndex}_${chunkIndex}`,
                    book_id:bookId,
                    book_name:BOOK_NAME,
                    chapter_num:chapterIndex,
                    index:chunkIndex,
                    content:chunk,
                    vector:vector,
                }
            })
        )
        // 插入到向量数据库中
        const insertResult = await client.insert({
            collection_name: COLLECTION_NAME,
            data: insertData,
        })
        return Number(insertResult.insert_cnt||0);
    } catch (error) {
        
    }
}

PS: 虽然使用循环迭代也能实现向量化,但耗时大幅增加。使用 Promise.all 并发执行能显著提高效率。

至此,专属知识库搭建完成,数据准备阶段结束。


4. 推理问答阶段

与传统基于文本字符的精确匹配或模糊查询不同,向量搜索能解决语义匹配问题(例如搜“水果”能匹配到“苹果”)。因为知识库已向量化,用户输入也必须向量化后才能进行相似度匹配。

问答阶段主要包含三个步骤:

4.1 用户输入向量化

调用之前封装好的函数即可。

const queryVector = await getEmbedding(question);

4.2 检索 (Search)

携带用户输入的向量去数据库中进行相似度匹配。

async function retrieveRelevantContent(question, k=3) {
    try {
        // 用户输入
        const queryVector = await getEmbedding(question);
        // 调用searc方法进行查询
        const searchResult = await client.search({
            collection_name: COLLECTION_NAME,
            vector: queryVector,
            limit: k,
            metric_type: MetricType.COSINE,
            output_fields: ['id', 'book_id', 'book_name', 'chapter_num', 'index', 'content']
        });
        return searchResult.results
    } catch(err) {
        console.log('向量搜索失败:', err.message);
        return [];
    }
}

search 方法参数说明:

  • collection_name: 集合名称。
  • vector: 用户输入的向量化表示。
  • limit: 返回结果数量(返回前 k 个最相似的结果)。
  • metric_type: 距离度量类型。此处使用 COSINE (余弦相似度),值域 0-1,1 表示完全相似。
  • output_fields: 需要返回的字段列表。

返回数据结构示例:

{
  "status": {
    "error_code": "Success",
    "reason": ""
  },
  "results": [
    [
      {
        "score": 0.9876543,          
        "id": "1001",                
        "book_id": "b_001",          
        "book_name": "西游记",        
        "chapter_num": 14,           
        "index": 5,                  
        "content": "唐僧在五行山下...", 
        "vector": []                 
      },
      {
        "score": 0.9654321,
        "id": "1005",
        "book_id": "b_001",
        "book_name": "西游记",
        "chapter_num": 14,
        "index": 8,
        "content": "孙悟空拜唐僧为师..."
      }
    ]
  ],
  "session_ts": 1710000000000,
  "collection_name": "your_collection_name"
}

4.3 生成回答 (Generation)

将检索到的内容组装进 Prompt,交给大模型生成最终答案,从而解决数据孤岛和幻觉问题。

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

async function answerEbookQuestion(question, k=3) {
    try {
        console.log('开始回答问题:', question);

        const retrievedContent = await retrieveRelevantContent(question, k);
        console.log(`[检索到的内容]`, retrievedContent);
        if(retrievedContent.length === 0) {
            console.log('没有检索到相关内容');
            return '抱歉,没有找到相关内容';
        }
        // retrievedContent.forEach((item,index)=>{

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

        const prompt =`
        你是一个专业的《天龙八部》小说助手,基于小说内容回答问题。用准确、详细的语言。

        请根据以下《天龙八部》小说片段内容回答问题:
        ${context}

        用户问题:${question}

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

        AI助手的回答:
        `
        console.log("AI助手的回答");

        const res = await model.invoke(prompt);
        console.log(res.content);
        return res.content;
    } catch(err) {
        return '抱歉,处理问题时出错';
    }
}

5. 主函数执行

最后,在主函数中连接 Milvus,加载集合,并发起问答测试。

async function main() {
    try {
        console.log('Connection to Milvus...');
        await client.connectPromise;
        try {
            await client.loadCollection({
                collection_name: COLLECTION_NAME,
            });
            console.log('Collection already loaded');
        } catch(err) {
            console.log('Collection not loaded');
        }

        const result=await answerEbookQuestion("谁的武功最厉害?")
        console.log(`最终回答:${result}`);
    } catch(err) {
        console.error('Connection to Milvus failed:', err.message);
    }
}

main();

6. 总结:深入理解 RAG 架构

通过构建《天龙八部》阅读助手,我们不仅完成了一个具体的应用案例,更深刻理解了 RAG (Retrieval-Augmented Generation) 这一大模型应用核心范式的本质。

RAG 的核心价值

RAG 不仅仅是一种技术组合,它是解决大语言模型(LLM)“先天不足”的关键方案:

  • 打破知识边界:让模型能够访问训练数据之外的私有数据(如公司内部文档、最新新闻、特定书籍)。
  • 抑制幻觉产生:通过“先检索后生成”的机制,强制模型基于事实依据(Grounding)回答,大幅减少胡编乱造。
  • 可追溯性:生成的答案可以关联到具体的原文片段(如本例中的章节和段落),提供了答案的来源证明,增加了可信度。

关键流程复盘

成功的 RAG 系统依赖于两个紧密耦合的阶段:

  1. 数据准备(ETL + Embedding)

    • 切分策略是基石:合理的 chunkSizechunkOverlap 设置,既保证了语义的完整性,又避免了信息过载。
    • 向量化是桥梁:将非结构化文本转化为高维向量,使得计算机能够理解“语义相似度”而非仅仅是“字符匹配”。
    • 存储是保障:利用 Milvus 等专用向量数据库,实现海量数据下的毫秒级检索。
  2. 推理问答(Retrieve + Generate)

    • 语义检索:将用户问题转化为向量,在知识库中寻找最相关的上下文,解决了传统关键词搜索无法处理语义泛化的问题。
    • 提示词增强:将检索到的上下文动态组装进 Prompt,充当模型的“短期记忆”或“参考资料”,引导模型输出精准答案。

技术启示

本项目展示了 LangChain 作为编排框架的灵活性(轻松加载文档、切分文本、调用模型)与 Milvus 作为向量底座的高性能(高效索引、相似度搜索)的完美结合。

对于开发者而言,掌握 RAG 意味着掌握了通往垂直领域大模型应用的钥匙。无论是构建企业知识库、智能客服,还是像本文这样的个性化阅读助手,RAG 都是当前最具落地价值和技术可行性的架构方案。随着技术的演进,结合混合检索、重排序(Re-rank)以及多模态能力的 RAG 系统,将在未来展现出更强大的智能水平。