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

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

你是否也有一堆 PDF 文档,想对它们提问却无从下手?本文将手把手教你用 LangChain 构建一个完整的 RAG(检索增强生成)系统,让你的 PDF 变得"可对话"。


一、RAG 是什么?

RAG(Retrieval-Augmented Generation)即"检索增强生成"。它的核心思想很简单:

  1. 检索:从文档中找到与问题最相关的内容片段
  2. 增强:将这些片段作为上下文(Context)提供给大语言模型
  3. 生成:大模型基于上下文生成精准回答

相比直接让大模型回答,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 个文本块

关键点chunkSizechunkOverlap 需要根据文档类型调优。金融/法律文档建议 500-800 字符,常规文档可以 1000-2000。重叠部分能保证上下文不被硬切断裂。

Step 4:自定义 Embedding 封装

LangChain 内置的 AlibabaTongyiEmbeddings 只支持普通文本 embedding 端点,而 tongyi-embedding-vision-plus 是一个多模态模型,需要使用不同的 API 端点。我们基于 @langchain/coreEmbeddings 基类自定义封装:

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 加"记忆"

当前系统是无状态的------每次提问都是独立的。如果要支持多轮对话,有几种方式:

  1. 将历史对话作为检索输入的一部分:把最近 N 轮对话拼接到当前问题前
  2. 使用 LangChain 的 HistoryAwareRetriever:自动将对话历史转化为检索 query
  3. 独立的对话记忆模块 :使用 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 行。希望能帮你快速上手,然后根据自己的场景做进一步的优化和定制。


相关推荐
打小就很皮...1 小时前
基于Python + LangChain + 通义千问的聊天机器人实战
前端·langchain·机器人·千问
Flittly1 小时前
【LangGraph新手村系列】(5)时间旅行:浏览历史、分叉时间线与修改过去
python·langchain
飞Link1 小时前
智能体时代的“紧箍咒”:深度解析 Agent 治理架构与 AI 杀伤开关
人工智能·架构
飞Link1 小时前
2000 亿砸向算力:字节跳动 AI 基建跨越,后端与运维的“万亿 Token”生死战
运维·人工智能
zhangfeng11332 小时前
小龙虾 wordbuddy 安装浏览器控制器 agent-browser npm install -g agent-browse
前端·人工智能·npm·node.js
阿里云大数据AI技术2 小时前
一条 SQL 生成广告:Hologres 如何实现素材生成到投放分析一体化
人工智能·sql
liudanzhengxi2 小时前
GitSubmodule避坑全攻略
人工智能·新人首发
用户425210800602 小时前
Claude Code Linux 服务器部署与配置
人工智能
OJAC1112 小时前
学过Python却不敢投AI岗,他最后拿下12K offer
人工智能