🚗 把小说装进数据库了:我的第一个 RAG,和它的五个硬伤

38 阅读11分钟

写在前面:readme 开篇有一句话,道出了 RAG 存在的根本理由——"公司内部的 Agent 基本都要用到 RAG。llm 能思考,但不知道公司内部的文档,我们需要基于内部文档来回答。" 一句话说透了:大模型很聪明,但它对你的公司、你的项目、你的私有资料一无所知。今天这节是从零搭一个 RAG——把一整本《天龙八部》的 EPUB 塞进向量数据库,然后让它回答关于小说的问题。但更有价值的是后半段:readme 毫不留情地列了朴素 RAG 的五个硬伤。搭起来只是第一步,知道自己搭的东西哪里不行,才是进步的开始。以下所有代码均来自课堂真实文件。


一、为什么要 RAG:从闭卷到开卷

先打个比方。

不用 RAG 的 LLM,像一场闭卷考试。 它只能靠训练时"背"下来的知识答题——记得住就答,记不住就……编。这就是幻觉的来源:模型不会说"我不知道",它会顺着语言概率往下编一个听起来合理的答案。

用了 RAG 的 LLM,像一场开卷考试。 你先把参考书放在它手边,它答题前先翻书,找到相关段落再组织答案。

readme 那句"llm 能思考,但不知道公司内部的文档",说的就是这个——思考能力它有了,缺的是资料。

RAG(Retrieval-Augmented Generation,检索增强生成)就是给模型配一套"翻书系统":

用户提问
  ↓
【检索】从知识库里找出相关片段
  ↓
【增强】把片段拼进 prompt
  ↓
【生成】LLM 基于资料回答

三个字母,三个动作。听起来简单——但今天你会看到,"检索"这一步有多少坑。


二、第一步:把 EPUB 拆成能检索的碎片

ebook-writter.mjs 这个文件名很直白——"电子书写入器"。它干的事就是把一本书加工成向量数据库能存的形态。

加载 EPUB:按章拆

import { parse } from 'node:path';   // 区别于其他模块
import { EPubLoader } from "@langchain/community/document_loaders/fs/epub";  // 加载 EPUB 文件
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";    // 文本拆分器

const EPUB_FILE = './天龙八部.epub';   // 每次换本书,只改这一行
const CHUNK_SIZE = 500;                // 拆分到 500 个字符

// 从文件名提取书名(去掉扩展名)
const BOOK_NAME = parse(EPUB_FILE).name;

两个细节值得说:

第一个是 import { parse } from 'node:path' 前面的注释:

// node内置模块,npm安装的第三方模块,开发者编写的模块
import { parse } from 'node:path'; // 区别于其他模块

Node.js 生态里模块有三种来源——内置模块、第三方包、自己写的文件。node:path 这个 node: 前缀是显式声明"这是内置模块"。好处很实在:万一你 npm install 了一个也叫 path 的第三方包,不加前缀可能会加载错——加了 node: 就永远指向内置的那个。

第二个是 parse(EPUB_FILE).name 的巧思:

// 从文件名提取书名(去掉扩展名)
const BOOK_NAME = parse(EPUB_FILE).name;  // 返回文件对象信息

parse('./天龙八部.epub') 返回 { root: '', dir: '.', base: '天龙八部.epub', ext: '.epub', name: '天龙八部' }。取 .name 就得到了"天龙八部"。

为什么不直接写 const BOOK_NAME = '天龙八部'? 因为这样就形成了单一数据源——换书的时候只改 EPUB_FILE 一行,书名自动跟着变。 手写死书名,早晚会出现"文件名换了、书名忘改"的尴尬。

加载:

const loader = new EPubLoader(
  EPUB_FILE,
  {
    splitChapters: true,   // 按章节拆分
  }
);

const documents = await loader.load();
console.log(`✓ 加载完成,共 ${documents.length} 个章节\n`);

splitChapters: true 让加载器按章节切割——一本小说几百章,自动分好。

二次拆分:500 字一刀

章节拿到了,但一章可能有几千字,还是太长。所以要做二次拆分:

// 创建文本拆分器,拆分到 500 个字符
// 没指定 separators 默认为 ["\n\n", "\n", " ", ""]
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE,        // 500 字符
  chunkOverlap: 50,             // 重叠 50 个字符,保持上下文连贯性
});

RecursiveCharacterTextSplitter(递归字符拆分器)是 LangChain 最常用的拆分器。它的"递归"体现在按优先级尝试切分点——注释写得清清楚楚:

