从零实现一个 PDF 智能问答系统:基于 LangChain + DeepSeek 的 RAG 实战
你是否也有一堆 PDF 文档,想对它们提问却无从下手?本文将手把手教你用 LangChain 构建一个完整的 RAG(检索增强生成)系统,让你的 PDF 变得"可对话"。
一、RAG 是什么?
RAG(Retrieval-Augmented Generation)即"检索增强生成"。它的核心思想很简单:
- 检索:从文档中找到与问题最相关的内容片段
- 增强:将这些片段作为上下文(Context)提供给大语言模型
- 生成:大模型基于上下文生成精准回答
相比直接让大模型回答,RAG 解决了"大模型不知道你的私有数据"的问题,同时避免了高昂的模型微调成本。
架构总览
css
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ PDF 文档 │────▶│ 文本切分/向量化 │────▶│ 向量数据库 │
└─────────────┘ └───────────────┘ └──────────────┘
│
┌────────▼───────┐
│ 语义检索器 │
└────────┬───────┘
│ 检索 top-k 片段
┌────────▼───────┐
│ Prompt + Context│────▶ DeepSeek 生成回答
└────────────────┘
二、技术选型
| 组件 | 选型 | 理由 |
|---|---|---|
| PDF 加载 | PDFLoader (LangChain) |
开箱即用,支持逐页解析 |
| 文本切分 | RecursiveCharacterTextSplitter |
按段落/句子递归切分,保留语义完整性 |
| 向量化模型 | tongyi-embedding-vision-plus (DashScope) |
阿里通义千问多模态 embedding,中文效果好 |
| 向量存储 | MemoryVectorStore + JSON 持久化 |
无需额外数据库,轻量可缓存 |
| 大模型 | DeepSeek (deepseek-chat) |
性价比极高,中文能力优秀 |
| 运行环境 | Node.js + LangChain.js | 前后端统一语言,快速开发 |
三、从 0 开始搭建
Step 1:初始化项目
bash
mkdir pdfRAG && cd pdfRAG
npm init -y
# 设置 type: "module" 以支持 ES Module
在 package.json 中修改:
json
{
"type": "module"
}
安装依赖:
bash
npm install langchain @langchain/core @langchain/community @langchain/openai dotenv pdf-parse
Step 2:配置环境变量
创建 .env 文件:
env
# DeepSeek 配置(LLM 问答)
DEEPSEEK_API_KEY="your-deepseek-api-key"
DEEPSEEK_BASE_URL="https://api.deepseek.com"
DEEPSEEK_MODEL="deepseek-chat"
# DashScope 配置(Embedding 向量化)
DASHSCOPE_API_KEY="your-dashscope-api-key"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
EMBEDDING_MODEL="tongyi-embedding-vision-plus-2026-03-06"
Step 3:实现 PDF 加载与切分
javascript
// loadPdf.js
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const loader = new PDFLoader("./zqcy.pdf");
const docs = await loader.load(); // 逐页加载
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800, // 每块 800 字符
chunkOverlap: 100 // 100 字符重叠,避免切断上下文
});
const splitDocs = await splitter.splitDocuments(docs);
// 255 页 PDF → 458 个文本块
关键点 :chunkSize 和 chunkOverlap 需要根据文档类型调优。金融/法律文档建议 500-800 字符,常规文档可以 1000-2000。重叠部分能保证上下文不被硬切断裂。
Step 4:自定义 Embedding 封装
LangChain 内置的 AlibabaTongyiEmbeddings 只支持普通文本 embedding 端点,而 tongyi-embedding-vision-plus 是一个多模态模型,需要使用不同的 API 端点。我们基于 @langchain/core 的 Embeddings 基类自定义封装:
javascript
// embeddings.js
import { Embeddings } from "@langchain/core/embeddings";
import { chunkArray } from "@langchain/core/utils/chunk_array";
const DASHSCOPE_URL =
"https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding";
export class TongyiVisionEmbeddings extends Embeddings {
constructor(fields) {
super(fields);
this.modelName = fields?.modelName ?? "tongyi-embedding-vision-plus-2026-03-06";
this.batchSize = fields?.batchSize ?? 10;
this.apiKey = fields?.apiKey;
}
async embedDocuments(texts) {
const batches = chunkArray(texts, this.batchSize);
const results = await Promise.all(
batches.map(async (batch) => {
const res = await fetch(DASHSCOPE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.modelName,
input: { contents: batch.map((text) => ({ text })) },
// 关键!多模态 endpoint 的 input 格式为 contents: [{text: "..."}]
}),
});
const data = await res.json();
return data.output.embeddings.map((item) => item.embedding);
})
);
return results.flat();
}
async embedQuery(text) {
const [embedding] = await this.embedDocuments([text]);
return embedding;
}
}
常见坑 :不同的 embedding 模型有不同的 API 格式。普通文本 embedding 用 input.texts,而多模态 embedding 用 input.contents。如果格式不对,API 可能返回空向量或报错。
Step 5:向量化与检索器构建
javascript
// 完整流程
import { MemoryVectorStore } from "langchain/vectorstores/memory";
const embeddings = new TongyiVisionEmbeddings({
modelName: "tongyi-embedding-vision-plus-2026-03-06",
batchSize: 10, // DashScope 限制每批最多 10 条
apiKey: process.env.DASHSCOPE_API_KEY
});
// 将文本块转为向量并存入内存
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, embeddings);
// 创建检索器(默认余弦相似度)
const retriever = vectorStore.asRetriever({ k: 6 }); // 召回 top-6
为什么用 MemoryVectorStore? 它是纯内存的向量存储,无需安装任何数据库(Chroma、Pinecone 等),快速上手开发。生产环境建议换用 FAISS 或 pgvector 等持久化方案。
Step 6:向量缓存持久化
每次启动都重新向量化 458 个文本块需要 2-3 分钟。我们实现一个简单的 JSON 缓存机制:
javascript
// store.js --- 向量缓存管理
export async function saveVectorStore(store) {
const vectors = store.memoryVectors.map((v) => v.embedding);
const docs = store.memoryVectors.map((v) => ({
pageContent: v.content,
metadata: v.metadata,
}));
await writeFile("vectors.json", JSON.stringify(vectors));
await writeFile("documents.json", JSON.stringify(docs));
}
export async function loadVectorStore(embeddings) {
try {
const docs = JSON.parse(await readFile("documents.json", "utf-8"));
const vectors = JSON.parse(await readFile("vectors.json", "utf-8"));
const store = new MemoryVectorStore(embeddings);
await store.addVectors(vectors, docs.map((d) => new Document(d)));
return store;
} catch {
return null; // 缓存不存在,返回 null
}
}
MemoryVectorStore 内部使用 memoryVectors 数组存储 {content, embedding, metadata}。我们可以直接序列化这个数组到磁盘,启动时反序列化重建。
Step 7:实现 RAG 问答引擎
javascript
// extractor.js
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
export async function extractInfoFromPdf(retriever, question) {
// 1. 初始化 DeepSeek
const model = new ChatOpenAI({
modelName: "deepseek-chat",
temperature: 0,
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: "https://api.deepseek.com" // DeepSeek 兼容 OpenAI 格式
}
});
// 2. 构建 Prompt
const prompt = ChatPromptTemplate.fromTemplate(`
你是专业的文档分析助手,请根据以下内容回答问题。
## 要求
1. 严格基于检索到的内容回答,不要编造
2. 如果内容不足以回答,请如实说无法回答
## 检索到的内容
{context}
## 用户问题
{question}
`);
// 3. 检索相关文档
const docs = await retriever.invoke(question);
// 4. 组装上下文
const context = docs.map((d, i) =>
`[片段 ${i + 1}]\n${d.pageContent}`
).join("\n\n");
// 5. LCEL 链式调用
const chain = prompt.pipe(model).pipe(new StringOutputParser());
return await chain.invoke({ context, question });
}
LCEL(LangChain Expression Language) 是 LangChain 推荐的链式写法。prompt.pipe(model).pipe(outputParser) 等价于"把 prompt 的输出传给 model,再把 model 的输出传给 parser",非常直观。
Step 8:交互式问答终端
javascript
// index.js
import { createInterface } from "readline";
import { loadPdfToRetriever } from "./loadPdf.js";
import { extractInfoFromPdf } from "./extractor.js";
const answerCache = new Map();
async function main() {
console.log("📂 正在加载 PDF...");
const retriever = await loadPdfToRetriever("./zqcy.pdf");
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "❯ " });
rl.prompt();
rl.on("line", async (line) => {
const input = line.trim();
if (/^(exit|quit|q)$/i.test(input)) { rl.close(); return; }
if (!input) { rl.prompt(); return; }
// 缓存检查(相同问题直接返回)
if (answerCache.has(input)) {
console.log(`📄 回答(缓存):\n${answerCache.get(input)}`);
rl.prompt();
return;
}
const result = await extractInfoFromPdf(retriever, input);
console.log(`\n📄 回答:\n${result}\n`);
answerCache.set(input, result);
rl.prompt();
});
}
到这里,一个完整的 RAG 系统就搭建完成了!
四、避坑指南
1. API 不兼容问题
问题 :tongyi-embedding-vision-plus 不支持 OpenAI 兼容模式。
makefile
BadRequestError: Unsupported model for OpenAI compatibility mode
解决 :使用 DashScope 原生端点 dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding,而非兼容模式 compatible-mode/v1。
2. Batch Size 限制
问题:DashScope 限制每批最多 10 条,默认 batchSize 为 24 会报 400。
vbnet
BadRequestError: batch size is invalid, it should not be larger than 10
解决 :设置 batchSize: 10。
3. 向量化耗时与缓存
问题:458 个文本块首次向量化需要 2-3 分钟,每次启动都重复这个过程体验极差。
解决:将向量数据缓存到磁盘 JSON 文件,第二次启动秒级加载。
4. DeepSeek vs OpenAI 地址
DeepSeek 的 API 兼容 OpenAI 格式,但 baseURL 需要改为 https://api.deepseek.com。使用 ChatOpenAI 类即可调用,无需额外的 SDK。
五、成果演示
首次启动:
bash
$ node query.js "报考条件有哪些?"
📂 正在加载 PDF...
✅ 加载完成,共 255 页
✂️ 切分为 458 个文本块
🔮 正在向量化...(约 2 分钟)
✅ 向量存储构建完成
💾 已缓存到磁盘
📚 检索到 6 个相关片段
🤖 正在调用 DeepSeek...
📄 回答:
1. 年满18周岁
2. 满足以下任一条件:
- 大专及以上学历
- 高中学历 + 36个月以上工作经历
- 证券行业机构已开具录用通知的应届生
3. 具有完全民事行为能力
第二次查询同一问题(缓存命中,秒级响应):
bash
📦 从缓存加载向量数据...
✅ 缓存加载完成(458 条)
✅ 检索器就绪(使用缓存)
六、扩展思考
给这个 RAG 加"记忆"
当前系统是无状态的------每次提问都是独立的。如果要支持多轮对话,有几种方式:
- 将历史对话作为检索输入的一部分:把最近 N 轮对话拼接到当前问题前
- 使用 LangChain 的
HistoryAwareRetriever:自动将对话历史转化为检索 query - 独立的对话记忆模块 :使用
BufferMemory存储对话摘要
更好的检索方式
目前使用余弦相似度进行向量检索。对于专业性强的文档,可以:
- 混合检索 :向量检索 + BM25 关键词检索,再用
EnsembleRetriever融合结果 - 重排序(Rerank):先用粗召回取 top-20,再用 Cross-encoder 重排序取 top-6
从原型到生产
| 阶段 | 向量存储 | 部署方式 |
|---|---|---|
| 原型开发 | MemoryVectorStore | CLI 终端 |
| 小规模 | FAISS / Chroma | Express API |
| 生产级 | pgvector / Milvus / Qdrant | Docker + 异步队列 |
七、完整项目结构
bash
pdfRAG/
├── .env # API Key 配置
├── embeddings.js # 自定义 embedding 封装
├── loadPdf.js # PDF 加载 + 切分 + 向量化
├── extractor.js # RAG 问答引擎
├── store.js # 向量缓存持久化
├── index.js # 交互式终端
├── query.js # 一键查询脚本
├── .vector-cache/ # 向量缓存目录
│ ├── documents.json # ─ 文档元数据
│ └── vectors.json # ─ 向量数据
└── package.json
八、快速上手
bash
# 1. 克隆项目
cd pdfRAG
# 2. 安装依赖
npm install
# 3. 配置 .env 中的 API Key
# 编辑 .env 填入 DeepSeek + DashScope 的 API Key
# 4. 一键问答
node query.js "你的问题是什么?"
# 5. 交互式问答
node index.js
写在最后
RAG 的本质并不复杂------检索 + 生成。真正需要花心思的地方在于:
- 文档理解:PDF 本身的结构(表格、页眉页脚、多栏排版)会影响切分质量
- 检索质量:chunk 大小、重叠率、embedding 模型的选择都直接影响召回效果
- Prompt 工程:同样的检索结果,不同的 prompt 能引导出截然不同的回答质量
这个项目从零到一搭建了一个可运行的 RAG 系统,代码量不到 200 行。希望能帮你快速上手,然后根据自己的场景做进一步的优化和定制。