在当今的AI开发领域, <math xmlns="http://www.w3.org/1998/Math/MathML"> R e t r i e v a l − A u g m e n t e d G e n e r a t i o n ( R A G ) Retrieval-Augmented Generation(RAG) </math>Retrieval−AugmentedGeneration(RAG) 技术已成为构建智能应用的核心组成部分。它通过从外部来源检索相关信息来增强大语言模型的生成能力,避免了模型幻觉问题,并提升了响应的准确性和相关性。其中,文档加载(Loader) 和 分割(Splitter) 是 RAG 流程中的关键步骤。本文将深入探讨 LangChain 框架中这些组件的使用方式,结合实际代码示例,分享从网页加载文档、进行语义分割、嵌入向量存储到最终检索并回答问题的完整实践过程。通过这个分享,希望能帮助大家更好地理解和应用 RAG 技术在实际项目中的落地。
RAG Loader的基础概念
RAG的核心在于"检索增强生成",它将知识库中的信息作为模型输入的补充来源。Loader是负责从各种数据源加载原始文档的工具,而Splitter则将这些文档切割成更小的、可管理的片段(chunks),以便后续的向量嵌入和检索。
在LangChain中,Loader支持多种文件类型和来源,包括本地文件、数据库、网页等。这使得开发者可以灵活地从互联网或内部系统中提取数据。特别是社区模块@langchain/community,它提供了丰富的Loader实现,涵盖了PDF、CSV、JSON等多种格式。对于网页内容,CheerioWebBaseLoader是一个强大工具,它利用Cheerio库(一个后端CSS选择器库)来解析HTML,就像操作前端DOM节点一样,允许开发者指定选择器来提取特定元素。
Splitter的作用同样不可忽视。原始文档往往过长,直接嵌入向量会丢失语义细节或超过模型的上下文限制。Splitter通过定义分割规则,将文档拆分成chunks。常见的分割策略包括基于字符数、句子或语义的分割。其中,中文内容常使用"。、,、?、!"等标点作为天然的语义分隔符,这能保持chunks的语义连贯性。同时,chunkSize(块大小)和chunkOverlap(重叠部分)是关键参数:chunkSize控制每个片段的长度,chunkOverlap确保相邻chunks之间有重叠,以避免语义断裂。
这些组件的组合使用,能将 rawDocument 转化为结构化的 Document 对象,每个Document包含pageContent(内容)和metadata(元数据),为后续的向量存储和检索奠定基础。
网页Loader的实践应用
让我们从Loader入手,探讨如何从网页加载文档。假设我们需要从一个在线文章中提取正文内容,比如一篇技术博客。使用CheerioWebBaseLoader,可以指定URL和CSS选择器,只加载感兴趣的部分,避免无关的广告或导航栏干扰。
在代码中,首先需要导入必要的模块:
JavaScript
javascript
import "cheerio"; // 后端,使用CSS选择器,像操作前端一样查找DOM节点
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
这里,import "cheerio" 是为了引入Cheerio库,虽然在实际执行中可能需要更精确的导入方式(如import * as cheerio from 'cheerio'),但在LangChain的Loader中,它已内置支持。接下来,实例化Loader:
JavaScript
arduino
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7233327509919547452?searchId=20260302193603120AE3328025B138C1FB',
{
selector: '.main-area p'
}
);
这个Loader针对指定的URL,使用'.main-area p'选择器提取段落内容。调用load()方法后,会返回一个Document数组:
JavaScript
ini
const documents = await cheerioLoader.load();
documents中每个元素是一个Document对象,包含从网页提取的pageContent和metadata(如source URL)。这步操作简单高效,尤其适合爬取博客、新闻或文档站点的内容。需要注意的是,网页加载受网络和站点结构影响,如果目标页面动态渲染(如React应用),可能需要结合其他工具如PlaywrightLoader来处理JavaScript渲染。
文档Splitter的语义分割策略
加载文档后,下一步是分割。RecursiveCharacterTextSplitter是LangChain中一个常用Splitter,它递归地根据分隔符切割文本,直到chunks达到指定大小。
在代码中,我们这样配置Splitter:
JavaScript
arduino
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 大小
chunkOverlap: 50, // 重叠 保证语义的连贯性
separators: ['。', ',', '?', '!'] // 分割符
});
这里,chunkSize设为400字符,适合大多数嵌入模型的输入限制。chunkOverlap为50,确保相邻chunks有重叠部分,防止句子被生硬切割导致语义丢失。separators优先使用中文标点作为分隔符,这比纯字符分割更注重语义,因为"。、?"等自然地划分句子。
然后,调用splitDocuments():
JavaScript
javascript
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(splitDocuments);
console.log(`文档分割完成,共${splitDocuments.length}个片段`);
splitDocuments是一个Document数组,每个元素是分割后的chunk。日志输出显示分割结果,便于调试。通过这种方式,原始文档被转化为更细粒度的片段,适合向量嵌入。
在 separators 中,顺序很重要。Splitter会从第一个分隔符开始尝试切割,如果失败则递归到下一个。通常,将较大的分隔符(如段落换行)放在前面,但这里针对中文句子,标点优先是合理的。如果文档是英文,可调整为['\n\n', '\n', ' ', '']。另外,如果chunkSize过大,可能导致嵌入向量丢失细节;过小则增加存储开销。建议根据具体内容测试调整。
向量嵌入与存储的实现
分割后,我们需要将chunks嵌入向量空间,以便相似度检索。LangChain集成OpenAI的Embeddings模型来实现这一点。
首先,配置环境(假设使用dotenv加载):
JavaScript
javascript
import 'dotenv/config';
import { OpenAIEmbeddings } from '@langchain/openai';
实例化Embeddings:
JavaScript
arduino
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_API_BASE_URL,
}
});
这里,使用环境变量管理API密钥和模型名,确保安全性。然后,创建向量存储:
JavaScript
javascript
console.log('正在创建向量存储...');
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocuments, embeddings);
console.log('向量存储创建完成');
MemoryVectorStore是一个内存中的向量存储,适合开发测试,不持久化数据。对于生产环境,可替换为Pinecone或FAISS等分布式存储。fromDocuments()方法自动嵌入每个chunk,并存储向量。
检索与问题回答的集成
向量存储就绪后,我们创建Retriever:
JavaScript
ini
const retriever = await vectorStore.asRetriever({ k: 2 });
这将返回一个Retriever对象,k=2表示每次检索返回前2个最相似的chunks。
现在,定义问题:
JavaScript
ini
const questions = ['父亲的去世对作者的人生态度产生了怎样的根本性逆转?'];
针对每个问题,进行检索:
JavaScript
javascript
for (const question of questions) {
console.log("=".repeat(80));
console.log(`问题:${question}`);
console.log("=".repeat(80));
const retrievedDocs = await retriever.invoke(question);
const scoreResults = await vectorStore.similaritySearchWithScore(question, 2);
// score 是距离,越小越相似
console.log("\n [检索到文档及相似度得分]");
retrievedDocs.forEach((doc, i) => {
const scoreResult = scoreResults.find(([scoredDoc]) => scoredDoc.pageContent === doc.pageContent);
const score = scoreResult ? scoreResult[1] : null;
const similarity = score ? (1 - score).toFixed(4) : "N/A";
console.log(`\n 文档${i+1}:相似度:${similarity}`);
console.log(`内容:${doc.pageContent}`);
if (doc.metadata && Object.keys(doc.metadata).length > 0) {
console.log(`元数据:${JSON.stringify(doc.metadata)}`);
}
});
}
这里,使用invoke()检索文档,并通过similaritySearchWithScore()获取分数(余弦距离,越小越相似)。转换相似度为(1 - score),便于理解。输出包括内容和元数据,帮助验证检索质量。
最后,构建提示并调用模型回答:
JavaScript
javascript
const content = retrievedDocs
.map((doc, i) => `[片段${i+1}] ${doc.pageContent}`)
.join('\n\n---\n\n');
const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${content}
问题:
${question}
回答:
`;
console.log("\n [AI 回答]");
const response = await model.invoke(prompt);
console.log(response.content);
提示模板将检索到的chunks作为上下文输入ChatOpenAI模型(temperature=0确保确定性输出)。模型配置类似Embeddings,使用环境变量。
这个流程完整实现了RAG:从加载网页,到分割、嵌入、检索,再到生成回答。针对示例问题"父亲的去世对作者的人生态度产生了怎样的根本性逆转?",假设网页内容是相关文章,检索会匹配相关chunks,模型基于此给出精准回答。
扩展应用场景
RAG Loader不止于网页,可扩展到多模态数据,如结合ImageLoader处理图片描述,或AudioLoader转录语音。实际项目中,可构建知识问答系统、文档搜索工具或聊天机器人。例如,在企业内部知识库中,加载Confluence页面,分割后嵌入,员工查询时实时检索。
结合LangChain的Chain模块,可将这个流程封装成链:Loader -> Splitter -> Embedder -> Retriever -> LLM。未来,随着模型进步,RAG将更智能,如集成多跳检索或自适应chunking。
通过这个代码实践,我们看到LangChain简化了RAG实现,让AI工程师快速原型化。希望这个分享能激发大家探索更多Loader和Splitter的组合,构建高效AI应用。