LangChain.js RAG 实战(上):环境搭建与文档处理

随着大模型技术的普及,RAG(检索增强生成)已成为企业级应用的标配。作为前端开发者,我们可以直接利用熟悉的 JavaScript/TypeScript 技术栈来构建强大的 AI 应用。本文是我学习 LangChain.js 的学习笔记,记录了从零开发 RAG 应用的过程。

目录


什么是 RAG

RAG(Retrieval-Augmented Generation,检索增强生成) 听起来很高大上,其实原理特别简单。

你可以把它想象成开卷考试

  • 传统的 LLM(闭卷考试):模型全靠训练时“背”下来的知识回答。如果问它最新的新闻,或者公司内部的私密数据,它要么说不知道,要么就开始一本正经地胡说八道(幻觉)。
  • RAG(开卷考试):当用户提问时,系统先去翻阅你提供的“教科书”(知识库),找到相关的段落,然后把这些段落连同问题一起扔给 LLM。LLM 看着参考资料回答,自然就准确多了。

技术上的流程是这样的:

  1. 检索:根据用户的问题,从知识库中找到相关的文档
  2. 增强:将这些相关文档作为上下文,与用户的问题一起提供给大语言模型
  3. 生成:大模型基于检索到的上下文生成准确的答案

一个简单的例子

想象你在向一个助手提问:

用户:"Nike 2023 年的收入是多少?"

没有 RAG 的 LLM

可能会回答不知道,或者给出过时/错误的信息(因为模型的训练数据有限)

有 RAG 的 LLM

  1. 先从 Nike 2023 年的财务报告中检索到相关段落
  2. 将这些段落作为上下文提供给模型
  3. 模型基于准确的信息回答:Nike 2023 年的收入是 512 亿美元

为什么需要 RAG

  • 解决幻觉问题:强制模型参考真实文档,减少编造信息
  • 突破知识截止:实时更新知识库,获取最新信息
  • 可验证的来源:提供答案出处,增加可信度
  • 保护隐私安全:基于企业内部文档构建私有知识库

RAG 常见的应用场景

  • 企业知识库问答:员工快速查找公司政策、技术文档、流程规范
  • 智能客服系统:24*7 自动回答客户常见问题
  • 文档分析与理解:快速从长篇报告中提取关键信息

RAG 的核心概念

理解 RAG 需要掌握几个核心概念。

1. 文档向量化(Embedding)

问题:计算机如何理解"苹果手机"和"iPhone"是相似的概念?

解决:将文本转换为数字向量(一组数字),语义相近的文本在向量空间中距离更近。

示例

"苹果手机"  →  [0.23, -0.45, 0.78, ...]
"iPhone"    →  [0.25, -0.43, 0.76, ...]
"香蕉"      →  [-0.67, 0.34, -0.12, ...]

前两个向量的距离很近,表示它们语义相似。

2. 向量相似度

余弦相似度(Cosine Similarity)是最常用的计算方法:

  • 1 = 完全相同
  • 0 = 完全不相关
  • -1 = 完全相反

3. RAG 的基本流程

┌─────────────────────────────────────────────────────────────┐
│                        RAG 完整流程                          │
└─────────────────────────────────────────────────────────────┘

  用户问题


┌─────────────┐
│  向量化查询  │  将问题转换为向量
└─────────────┘


┌─────────────────────────────────────┐
│        向量搜索(检索阶段)          │
│  • 在向量库中找到最相似的文档块      │
│  • 通常返回 top-k 个结果            │
└─────────────────────────────────────┘


┌─────────────────────────────────────┐
│        构建提示词(增强阶段)        │
│  • 将检索到的文档作为上下文          │
│  • 与用户问题组合成完整的 prompt     │
└─────────────────────────────────────┘


┌─────────────────────────────────────┐
│        LLM 生成答案(生成阶段)      │
│  • 基于上下文生成准确的回答          │
│  • 可以引用来源                      │
└─────────────────────────────────────┘


   返回答案

简单来说,就是先把知识切碎(向量化),存起来(索引);等用户问的时候,搜出相关的碎片(检索),拼在一起给 AI 看(增强),最后让 AI 回答(生成)。


技术栈选择

为什么选择 LangChain.js?

说实话,市面上关于 LangChain 的教程 90% 都是 Python 的。那我为什么还要头铁选 JS 版?

  1. 前端友好:作为前端出身,实在不想为了写个 Demo 再去折腾 Python 的虚拟环境、包管理那些东西。能用 TypeScript 搞定全栈,何乐而不为?
  2. 全栈整合:现在的 Next.js / NestJS 应用直接集成 LangChain.js 非常顺滑,不用再单独起一个 Python 服务做中间层。
  3. 类型安全:TypeScript 的类型提示在处理复杂的数据流时真的救命,比 Python 猜类型舒服多了。

