深入浅出 RAG 与向量数据库:从 Milvus 基础到电子书级语义搜索实战

在当前这个大模型(LLM)狂飙的时代,我们正经历着从传统软件向 Agentic AI(智能体 AI) 的范式演进。几乎所有主流的 AI Agent 产品,底层都离不开一项核心基础设施------向量数据库(Vector Database)

这篇文章将带你从零开始,理清传统数据库与向量数据库的核心差异,并通过 Milvus 的日记检索 Demo,一步步拆解如何为一本几十万字的《天龙八部》电子书构建一套完整的 RAG(检索增强生成) 系统。

一、从传统 CRUD 到语义检索:为什么我们需要向量数据库?

1.1 架构视角的转变

在传统的业务开发中,前后端的工作模式非常固定:

  • 前端:展示文章列表页、详情页、表单页。
  • 后端(MySQL / PostgreSQL) :围绕着业务实体进行经典的 CRUD(增删改查) 操作。

而在 Agentic AI 的场景下,这种模式被彻底颠覆:

  • 前端:演变成了 Chatbot 对话框、智能搜索页面。
  • 后端(Milvus 等向量数据库) :业务核心变成了 Embedding(向量化嵌入)Retriever(检索) 。数据的录入变成了文本向量化后的"新增",查询则变成了基于多维浮点数组的"相似度检索"。

1.2 文本匹配 vs 语义搜索

我们熟悉的关系型数据库擅长精确匹配 :主键匹配、等值查询、范围筛选,或者使用 LIKE / 正则表达式进行模糊搜索(例如 LIKE '%段誉%')。

但这种基于关键词匹配(Lexical Search)的手段,在面对人类复杂的自然语言时显得非常捉襟见肘。它只能实现"按字面找",无法"按意思找"。

  • 场景痛点:用户问「有没有写户外、爬山、放松心情的日记?」。如果日记的正文是「周末和几个老友去郊外走走,在山顶吹风,享受大自然」,传统数据库因为没有命中"户外"、"爬山"等精确词汇,就会将这条记录漏掉。另外,关键词搜索无法区分多义词(比如"苹果"是手机还是水果)。
  • 向量搜索(Semantic Search) :我们将一段文字输入给大模型的 Embedding API,它会返回一个浮点数数组(如 [0.12, -0.45, 0.89...])。这个多维数组就是这段文本在多维语义空间中的坐标。语义越相近的文本,它们在空间中的距离就越近 。这就需要像 Milvus 这样的专业向量数据库,来存储海量的向量,并提供极致的相似度计算性能。

二、Milvus 的"Hello World":日记语义搜索 Demo

下面这段代码展示了最简流程:连接 Milvus → 将文本转为向量 → 在集合中做相似度搜索

2.1 依赖与基础配置

首先,我们需要安装官方 SDK 和 LangChain 提供的相关模块,并初始化客户端和大模型 Embedding 模型:

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

// 1. 实例化 Milvus 客户端 (使用 Zilliz Cloud 的 Endpoint 和 Token)
const client = new MilvusClient({
    address: process.env.MILVUS_ADDRESS,  
    token: process.env.MILVUS_TOKEN,      
});

// 2. 初始化大模型的 Embedding 能力
const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.OPENAI_API_KEY,
    model: process.env.EMBEDDING_MODEL_NAME, // 例如:text-embedding-3-small
    configuration: { baseURL: process.env.OPENAI_BASE_URL },
    dimensions: 1024,  // 核心约束:此维度必须与后续建表时的维度严格一致!
});

// 辅助函数:将文本转为向量
async function getEmbeddings(text) {
    return await embeddings.embedQuery(text);
}

2.2 连接状态检查与集合加载

在操作 Milvus 时,一个极易踩坑的点是:查询之前,必须确保 Collection(集合)已经被加载到内存中

JavaScript 复制代码
// 3. 检查集群健康状态
const checkHealth = await client.checkHealth();
if (!checkHealth.isHealthy) {
    console.error('Milvus连接失败:', checkHealth.reasons);
    process.exit(1);
}

// 4. 将集合加载到内存(至关重要!)
await client.loadCollection({ collection_name: 'ai_diary' });

2.3 距离度量:余弦相似度(Cosine Similarity)

检索的核心代码如下:

JavaScript 复制代码
// 5. 将用户提问转化为相同维度的向量
const query = "我想看看关于户外活动的日记";
const queryVector = await getEmbeddings(query);

// 6. 执行相似度搜索
const SearchRes = await client.search({
    collection_name: 'ai_diary',
    vector: queryVector,
    limit: 3, // 取最相似的 Top 3
    metric_type: MetricType.COSINE, // 指定距离度量方式为余弦相似度
    output_fields: ['id', 'content', 'date', 'mood', 'tags'],
});
console.log(SearchRes);

这里我们使用了 MetricType.COSINE。在向量数学中,余弦相似度衡量的是两个向量在空间中夹角的余弦值,公式为:

