乐于分享
好东西不私藏

AI Case 2 · PDF文档智能问答

AI Case 2 · PDF文档智能问答

AI Case 2 · 文档智能问答:上传PDF就能问

每天一个AI实战案例,21天从入门到精通
这是第2天,今天我们要构建一个PDF文档智能问答系统——上传一份PDF,然后像聊天一样向它提问。

一、今天要做什么?

你有没有遇到过这种场景:

  • 收到一份50页的技术文档,老板让你”快速总结一下核心要点”
  • 客户发来一份PDF合同,你需要快速找到某项条款的具体内容
  • 团队知识库里存了上百份PDF报告,每次找信息都要手动翻

PDF文档智能问答就是解决这些痛点的。它的核心思路是:

  1. 解析PDF → 提取全部文本
  2. 文本分块 → 把长文档切成小块
  3. 向量化 → 用Embedding模型把文本变成数学向量
  4. 存入向量数据库 → 建立语义索引
  5. 用户提问时 → 语义检索最相关的片段 → 喂给LLM生成精准答案

这就是RAG(Retrieval-Augmented Generation,检索增强生成)的经典流程。

二、系统架构

整个系统分为离线索引阶段在线问答阶段

核心组件说明:

组件 作用 推荐方案
PDF解析器 提取PDF中的文本内容 PyPDF2 / pdfplumber
文本分割器 按语义边界切分文本 RecursiveCharacterTextSplitter
Embedding模型 文本向量化 text-embedding-3-small / bge-large
向量数据库 存储和检索向量 FAISS / ChromaDB / Milvus
LLM 理解上下文生成答案 GPT-4 / Qwen / GLM-4

三、完整代码实现

3.1 安装依赖

pip install PyPDF2 faiss-cpu chromadb sentence-transformers openai langchain

3.2 核心实现代码

"""
PDF文档智能问答系统
功能:上传PDF文件,基于RAG实现智能问答
"""


import os
import io
from PyPDF2 import PdfReader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

# ============ 配置 ============
API_KEY = "your-api-key"          # 替换为你的API Key
API_BASE = "https://api.openai.com/v1"  # 或国内代理地址
MODEL_NAME = "gpt-4o-mini"       # LLM模型
EMBEDDING_MODEL = "text-embedding-3-small"  # Embedding模型
CHUNK_SIZE = 500                  # 每块文本大小
CHUNK_OVERLAP = 50                # 块间重叠字符数
TOP_K = 3                         # 检索返回的文档块数

# ============ 第1步:PDF解析 ============
def extract_text_from_pdf(file_path):
    """从PDF文件中提取全部文本"""
    reader = PdfReader(file_path)
    full_text = ""
    for i, page in enumerate(reader.pages):
        text = page.extract_text()
        if text:
            full_text += f"\n--- 第{i+1}页 ---\n{text}"
    return full_text

def extract_text_from_pdf_bytes(pdf_bytes):
    """从PDF字节流中提取文本(适用于Web上传场景)"""
    reader = PdfReader(io.BytesIO(pdf_bytes))
    full_text = ""
    for i, page in enumerate(reader.pages):
        text = page.extract_text()
        if text:
            full_text += f"\n--- 第{i+1}页 ---\n{text}"
    return full_text

# ============ 第2步:文本分块 ============
def split_text(text, chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP):
    """将长文本按语义切分为小块"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n""\n""。""."" """],
        length_function=len,
    )
    chunks = splitter.split_text(text)
    print(f"文本分为 {len(chunks)} 个块")
    return chunks

# ============ 第3步:构建向量索引 ============
def build_vector_store(chunks):
    """将文本块向量化并存入FAISS索引"""
    embeddings = OpenAIEmbeddings(
        model=EMBEDDING_MODEL,
        openai_api_key=API_KEY,
        openai_api_base=API_BASE,
    )
    vector_store = FAISS.from_texts(chunks, embeddings)
    return vector_store