虽然 JS 版的文档有时候更新比 Python 版慢半拍,但社区活跃度很高,坑基本都能填上。


环境准备

开始搭建 RAG 应用的开发环境。

1. 前置要求

  • Node.js:建议 v18 或更高版本
  • 包管理器:pnpm、npm 或 yarn
  • 代码编辑器:VS Code

2. 创建项目

# 创建项目目录
mkdir langchain-rag-demo
cd langchain-rag-demo

# 初始化 npm 项目
npm init -y

# 或者使用 pnpm
pnpm init

3. 安装依赖

基础依赖

# 核心包
pnpm add @langchain/core @langchain/community

# 文档加载器(PDF、文本等)
pnpm add pdf-parse@1

# 文本分割器
pnpm add @langchain/textsplitters

# 环境变量管理
pnpm add dotenv

Embedding 服务(本文使用 Ollama)

# Ollama(本地模型,无需 API key)
pnpm add @langchain/ollama

向量存储

# 开发环境:使用内存存储(用于学习,无需额外安装)

4. 配置环境变量

创建 .env 文件(Ollama 无需 API key):

# Ollama(本文使用)
# 无需 API key,确保 Ollama 服务运行即可
# 默认地址:http://localhost:11434

# 阿里云百炼(可选,作为备选方案)
# DASHSCOPE_API_KEY=sk-your-dashscope-key-here

⚠️ 注意:不要将 .env 文件提交到 Git,添加到 .gitignore

echo ".env" >> .gitignore
echo "node_modules" >> .gitignore

5. 配置 TypeScript

直接复制即可:

{
  "compilerOptions": {
    "moduleDetection": "force",
    "module": "nodenext",
    "target": "es2022",
    "moduleResolution": "nodenext",
    "allowJs": true,
    "esModuleInterop": true,
    "isolatedModules": true,
  },
}

添加运行脚本(package.json):

{
  "scripts": {
    "rag": "tsx src/rag/index.ts"
  },
  "type": "module"
}

安装开发依赖:

pnpm add -D typescript tsx @types/node

Document 对象:万物皆可 Document

在 LangChain 的世界里,不管你读的是 PDF、网页、Markdown 还是 Word,最终都会被统一封装成 Document 对象。

理解它的结构非常简单,它就两个核心属性:

interface Document {
  pageContent: string;            // 文本内容
  metadata: Record<string, any>;  // 元数据(来源、页码、作者等)
}

为什么 Metadata 很重要?

很多新手容易忽略 metadata,只关注文本内容。但在实际的 RAG 应用中,元数据是精准检索的关键。

比如,当用户问“Nike 2023 年的营收是多少?”时,如果你在检索时能通过元数据过滤掉 year: 2022 的文档,准确率直接翻倍。

Document 对象示例

虽然我们通常从文件加载文档,但也可以手动创建 Document 对象来理解其结构:

import { Document } from "@langchain/core/documents";

const doc = new Document({
  pageContent: "Nike 是一家全球知名的运动品牌,成立于 1967 年。",
  metadata: {
    source: "nike-info",
    category: "company",
  },
});

console.log(doc.pageContent);
// 输出: Nike 是一家全球知名的运动品牌,成立于 1967 年。

Embedding 服务示例

本文使用 Ollama 本地模型(完全免费、无需 API key)。

什么是向量维度?

向量维度(Vector Dimension)指的是 Embedding 模型生成的数字向量的长度。

简单来说,维度越高,能表达的语义越丰富,但计算和存储成本也越高。本文使用的 nomic-embed-text 模型生成的向量维度是 768

使用 Ollama Embeddings

import { OllamaEmbeddings } from "@langchain/ollama";

const embeddings = new OllamaEmbeddings({
  model: "nomic-embed-text",
  baseUrl: "http://localhost:11434",
});

// 生成向量
const vector = await embeddings.embedQuery("Nike 是一家运动品牌");
console.log(`向量维度: ${vector.length}`);  // 输出:768
console.log(`前 10 个值: ${vector.slice(0, 10)}`);  // 示例:[-0.23, 0.45, ...]

使用 Ollama 前的准备

  1. 安装 Ollama
# macOS
brew install ollama

# Linux
curl -fsSL https://ollama.com/install.sh | sh
  1. 拉取 embedding 模型
# 推荐模型(性能好、体积小)
ollama pull nomic-embed-text
  1. 启动 Ollama 服务
ollama serve
  1. 验证安装
curl http://localhost:11434/api/tags

说明:Ollama 完全免费,数据在本地运行。如果硬件配置较低,可以使用阿里云百炼等云服务。


文档加载器:把数据喂给 LLM

LangChain 提供了极其丰富的加载器(Loaders),几乎支持所有你能想到的数据源。

