我把《天龙八部》塞进向量数据库后,终于搞懂了 RAG 到底是个啥

说实话,RAG 这个词我听了快一年了。

每次技术群里有人聊 "检索增强生成",我都假装在忙别的,生怕被问到。文档也翻过,概念也背过,但总觉得隔着一层纱 ------ 知道它是 "先搜再生成",但到底怎么搜?搜的是什么?向量数据库又是个什么东西?

直到上周,我突发奇想:能不能让 AI 帮我回答《天龙八部》里的问题?比如 "鸠摩智到底会多少门武功?"、"段誉的六脉神剑时灵时不灵到底是为啥?"

GPT 肯定答不准,它又没读过原著。但如果我把整本书喂给它呢?

这个念头一出来,我就停不下来了。三天后,我不仅跑通了整个流程,还顺便把 RAG 这玩意儿给整明白了。

一切从一个 "搜不到" 开始

最开始我想的很简单:不就是把书的内容复制粘贴给大模型吗?

然后我就傻眼了。《天龙八部》全书一百多万字,GPT 的上下文窗口再大也塞不下啊。而且就算塞得下,每次提问都传一百万字过去,那账单不得爆炸?

我当时第一反应是,那就分段呗。把书切成一段一段的,提问的时候找到相关的那几段,只传那几段过去。

哎,这不就是 RAG 吗?

后来我才知道,我这个 "朴素的想法",就是检索增强生成最核心的思路 ------ 你别让模型什么都记,它记不住也记不准。你把知识存在外面,需要的时候去捞相关的那几块,再喂给模型。

我的理解RAG 不是什么黑科技,它本质上就是 "开卷考试"。模型不用背知识点,考试的时候给它一本参考书,让它自己翻到相关的那几页,再根据那几页的内容来答题。

但问题来了:怎么知道哪几段跟问题相关?

你总不能每问一个问题,就把一百万字从头到尾比对一遍吧?那也太慢了。

这时候就轮到 "向量" 出场了。

向量这玩意儿,其实就是 "语义指纹"

我之前一直觉得 "向量"、"embedding" 这些词特别高大上,像什么数学黑科技。

直到我看到一个类比,瞬间就通了。

你想啊,两句话意思差不多,但字面上可能完全不一样。比如 "段誉会什么武功?" 和 "段誉的绝学有哪些?"------ 字面上没几个字是相同的,但人一看就知道是一个意思。

计算机怎么判断两句话 "意思像不像"?

答案就是:把每一句话都变成一串数字。意思相近的话,它们的数字串在 "空间" 里的位置就离得近;意思差得远的,位置就离得远。

这串数字,就叫向量 ,也叫embedding(嵌入)

就好比每个人都有个 "性格指纹"------ 用一百个维度来描述一个人:外向程度、幽默感、喜欢甜食的程度...... 两个性格越像的人,他们的 "指纹" 就越接近。

文本也是一样的道理。一段文字被转成 1024 维的向量,就相当于给这段文字拍了一张 "语义照片"。你要找跟问题最相关的段落,就把问题也拍一张 "语义照片",然后去数据库里找照片最像的那几段。

这个 "找最像的" 过程,就叫相似度搜索

那向量数据库又是干啥的?

如果只有几段文字,你一条条比也无所谓。但如果有几十万、几百万段呢?

你总不能每次搜索都遍历一遍吧?那跟翻书有啥区别。

这时候就需要向量数据库了。它专门存这些向量,而且提前建好了索引,找起来特别快。

我用的是 Milvus,一个开源的向量数据库。你可以把它理解成一个 "语义搜索引擎"------ 你输入一句话,它帮你找出意思最接近的那些段落。

整个 RAG 的流程,说穿了就是这么几步:

  1. 加载:把书读进来(EPUB、PDF、网页都行)
  2. 切分:把整本书切成一小块一小块的(chunk)
  3. 向量化:给每一小块拍一张 "语义照片"(生成向量)
  4. 入库:把小块文本和对应的向量存进向量数据库
  5. 检索:用户提问时,把问题也变成向量,去数据库里找最像的几个小块
  6. 生成:把找到的小块和问题一起塞给大模型,让它根据这些内容回答

就这么六步。没什么神秘的。

先跑通最朴素的版本

