零基础搭建PDF智能问答系统

作者:技术拾光时间:2026-06-18 06:29:01浏览:477

基于LangChain与DeepSeek构建PDF智能问答系统,采用RAG技术实现检索增强生成。流程包括PDF加载、递归文本切分、通义多模态向量化、内存向量库存储、语义检索及DeepSeek回答生成。通过向量缓存持久化提升效率,无需模型微调即可精准回答私有数据问题。

从零实现一个 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 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 加"记忆"

当前系统是无状态的——每次提问都是独立的。如果想让它支持多轮对话,可以走这几条路:

  1. 把历史对话作为检索输入的一部分:最近 N 轮对话拼到当前问题前。
  2. 用 LangChain 的 HistoryAwareRetriever:自动把对话历史转化成检索 query。
  3. 独立对话记忆模块:用 BufferMemory 存储对话摘要。

更好的检索方式

现在用的是余弦相似度做向量检索。如果面对专业性很强的文档,可以考虑:

  • 混合检索:向量检索 + BM25 关键词检索,再用 EnsembleRetriever 融合结果。
  • 重排序(Rerank):先粗召回取 top-20,再用 Cross-encoder 重排序取 top-6。

从原型到生产

阶段向量存储部署方式
原型开发MemoryVectorStoreCLI 终端
小规模FAISS / ChromaExpress API
生产级pgvector / Milvus / QdrantDocker + 异步队列

七、完整项目结构

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 行。希望它能帮你快速上手,然后根据自己的场景做进一步的优化和定制。