📖 本章学习目标
- 用 TypeScript 实现完整的 RAG 流水线:加载 → 切分 → 向量化 → 存储 → 检索 → 生成
- 使用 LangChain 的 Chain 抽象串联多个步骤
- 对比同一问题在"有 RAG"和"无 RAG"两种模式下的回答质量差异
- 理解索引管道(离线)和检索-生成管道(在线)的职责分界
在《第2章:文本嵌入 —— 让计算机理解语义的桥梁》、《第3章:向量数据库入门》、《第4章:文档加载》、《第5章:文档切分》几个章节中,我们分别学会了生成文本向量、搭建向量数据库、加载各种格式文档、把长文档切成了合理大小的片段
现在是时候把所有零件组装成一台能真正工作的 RAG 引擎了。本章会走通Load → Split → Embed → Store → Retrieve → Generate 的完整链路。这是整个RAG基础篇最重要的章节,因为从这里开始,你不再是学习孤立的概念,而是将它们整合成一个可用的系统。
一、RAG 经典流水线的全景架构
1. 两个管道的职责分离
RAG流水线分索引管道和检索-生成管道。它们两个职责分离,运行的时机也不一样。
graph TD
subgraph 索引管道
A[原始文档] --> B[文档加载]
B --> C[文档切分]
C --> D[生成 Embedding]
D --> E[(向量数据库)]
end
subgraph 检索-生成管道
F[用户提问] --> G[生成问题 Embedding]
G --> H[向量相似度搜索]
E --> H
H --> I[组装 Prompt]
I --> J[LLM 生成答案]
J --> K[返回答案给用户]
end
索引管道通常是离线执行的,只在知识库更新时运行(每天几次到每周几次),更注重吞吐量而非延迟;而检索-生成管道是在线运行的,在每次用户提问时运行(每秒可能数百次),注重低延迟和高可用性。这个区分对后续的性能优化(《第17章:RAG 性能优化实战》)至关重要。很多初学者会把两个管道混在一起设计,导致索引任务阻塞查询请求,或者查询时重复生成 Embedding 浪费资源。
2. 数据流向详解
让我们跟踪一条数据从原始文档到最终答案的完整旅程:
索引阶段:
- 加载:从文件系统、API 或数据库读取原始文档(PDF、Markdown、CSV 等)
- 切分:将长文档按照给定的chunk大小切成小块,保持语义完整性
- 向量化:为每个 chunk 生成 Embedding 向量
- 存储:将向量、原文、元数据存入向量数据库,建立 HNSW 索引
检索-生成阶段:
- 接收查询:用户提出自然语言问题
- 向量化查询:为问题生成 Embedding 向量(使用与索引相同的模型)
- 相似度搜索:在向量数据库中找到 Top-K 最相关的 chunk
- 组装 Prompt:将检索到的 chunk 与用户问题、系统指令拼接成完整 Prompt
- 生成答案:LLM 基于 Prompt 生成最终答案
- 返回结果:将答案和引用来源返回给用户
二、环境准备
在开始编码之前,确保你已经安装了必要的依赖:
# 初始化项目
npm init -y
npm install langchain @langchain/openai @langchain/community chromadb
# 如果使用 PDF 加载需要额外依赖
npm install pdf-parse
配置环境变量(.env 文件):
OPENAI_API_KEY=sk-your-api-key
加载环境变量:
import dotenv from "dotenv";
dotenv.config();
三、索引管道实现
索引管道是离线的批处理任务,通常在文档首次导入时、文档更新时(通过文件监听或 Git Hook)或定时任务(如每天凌晨重新索引)时触发。
1. 文档加载与切分
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { Document } from "@langchain/core/documents";
async function loadAndSplit(filePath: string): Promise<Document[]> {
console.log(`正在加载文档: ${filePath}`);
// 加载文档
const loader = new TextLoader(filePath);
const docs = await loader.load();
console.log(`加载完成,共 ${docs.length} 个文档`);
// 切分文档
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每个 chunk 约 500 字符
chunkOverlap: 50, // 相邻 chunk 重叠 50 字符
});
const chunks = await splitter.splitDocuments(docs);
console.log(`切分完成,共 ${chunks.length} 个片段`);
console.log(`平均长度: ${(chunks.reduce((sum, c) => sum + c.pageContent.length, 0) / chunks.length).toFixed(0)} 字符`);
return chunks;
}
// 使用示例
const chunks = await loadAndSplit("./knowledge-base/deploy-guide.md");
代码解读:
- 加载阶段:
TextLoader读取 Markdown 文件,将其转换为Document对象 - 切分阶段:
RecursiveCharacterTextSplitter按语义边界切分,优先在段落、句子边界断开 - 统计信息:输出 chunk 数量和平均长度,帮助评估切分质量
注意,如果文档很大(> 10MB),考虑分批加载避免内存溢出。你可以并行加载多个文件,提高索引速度(见《第4章:文档加载》中的并行加载示例)。生成chunk的时候,要记录每个 chunk 的 metadata,包括 source、page number 等,方便后续溯源。
2. 向量化与存储
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChromaClient } from "chromadb";
async function indexDocuments(chunks: Document[], collectionName: string) {
console.log("开始向量化和存储...");
// 初始化 Embedding 模型
const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
// 连接 Chroma 客户端
const client = new ChromaClient({ path: "http://localhost:8000" });
// 检查 Collection 是否已存在,如果存在则删除(重新索引)
try {
await client.deleteCollection({ name: collectionName });
console.log(`已删除旧的 Collection: ${collectionName}`);
} catch (error) {
// Collection 不存在,忽略错误
}
// 批量生成向量并存入 Chroma
const vectorStore = await Chroma.fromDocuments(
chunks,
embeddings,
{
collectionName: collectionName,
url: "http://localhost:8000",
}
);
console.log(`索引完成,共存储 ${chunks.length} 个向量`);
return vectorStore;
}
// 使用示例
const vectorStore = await indexDocuments(chunks, "deploy_guide");
代码解读:
- Embedding 模型:使用 OpenAI 的 text-embedding-3-small,性价比高
- Collection 管理:先删除旧的 Collection(如果存在),确保索引是最新的
- 批量操作:
Chroma.fromDocuments()内部做了三件事:逐条调用 Embedding API 生成向量、将向量和原文存入 Chroma、创建 HNSW 索引。
同样,对于大规模数据(> 10,000 chunks),可以考虑分批插入,避免单次请求过大。创建和存储的过程中可以监控 Embedding API 的调用次数和成本,必要时引入缓存,同时记录索引耗时,建立性能基线,方便做性能优化。
// 分批插入示例(适合大规模数据)
async function batchIndexDocuments(
chunks: Document[],
embeddings: OpenAIEmbeddings,
collectionName: string,
batchSize: number = 100
) {
const client = new ChromaClient({ path: "http://localhost:8000" });
// 创建 Collection
const collection = await client.createCollection({ name: collectionName });
console.log(`开始分批索引,共 ${chunks.length} 个 chunk,每批 ${batchSize} 个`);
for (let i = 0; i < chunks.length; i += batchSize) {
const batch = chunks.slice(i, i + batchSize);
// 生成批次向量
const texts = batch.map(c => c.pageContent);
const vectors = await embeddings.embedDocuments(texts);
// 插入到 Chroma
await collection.add({
ids: batch.map((_, idx) => `chunk_${i + idx}`),
embeddings: vectors,
documents: texts,
metadatas: batch.map(c => c.metadata),
});
console.log(`已处理 ${Math.min(i + batchSize, chunks.length)} / ${chunks.length}`);
}
console.log("索引完成");
}
四、检索-生成管道实现
检索-生成管道是在线的实时服务,通常需要在毫秒级响应。
1. 检索阶段
import { VectorStoreRetriever } from "@langchain/core/vectorstores";
// 将向量数据库包装为 Retriever
const retriever = vectorStore.asRetriever({
k: 4, // 返回 Top-4 最相关片段
});
// 执行检索
const query = "预发环境的部署步骤是什么?";
console.log(`正在检索: "${query}"`);
const relevantDocs = await retriever.invoke(query);
console.log(`找到 ${relevantDocs.length} 个相关片段:\n`);
for (const [i, doc] of relevantDocs.entries()) {
console.log(`[${i + 1}] 来源: ${doc.metadata.source}`);
console.log(` 内容预览: ${doc.pageContent.slice(0, 100)}...`);
console.log();
}
代码解读:
- Retriever 抽象:
asRetriever()将向量数据库包装为统一的检索接口 - k 值选择:返回 Top-4 结果,平衡召回率和噪声
- 结果展示:输出每个片段的来源和内容预览,便于调试
其中,k 值设置的大小会影响检索的效果,太小可能遗漏相关信息,太大则引入噪声。典型范围是 3-10,具体需要根据评估确定(见《第12章:RAG 系统评估与指标体系》)。可以结合元数据过滤缩小搜索范围(见《第9章:高级检索策略》)
2. 增强与生成:两种实现方式
检索的下一个阶段就是增强,所谓增强指的是将检索到的相关文档或片段,作为上下文信息添加到用户的原始提问中,从而构建出一个信息更丰富、指向性更明确的“增强提示(Prompt)”。在LangChain体系下,你不仅可以使用手动拼装的方式增强Prompt,也可以使用Chain来实现。
方式一:手动组装 Prompt
适合对 Prompt 格式有精细控制需求的场景。
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
const llm = new ChatOpenAI({
modelName: "gpt-4o",
temperature: 0, // 降低随机性,提高一致性
});
// 手动拼接上下文
const context = relevantDocs
.map((doc, i) => `[参考资料 ${i + 1}]\n${doc.pageContent}`)
.join("\n\n");
const systemPrompt = `你是一个企业知识库助手。请根据以下参考资料回答问题。
要求:
1. 严格基于提供的参考资料回答,不要编造信息
2. 如果资料中没有相关信息,请明确说明"根据现有资料无法回答"
3. 在回答中标注引用来源,如"[1]"、"[2]"
4. 回答要简洁清晰,避免冗余`;
const userPrompt = `## 参考资料
${context}
## 问题
${query}`;
// 调用 LLM
const messages = [
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
];
const response = await llm.invoke(messages);
console.log("答案:", response.content);
这种方式的优点是你完全控制 Prompt 格式和内容,易于调试和优化,也不依赖框架的抽象。缺点则是代码冗长,需要自己处理消息格式,在更换 LLM 等场景时需要手动调整代码。
方式二:使用 LangChain Chain 串联
适合标准化流程,代码更简洁。
import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { pull } from "langchain/hub";
import { createRetrievalChain } from "langchain/chains/retrieval";
import { ChatPromptTemplate } from "@langchain/core/prompts";
// 从 LangChain Hub 拉取 RAG Prompt 模板
const promptTemplate = await pull<ChatPromptTemplate>("langchain-ai/retrieval-qa-chat");
// 创建文档组合链(负责将检索结果和问答组合)
const combineDocsChain = await createStuffDocumentsChain({
llm,
prompt: promptTemplate,
});
// 串联检索 + 组合 → 完整的 RAG 链
const ragChain = await createRetrievalChain({
retriever,
combineDocsChain,
});
// 执行完整流程
const result = await ragChain.invoke({ input: query });
console.log("答案:", result.answer);
console.log("\n引用来源:");
for (const doc of result.context) {
console.log(`- ${doc.metadata.source}`);
}
这种方式的优点是代码简洁,几行即可完成 RAG 流程,且其内置了最佳实践(如 Prompt 模板、错误处理)。另外这种方式还易于替换组件(换 LLM、换向量数据库等)。缺点也很明显,整个实现是黑盒性质,难以精细控制。而且它还依赖 LangChain Hub 的模板质量,学习曲线较陡。
一般建议在开发阶段或有特殊需求的场景使用手写 Prompt的方式,便于理解和调试。其它的生产环境建议使用用 Chain 抽象,代码更简洁易维护,并且可以使用生态上的一些经过无数验证的解决方案。
3. 封装为可复用的服务
真实的实践中,我们不会到处写检索-生成管道的代码,通常会根据具体的需求,将其封装为一个函数,方便在各个地方调用。
interface RAGResponse {
answer: string;
sources: Array<{
source: string;
content: string;
}>;
}
class RAGService {
private retriever: VectorStoreRetriever;
private llm: ChatOpenAI;
constructor(vectorStore: Chroma) {
this.retriever = vectorStore.asRetriever({ k: 4 });
this.llm = new ChatOpenAI({
modelName: "gpt-4o",
temperature: 0,
});
}
async query(question: string): Promise<RAGResponse> {
// 检索
const relevantDocs = await this.retriever.invoke(question);
// 组装 Prompt
const context = relevantDocs
.map((doc, i) => `[${i + 1}] ${doc.pageContent}`)
.join("\n\n");
const prompt = `根据以下参考资料回答问题。如果资料中没有相关信息,请明确说明。
参考资料:
${context}
问题:${question}
回答:`;
// 生成答案
const response = await this.llm.invoke(prompt);
// 组装返回结果
return {
answer: response.content as string,
sources: relevantDocs.map(doc => ({
source: doc.metadata.source,
content: doc.pageContent.slice(0, 200) + "...",
})),
};
}
}
// 使用示例
const ragService = new RAGService(vectorStore);
const result = await ragService.query("预发环境的部署步骤是什么?");
console.log("答案:", result.answer);
console.log("\n来源:");
result.sources.forEach((src, i) => {
console.log(`${i + 1}. ${src.source}`);
});
五、效果对比:有 RAG vs 无 RAG
为了直观展示 RAG 的价值,我们对比同一个问题在两种模式下的回答:
async function compareRAGvsNoRAG(query: string) {
console.log(`问题: "${query}"\n`);
// 无 RAG:直接问 LLM
console.log("=== 无 RAG(纯 LLM)===");
const noRAGAnswer = await llm.invoke(query);
console.log(noRAGAnswer.content);
// 有 RAG:走完整流水线
console.log("=== 有 RAG(检索增强)===");
const ragResult = await ragChain.invoke({ input: query });
console.log(ragResult.answer);
// 显示引用来源
console.log("=== 引用来源 ===");
for (const [i, doc] of ragResult.context.entries()) {
console.log(`[${i + 1}] ${doc.metadata.source}`);
console.log(` ${doc.pageContent.slice(0, 150)}...`);
}
}
// 测试
await compareRAGvsNoRAG("预发环境的部署步骤是什么?");
结果如下:
问题: "预发环境的部署步骤是什么?"
=== 无 RAG(纯 LLM)===
抱歉,我无法访问您公司的内部部署流程信息。一般来说,预发环境的部署可能包括以下步骤:
1. 代码审查和合并
2. 构建和测试
3. 部署到预发服务器
...
=== 有 RAG(检索增强)===
根据部署文档,预发环境的部署步骤如下:
1. 代码审查:确保所有代码已通过 Code Review 并合并到 develop 分支 [1]
2. 构建镜像:使用 Docker 构建应用镜像,标签为 staging-{commit_hash} [1]
3. 部署到预发环境:通过 Kubernetes 部署到 staging namespace [2]
4. 验证:访问 xxx.xxx.xx 进行功能验证 [2]
5. 通知团队:在 Slack #deployments 频道发送部署通知 [3]
=== 引用来源 ===
[1] docs/deploy-guide.md
部署流程分为代码审查、构建、部署三个主要阶段...
[2] docs/staging-env.md
预发环境地址为xxx.xxxx.xxx,需要 VPN 访问...
[3] docs/team-workflow.md
所有部署完成后需要在 Slack 频道通知团队...
从对比中可以真实的感受到如下差异:
| 维度 | 无 RAG | 有 RAG |
|---|---|---|
| 准确性 | 通用回答,可能不适用于你的场景 | 基于实际文档,准确可靠 |
| 可溯源性 | 无法追溯信息来源 | 每条信息都有明确的来源标注 |
| 幻觉风险 | 高,可能编造不存在的步骤 | 低,受检索内容约束 |
| 时效性 | 依赖训练数据,可能过时 | 实时更新,反映最新文档 |
对于"预发环境的部署步骤是什么"这类私有知识问题,无 RAG 的 LLM 只能编造通用答案甚至直接承认不知道;有 RAG 的版本能从你的部署文档中提取准确的步骤,并标注信息来源。
六、完整的端到端示例
将索引和检索-生成两个管道整合到一个完整的脚本中:
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChromaClient } from "chromadb";
import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { pull } from "langchain/hub";
import { createRetrievalChain } from "langchain/chains/retrieval";
import { ChatOpenAI } from "@langchain/openai";
async function main() {
console.log("=== RAG 系统演示 ===\n");
// ========== 索引管道 ==========
console.log("【阶段 1】索引管道\n");
const filePath = "./knowledge-base/deploy-guide.md";
const collectionName = "deploy_guide";
// 加载和切分
const loader = new TextLoader(filePath);
const docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const chunks = await splitter.splitDocuments(docs);
console.log(`✓ 文档已切分为 ${chunks.length} 个片段\n`);
// 向量化和存储
const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
const client = new ChromaClient({ path: "http://localhost:8000" });
// 删除旧 Collection
try {
await client.deleteCollection({ name: collectionName });
} catch (error) {
// 忽略
}
const vectorStore = await Chroma.fromDocuments(
chunks,
embeddings,
{ collectionName, url: "http://localhost:8000" }
);
console.log(`✓ 索引完成,已存储 ${chunks.length} 个向量\n`);
// ========== 检索-生成管道 ==========
console.log("【阶段 2】检索-生成管道\n");
const llm = new ChatOpenAI({ modelName: "gpt-4o", temperature: 0 });
const retriever = vectorStore.asRetriever({ k: 4 });
const promptTemplate = await pull("langchain-ai/retrieval-qa-chat");
const combineDocsChain = await createStuffDocumentsChain({
llm,
prompt: promptTemplate,
});
const ragChain = await createRetrievalChain({
retriever,
combineDocsChain,
});
// 测试查询
const questions = [
"预发环境的部署步骤是什么?",
"如何访问预发环境?",
"生产发布由谁负责?",
];
for (const question of questions) {
console.log(`问题: ${question}`);
const result = await ragChain.invoke({ input: question });
console.log(`答案: ${result.answer}\n`);
}
console.log("=== 演示结束 ===");
}
main().catch(console.error);
运行这个脚本,你会看到完整的 RAG 流程从索引到查询的全过程。
注意,这个示例里是构建了一道完整的RAG管线,使用的
vectorStore是创建索引时初始化的。而在真实的线上生产环境中,索引和检索-生成这两个过程是完全解耦的,它们通常作为两个独立的服务运行。此时vectorStore可以使用LangChain提供的Chroma.fromExistingCollectionAPI来初始化。
FAQ
Q1:索引管道的执行频率应该是多少?
取决于知识库的更新速度。最简单的策略是每次文档变更后触发一次索引更新。具体实现可以用文件监听(fs.watch)或 Git Hook。在《第18章:RAG 系统的生产部署》中会在 CI/CD 中集成这个流程。
对于小型团队,每天定时重新索引一次也是可行的方案。对于大型知识库,可以实现增量索引,只更新变更的文档。
Q2:为什么检索到的文档要注意顺序?
LLM 存在"Lost in the Middle"现象:对中间位置的信息关注度最低,最关注开头和结尾。因此应该把最相关的片段放在 Prompt 的开头或结尾。
在《第11章:Prompt 工程与上下文优化》中,我们会讨论如何通过重排序(Re-ranking)优化检索结果的顺序。
Q3:LangChain 的 Chain 需要学习吗?可以直接手写串联逻辑?
完全可以手写。本章展示了两种方式就是为了说明:Chain 只是语法糖,你完全可以用自己的逻辑串联。在《第15章:Agent RAG —— 让 RAG 具备自主决策能力》中讲 Agent 时会更多地走向手写 Function Calling 循环,而不是依赖框架的 Chain 抽象。
建议的学习路径是:
- 先手写理解每个环节的原理
- 再用 Chain 抽象简化代码
- 最后根据需求决定是否需要自定义逻辑
Q4:如何处理检索结果为空的情况?
如果检索不到相关文档,应该明确告知用户,而不是让 LLM 自由发挥。可以在 Prompt 中加入明确的指令:
if (relevantDocs.length === 0) {
return "抱歉,知识库中没有找到相关信息。请尝试换一种提问方式,或联系人工客服。";
}
更好的做法是设置一个相似度阈值,只有超过阈值的結果才被视为相关:
const results = await collection.query({
queryEmbeddings: [queryEmbedding],
nResults: 10,
});
// 过滤低相似度结果
const threshold = 0.7;
const filteredDocs = results.documents[0].filter((_, i) =>
1 - results.distances[0][i] >= threshold
);
Q5:RAG 系统的延迟主要来自哪些环节?
典型的延迟分布:
- Embedding 生成(查询):50-200ms
- 向量搜索:5-20ms
- LLM 生成:500-2000ms(取决于答案长度)
- 网络传输:50-100ms
总延迟通常在 1-3 秒之间。优化策略包括:
- 缓存常用查询的 Embedding
- 使用更快的 Embedding 模型
- 流式输出 LLM 答案(见练习 3)
- 优化向量数据库的索引参数
详细内容见《第17章:RAG 性能优化实战》。
练习
练习 1:跑通你的第一条 RAG 流水线
准备一份你自己的短文档(如简历、项目 README、技术博客),走通本章的完整流程。确保索引和检索-生成两个阶段都正确执行。
验证标准:
- 针对文档中的具体信息提问,RAG 回答准确且有引用来源
- 能够展示检索到的原始片段
- 对比有 RAG 和无 RAG 的回答差异
提示:
- 可以从 GitHub 上找一个开源项目的 README 作为测试文档
- 准备 3-5 个测试问题,覆盖文档的不同部分
- 记录每个环节的耗时,建立性能基线
练习 2:对比不同 k 值的效果
分别设置 k=1、k=3、k=8,用同一个问题测试,记录每种设置下的回答质量和检索延迟。
验证标准:
- 能解释 k 值增加带来的质量提升与延迟增加的权衡
- 画出 k 值与回答质量的关系图(主观评分 1-5 分)
- 给出推荐的 k 值及理由
提示:
- 对于简单问题,k=1-3 可能足够
- 对于复杂问题,可能需要 k=5-10
- 过大的 k 值会引入噪声,反而降低质量
练习 3:实现流式输出
将生成阶段的 llm.invoke() 改为 llm.stream(),实现逐 Token 流式输出答案。
验证标准:
- 答案能够逐字显示,用户体验更流畅
- 能够正确处理流式输出的异步迭代
- 总延迟与非流式版本基本一致
提示:
const stream = await llm.stream(prompt);
for await (const chunk of stream) {
process.stdout.write(chunk.content); // 逐块输出
}
练习 4:添加错误处理和日志
为 RAG 服务添加完善的错误处理和日志记录,包括:
- Embedding API 调用失败的重试机制
- 向量数据库连接失败的降级策略
- 每个环节的耗时统计
- 查询日志记录(问题、检索结果、答案)
验证标准:
- 模拟 API 超时,系统能够重试并最终返回错误或降级结果
- 日志中包含完整的查询链路信息
- 能够统计平均查询延迟和成功率
📚 延伸阅读
- LangChain JS RAG Tutorial — LangChain 官方 RAG 教程,包含更多高级用法
- LangChain Hub Prompts — 社区 RAG Prompt 模板,可以直接拉取使用
- Lost in the Middle 论文 — Liu et al., "Lost in the Middle: How Language Models Use Long Contexts",揭示了 LLM 对长上下文中不同位置信息的关注度差异
- Chroma Documentation — Chroma 向量数据库的完整文档
- OpenAI API Reference — OpenAI API 的详细参考,包括 Embedding 和 Chat Completion 接口