我这个人学东西有个习惯,先不管什么优雅设计,先把最土的版本跑通再说。跑通了再慢慢优化。

我的目标很简单:把《天龙八部》的 EPUB 文件塞进 Milvus,然后问一个问题,它能返回最相关的几段文字。

第一步:把 EPUB 读进来

读 EPUB 我用的是 LangChain 的 EPubLoader,几行代码的事:

javascript 复制代码
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub';

const loader = new EPubLoader('./天龙八部.epub', {
    splitChapters: true // 按章节拆分,省得我自己切
});
const documents = await loader.load();
console.log(`加载完成,共${documents.length}个章节`);

说实话,这一步比我想象的顺利。splitChapters: true 一开,它自动按章节给你分好,每一章就是一个 document。

第二步:把章节切成小块

整章文字还是太长了,一章可能有好几千字。直接向量化的话,语义会被 "稀释"------ 一段话里既有段誉又有乔峰,向量就不知道该像谁了。

所以得再切小一点。

javascript 复制代码
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,    // 每块 500 字
    chunkOverlap: 50,  // 相邻两块重叠 50 字,防止把一句话从中间切断
});

const chunks = await textSplitter.splitText(chapterContent);

这里有个参数我一开始没太在意:chunkOverlap

为啥要重叠?因为如果你正好在 "段誉使出六脉神剑" 这句话中间切了一刀,前一半在上一块,后一半在下一块,那两块的语义都不完整。重叠几十字,就能保证每句话至少在一块里是完整的。

踩过的坑chunkSize 不是越小越好。太小的话,每块上下文太少,语义就不完整了 ------ 你搜 "六脉神剑",可能只匹配到 "神剑" 两个字所在的那块,前面的 "段誉使出六" 被切到上一块去了。

我试了几个值,500 字左右 + 50 字重叠,对中文小说来说效果还不错。

第三步:向量化并存入 Milvus

这是最核心的一步。每一块文字,我都要调用 embedding 模型把它变成向量,然后存进 Milvus。

embedding 我用的是阿里的通义千问 text-embedding-v3,1024 维。

javascript 复制代码
import { OpenAIEmbeddings } from '@langchain/openai';

const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.DASHSCOPE_API_KEY,
    model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
    configuration: {
        baseURL: process.env.DASHSCOPE_API_BASE_URL,
    },
    dimensions: 1024,
});

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

然后是 Milvus 的集合(collection)设计,你可以理解成 "建表":

javascript 复制代码
await client.createCollection({
    collection_name: 'ebook',
    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: 200 },
        { 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 } // 注意维度要一致!
    ]
});

这里有个坑我必须提一下 ------向量维度必须跟 embedding 模型输出的维度一模一样。我一开始把 dim 写成了 768,结果插入的时候直接报错,查了半天才发现是维度对不上。

然后建索引:

javascript 复制代码
await client.createIndex({
    collection_name: 'ebook',
    field_name: 'vector',
    index_type: IndexType.IVF_FLAT,
    metric_type: MetricType.COSINE,
    params: {
        'nlist': 1024,
    },
});

IVF_FLAT 是啥?简单说就是先把所有向量聚成 1024 个 "小组"(nlist = 1024),搜索的时候先找最像的几个小组,再在小组里精确比对。比全量比对快多了。

COSINE 就是余弦相似度,衡量两个向量方向有多接近 ------ 方向越一致,说明语义越像。

第四步:批量插入

然后就是循环每一章,切块,生成向量,插入数据库:

javascript 复制代码
async function insertChunksBatch(chunks, bookId, chapterNum) {
    const insertData = await Promise.all(
        chunks.map(async (chunk, chunkIndex) => {
            const vector = await getEmbedding(chunk);
            return {
                id: `${bookId}_${chapterNum}_${chunkIndex}`,
                book_id: bookId,
                book_name: BOOK_NAME,
                chapter_num: chapterNum,
                index: chunkIndex,
                content: chunk,
                vector: vector,
            }
        })
    );
    const insertResult = await client.insert({
        collection_name: COLLECTION_NAME,
        data: insertData,
    });
    return Number(insertResult.insert_cnt) || 0;
}

我用 Promise.all 并发生成向量,速度还可以。一百多万字的书,大概十几分钟就插完了。

来,搜一下试试