常用加载器一览

  • PDFLoader: 处理 PDF 文件(本文重点)。
  • TextLoader: 处理 .txt.md 等纯文本。
  • CSVLoader / JSONLoader: 处理结构化数据。
  • CheerioWebBaseLoader: 爬取网页内容。
  • NotionLoader / GitHubLoader: 对接第三方工具。

你可以在 LangChain 官方文档 中找到几百种集成。但在实际开发中,PDF 和 Markdown 是最常见的两种格式。

示例:加载 PDF 文档

我们直接进入实战,使用 Nike 2023 年财务报告(10-K 文件)作为案例。

准备工作

项目中的 PDF 文件路径:

src/rag/data/nke-10k-2023.pdf

如需下载,可以从以下位置获取:

示例 1:基础 PDF 加载

import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const pdfPath = path.join(__dirname, 'data', 'nke-10k-2023.pdf');
const loader = new PDFLoader(pdfPath);

const docs = await loader.load();

文本分割的重要性

为什么需要文本分割?

加载完文档后,下一步是将长文档分割成更小的文本块(chunks)。这是因为:

1. LLM 的上下文窗口限制

  • GPT-3.5:4K tokens(约 3000 个单词)
  • GPT-4:8K-32K tokens
  • Claude 2:100K tokens
  • 本地模型:通常更小

如果文档太长,LLM 无法一次性处理。

2. 提高检索精度

❌ 不分割的情况:
整个 100 页的 PDF → 一个向量
→ 检索时只能返回整篇文档
→ 精确度低

✅ 分割后:
每 500-1000 字符 → 一个向量
→ 检索时返回相关段落
→ 精确度高

3. 避免信息"冲淡"

假设你在查询 "Nike 的毛利率":

  • 大文档:包含大量无关信息,相关的毛利率信息被"冲淡"
  • 小文档:集中在毛利率段落,更容易被检索到

4. 节省成本

  • 向量化更小的文本块更便宜
  • 检索和生成的 token 数更少
  • 减少 LLM 的处理时间

分割的挑战

文本分割不是简单的"按字符数切分",需要考虑:

  1. 语义完整性:不要在句子中间切断
  2. 上下文连贯性:保留足够的上下文
  3. 信息不丢失:重要信息不能被分割到两个块
  4. 大小适中:每个块不能太大或太小

RecursiveCharacterTextSplitter 详解

LangChain 提供了多种文本分割器,其中 RecursiveCharacterTextSplitter 是最推荐的通用选择。

工作原理

它按优先级递归地尝试不同的分隔符。如果一段文本用第一个分隔符分割后仍然超过 chunkSize,它会拿那一段继续用下一个分隔符分割,而不是只分割一次。

1. 段落分隔符  - 优先级最高
2. 句子分隔符 . 或 ! 或 ?
3. 单词分隔符 (空格)
4. 字符级分割 - 最后手段

核心参数

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const textSplitter = new RecursiveCharacterTextSplitter({
  keepSeparator: false,      // 是否在输出中保留分隔符(可选)
  lengthFunction: (text) => text.length,  // 计算长度的函数(可选)
});

💡 避坑指南:字符 vs Token

在 LangChain.js 中,默认的 chunkSize 是基于字符数(Characters)计算的。而大模型的上下文窗口(Context Window)是基于 Tokens 计算的。

  • 对于英文:1 个 Token $\approx$ 4 个字符或 0.75 个单词。
  • 对于中文:1 个汉字通常占用 1~2 个 Tokens。

如果你的文档主要是中文,建议 chunkSize 设置得稍微小一点(如 500-800),以防单个块的 Token 数超出模型限制。

参数详解

1. chunkSize(块大小)

定义每个文本块的最大字符数。

参考值:从 1000 开始,根据实际效果调整。

2. chunkOverlap(重叠大小)

相邻文本块之间共享的字符数。

作用

  • 保持上下文连贯性
  • 避免重要信息在边界被切断
  • 确保同一个句子不会分散到多个块

参考值:chunkSize 的 10-20%

// 示例
chunkSize: 1000
chunkOverlap: 200  // 20%

// 结果:
Chunk 1: [0:1000]
Chunk 2: [800:1800]   // 800-1000 重叠
Chunk 3: [1600:2600]  // 1600-1800 重叠

为什么需要重叠

❌ 无重叠:
Chunk 1: "...公司的毛利率为"
Chunk 2: "43.5%,相比去年下降了..."

→ 查询"毛利率是多少?"时,Chunk 1 信息不完整

✅ 有重叠:
Chunk 1: "...公司的毛利率为 43.5%,相比去年..."
Chunk 2: "毛利率为 43.5%,相比去年下降了 250 个基点..."

→ 两个块都包含完整信息

3. separators(分隔符列表)

