您的位置:首页 > 手游攻略 > 我把《天龙八部》塞进向量数据库后,终于搞懂了 RAG 到底是个啥

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

作者:互联网  时间: 2026-09-02 12:11:57  

我把《天龙八部》塞进向量数据库后,终于搞懂了 RAG 到底是个啥需要先看清适用场景和关键步骤,避免只记结论却忽略实际限制。

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

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

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

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

哎,这不就是 RAG 吗?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

先跑通最朴素的版本

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

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

第一步:把 EPUB 读进来

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

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

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

第二步:把章节切成小块

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

所以得再切小一点。

import { RecursiveCharacterTextSplitter } from'@langchain/textsplitters';const textSplitter = newRecursiveCharacterTextSplitter({chunkSize: 500,// 每块 500 字chunkOverlap: 50,// 相邻两块重叠 50 字,防止把一句话从中间切断});const chunks = await textSplitter.splitText(chapterContent);

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

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

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

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

第三步:向量化并存入 Milvus

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

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

import { OpenAIEmbeddings } from'@langchain/openai';const embeddings = newOpenAIEmbeddings({apiKey: process.env.DASHSCOPE_API_KEY,model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,configuration: {baseURL: process.env.DASHSCOPE_API_BASE_URL,},dimensions: 1024,});constgetEmbedding = async (text) => {const result = await embeddings.embedQuery(text);return result;}

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

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,结果插入的时候直接报错,查了半天才发现是维度对不上。

然后建索引:

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 就是余弦相似度,衡量两个向量方向有多接近 —— 方向越一致,说明语义越像。

第四步:批量插入

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

asyncfunctioninsertChunksBatch(chunks, bookId, chapterNum) {const insertData = awaitPromise.all(chunks.map(async (chunk, chunkIndex) => {const vector = awaitgetEmbedding(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,});returnNumber(insertResult.insert_cnt) || 0;}

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

来,搜一下试试

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

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

const query = '段誉会什么武功?';const queryVector = awaitgetEmbedding(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,扔给大模型就行了。

asyncfunctionanswerEbookQuestion(question, k = 3) {// 第一步:检索相关内容const retrievedContent = awaitretrieveRelevantContent(question, k);// 第二步:把检索结果拼成上下文const content = retrievedContent.map((item, i) =>`[片段${i + 1}]章节:第${item.chapter_num}章内容:${item.content}`).join('nn---nn');// 第三步:构造 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

await client.loadCollection({collection_name: 'ebook',});

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

坑四:以为 RAG 是万能的

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

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

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

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

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

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

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

我觉得是两个:

第一个是 "时效性"。

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

第二个是 "私有化"。

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

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

最后说几句

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

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

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

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

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

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

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

最新游戏

更多

Copyright©2010-2019. All rights reserved | 波波三国游戏官网|[email protected]

备案编号:湘ICP备2022015115号-4