// 没指定 separators 默认为 ["\n\n", "\n", " ", ""]

拆分的顺序是:

先按 "\n\n"(段落)切 → 还是太长?
  再按 "\n"(换行)切 → 还是太长?
    再按 " "(空格)切 → 还是太长?
      最后按 ""(逐字符)切

为什么这个顺序? 因为切分点越"自然",语义破坏越小。段落是天然的意义单元,字是最后的无奈之选——优先在段落边界切,实在不行才切字。

chunkOverlap: 50 也是个关键设计——相邻两个块重叠 50 个字符。 为什么?

假设一刀切下去正好切在一句话中间:

块 1:...虚竹在少林寺中长大,他的父亲
块 2:是玄慈方丈,这件事他一直不知道...

如果检索命中块 2,"他的父亲是谁"这个信息就在块 1 里丢了一半。重叠 50 字就是为了让被切断的上下文,在至少一个块里保持完整。

建集合:Milvus 里的"书架"

文本切好了,接下来在 Milvus 里建容器——集合(Collection):

const COLLECTION_NAME = 'ebook_collection';
const VECTOR_DIM = 1024;

const hasCollection = await client.hasCollection({
  collection_name: COLLECTION_NAME
});

if (!hasCollection.value) {
  console.log('创建集合...');
  await client.createCollection({
    collection_name: COLLECTION_NAME,
    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: VECTOR_DIM }
    ]
  });
}

七个字段,各有分工:

字段类型作用
idVarChar(主键)唯一标识
book_idVarChar哪本书(支持多本书共存)
book_nameVarChar书名
chapter_numInt32第几章(用来定位和引用原文)
indexInt32章节内的第几块
contentVarChar原文内容
vectorFloatVector(1024)语义向量

注意 book_id 和 book_name 是分开存的——一个存 ID(用来筛数据),一个存名字(用来显示)。 如果存了《天龙八部》和《射雕英雄传》两本书,检索时可以按 book_id 过滤,只在这一本里搜。

chapter_num 这个字段在后面的应用里特别有用——回答问题时可以标注"这段话出自第 23 章",让用户能自己核对。

建索引:给向量空间装个"目录"

await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  // IVF_FLAT:把向量聚类分成很多小桶,检索时只扫最相关的几个桶,速度快
  // IVF_FLAT 先分桶,只在目标桶里暴力比对
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE,
  params: { nlist: 1024 }
});

注释把 IVF_FLAT 讲得很到位:

"把向量聚类分成很多小桶,检索时只扫最相关的几个桶,速度快"

IVF = Inverted File(倒排文件),思路是"先粗筛再精比":

1024 维空间里,把向量按距离聚成 nlist=1024 个"桶"
    ↓
搜索时,先找到离查询向量最近的几个桶
    ↓
只在这些桶里逐个精确比对

nlist: 1024 就是桶的数量。桶越多定位越准,但每个桶里的数据少;桶越少定位越粗,但每个桶比对的量大。 这是个需要根据数据量调的参数。

MetricType.COSINE 是余弦相似度——判断两个向量方向是否一致,前面学过,不重复。

插入:边处理边写

最值得学的是插入的组织方式:

// 遍历每个章节,进行二次拆分并立即插入
for (let chapterIndex = 0; chapterIndex < documents.length; chapterIndex++) {
  const chapter = documents[chapterIndex];
  const chapterContent = chapter.pageContent;
  
  console.log(`处理第 ${chapterIndex + 1}/${documents.length} 章...`);
  
  // 使用 splitter 进行二次拆分
  const chunks = await textSplitter.splitText(chapterContent);
  
  if (chunks.length === 0) {
    console.log(`  跳过空章节\n`);
    continue;
  }

  // 立即生成向量并插入该章节的所有片段
  const insertedCount = await insertChunksBatch(chunks, bookId, chapterIndex + 1);
  totalInserted += insertedCount;
}

注意这个结构——按章节循环,处理完一章立刻插入,而不是"全部处理完再统一插入"。

这样做的好处是:

策略优点缺点
全部处理完再插入逻辑简单内存里要存整本书的所有块;中途失败全白干
逐章处理逐章插入内存占用小;中途失败已完成的保留逻辑稍复杂

函数名 loadAndProcessEPubStreaming 里的 "Streaming" 就是这个意思——流式处理。

ID 的生成:一行看出数据关系