控制文本如何被分割。默认值:["\n\n", "\n", " ", ""]

自定义示例

// 针对 Markdown 的分割器
const markdownSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
  separators: [
    "\n## ",      // 二级标题
    "\n### ",     // 三级标题
    "\n\n",       // 段落
    "\n",         // 行
    " ",          // 词
    ""            // 字符
  ],
});

递归分割的优势

为什么叫“递归”?简单说就是不死板

它不会一上来就暴力地按 1000 字切一刀。它会先试着用“双换行符”(段落)来切。如果切完的一段还是太长,它会再进到这一段里,用“单换行符”(句子)继续切。

这种方式最大程度地保留了文本的语义结构,不会莫名其妙地把一句话切成两半。

让我们看一个具体的例子:

原始文本

第一段。

第二段。

第三段。第四段。第五段。

使用 RecursiveCharacterTextSplitter(chunkSize=20)

尝试 "\n\n" 分割:
✅ ["第一段。\n\n第二段。\n\n第三段。第四段。第五段。"]

"第一段。" 太短,继续分割

尝试 "\n" 分割:
✅ ["第一段。\n\n第二段。\n\n第三段。", "第四段。", "第五段。"]

"第一段。\n\n第二段。\n\n第三段。" 仍然太长

尝试 " " 分割:
✅ ["第一段。\n\n第二段。", "第三段。", "第四段。", "第五段。"]

其他分割器(如简单的字符分割):

❌ ["第一段。\n\n第二段。\n\n第", "三段。第四段。第五段。"]
→ 在"第"字处截断,破坏了语义

结论:除非你有非常特殊的格式需求(比如代码文件用 CodeTextSplitter,Markdown 用 MarkdownTextSplitter),否则无脑选 RecursiveCharacterTextSplitter 就对了。


实战:加载与分割

现在,我们将把学到的知识应用到代码中。我们将创建一个完整的 RAG 入口文件 src/rag/index.ts,并在其中实现文档的加载和分割。

完整代码实现

编辑 src/rag/index.ts

import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import path from 'path';
import { fileURLToPath } from 'url';
import 'dotenv/config';

// 1. 设置路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pdfPath = path.join(__dirname, 'data', 'nke-10k-2023.pdf');

// 2. 加载 PDF
console.log("📄 正在加载文档...");
const loader = new PDFLoader(pdfPath);
const docs = await loader.load();
console.log(`✅ 加载完成,共 ${docs.length} 页`);

// 3. 分割文本
console.log("✂️  正在分割文档...");
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});

const allSplits = await textSplitter.splitDocuments(docs);
console.log(`✅ 分割完成,共生成 ${allSplits.length} 个文本块`);

// 打印第一个文本块看看效果
console.log("\n🔍 第一个文本块示例:");
console.log(allSplits[0].pageContent.slice(0, 200) + "...");
console.log("\n元数据:", allSplits[0].metadata);

运行代码

pnpm rag

你将看到类似以下的输出,证明文档已经成功加载并被分割成了更小的块,为后续的向量化做好了准备。

📄 正在加载文档...
✅ 加载完成,共 107 页
✂️  正在分割文档...
✅ 分割完成,共生成 514 个文本块

🔍 第一个文本块示例:
Table of Contents
UNITED STATES
SECURITIES AND EXCHANGE COMMISSION...

元数据: { source: '.../src/rag/data/nke-10k-2023.pdf', pdf: { ... }, loc: { pageNumber: 1 } }

经验之谈:参数怎么调?

很多同学问 chunkSizechunkOverlap 到底该设多少?这里分享一些我的实战经验:

  1. 默认起手式chunkSize: 1000, chunkOverlap: 200。这个配置在大多数通用文档(如 PDF 报告、文章)上表现都很稳。
  2. 中文环境:如果你的文档全是中文,建议适当调小 chunkSize(比如 500-800)。因为中文的信息密度比英文大,同样的字符数,中文包含的语义更多,太长了容易让 Embedding 模型“消化不良”。
  3. 重叠的重要性chunkOverlap 千万别设为 0。保持 10%-20% 的重叠能有效防止一句话被切成两半,导致语义丢失。
  4. 特殊场景:问答对拆分:如果你处理的是 FAQ 文档(问答对),普通的按长度切分可能会把问题和答案切开。这时候可以考虑预处理文档,把每个“问题+答案”合并成一行,或者使用自定义分隔符(如 Q:)来确保它们始终在同一个 chunk 里。
  5. 调试技巧:不要迷信理论值。最好的办法是像上面代码那样,打印出前几个 chunk 看看。如果发现经常有半截句子,就调大 overlap;如果发现包含太多无关废话,就调小 chunkSize。

本文源码

示例代码已上传至github: https://github.com/Colin3191/langchain-demo