# ============ 第4步:构建问答链 ============
def build_qa_chain(vector_store):
    """构建检索增强问答链"""
    llm = ChatOpenAI(
        model=MODEL_NAME,
        openai_api_key=API_KEY,
        openai_api_base=API_BASE,
        temperature=0,
    )

    # 自定义Prompt模板
    prompt_template = """基于以下文档内容回答用户的问题。
如果文档中没有相关信息,请明确告知用户"该文档中未找到相关信息"。
回答时请引用文档中的具体内容,并标注出处页码(如果有的话)。

参考文档:
{context}

用户问题:{question}

请用中文回答:"""


    PROMPT = PromptTemplate(
        template=prompt_template,
        input_variables=["context""question"],
    )

    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",       # 将检索到的所有片段拼接
        retriever=vector_store.as_retriever(search_kwargs={"k": TOP_K}),
        chain_type_kwargs={"prompt": PROMPT},
        return_source_documents=True,  # 返回引用来源
    )
    return qa_chain

# ============ 第5步:完整问答流程 ============
class PDFChatBot:
    """PDF智能问答机器人"""

    def __init__(self, api_key, api_base=API_BASE):
        self.api_key = api_key
        self.api_base = api_base
        self.vector_store = None
        self.qa_chain = None
        self.file_name = None

    def load_pdf(self, file_path):
        """加载PDF文件并建立索引"""
        self.file_name = os.path.basename(file_path)
        print(f"📄 正在解析: {self.file_name}")

        # 提取文本
        text = extract_text_from_pdf(file_path)
        print(f"📝 提取文本长度: {len(text)} 字符")

        # 文本分块
        chunks = split_text(text)
        if not chunks:
            print("❌ 未能提取到有效文本")
            return False

        # 构建向量索引
        print("🔍 正在构建向量索引...")
        self.vector_store = build_vector_store(chunks)

        # 构建问答链
        self.qa_chain = build_qa_chain(self.vector_store)
        print("✅ 索引构建完成,可以开始提问了!")
        return True

    def ask(self, question):
        """向文档提问"""
        if not self.qa_chain:
            return "请先加载PDF文件"

        result = self.qa_chain({"query": question})

        answer = result.get("result""")
        sources = result.get("source_documents", [])

        # 格式化输出
        output = f"**回答:**\n{answer}\n\n"
        if sources:
            output += f"**引用来源(共{len(sources)}个相关片段):**\n"
            for i, doc in enumerate(sources):
                snippet = doc.page_content[:100].replace("\n"" ")
                output += f"  {i+1}{snippet}...\n"

        return output


# ============ 使用示例 ============
if __name__ == "__main__":
    # 初始化机器人
    bot = PDFChatBot(api_key=API_KEY, api_base=API_BASE)

    # 加载PDF
    bot.load_pdf("example.pdf")

    # 开始对话
    questions = [
        "这份文档的主要内容是什么?",
        "文档中关于XXX的具体描述是什么?",
        "帮我总结文档的核心结论。",
    ]

    for q in questions:
        print(f"\n{'='*50}")
        print(f"👤 提问: {q}")
        print(f"🤖 回答: {bot.ask(q)}")

3.3 进阶:使用ChromaDB持久化

"""使用ChromaDB实现向量索引的持久化存储"""

import chromadb
from chromadb.config import Settings

def build_persistent_store(chunks, persist_dir="./chroma_db"):
    """构建持久化向量存储"""
    # 初始化ChromaDB客户端
    client = chromadb.Client(Settings(
        chroma_db_impl="duckdb+parquet",
        persist_directory=persist_dir,
    ))

    # 创建集合
    collection = client.get_or_create_collection(
        name="pdf_documents",
        metadata={"hnsw:space""cosine"},
    )

    # 分批存入(ChromaDB有批量限制)
    batch_size = 500
    for i in range(0len(chunks), batch_size):
        batch = chunks[i:i + batch_size]
        ids = [f"chunk_{i+j}" for j in range(len(batch))]
        collection.add(
            documents=batch,
            ids=ids,
        )

    # 持久化到磁盘
    client.persist()
    print(f"已存储 {len(chunks)} 个文档块到 {persist_dir}")
    return collection