// 手动生成 ID:book_id_chapterNum_index
return {
  id: `${bookId}_${chapterNum}_${chunkIndex}`,
  book_id: bookId,
  book_name: BOOK_NAME,
  chapter_num: chapterNum,
  index: chunkIndex,
  content: chunk,
  vector: vector
};

id 的格式是 1_23_5 这样的——书 ID_章节号_块序号。

为什么不随便用个 UUID?因为这种可读 ID 有三个好处:

  1. 定位方便——看到 1_23_5 立刻知道"第 1 本书第 23 章第 5 块"
  2. 天然去重——同一位置重复插入会覆盖而不是产生重复
  3. 调试友好——排查问题时一眼能看出数据来源

这是一种很朴素的工程直觉:让 ID 自己讲故事。

向量生成用了 Promise.all 并行:

const insertData = await Promise.all(
  chunks.map(async (chunk, chunkIndex) => {
    const vector = await getEmbedding(chunk);
    return { ... };
  })
);

调 embedding API 是网络请求,一个个等太慢——Promise.all 让同一章的多个块并发请求,快得多。


三、第二步:最朴素的 RAG

书入库了,该问问题了。naive-rag.mjs 这个文件名很坦率——naive,朴素的、天真的。它就是 RAG 的最小实现。

两个节点,一条直线

const GrapgState = Annotation.Root({
    question: Annotation,      // 问题
    k: Annotation,             // 检索数量
    documents: Annotation,     // 检索到的文档
    generation: Annotation,    // 生成的内容
})

const graph = new StateGraph(GrapgState)
    .addNode("retrieve", retrieveNode)
    .addNode("generate", generateNode)
    .addEdge(START, "retrieve")
    .addEdge("retrieve", "generate")
    .addEdge("generate", END)
    .compile();

四个状态字段,两个节点,一条直线:

START → retrieve(检索) → generate(生成) → END

这就是最朴素的 RAG——把"检索"和"生成"串成一条流水线。 LangGraph 前面学过,这里用得很基础。

节点一:检索

const retrieveNode = async (state) => {
    const documents = await retrieveRelevantContent(state.question, state.k);
    return {
        question: state.question,
        k: state.k,
        documents
    };
}

retrieveRelevantContent 里:

const docsWithScores = await vectorStore.similaritySearchWithScore(question, k);
return docsWithScores.map(([doc, score]) => ({
    score,
    content: doc.pageContent,
    id: doc.metadata?.id ?? "unknown",
    book_id: doc.metadata?.book_id ?? "未知",
    chapter_num: doc.metadata?.chapter_num ?? "未知",
    index: doc.metadata?.index ?? "未知",
}));

similaritySearchWithScore 返回 [文档, 分数] 的数组——不仅拿到文档,还拿到相似度分数。 这很重要:分数是后面做筛选、排序的依据。

注意那个 ?? "未知"——空值合并运算符。 doc.metadata?.id 如果取不到(undefined 或 null),就用 "未知" 兜底。配合前面的可选链 ?.,两层防护,防止元数据缺失导致整个对象结构崩掉。

节点二:生成

