基于LangChain与DeepSeek构建PDF智能问答系统,采用RAG技术实现检索增强生成。流程包括PDF加载、递归文本切分、通义多模态向量化、内存向量库存储、语义检索及DeepSeek回答生成。通过向量缓存持久化提升效率,无需模型微调即可精准回答私有数据问题。
零基础搭建PDF智能问答系统
时间:2026-06-18
浏览:476
文章目录
从零实现一个 PDF 智能问答系统:基于 LangChain + DeepSeek 的 RAG 实战
先说RAG是什么——它的全称是Retrieval-Augmented Generation,中文翻译过来就是“检索增强生成”。名字听着挺唬人,核心思想其实简单到两三句话就能说清楚。
- 检索:从文档里捞出和问题最相关的内容片段。
- 增强:把这些片段当作上下文(Context)喂给大模型。
- 生成:大模型拿着这份现成的参考材料,给出精准回答。
直接让大模型硬答,它根本不知道你的私有数据长什么样。RAG这条路径,恰好绕过了这个短板,还不用花大价钱去做模型微调。怎么想都划算。
整个流程的画风是这样的:
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ 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:初始化项目
mkdir pdfRAG && cd pdfRAG
npm init -y
# 设置 type: "module" 以支持 ES Module
在 package.json 中修改:
{"type": "module"}
然后安装依赖:
npm install langchain @langchain/core @langchain/community @langchain/openai dotenv pdf-parse
Step 2:配置环境变量
创建 .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 加载与切分
// 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 基类自己封装一个:
// 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:向量化与检索器构建
// 完整流程
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 缓存机制,一劳永逸:
// 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 问答引擎
// 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,再把 mode 的输出传给 parser”就好,思路清晰得很。
Step 8:交互式问答终端
// 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 兼容模式。
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。
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。
五、成果演示
首次启动:
$ node query.js "报考条件有哪些?"
正在加载 PDF...
加载完成,共 255 页
切分为 458 个文本块
正在向量化...(约 2 分钟)
向量存储构建完成
已缓存到磁盘
检索到 6 个相关片段
正在调用 DeepSeek...
回答:
1. 年满18周岁
2. 满足以下任一条件:
- 大专及以上学历
- 高中学历 + 36个月以上工作经历
- 证券行业机构已开具录用通知的应届生
3. 具有完全民事行为能力
第二次查询同一问题(缓存命中,秒级响应):
从缓存加载向量数据...
缓存加载完成(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 + 异步队列 |
七、完整项目结构
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
八、快速上手
# 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 行。希望它能帮你快速上手,然后根据自己的场景做进一步的优化和定制。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。