def search_similar(collection, query_text, embeddings_fn, top_k=3):
    """语义检索"""
    query_embedding = embeddings_fn([query_text])[0]
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=top_k,
    )
    return results

3.4 进阶:流式输出

"""实现流式输出,提升用户体验"""

def stream_qa(qa_chain, question):
    """流式问答"""
    docs = qa_chain.retriever.get_relevant_documents(question)
    context = "\n\n".join([d.page_content for d in docs])

    prompt = f"""基于以下文档内容回答问题:
{context}

问题:{question}

回答:"""


    for chunk in qa_chain.llm.stream(prompt):
        print(chunk.content, end="", flush=True)
    print()

四、关键技术详解

4.1 文本分块策略

分块是RAG系统中最关键的参数调优点之一

策略 适用场景 优缺点
固定长度分块 通用场景 简单但可能切断语义
递归分块 多种文档类型 按分隔符递归切分,保留语义
语义分块 长文档 基于Embedding相似度切分,效果最好
父子分块 需要精确引用 检索小块,返回大块上下文

调参建议:

  • chunk_size=500, overlap=50 → 适合一般文档
  • chunk_size=1000, overlap=100 → 适合技术文档(代码块较多)
  • chunk_size=300, overlap=30 → 适合FAQ/合同类精确查询

4.2 Embedding模型选择

模型 维度 中文支持 推荐场景
text-embedding-3-small 1536 支持 通用场景,性价比高
text-embedding-3-large 3072 支持 高精度需求
bge-large-zh 1024 优秀 纯中文场景,开源首选
text2vec-large-chinese 1024 优秀 中文长文本

4.3 检索优化技巧

# 技巧1:混合检索(关键词 + 语义)
from langchain.retrievers import BM25Retriever, EnsembleRetriever

bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 3

faiss_retriever = vector_store.as_retriever(search_kwargs={"k"3})

ensemble = EnsembleRetriever(
    retrievers=[bm25_retriever, faiss_retriever],
    weights=[0.40.6],  # BM25权重0.4, 语义权重0.6
)

# 技巧2:重排序(Reranker)
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker

reranker = CrossEncoderReranker(top_n=3)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=vector_store.as_retriever(),
)

五、常见问题与解决方案

Q1:PDF提取出的文本是空的?
→ 有些PDF是扫描件(图片型PDF),需要先用OCR工具(如Tesseract、PaddleOCR)进行文字识别。

Q2:回答不够准确?
→ 调大top_k参数获取更多上下文;优化分块策略确保语义完整性;尝试使用混合检索。

Q3:处理大文档很慢?
→ 使用ChromaDB的持久化功能,只需索引一次;对Embedding做批量处理减少API调用。

Q4:如何支持多文档?
→ 给每个文档块添加metadata(如文件名、页码),检索时可以按文档来源过滤。

六、今日小结

今天我们用不到200行代码构建了一个完整的PDF智能问答系统,核心知识点:

  • ✅ PDF文本提取(PyPDF2)
  • ✅ 文本分块策略(RecursiveCharacterTextSplitter)
  • ✅ 向量化与存储(FAISS / ChromaDB)
  • ✅ RAG检索增强生成(RetrievalQA Chain)
  • ✅ 自定义Prompt模板优化回答质量

明天预告: 第3期——关键词提取与文本摘要,学习如何用AI快速提取文章的核心信息和自动生成摘要。


关注公众号「AI实践日记」,每天一个可运行的AI实战案例。
完整代码可在公众号后台回复「AI案例」获取。