数据插完了,最激动人心的时刻来了 ------ 搜一个问题看看效果。

我搜的是:"段誉会什么武功?"

javascript 复制代码
const query = '段誉会什么武功?';
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
    collection_name: 'ebook',
    vector: queryVector,
    limit: 3,  // 返回最相关的 3 段
    metric_type: MetricType.COSINE,
    output_fields: ['id', 'chapter_num', 'content'],
});

跑出来的结果,说实话,我当时有点震惊。

第一段直接就是段誉在无量山学会北冥神功和凌波微步那段,相似度 0.8 多分。第二段是六脉神剑的内容,第三段提到了他用六脉神剑跟鸠摩智打的情节。

你说准不准?真的挺准的。

但问题也来了 ------ 它返回的是 "原文片段",不是 "答案"。我问的是 "段誉会什么武功",它给我三段原文,我还得自己从里面提炼答案。

那能不能让大模型帮我提炼?

加上大模型,才是完整的 RAG

这一步其实最简单。把搜出来的几段原文,跟问题一起拼成一个 prompt,扔给大模型就行了。

javascript 复制代码
async function answerEbookQuestion(question, k = 3) {
    // 第一步:检索相关内容
    const retrievedContent = await retrieveRelevantContent(question, k);
    
    // 第二步:把检索结果拼成上下文
    const content = retrievedContent.map((item, i) => `
    [片段${i + 1}]
    章节:第${item.chapter_num}章
    内容:${item.content}
    `).join('\n\n---\n\n');

    // 第三步:构造 prompt,让大模型根据上下文回答
    const prompt = `你是一个专业的《天龙八部》小说助手。
    基于小说回答问题,用准确、详细的语言。
    请根据以下小说片段内容回答问题:
    ${content}
    用户问题:
    ${question}
    
    回答要求:
    1. 如果片段中有相关信息,请结合小说内容给出详细准确的回答。
    2. 可以综合多个片段的内容,提供完整的答案。
    3. 如果片段中没有相关信息,请如实告知用户。
    4. 回答要准确,符合小说的情节和任务设定。
    5. 可以引用原文内容来支持你的回答。
    AI 助手的回答:
    `
    
    const response = await model.invoke(prompt);
    return response.content;
}

我问了一句 "鸠摩智会什么武功?",返回的 top 5 片段里有火焰刀、小无相功、七十二绝技...... 然后大模型综合起来给了一个非常完整的回答。

那一刻我真的觉得,RAG 这玩意儿太香了。

它不需要模型 "记住" 所有知识,你只要把知识放在外面,需要的时候捞出来给它看就行。模型只负责 "理解和组织语言",知识由你来提供。

我掉进去的那些坑

说起来轻松,实际上踩的坑可不少。

坑一:维度对不上

前面提过一嘴,这个真的坑了我半小时。

Milvus 建集合的时候向量维度写的是 1024,结果我 embedding 模型用的是另一个,输出的是 1536 维,插入的时候直接报了个维度不匹配的错。

一开始我还以为是 Milvus 的 bug,翻了半天文档,最后发现是我自己参数写错了。

记住:建表时的 dim 必须和 embedding 模型的输出维度严格一致。

坑二:chunkSize 太大或太小

我最开始图省事,把 chunkSize 设成了 2000。结果搜出来的东西特别 "泛"------ 因为一块太大了,里面什么内容都有,跟谁都能沾点边。

后来改成 200,又太碎了。搜 "六脉神剑",匹配到的片段只有 "神剑" 两个字,上下文全没了。

最后调到 500 + 50 重叠,效果刚刚好。这个值跟你的数据类型有关,不是固定的,得自己试。

坑三:忘记 loadCollection

Milvus 有个概念叫 "加载集合"------ 你建好了集合,建好了索引,但要搜索之前,得先把集合加载到内存里。

我第一次写搜索代码的时候,直接就 search 了,结果报错说集合没加载。我还以为是数据没插进去,查了半天 hasCollection,结果是有的。

后来才发现,得先调用 loadCollection

javascript 复制代码
await client.loadCollection({
    collection_name: 'ebook',
});

而且这个加载可能需要几秒钟,不是瞬时的。如果你的数据量很大,可能要等更久。

坑四:以为 RAG 是万能的

