LangChain.js RAG 实战(上):环境搭建与文档处理
随着大模型技术的普及,RAG(检索增强生成)已成为企业级应用的标配。作为前端开发者,我们可以直接利用熟悉的 JavaScript/TypeScript 技术栈来构建强大的 AI 应用。本文是我学习 LangChain.js 的学习笔记,记录了从零开发 RAG 应用的过程。
目录
- 什么是 RAG
- 为什么需要 RAG
- RAG 的应用场景
- RAG 的核心概念
- 技术栈选择
- 环境准备
- Document 抽象详解
- 文档加载器
- 示例:加载 PDF 文档
- 文本分割的重要性
- RecursiveCharacterTextSplitter 详解
- 实战:加载与分割
- 最佳实践
- 常见问题
什么是 RAG
RAG(Retrieval-Augmented Generation,检索增强生成) 听起来很高大上,其实原理特别简单。
你可以把它想象成开卷考试。
- 传统的 LLM(闭卷考试):模型全靠训练时“背”下来的知识回答。如果问它最新的新闻,或者公司内部的私密数据,它要么说不知道,要么就开始一本正经地胡说八道(幻觉)。
- RAG(开卷考试):当用户提问时,系统先去翻阅你提供的“教科书”(知识库),找到相关的段落,然后把这些段落连同问题一起扔给 LLM。LLM 看着参考资料回答,自然就准确多了。
技术上的流程是这样的:
- 检索:根据用户的问题,从知识库中找到相关的文档
- 增强:将这些相关文档作为上下文,与用户的问题一起提供给大语言模型
- 生成:大模型基于检索到的上下文生成准确的答案
一个简单的例子
想象你在向一个助手提问:
用户:"Nike 2023 年的收入是多少?"
没有 RAG 的 LLM:
可能会回答不知道,或者给出过时/错误的信息(因为模型的训练数据有限)
有 RAG 的 LLM:
- 先从 Nike 2023 年的财务报告中检索到相关段落
- 将这些段落作为上下文提供给模型
- 模型基于准确的信息回答:Nike 2023 年的收入是 512 亿美元
为什么需要 RAG
- 解决幻觉问题:强制模型参考真实文档,减少编造信息
- 突破知识截止:实时更新知识库,获取最新信息
- 可验证的来源:提供答案出处,增加可信度
- 保护隐私安全:基于企业内部文档构建私有知识库
RAG 常见的应用场景
- 企业知识库问答:员工快速查找公司政策、技术文档、流程规范
- 智能客服系统:24*7 自动回答客户常见问题
- 文档分析与理解:快速从长篇报告中提取关键信息
RAG 的核心概念
理解 RAG 需要掌握几个核心概念。
1. 文档向量化(Embedding)
问题:计算机如何理解"苹果手机"和"iPhone"是相似的概念?
解决:将文本转换为数字向量(一组数字),语义相近的文本在向量空间中距离更近。
示例:
前两个向量的距离很近,表示它们语义相似。
2. 向量相似度
余弦相似度(Cosine Similarity)是最常用的计算方法:
- 1 = 完全相同
- 0 = 完全不相关
- -1 = 完全相反
3. RAG 的基本流程
简单来说,就是先把知识切碎(向量化),存起来(索引);等用户问的时候,搜出相关的碎片(检索),拼在一起给 AI 看(增强),最后让 AI 回答(生成)。
技术栈选择
为什么选择 LangChain.js?
说实话,市面上关于 LangChain 的教程 90% 都是 Python 的。那我为什么还要头铁选 JS 版?
- 前端友好:作为前端出身,实在不想为了写个 Demo 再去折腾 Python 的虚拟环境、包管理那些东西。能用 TypeScript 搞定全栈,何乐而不为?
- 全栈整合:现在的 Next.js / NestJS 应用直接集成 LangChain.js 非常顺滑,不用再单独起一个 Python 服务做中间层。
- 类型安全:TypeScript 的类型提示在处理复杂的数据流时真的救命,比 Python 猜类型舒服多了。
虽然 JS 版的文档有时候更新比 Python 版慢半拍,但社区活跃度很高,坑基本都能填上。
环境准备
开始搭建 RAG 应用的开发环境。
1. 前置要求
- Node.js:建议 v18 或更高版本
- 包管理器:pnpm、npm 或 yarn
- 代码编辑器:VS Code
2. 创建项目
3. 安装依赖
基础依赖
Embedding 服务(本文使用 Ollama)
向量存储
4. 配置环境变量
创建 .env 文件(Ollama 无需 API key):
⚠️ 注意:不要将 .env 文件提交到 Git,添加到 .gitignore:
5. 配置 TypeScript
直接复制即可:
添加运行脚本(package.json):
安装开发依赖:
Document 对象:万物皆可 Document
在 LangChain 的世界里,不管你读的是 PDF、网页、Markdown 还是 Word,最终都会被统一封装成 Document 对象。
理解它的结构非常简单,它就两个核心属性:
为什么 Metadata 很重要?
很多新手容易忽略 metadata,只关注文本内容。但在实际的 RAG 应用中,元数据是精准检索的关键。
比如,当用户问“Nike 2023 年的营收是多少?”时,如果你在检索时能通过元数据过滤掉 year: 2022 的文档,准确率直接翻倍。
Document 对象示例
虽然我们通常从文件加载文档,但也可以手动创建 Document 对象来理解其结构:
Embedding 服务示例
本文使用 Ollama 本地模型(完全免费、无需 API key)。
什么是向量维度?
向量维度(Vector Dimension)指的是 Embedding 模型生成的数字向量的长度。
简单来说,维度越高,能表达的语义越丰富,但计算和存储成本也越高。本文使用的 nomic-embed-text 模型生成的向量维度是 768。
使用 Ollama Embeddings
使用 Ollama 前的准备:
- 安装 Ollama:
- 拉取 embedding 模型:
- 启动 Ollama 服务:
- 验证安装:
说明:Ollama 完全免费,数据在本地运行。如果硬件配置较低,可以使用阿里云百炼等云服务。
文档加载器:把数据喂给 LLM
LangChain 提供了极其丰富的加载器(Loaders),几乎支持所有你能想到的数据源。
常用加载器一览
- PDFLoader: 处理 PDF 文件(本文重点)。
- TextLoader: 处理
.txt、.md等纯文本。 - CSVLoader / JSONLoader: 处理结构化数据。
- CheerioWebBaseLoader: 爬取网页内容。
- NotionLoader / GitHubLoader: 对接第三方工具。
你可以在 LangChain 官方文档 中找到几百种集成。但在实际开发中,PDF 和 Markdown 是最常见的两种格式。
示例:加载 PDF 文档
我们直接进入实战,使用 Nike 2023 年财务报告(10-K 文件)作为案例。
准备工作
项目中的 PDF 文件路径:
如需下载,可以从以下位置获取:
示例 1:基础 PDF 加载
文本分割的重要性
为什么需要文本分割?
加载完文档后,下一步是将长文档分割成更小的文本块(chunks)。这是因为:
1. LLM 的上下文窗口限制
- GPT-3.5:4K tokens(约 3000 个单词)
- GPT-4:8K-32K tokens
- Claude 2:100K tokens
- 本地模型:通常更小
如果文档太长,LLM 无法一次性处理。
2. 提高检索精度
3. 避免信息"冲淡"
假设你在查询 "Nike 的毛利率":
- 大文档:包含大量无关信息,相关的毛利率信息被"冲淡"
- 小文档:集中在毛利率段落,更容易被检索到
4. 节省成本
- 向量化更小的文本块更便宜
- 检索和生成的 token 数更少
- 减少 LLM 的处理时间
分割的挑战
文本分割不是简单的"按字符数切分",需要考虑:
- 语义完整性:不要在句子中间切断
- 上下文连贯性:保留足够的上下文
- 信息不丢失:重要信息不能被分割到两个块
- 大小适中:每个块不能太大或太小
RecursiveCharacterTextSplitter 详解
LangChain 提供了多种文本分割器,其中 RecursiveCharacterTextSplitter 是最推荐的通用选择。
工作原理
它按优先级递归地尝试不同的分隔符。如果一段文本用第一个分隔符分割后仍然超过 chunkSize,它会拿那一段继续用下一个分隔符分割,而不是只分割一次。
核心参数
💡 避坑指南:字符 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%
为什么需要重叠?
3. separators(分隔符列表)
控制文本如何被分割。默认值:["\n\n", "\n", " ", ""]
自定义示例:
递归分割的优势
为什么叫“递归”?简单说就是不死板。
它不会一上来就暴力地按 1000 字切一刀。它会先试着用“双换行符”(段落)来切。如果切完的一段还是太长,它会再进到这一段里,用“单换行符”(句子)继续切。
这种方式最大程度地保留了文本的语义结构,不会莫名其妙地把一句话切成两半。
让我们看一个具体的例子:
原始文本:
使用 RecursiveCharacterTextSplitter(chunkSize=20):
其他分割器(如简单的字符分割):
结论:除非你有非常特殊的格式需求(比如代码文件用
CodeTextSplitter,Markdown 用MarkdownTextSplitter),否则无脑选RecursiveCharacterTextSplitter就对了。
实战:加载与分割
现在,我们将把学到的知识应用到代码中。我们将创建一个完整的 RAG 入口文件 src/rag/index.ts,并在其中实现文档的加载和分割。
完整代码实现
编辑 src/rag/index.ts:
运行代码
你将看到类似以下的输出,证明文档已经成功加载并被分割成了更小的块,为后续的向量化做好了准备。
经验之谈:参数怎么调?
很多同学问 chunkSize 和 chunkOverlap 到底该设多少?这里分享一些我的实战经验:
- 默认起手式:
chunkSize: 1000, chunkOverlap: 200。这个配置在大多数通用文档(如 PDF 报告、文章)上表现都很稳。 - 中文环境:如果你的文档全是中文,建议适当调小
chunkSize(比如 500-800)。因为中文的信息密度比英文大,同样的字符数,中文包含的语义更多,太长了容易让 Embedding 模型“消化不良”。 - 重叠的重要性:
chunkOverlap千万别设为 0。保持 10%-20% 的重叠能有效防止一句话被切成两半,导致语义丢失。 - 特殊场景:问答对拆分:如果你处理的是 FAQ 文档(问答对),普通的按长度切分可能会把问题和答案切开。这时候可以考虑预处理文档,把每个“问题+答案”合并成一行,或者使用自定义分隔符(如
Q:)来确保它们始终在同一个 chunk 里。 - 调试技巧:不要迷信理论值。最好的办法是像上面代码那样,打印出前几个 chunk 看看。如果发现经常有半截句子,就调大 overlap;如果发现包含太多无关废话,就调小 chunkSize。
本文源码
示例代码已上传至github: https://github.com/Colin3191/langchain-demo