🔥 从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错

🔥 从零搭建 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 模型转成向量不就行了?

不行。原因有三:

  1. 太大:Embedding 模型有 token 上限(通常 512~8192),一篇长文根本塞不下
  2. 太杂:一篇 5000 字的文章涵盖多个主题,转成一个向量后语义模糊,检索时什么都匹配不精准
  3. 太贵:检索时要算相似度,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. 语义优先:永远优先在最强的语义边界切分
  2. 优雅降级:切不动就换更细的分隔符,不会报错
  3. 保底机制:所有分隔符都试过了,硬切确保不会无限递归

🐛 踩坑记录

踩坑 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 小结

分词器的核心思想,三句话讲完:

  1. Loader 负责"搬运":把各种格式的知识统一成 Document 标准格式
  2. Splitter 负责"切割":在语义边界处切分,保持每个 chunk 的完整性
  3. 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 做了两件事:

  1. 调用 embeddings 把每个 chunk 的文本转成向量
  2. 把向量 + 原始文本一起存进内存
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 也救不回来。


🔗 参考资料


💬 交流讨论

你在做 RAG 的时候遇到过什么问题?chunkSize 和 overlap 是怎么调的?Embedding 模型选了哪个?欢迎在评论区分享你的经验!


觉得有用?点个赞👍收藏⭐关注👆,下一篇讲 RAG 的进阶优化------Rerank 重排序、混合检索、以及生产级向量数据库选型!

相关推荐
zhou lily14 小时前
超自动化落地:RPA+AI如何打通业务流程的“最后一公里”?
人工智能·自动化·rpa
tyqtyq2214 小时前
HarmonyOS AI 应用开发实战:简历项目经历改写系统
人工智能·学习·华为·生活·harmonyos
小柯南敲键盘15 小时前
批量图片翻译与视频字幕一站式解决高效跨境电商沟通难题
大数据·人工智能·python·音视频
FreeBuf_15 小时前
仅用六分钟,黑客借助Gemini CLI自主构建并迁移C&C僵尸网络
网络·人工智能
xd18557855515 小时前
表情包配文-基于鸿蒙的AI表情包配文生成应用开发实践
人工智能·华为·harmonyos·鸿蒙
石山代码15 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
对讲机数码科普16 小时前
聚焦全场景适配 打造寒地通信产品矩阵
人工智能
空中湖16 小时前
Spring AI RAG 完整实战:从零搭建企业知识库问答系统
java·人工智能·spring
To_OC16 小时前
再也不用手动整理会议纪要!我给 Claude 封装了一套永久自动化 Skill
人工智能·agent·claude