跑通之后我特别兴奋,啥问题都问。

结果问了个 "乔峰和郭靖谁更厉害?"------ 直接翻车了。

为啥?因为《天龙八部》里根本没有郭靖啊!RAG 只能根据你给它的资料回答,资料里没有的,它就答不上来(或者胡说八道)。

还有那种需要跨章节推理的问题,比如 "段誉全书一共使出过几次六脉神剑?"------ 这个得把所有提到六脉神剑的片段都找出来,然后逐一计数。RAG 只能找到相关片段,但 "计数" 这种推理能力,它不一定行。

别神化 RAGRAG 解决的是 "知识注入" 的问题 ------ 让大模型能用上你提供的知识。但它不是万能的,复杂推理、跨文档综合、精确计算这些事,它照样搞不定。

往深了想一层:RAG 到底解决了什么问题?

跑通整个流程之后,我回头想了想,RAG 这玩意儿到底解决了什么本质问题?

我觉得是两个:

第一个是 "时效性"。

大模型的训练数据是截止到某个时间点的。你问它昨天发生了什么,它不知道。但 RAG 可以 ------ 你把最新的文档塞进向量数据库,它就能基于最新的内容回答。

第二个是 "私有化"。

你公司的内部文档、业务数据、客户资料,总不能拿去训练大模型吧?但你可以放在自己的向量数据库里,提问的时候捞出来给模型看。模型看完就忘(不保留),数据还在你手里。

这也是为什么企业级应用里 RAG 这么火 ------ 它让大模型能用你的数据,又不会把你的数据泄露出去。

最后说几句

折腾了三天,从 "RAG 是啥" 到跑通一个完整的电子书问答,我最大的收获不是学会了几个 API,而是对 "大模型怎么跟外部世界交互" 这件事有了体感。

我总结了三个最关键的点:

第一,向量不是玄学,就是 "语义的数字化"。 别被那些高大上的名词吓到了,本质上就是把文字变成数字串,意思像的数字串就离得近。就这么简单。

第二,RAG 的核心是 "检索",不是 "生成"。 生成那一步大模型已经做得很好了,真正决定效果的是你能不能把最相关的那几段找出来。检索质量不行,再厉害的模型也答不对。

第三,chunk 切分是个手艺活。 多大的块、多少重叠、用什么分隔符,没有标准答案,得根据你的数据特点去调。这一步往往是 RAG 效果好坏的关键。

当然,RAG 也不是银弹。它适合那种 "答案就在文档里,你帮我找出来" 的问题。如果问题需要深度推理、跨文档综合、或者答案根本不在你的知识库⾥,那 RAG 也帮不上太大忙。

好了,就写到这。如果你也在学 RAG,或者跑的时候遇到了什么坑,欢迎在评论区聊聊。我也想看看你是怎么理解这玩意儿的 ------ 毕竟,每个人踩过的坑不一样,说不定你的经验能帮我少走点弯路呢。

相关推荐
蜡台2 小时前
AI Agent(智能体)入门基础教程
人工智能
Akir.weiwen3 小时前
Token 层差异:从颜色值到语义状态的三层跃迁
人工智能·设计规范
迷迭香yy3 小时前
集合竞价数据挖掘实战:用Python构建开盘信号识别系统
人工智能·python·数据挖掘
大模型momo3 小时前
Spring AI 实战:多 Agent 协作实战 —— 分工拆解复杂旅游行程任务
人工智能·spring·ai·agent·旅游
小程故事多_804 小时前
从A2C、TRPO、PPO到GRPO,强化学习策略梯度算法完整演进与大模型落地实战解析
人工智能·算法
冬奇Lab4 小时前
开源项目第176期:Better Harness — 不审查 diff,审查工作流本身,给 AI 编程 Agent 的五维评估框架
人工智能·开源·agent
冬奇Lab4 小时前
代码库知识库系列(07):混合检索 BM25 + 向量——Q8 还是失败,而且总分退步了
人工智能
ajassi20004 小时前
AI语音智能体开发日记(十一)为智能设备“声”临其境——详解音频资源自动化生成流程
人工智能·ai·ai编程
2601_949499944 小时前
400G组网低功耗优选!芯瑞科技400G VR4 QSFP112光模块赋能智算中心高速互联
大数据·人工智能·科技