首页 > 教程攻略 > ai教程 >从零实现一个 PDF 智能问答系统

从零实现一个 PDF 智能问答系统

来源:互联网 时间:2026-06-18 07:25:42

从零实现一个 PDF 智能问答系统:基于 LangChain + DeepSeek 的 RAG 实战

先说RAG是什么——它的全称是Retrieval-Augmented Generation,中文翻译过来就是“检索增强生成”。名字听着挺唬人,核心思想其实简单到两三句话就能说清楚。 从零实现一个 PDF 智能问答系统 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("