从零实现一个 PDF 智能问答系统
来源:互联网
时间:2026-06-18 07:25:42
从零实现一个 PDF 智能问答系统:基于 LangChain + DeepSeek 的 RAG 实战
先说RAG是什么——它的全称是Retrieval-Augmented Generation,中文翻译过来就是“检索增强生成”。名字听着挺唬人,核心思想其实简单到两三句话就能说清楚。

1. 检索:从文档里捞出和问题最相关的内容片段。
2. 增强:把这些片段当作上下文(Context)喂给大模型。
3. 生成:大模型拿着这份现成的参考材料,给出精准回答。
直接让大模型硬答,它根本不知道你的私有数据长什么样。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 sa veVectorStore(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}]
${d.pageContent}`)
.join("
");
// 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:交互式问答终端
```
// 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("