🔥 从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错
摘要:本文从零开始,用 Node.js + LangChain 手写一个完整的 RAG 流程。从网页爬取、文档切分,到向量化存储和检索生成,每一步都掰碎了讲清楚。
📌 前言
最近在搞 RAG(检索增强生成),发现一个扎心的事实:
知识库的质量,80% 取决于你怎么"切"文档。
你把一篇 5000 字的文章一股脑塞给向量数据库,检索出来的结果要么太泛、要么太碎,大模型拿到的上下文就是一坨浆糊。
但如果切得好------每个 chunk 都是一个完整的语义单元,检索精度直接起飞。
今天就带大家从零开始,用 Node.js + LangChain 手写一个完整的 RAG 流程,从网页爬取到最终生成回答,每一步都掰碎了讲。
🎯 本文适合谁
- 想搞 RAG 但不知道完整流程长什么样的同学
- 用过 LangChain 但不理解 Splitter / Embedding 底层原理的同学
- 想从零手写一个 RAG 知识库的 Node.js 开发者
- 面试被问"RAG 的 chunk 怎么切"答不上来的同学
📚 先搞懂一个问题:为什么要切?
知识库的本质
知识库里放的是"知识",但知识的形态五花八门:
| 形态 | 例子 |
|---|---|
| 文档 | Word、PDF、Markdown |
| 网页 | 掘金文章、博客、Wiki |
| 视频 | Bilibili 视频字幕 |
| 社交 | Twitter、微博 |
这些形态各异的东西,最终都要变成一个统一的格式------Document:
javascript
{
pageContent: "这里是文档的实际内容...",
metadata: {
source: "https://juejin.cn/post/xxx",
// 其他元信息
}
}
这是 LangChain 定义的标准文档格式。pageContent 是文本内容,metadata 是来源等元信息。
为什么不直接塞进去?
你可能会想:直接把整个 Document 丢给 Embedding 模型转成向量不就行了?
不行。原因有三:
- 太大:Embedding 模型有 token 上限(通常 512~8192),一篇长文根本塞不下
- 太杂:一篇 5000 字的文章涵盖多个主题,转成一个向量后语义模糊,检索时什么都匹配不精准
- 太贵:检索时要算相似度,chunk 越大计算越慢,噪音越多
所以,切是必须切的。问题是怎么切。
🔄 RAG 完整流程
先看全貌,再逐个击破:
css
原始知识(URL / PDF / Word ...)
│
▼
┌──────────┐
│ Loader │ ← 把各种格式统一成 Document
└────┬─────┘
│ Document(pageContent + metadata)
▼
┌──────────┐
│ Splitter │ ← 把大 Document 切成小 chunk
└────┬─────┘
│ [chunk1, chunk2, chunk3, ...]
▼
┌──────────────┐
│ Embedding │ ← 每个 chunk 转成向量
└──────┬───────┘
│ [vector1, vector2, vector3, ...]
▼
┌──────────────┐
│ Vector Store │ ← 存入向量数据库
└──────┬───────┘
│
▼
┌──────────────┐
│ Retriever │ ← 用户问题 → 向量 → 相似度检索 Top-K
└──────┬───────┘
│
▼
┌──────────────┐
│ Generator │ ← 检索结果 + 问题 → LLM 生成回答
└──────────────┘
今天带大家走完这 6 步,其中 Splitter(分词器)是承上启下的核心------切得好,检索就准;切得烂,再强的 Embedding 也救不回来。
🕷️ 第一步:爬虫------把网页变成纯文本
知识库的源头是一个网页 URL。要处理它,第一步是把网页内容抓下来。
手写爬虫:axios + cheerio
javascript
import axios from 'axios';
import * as cheerio from 'cheerio';
const targetUrl = "https://juejin.cn/post/7660707431753678854";
async function crawlPage() {
// 1. 发 HTTP 请求,拿到 HTML 字符串
const { data: html } = await axios.get(targetUrl);
// 2. 把 HTML 字符串解析成内存中的 DOM 树
const $ = cheerio.load(html);
// 3. 用 CSS 选择器精准提取目标内容
const pageContent = $('.main-area p').text();
console.log(pageContent);
}
crawlPage();
cheerio 的核心思路
cheerio 做的事情,用一句话概括:
HTML 字符串 → 内存中的 DOM 树 → CSS 选择器遍历 → 返回目标节点
javascript
"html 字符串"
│
▼ cheerio.load()
┌─────────┐
│ DOM 树 │ ← 在内存中虚拟化一个树结构
└────┬────┘
│ $('.main-area p') ← CSS 选择器入参
▼
┌─────────┐
│ 树遍历 │ ← 按选择器路径遍历
└────┬────┘
│
▼
"目标文本" ← 返回匹配节点的内容
为什么用 cheerio 而不是正则?因为前端思维更高效。正则匹配 HTML 是噩梦,而 cheerio 让你像写 jQuery 一样精准提取内容。
📦 第二步:Loader------统一文档格式
手写爬虫能用,但 LangChain 提供了更优雅的方案:Loader。
javascript
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
const cheerioLoader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7660707431753678854",
{
selector: '.main-area p' // CSS 选择器,缩小提取范围
}
);
const documents = await cheerioLoader.load();
// 输出:Document[] ------ 标准格式的文档数组
Loader 做了什么?
| 输入 | 处理 | 输出 |
|---|---|---|
| URL + CSS 选择器 | axios 请求 → cheerio 解析 → 提取文本 | Document { pageContent, metadata } |
Loader 的核心价值是标准化。不管你喂给它的是 URL、PDF 还是 Word,出来的都是统一的 Document 格式,下游的 Splitter 不用关心数据来源。
LangChain 社区有各种 Loader:
| Loader | 来源 |
|---|---|
CheerioWebBaseLoader |
网页 |
PDFLoader |
PDF 文件 |
TextLoader |
纯文本 |
CSVLoader |
CSV 表格 |
DirectoryLoader |
整个文件夹 |
✂️ 第三步:Splitter------核心中的核心
终于到了重头戏。Splitter 的任务是:把一个大 Document 切成多个小 chunk,每个 chunk 都有完整的语义。
为什么不能随便切?
假设有一段话:
"张三今天去了北京。他参观了故宫。故宫是明清两代的皇家宫殿。"
如果你按固定字数切(比如每 10 个字一刀):
vbnet
chunk1: "张三今天去了北京。他参"
chunk2: "观了故宫。故宫是明清两"
chunk3: "代的皇家宫殿。"
完蛋。"他参"被切断了,"两"和"代"分家了,语义全碎了。
正确的切法:按语义边界切
vbnet
chunk1: "张三今天去了北京。"
chunk2: "他参观了故宫。"
chunk3: "故宫是明清两代的皇家宫殿。"
每个 chunk 都是一个完整的句子,语义独立且自洽。
这就是 RecursiveCharacterTextSplitter 的核心思想:优先在语义边界切分,而不是机械地按字数切。
🧠 RecursiveCharacterTextSplitter 深度解析
先看完整代码:
javascript
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个 chunk 最大 400 字符
separators: ["。", "!", "?"], // 按中文句号、感叹号、问号切分
chunkOverlap: 100, // 相邻 chunk 重叠 100 字符
});
const splitDocuments = await textSplitter.splitDocuments(documents);
三个参数,每一个都有深意。
参数一:chunkSize------每块多大?
javascript
chunkSize: 400 // 每个 chunk 最多 400 个字符
为什么是 400?
这是一个经验值,需要权衡:
| chunkSize | 优点 | 缺点 |
|---|---|---|
| 太小(< 100) | 检索精度高 | 语义不完整,上下文缺失 |
| 太大(> 1000) | 上下文丰富 | 检索精度低,噪音多 |
| 适中(200~500) | 平衡精度和语义 | 需要根据内容调整 |
经验值:
- 中文内容:300~500 字符
- 英文内容:500~1000 字符
- 代码:1000~2000 字符
参数二:separators------在哪里切?
javascript
separators: ["。", "!", "?"] // 句号、感叹号、问号
这是最关键的参数。 它定义了"语义边界"的优先级。
为什么不按逗号切?
逗号分割的是句子内部的成分,不是完整的语义单元:
arduino
❌ 按逗号切:
"今天天气很好,适合出门,但要带伞"
→ chunk1: "今天天气很好"
→ chunk2: "适合出门" ← 缺少上下文,语义不完整
✅ 按句号切:
"今天天气很好,适合出门,但要带伞。明天可能下雨。"
→ chunk1: "今天天气很好,适合出门,但要带伞。"
→ chunk2: "明天可能下雨。" ← 语义完整
separators 的递归逻辑
RecursiveCharacterTextSplitter 的"递归"体现在:如果按第一个分隔符切完还是太大,就用下一个分隔符继续切。
假设 chunkSize: 400,separators 是 ["。", "!", "?"]:
makefile
原始文本(1200字符):
"第一段话...(300字)...。第二段话...(500字)...。第三段话...(400字)...。"
第1轮:按 "。" 切
→ chunk1: "第一段话...(300字)...。" ✅ < 400,保留
→ chunk2: "第二段话...(500字)...。" ❌ > 400,太大了
→ chunk3: "第三段话...(400字)...。" ✅ = 400,保留
第2轮:对 chunk2 按 "。" 再切(如果 chunk 内还有句号)
→ 如果没有句号了,就按下一个分隔符 "!" 切
→ 如果还是太大,按 "?" 切
→ 如果所有分隔符都试过了还是太大,硬切到 chunkSize
用代码模拟这个逻辑:
javascript
// ⚠️ 以下是简化版伪代码,帮助理解核心思想
// 实际 LangChain 实现还会:
// 1. 保留分隔符(split 后会 join 回去,不会丢失分隔符)
// 2. 合并相邻小 chunk 以接近 chunkSize,减少碎片
// 3. 处理 chunkOverlap 的重叠窗口滑动逻辑
function recursiveSplit(text, separators) {
if (text.length <= chunkSize) return [text];
// 尝试用第一个分隔符切
const [firstSep, ...restSeps] = separators;
const chunks = text.split(firstSep);
if (chunks.length > 1) {
// 切成功了,递归处理每个 chunk,然后展平
return chunks
.map(chunk => {
if (chunk.length <= chunkSize) return [chunk];
// 还是太大,用剩余分隔符递归切
if (restSeps.length > 0) return recursiveSplit(chunk, restSeps);
// 没有分隔符了,硬切------注意:实际实现会把剩余部分继续切分,不会丢弃
const parts = [];
for (let i = 0; i < chunk.length; i += chunkSize) {
parts.push(chunk.slice(i, i + chunkSize));
}
return parts;
})
.flat();
}
// 第一个分隔符切不动,换下一个
if (restSeps.length > 0) return recursiveSplit(text, restSeps);
// 所有分隔符都试过了,硬切(同样不会丢弃剩余部分)
const parts = [];
for (let i = 0; i < text.length; i += chunkSize) {
parts.push(text.slice(i, i + chunkSize));
}
return parts;
}
分隔符怎么选?
| 场景 | 推荐 separators |
|---|---|
| 中文文章 | ["。", "!", "?", ";", ","] |
| 英文文章 | ["\n\n", "\n", ". ", " ", ""] |
| 代码 | ["\n\n", "\n", " ", ""] |
| Markdown | ["\n\n", "\n", ""] |
原则:从大到小,从强到弱。先切段落,再切句子,再切词,最后硬切。
其他 Splitter 类型
LangChain 还提供了其他 Splitter,适用不同场景:
| Splitter | 切分方式 | 适用场景 |
|---|---|---|
RecursiveCharacterTextSplitter |
按分隔符递归切 | 通用首选,本文重点讲的 |
CharacterTextSplitter |
按单一字符切 | 简单场景,不需要递归 |
TokenTextSplitter |
按 token 数切 | 需要精确控制 token 量 |
MarkdownTextSplitter |
按 Markdown 标题/段落切 | Markdown 文档,保留结构 |
选型建议 :不确定用哪个?选 RecursiveCharacterTextSplitter,它是最通用的。
参数三:chunkOverlap------语义的"安全气囊"
javascript
chunkOverlap: 100 // 相邻 chunk 重叠 100 个字符
这是最容易被忽略但最重要的参数。
为什么需要 overlap?
看这个例子:
arduino
原文:
"...故宫始建于永乐四年。它位于北京市中心。它是世界上最大的宫殿群..."
chunk1(无 overlap): "...故宫始建于永乐四年。"
chunk2(无 overlap): "它位于北京市中心。它是世界上最大的宫殿群..."
问题:chunk1 里的"它"指的是什么?检索到 chunk1 时,上下文里没有"故宫"!
加上 overlap:
vbnet
chunk1: "...故宫始建于永乐四年。"
chunk2: "故宫始建于永乐四年。它位于北京市中心。它是世界上最大的宫殿群..."
^^^^^^^^^^^^^^^^^^^^^^^^
这 100 个字符就是 overlap------chunk2 的开头重复了 chunk1 的结尾
这样检索到 chunk2 时,也能命中"故宫"相关的语义
overlap 的工作原理
ini
原始文本: [A B C D E F G H I J K L M N O P](16个句子)
chunkSize = 6 个句子
chunkOverlap = 3 个句子
切分结果:
chunk1: [A B C D E F]
chunk2: [D E F G H I] ← 前面重叠了 D E F(3 个)
chunk3: [G H I J K L] ← 前面重叠了 G H I(3 个)
chunk4: [J K L M N O] ← 前面重叠了 J K L(3 个)
chunk5: [M N O P] ← 前面重叠了 M N O(3 个)
每个 chunk 的结尾和下一个 chunk 的开头有重叠,确保切断处的语义不会丢失。
overlap 设多少合适?
| chunkSize | 推荐 overlap | 比例 |
|---|---|---|
| 200 | 40~50 | 20~25% |
| 400 | 80~100 | 20~25% |
| 1000 | 150~200 | 15~20% |
经验法则:overlap 是 chunkSize 的 20%~25%。太小了语义连不上,太大了浪费存储和计算。
💡 设计思想:为什么叫"递归"?
RecursiveCharacterTextSplitter 的"递归"不仅指代码层面的递归调用,更是指分隔符的递归降级------对"切不动"的部分递归调用,而不是对整个文本重新切分:
makefile
第1优先级: "。"(句号) → 能切就切
↓ 切不动
第2优先级: "!"(感叹号)→ 能切就切
↓ 切不动
第3优先级: "?"(问号) → 能切就切
↓ 切不动
兜底方案: 硬切到 chunkSize
这个设计的精妙之处在于:
- 语义优先:永远优先在最强的语义边界切分
- 优雅降级:切不动就换更细的分隔符,不会报错
- 保底机制:所有分隔符都试过了,硬切确保不会无限递归
🐛 踩坑记录
踩坑 1:中文分隔符的选择
一开始我用英文的分隔符 ["\n\n", "\n", ". ", " ", ""] 来切中文文章,结果切出来一堆碎片。
原因 :中文的句号是 。 不是 .,段落分隔也不一定用 \n\n。
解决 :根据内容语言选择分隔符。中文用 ["。", "!", "?", ";", ","]。
踩坑 2:chunkSize 设置太大
一开始设了 chunkSize: 2000,以为越大越好。结果检索时返回的内容太杂,大模型拿到一堆无关信息。
原因:chunk 太大,语义太泛,向量相似度计算时噪音太多。
解决:缩小到 400,检索精度显著提升。
踩坑 3:chunkOverlap 设为 0
以为 overlap 浪费存储,设成了 0。结果检索到的 chunk 经常缺少上下文。
原因:切断处的语义完全丢失,chunk 边界的信息两边都查不到。
解决:设为 chunkSize 的 20%(即 80~100)。
📊 参数调优速查表
| 参数 | 中文文章 | 英文文章 | 代码 | Markdown |
|---|---|---|---|---|
| chunkSize | 300~500 | 500~1000 | 1000~2000 | 500~1000 |
| separators | ["。","!","?",";",","] |
["\n\n","."," ",""] |
["\n\n"," ",""] |
["\n\n"," ",""] |
| chunkOverlap | 20% | 20% | 10~15% | 15~20% |
💡 Splitter 小结
分词器的核心思想,三句话讲完:
- Loader 负责"搬运":把各种格式的知识统一成 Document 标准格式
- Splitter 负责"切割":在语义边界处切分,保持每个 chunk 的完整性
- Overlap 负责"补救":用冗余重叠弥补切分导致的语义断裂
记住这个公式:
好的 chunk = 语义边界切分 + 合适的大小 + 适当的重叠
切得好,大模型才能检索到精准的上下文,回答才能靠谱。
分词器讲完了,但 chunk 只是纯文本,大模型没法高效地"搜索"它们。接下来要做三件事:Embedding (文本变向量)、存储 + 检索 (相似度匹配)、Augmented Generation(喂给 LLM 生成回答)。
🔢 第四步:Embedding------把文本变成向量
文本 → 数学
人能读懂文字,但计算机要算相似度,只能靠数字。Embedding 就是把一段文本映射成一个高维向量(比如 1536 维的浮点数组)。
arduino
"fs 模块有哪些 API"
↓ text-embedding-v2
[0.0123, -0.0456, 0.0789, ..., 0.0321] ← 1536 个浮点数
语义相近的文本,在向量空间里的距离也近。"fs 模块的 API"和"Node.js 文件系统接口"的向量会很接近,而和"今天天气不错"的向量会很远。
配置 Embedding 模型
javascript
import { OpenAIEmbeddings } from '@langchain/openai';
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME, // 比如 text-embedding-v2
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
这里用的是阿里云 DashScope 的 text-embedding-v2 模型,通过 OpenAI 兼容接口调用。你可以换成任何支持 OpenAI 协议的 Embedding 服务。
.env 配置:
bash
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDINGS_MODEL_NAME=text-embedding-v2
MODEL_NAME=qwen-plus
Embedding 模型怎么选?
| 模型 | 维度 | 特点 |
|---|---|---|
text-embedding-v2(阿里云) |
1536 | 中文效果好,性价比高 |
text-embedding-3-small(OpenAI) |
1536 | 英文为主,通用性强 |
text-embedding-3-large(OpenAI) |
3072 | 精度最高,成本也最高 |
选型原则:中文场景优先用国产模型(阿里、智谱),英文场景用 OpenAI。
🗄️ 第五步:向量数据库------存储和检索
MemoryVectorStore
向量数据库有很多种(Pinecone、Weaviate、Milvus、Chroma...),这里用最简单的内存向量库做演示:
javascript
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments, // 切好的 chunk
embeddings // Embedding 模型
);
console.log("向量存储完成");
fromDocuments 做了两件事:
- 调用
embeddings把每个 chunk 的文本转成向量 - 把向量 + 原始文本一起存进内存
css
chunk: "fs 模块提供了文件系统的操作接口..."
↓ embeddings.embedQuery()
向量: [0.0123, -0.0456, 0.0789, ...]
↓ 存入 MemoryVectorStore
┌──────────────────────────────┐
│ 向量: [0.0123, -0.0456, ...] │
│ 原文: "fs 模块提供了..." │
│ 元数据: { source: "..." } │
└──────────────────────────────┘
相似度检索
javascript
// 方式一:简单检索,返回最相关的 k 个 chunk
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke("fs 模块有哪些 API");
// 方式二:带相似度评分的检索
const scoredResults = await vectorStore.similaritySearchWithScore(
"fs 模块有哪些 API", 3
);
检索过程:
makefile
用户问题: "fs 模块有哪些 API"
↓ Embedding
问题向量: [0.0234, -0.0567, 0.0890, ...]
↓ 计算与所有 chunk 向量的距离
↓ 按距离排序,取 Top-K
返回: [chunk_7, chunk_12, chunk_3] ← 最相关的 3 个 chunk
相似度评分解读
javascript
scoredResults.forEach(([doc, score], i) => {
// score 的含义取决于向量库的距离算法:
// - 余弦距离:score 在 0~2 之间,越小越相似,similarity = 1 - score
// - L2 距离:score >= 0,越小越相似,但可以大于 1
// MemoryVectorStore 默认用 L2 距离,直接看 score 大小即可
console.log(`[文档 ${i + 1}] 距离: ${score.toFixed(4)}(越小越相似)`);
console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
});
注意 :不同的向量库用的距离计算方式不同(余弦相似度、L2 距离、内积),
score的含义也不同。这里直接用原始距离展示,越小越相似。
🤖 第六步:Augmented Generation------RAG 的 A
检索到了相关 chunk,最后一步是把它们"增强"到 LLM 的 prompt 里,让大模型基于这些内容来回答。
拼接上下文
javascript
const context = docs
.map((doc, i) => `[片段${i}]\n${doc.pageContent}`)
.join("\n\n-----\n\n");
const prompt = `
你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容
${context}
问题:fs 模块有哪些 API
你的回答:`;
调用 LLM 生成回答
javascript
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME, // 比如 qwen-plus
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const response = await model.invoke(prompt);
console.log(response.content);
为什么 temperature 设为 0? RAG 场景下我们希望大模型忠实于检索到的内容,而不是自由发挥。temperature 越低,回答越确定、越接近原文。
🎬 完整代码一览
安装依赖
bash
npm install axios cheerio @langchain/community @langchain/textsplitters @langchain/openai @langchain/classic dotenv
完整代码
javascript
import 'dotenv/config';
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
// 1. 加载网页
const loader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7660707431753678854",
{ selector: '.main-area p' }
);
const documents = await loader.load();
// 2. 切分文档
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
separators: ["。", "!", "?"],
chunkOverlap: 100,
});
const chunks = await splitter.splitDocuments(documents);
// 3. Embedding + 向量存储
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const vectorStore = await MemoryVectorStore.fromDocuments(chunks, embeddings);
// 4. 检索
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke("fs 模块有哪些 API");
// 5. 生成
const context = docs
.map((doc, i) => `[片段${i}]\n${doc.pageContent}`)
.join("\n\n-----\n\n");
const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const response = await model.invoke(`
你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容
${context}
问题:fs 模块有哪些 API
你的回答:`);
console.log(response.content);
运行效果示例
markdown
文档分割完成,共 12 个 chunk
创建向量存储
向量存储完成
================================================================================
fs 模块有哪些api
================================================================================
[文档 1] 距离: 0.3821(越小越相似)
内容: Node.js 的 fs 模块提供了文件系统操作的 API,包括读取文件 readFile、写入文件 writeFile...
[文档 2] 距离: 0.5234(越小越相似)
内容: 常用的 fs 方法还有:fs.existsSync 用于同步检查文件是否存在,fs.mkdir 用于创建目录...
[文档 3] 距离: 0.6712(越小越相似)
内容: 除了基本的文件操作,fs 模块还支持流式操作,比如 createReadStream 和 createWriteStream...
根据文章内容,fs 模块的主要 API 包括:
1. readFile / writeFile ------ 读写文件
2. existsSync ------ 检查文件是否存在
3. mkdir ------ 创建目录
4. createReadStream / createWriteStream ------ 流式读写
📊 完整总结
| 步骤 | 做什么 | 关键概念 |
|---|---|---|
| Loader | 从网页提取文本 → Document | Cheerio CSS 选择器 |
| Splitter | 把 Document 切成 chunk | 递归分隔符、chunkSize、overlap |
| Embedding | 把 chunk 文本转成向量 | 语义相似 → 向量距离近 |
| Vector Store | 存储向量,支持相似度检索 | 内存库 / 生产级数据库 |
| Retriever | 用户问题 → 向量 → Top-K | 余弦相似度、L2 距离 |
| Generator | context + question → LLM 回答 | RAG 的 A(Augmented) |
RAG 的本质就是:让大模型在回答问题时,先从知识库里找到最相关的段落作为参考,而不是靠自己的"记忆"瞎编。
分词器决定了检索的粒度------切得好,检索就准;切得烂,再强的 Embedding 也救不回来。
🔗 参考资料
- LangChain 文档 - Text Splitters
- LangChain 文档 - Document Loaders
- LangChain 文档 - Vector Stores
- LangChain 文档 - Embedding Models
- RecursiveCharacterTextSplitter 源码
💬 交流讨论
你在做 RAG 的时候遇到过什么问题?chunkSize 和 overlap 是怎么调的?Embedding 模型选了哪个?欢迎在评论区分享你的经验!
觉得有用?点个赞👍收藏⭐关注👆,下一篇讲 RAG 的进阶优化------Rerank 重排序、混合检索、以及生产级向量数据库选型!