<math xmlns="http://www.w3.org/1998/Math/MathML"> cos ⁡ ( θ ) = A ⋅ B ∥ A ∥ ∥ B ∥ \cos(\theta) = \frac{A \cdot B}{\|A\| \|B\|} </math>cos(θ)=∥A∥∥B∥A⋅B

夹角越小,余弦值越接近 1,代表两段文本的语义越相似。即使日记里完全没有"户外"两个字,只要语义指向类似的情境,它在空间中的向量夹角就会很小,从而被精准召回。

三、大模型的超能力插件:RAG 架构解析

理解了向量检索,我们就掌握了 RAG 的一半。

RAG(Retrieval-Augmented Generation,检索增强生成) 是目前解决大模型"幻觉(Hallucination)"最成熟的方案。当大模型遇到没有在训练数据集中见过的问题(如企业私有文档、最新发布的小说)时,它往往会一本正经地胡说八道。RAG 通过"外挂知识库"的方式解决了这个问题。

一个完整的 RAG 流程包含 6 个核心要素:

  1. 知识库准备:准备原始的非结构化数据(如各种类型的文本、PDF、MP3、视频等)。
  2. Loader(加载器) :使用 @langchain/community 提供的各类 Loader 将文件转换为代码可处理的格式。
  3. Splitter(分块器) :将巨大的长文本切片,拆分成一个个 Document 碎片。
  4. Document(文档对象) :包含 pageContent(正文碎片)和 metadata(元数据,如章节号、作者等信息)。
  5. Embedding Model:将碎片化为向量。
  6. Milvus:持久化存储这些向量及关联内容,以备随时被检索。

延伸理念 :在目前的开发流中,我们提倡 MVP (Minimal Viable Product)Vibe Coding 的理念。借助 Cursor 或 Claude Code 等 AI 编程助手,我们能实现"代码平权",快速把产品原型的想法通过 RAG 架构落地,随后再进入商业级别的精细化打磨。

四、实战 Step 1:构建电子书向量知识库 (ebook-writer.mjs)

目标:将《天龙八部》的 EPUB 电子书切片,并写入 Milvus。

4.1 确保集合与索引存在(Schema 设计)

和关系型数据库建表类似,我们需要在 Milvus 中定义 Schema。特别是元数据(Metadata)的设计非常重要,它能帮助我们在检索时进行标量过滤(Scalar Filtering)。

JavaScript 复制代码
import { DataType, IndexType } from '@zilliz/milvus2-sdk-node';

async function ensureBookCollection(bookId) {
    const hasCollection = await client.hasCollection({ collection_name: 'ebook' });
    if (!hasCollection.value) {
        // 创建集合并定义字段 (Schema)
        await client.createCollection({
            collection_name: 'ebook',
            fields: [
                { name: 'id', data_type: DataType.VarChar, max_length: 50, 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: 1024 }, // 向量字段
            ]
        });

        // 为向量字段创建倒排索引 (IVF_FLAT),加速海量数据检索
        await client.createIndex({
            collection_name: 'ebook',
            field_name: 'vector',
            index_type: IndexType.IVF_FLAT,
            metric_type: MetricType.COSINE,
            params: { nlist: 1024 },
        });
    }
    await client.loadCollection({ collection_name: 'ebook' });
}

4.2 Loader 加载与 Splitter 智能切分

如果把一整章直接塞给 Embedding 模型,不仅会超 Token 限制,而且语义混合在一起会导致检索极不精准。因此我们需要切块(Chunk)。

JavaScript 复制代码
import { EPubLoader } from "@langchain/community/document_loaders/fs/epub";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

// 1. 解析 EPUB,开启按章分割
const loader = new EPubLoader('./天龙八部.epub', { splitChapters: true });
const documents = await loader.load(); // 此时每个 document 代表一整章内容

// 2. 初始化递归字符切分器
const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,     // 每个块的最大容量
    chunkOverlap: 50,   // 重叠冗余量
});

深度解析 Splitter:

RecursiveCharacterTextSplitter 为什么好用?它是一种带策略的降级切割。它会优先寻找天然的语义切割符(如段落换行 \n\n,然后是句号 ,最后是逗号 )。它致力于在满足 chunkSize 限制的前提下,尽量保持一句话语义的完整性。

chunkOverlap(重叠部分,通常设为 chunkSize 的 10%)是为了防止一句重要的话刚好被一刀切成两半,通过牺牲一点空间,让上下块有部分交集,从而保障上下文语义不断层。

4.3 高效的数据入库

将文本切分为 chunks 后,我们需要调用大模型 API 获取向量,并执行批量插入操作:

JavaScript 复制代码
// 遍历每一章进行切块与入库
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
    const chapterContent = documents[chapterIndex].pageContent;
    // 将该章内容切分为 500 字左右的小碎片
    const chunks = await textSplitter.splitText(chapterContent);

    // 性能优化:利用 Promise.all 并发获取多段文本的 Embedding
    const insertData = await Promise.all(
        chunks.map(async (chunk, chunkIndex) => {
            const vector = await getEmbeddings(chunk);
            return {
                id: `${bookId}_${chapterIndex + 1}_${chunkIndex}`,
                book_id: bookId,
                book_name: "天龙八部",
                chapter_num: chapterIndex + 1,
                index: chunkIndex,
                content: chunk,
                vector: vector,
            }
        })
    );

    // 执行批量插入
    await client.insert({
        collection_name: 'ebook',
        data: insertData,
    });
}

五、实战 Step 2:验证语义召回能力 (ebook-query.mjs)

在接入大模型生成之前,我们必须先做一步"纯检索"验证。如果检索出来的数据是错的,大模型回答得再漂亮也是"Garbage in, garbage out"。

JavaScript 复制代码
// 1. 确保集合已加载
await client.loadCollection({ collection_name: 'ebook' });

const query = '段誉会什么武功?';
const queryVector = await getEmbeddings(query);

// 2. 向量库检索
const searchResult = await client.search({
    collection_name: 'ebook',
    vector: queryVector,
    limit: 3, // 取 Top 3 相关片段
    metric_type: MetricType.COSINE,
    output_fields: ['id', 'book_id', 'book_name', 'chapter_num', 'index', 'content'],
});

// 3. 打印召回结果
searchResult.results.forEach((item, index) => {
    console.log(`第 ${index + 1} 个结果: Score: ${item.score.toFixed(2)}`);
    console.log(`章节: ${item.chapter_num}, 内容: ${item.content}`);
});

此时你应该能看到控制台打印出包含凌波微步、六脉神剑等相关段落的内容。分数(Score)越接近 1,说明语义贴合度越高。

六、实战 Step 3:拼装提示词与大模型生成 (ebook-rag.mjs)

到了最后一步,我们将检索到的碎片级上下文喂给 LLM,让它进行润色和总结。

JavaScript 复制代码
// 假设 retrieveRelevantContent 封装了之前的检索逻辑,并返回了结果数组
const retrievedContent = await retrieveRelevantContent(question, 3);

// 1. 拼装 Context:将碎片转化为大模型可读的上下文
const context = retrievedContent
    .map((item, i) => `
    [片段${i+1}]
    章节:第 ${item.chapter_num} 章
    内容:${item.content}
    `).join('\n\n----\n\n');

// 2. 构建强约束 Prompt
const prompt = `
    你是一个专业的《天龙八部》小说阅读助手。基于小说内容回答问题,用准确、详细的语言。

    请根据以下提供的小说片段内容回答问题:
    
    【参考片段】
    ${context}

    【用户问题】
    ${question}

    【回答要求】
    1. 如果片段中有相关信息,请结合小说内容给出详细、准确的回答。
    2. 可以综合多个片段的内容,提供完整的答案。
    3. 如果片段没有相关信息,请如实告知用户"在检索到的资料中未找到相关答案",绝不可凭空捏造!
    4. 回答要符合小说的情节和人物设定,可适当引用原文内容支持。

    AI助手的回答:
`;

// 3. 调用大模型 (model 初始化略)
const response = await model.invoke(prompt);
console.log(response.content);

为什么这段 Prompt 如此重要?

因为大模型有强烈的"讨好型人格",如果不加约束,当它发现上下文中没有答案时,就会动用自己预训练的记忆强行回答。但在企业级 RAG 应用中(比如客服机器人解答退换货政策),我们要求的是 100% 忠于挂载的文档 ,所以 回答要求第 3 点 的防御性提示词必不可少。

七、总结

到这里,我们就完整走通了一条 RAG 的数据流:

  • ebook-writer.mjs:解决如何将海量数据"搬进"并"切碎"存入 Milvus。
  • ebook-query.mjs:解决如何用数学的方式(Cosine),精准地"捞出"相似度最高的段落。
  • ebook-rag.mjs:解决如何利用提示词工程,让大模型"戴着镣铐跳舞",生成可信、可溯源的答案。
相关推荐
gujunge3 小时前
Spring with AI (3): 定制对话——Prompt模板引入
ai·大模型·llm·openai·qwen·rag·spring ai·deepseek
吴佳浩4 小时前
OpenClaw、Claude Code 等 Agent 为什么都选择 Node.js?
前端·人工智能·langchain
爱打代码的小林5 小时前
基于 LangChain 实现带记忆功能的智能对话
人工智能·langchain
小超同学你好5 小时前
LangGraph 10. 记忆管理与三层记忆 与 OpenClaw Memory 模块介绍
人工智能·语言模型·langchain
张毫洁5 小时前
vue2项目搭建
前端·vue.js·node.js
此生只爱蛋5 小时前
【LangChain】提示词模版
langchain
村中少年7 小时前
本地模型工具ollama配置使用openclaw指南
llm·nodejs·虚拟机·qwen·ollama·openclaw
xun_xing7 小时前
一篇文章让你彻底熟悉AI大模型(二)
llm·openai·agent
方安乐7 小时前
pnpm与npm混用为什么会报错?
前端·npm·node.js