写在前面: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 }
]
});
}
七个字段,各有分工:
| 字段 | 类型 | 作用 |
|---|---|---|
id | VarChar(主键) | 唯一标识 |
book_id | VarChar | 哪本书(支持多本书共存) |
book_name | VarChar | 书名 |
chapter_num | Int32 | 第几章(用来定位和引用原文) |
index | Int32 | 章节内的第几块 |
content | VarChar | 原文内容 |
vector | FloatVector(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_23_5立刻知道"第 1 本书第 23 章第 5 块" - 天然去重——同一位置重复插入会覆盖而不是产生重复
- 调试友好——排查问题时一眼能看出数据来源
这是一种很朴素的工程直觉:让 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=?"向量化(花钱)
- 去 Milvus 搜 5 条最相似的片段(花钱)
- 把 5 段《天龙八部》原文拼进 prompt(占 token)
- 让 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 学会分诊"和"学会拆题"。