const generateNode = async (state) => {
    const context = state.documents
        .map((item, i) => `[片段${i+1}]
        章节:第${item.chapter_num}章
        内容:${item.content}
        `).join("\n\n-----------\n\n");

把检索到的文档拼成上下文——每个片段标注来源章节,用分隔线隔开。

然后是 prompt:

const prompt = `
你是一个专业的《天龙八部》小说助手。基于小说内容回答问题,用准确,详细的语言。
请根据以下天龙八部小说片段内容回答问题:
${context}
用户问题:${state.question}

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

这五条"回答要求"是精心设计的防幻觉约束,值得逐条看:

要求目的
1. 有相关信息就详细准确回答正向引导
2. 综合多个片段鼓励跨块推理
3. 没有相关信息就如实告知最关键的防幻觉条款
4. 符合小说情节和人物设定防止瞎编人物关系
5. 引用原文支撑让答案可核对

第 3 条和第 5 条尤其重要——允许模型说"不知道",并要求它给出出处。 这两条是所有 RAG 系统 prompt 的标配。

流式输出前面学过:

process.stdout.write("\n[AI回答(流式)]\n");
let generation = "";
const stream = await model.stream(prompt);
for await (const chunk of stream) {
    const text = typeof chunk.content === "string" ? chunk.content : "";
    if(!text) continue;
    generation += text;
    process.stdout.write(text);
}

用 process.stdout.write 而不是 console.log——不换行,让文字连续涌现。

连接 Milvus:那些索引参数

vectorStore = await Milvus.fromExistingCollection(embeddings,{
    collectionName: COLLECTION_NAME,
    url:"localhost:19530",
    textField:"content",        // 哪个字段是文本
    primaryField:"id",          // 哪个字段是主键
    vectorField:"vector",       // 哪个字段是向量
    indexCreateOptions:{
        metric_type:"COSINE",
        // 多层近邻索图向量索引
        // IVF_FLAT 桶
        index_type:"HNSW",
        param:{ M:16, efConstruction:200 },
        search_param:{ ef: 64 }
    }
});

fromExistingCollection 的意思是——集合已经建好了(上一个脚本建的),我直接连上去用。 四个字段映射告诉 LangChain:"文本在 content 字段、主键是 id、向量在 vector 字段"。

这里出现了另一种索引类型:HNSW,跟写入脚本里的 IVF_FLAT 不同。注释写得很简洁:

// 多层近邻索图向量索引
// IVF_FLAT 桶

两个索引对比一下:

索引原理特点
IVF_FLAT聚类分桶,桶内暴力比对建索引快,搜索需调 nlist/nprobe
HNSW构建多层近邻图,逐层向下查找搜索快、精度高,但占内存

M:16、efConstruction:200、ef:64 是 HNSW 的三个关键参数:

参数含义类比
M每个节点的邻居数每个人认识多少人
efConstruction建图时的搜索广度建关系网时考察多深
ef查询时的搜索广度找路时考察多少个候选

都是"越大越准越慢"的旋钮。


四、跑起来:阿朱的结局

const question = "阿朱的结局是什么?";
const kArg = 5;

结果输出:

result.documents.forEach((item, i) => {
    console.log(`\n【片段${i+1} 相似度:】${item.score.toFixed(4)}`);
    console.log(`书籍:${item.book_id}`);
    console.log(`章节:第${item.chapter_num}章`);
    console.log(`内容:${item.content.substring(0, 200)}...`);
});

每个片段都标出相似度分数、来源、章节——这是一份可核查的"检索报告"。用户能看见"AI 是基于哪几段话回答的"。

score.toFixed(4) 保留四位小数——分数展示保留精度,避免 0.8234567891... 这种噪音。


五、五个硬伤:朴素 RAG 的天花板

搭完了,能跑了。但 readme 立刻泼了盆冷水:

"这个流程太固定了,有些缺点。"

然后列了五条。这五条是今天最重要的内容——它们不只是"缺点清单",而是后面所有升级的路线图。

硬伤一:所有问题都走检索,浪费资源

"所有问题都走 RAG 检索?简单问题不需要检索,浪费资源(token 和 检索,增强流程)。1+1=?"

用户问"1+1 等于几",系统会:

  1. 把"1+1=?"向量化(花钱)
  2. 去 Milvus 搜 5 条最相似的片段(花钱)
  3. 把 5 段《天龙八部》原文拼进 prompt(占 token)
  4. 让 LLM 基于这些无关片段回答"1+1"(更贵,还容易被干扰)

readme 直接点出解法:

"两个分支,一个简单的问题,一个复杂的问题。llm 来判断简单?"

——让 LLM 先判断问题类型,简单的直接答,复杂的才检索。

硬伤二:没有纠错和评估机制

"没有纠错和评估机制,无法判断检索内容是否精确,是否足够。llm 评估函数"

检索回来 5 段文字,它们真的相关吗?够回答问题吗?朴素 RAG 不做任何判断——搜到什么就用什么。

极端情况:如果你问的内容书里根本没有,Milvus 也会"尽力"返回 5 条最相似的片段(哪怕相似度只有 0.3)。然后 LLM 拿着这 5 段无关内容,硬着头皮编一个答案。

readme 给的解法是"llm 评估函数"——用 LLM 自己判断"这些资料够不够用"。

硬伤三:处理不了多步检索的复杂问题

readme 举了个绝佳的例子:

"天龙八部中 四大恶人 排行第二的是谁?此人之子在身世揭晓前,其生父在武林中的公开身份是什么?"

拆解一下这个问题的回答链条:

第 1 跳:四大恶人排行第二的是谁?        → 叶二娘
第 2 跳:叶二娘的儿子是谁?              → 虚竹
第 3 跳:虚竹的生父是谁?                → 玄慈
第 4 跳:玄慈在武林中的公开身份是什么?   → 少林寺方丈

这是一个"多跳问题"(Multi-hop QA)——答案不在任何一段话里,而要沿着一条推理链跳好几次。

朴素 RAG 怎么做?它把这一整句话向量化,然后去搜最相似的片段。问题是——这一整句话的向量,混合了"四大恶人""生父""武林身份"好几个语义,跟任何单个原文片段的相似度都不高。 于是搜出来的片段可能只覆盖了链条中间的一环,缺头少尾。

readme 的解法:

"llm 规划能力,拆分,分步骤"

让 LLM 把这个大问题拆成有序的小问题,一个个去检索。

硬伤四:专业术语和精确实体,语义检索容易匹配不准

这条特别实在:

"专业术语、精确实体更适合关键词搜索,纯语义检索容易匹配不准。mysql like 查询 正则 文字匹配。高血糖、低血糖 自然语义相似度 相近。"

"高血糖"和"低血糖"——两个意思完全相反的词,但向量相似度极高。

为什么?因为它们共享"血糖"这个词,出现在相似的语境里,词法结构也几乎一样。Embedding 模型看的是"整体语义场",它很难捕捉到那个关键的"高/低"差异。

这在专业场景里是致命的:

场景风险
医疗高血糖 vs 低血糖,治疗方案完全相反
法律"有期徒刑三年" vs "有期徒刑十年"
代码useState vs useEffect
金融"买入" vs "卖出"

readme 给的解法方向是关键词搜索——用字面匹配来兜住这些"差一个字意思全变"的情况。

硬伤五:本地知识库没有的,它不会去联网,只会编

"本地知识库没有的内容,去网络搜索补充?llm 胡说"

问一个书里没有、也不在模型训练数据里的问题——比如"《天龙八部》2013 版电视剧里雁门关事件出现在第几集"。

朴素 RAG 的回答路径是:

检索 → 没找到相关片段 → 但 prompt 说要"回答问题" → 编一个

它不会说"我去网上查查",因为它压根没有联网这个能力。

readme 的解法:

"网络搜索来兜底。本地知识库没有的内容,不会主动去网络搜索补充,容易编造答案。网络搜索的结果,增强 prompt。混合检索 = 向量数据库 + 网络搜索 + elasticsearch"


六、硬伤的汇总:五个病,五个药

把 readme 的诊断和处方整理成一张表:

#硬伤症状药方
1所有问题都检索1+1 也要搜书路由分流(LLM 判断简单/复杂)
2没有评估机制资料够不够不知道评估节点(LLM 判断充分性)
3处理不了多跳问题复杂问题搜不准问题拆解(LLM 规划分步)
4语义匹配不准高血糖/低血糖混淆关键词检索(ES 倒排索引)
5不会联网兜底没资料就编网络搜索(补充上下文)

readme 对这次升级的总结极具画面感:

"死板的检索生成流程,升级为可思考、可判断、可纠错的智能 RAG 架构。"

三个词——可思考、可判断、可纠错。 这就是 Agentic RAG 的定义。

而 readme 里那句"langgraph 设计一个 graph 表示 Agentic RAG 流程",点明了实现工具——用 LangGraph 的网状编排能力,把上面那五个药方串成一个闭环。


七、朴素 RAG 的代码结构,其实很清晰

最后回头看一下 naive-rag.mjs 这个"朴素"实现。虽然它有五个硬伤,但它的代码结构是值得学习的模板:

GrapgState(状态定义)
├── question    问题
├── k           检索数量
├── documents   检索结果
└── generation  生成内容

retrieveNode(检索节点)
└── similaritySearchWithScore → 带分数的文档列表

generateNode(生成节点)
├── 拼接 context(带章节标注)
├── 五条防幻觉 prompt
└── 流式输出

graph(编排)
└── START → retrieve → generate → END

清晰、可读、可扩展。 后面所有的升级——无论是加路由、加拆解、加评估、加联网——都是在这个骨架上插节点。

这正是"从 naive 开始"的价值:先有一个能跑的简单版本,再逐个补短板。 而不是一开始就想设计一个完美的架构——那样往往连第一版都跑不起来。


PS:这节课最让人踏实的,是 readme 那种"自己拆自己台"的态度——刚搭完一个能跑的 RAG,立刻列出它五个不行的地方。搭得出来是能力,说得清哪里不行是水平。下一篇我们开始打补丁:第一个补丁是"让 RAG 学会分诊"和"学会拆题"。