怎么快速实现一个 ChatPDF

geekbing2024-10-02AIRAG

好奇心

最近在研究 RAG 相关的技术,刚好看到 ChatPDFopen in new window(一款像使用 ChatGPT 一样轻松地与 PDF 进行对话,还支持引用溯源的应用), 它突破了 LLM 上下文的限制,可以和长达几百页的 PDF 文件进行交互,很好奇它是怎么实现的。

chatpdf

架构设计

开始之前先说明一下,这篇文章假设你对嵌入模型、重排序模型、向量数据库、LLM 这些概念有基本了解,就不展开讲了。

核心原理

在实现 ChatPDF 之前,我最大的疑惑是 LLM 怎么知道内容来自 PDF 的哪一页?

分析下来其实很简单,核心分三步:

  1. 解析分块:将 PDF 文档切分成小块,为每一块打上标记(页码 + bbox 边界框)
  2. 检索返回:LLM 检索时,把相关文本块连同标记一起返回
  3. 前端高亮:前端根据页码和 bbox 坐标,在 PDF 上精准定位并高亮显示引用区域

这样就实现了下面这种效果:

这是我后面开发的产品 Chat2Reportopen in new window,里面有一个财报模块也采用了 RAG 技术,但是明显比 ChatPDF 的回答效果要好不少。

chat2report

系统流程

总结下来 ChatPDF 包括以下几个关键环节:

  1. 文档处理管道:解析 PDF 文档 → 提取文本 → 分块 → 保留元数据(页码/bbox)
  2. 向量存储:分块向量化 → 存入向量数据库
  3. 检索管道:用户提问 → 向量相似性检索 → 重排序精选
  4. 生成回答:使用检索的内容作为上下文 → LLM 生成带引用的答案

为了提高检索准确率,引入了重排序模型(Reranker Model),用于第二阶段精排。

技术选型

为实现上述流程,需要选择合适的工具。选型原则:性能够用 + 成本可控 + 易于集成。

组件选择理由对应流程环节
文档解析Doclingopen in new window支持智能分块、保留完整元数据(页码/bbox/表格结构)文档处理管道
RAG框架LlamaIndexopen in new window专为RAG设计,集成所有组件,开箱即用全流程编排
嵌入模型Jina Embeddings v3open in new window$0.02/百万token,MTEB性能超OpenAI,支持8192 token向量化
重排序Jina Reranker v2open in new window$0.02/百万token,支持100+语言,性能顶尖检索精排
向量数据库Qdrantopen in new windowRust开发性能强,文档详细,支持混合检索向量存储
LLMDeepSeekopen in new window性价比高,幻方量化出品,推理质量优秀答案生成

reranker_ranking

整套方案以 Jina 为核心(嵌入+重排序),统一定价 $0.02/百万token,相比 OpenAI 的 text-embedding-3-large($0.13/百万token)节省 85% 成本。

核心概念

在动手写代码之前,需要理解几个关键概念,这些概念贯穿整个实现过程。

向量嵌入基础

什么是向量嵌入?

向量嵌入是将文本转换为数字向量的过程。例如,"苹果公司发布新产品"这句话会被转换为一个 256 维的向量 [0.23, -0.45, 0.67, ...]。相似的文本会产生相似的向量。

为什么选择 256 维?

  • 精度够用:256 维在检索准确率上与 768 维、1024 维相比损失很小(<2%)
  • 存储节省:相比 768 维节省 67% 存储空间
  • 检索更快:向量维度越低,相似度计算越快

其实最开始选择的是 1024 维,后面吃了大亏,所以重新编辑更新了这篇文章。后面有讲如何选择嵌入维度。

余弦相似度 vs 其他距离度量

度量方式适用场景优点缺点
COSINE文本嵌入、语义搜索对长度不敏感,符合语义直觉需要向量非零
EUCLID图像特征、坐标数据直观,保留长度信息受向量长度影响大
DOT推荐系统、评分预测计算快,保留长度信息不对称,需要归一化
MANHATTAN高维稀疏向量、异常值场景快速,对异常值鲁棒不适合语义搜索

选择 COSINE(余弦相似度),是因为在 NLP 领域,它更符合人类对"相似"的理解,只关注方向而不关注长度。

分块策略

为什么需要分块?

PDF 文档可能有几百页,但 LLM 上下文窗口有限,向量检索也需要在合适的维度上进行。太大的块会包含无关信息,太小的块会丢失上下文。

HierarchicalChunker 智能分块

Docling 的 HierarchicalChunker 会:

  • 保持段落完整性,不在句子中间切断
  • 根据文档结构(标题、章节)进行分块
  • 自动关联同一标题下的内容块

元数据的重要性

每个块都会保留:

  • page:页码,用于引用溯源
  • bbox:边界框坐标,标记文本在页面中的精确位置
  • headings:标题层级,帮助理解上下文

这些元数据是实现点击页码跳转+高亮显示的关键。

RAG 检索流程

两阶段检索

  1. 粗排(Retrieval):similarity_top_k=5

    • 在向量数据库中快速检索出 5 个最相似的文档块
    • 基于向量余弦相似度排序
    • 速度快但精度有限
  2. 精排(Rerank):top_n=3

    • 使用专门的重排序模型对 5 个候选进行重新打分
    • 考虑更复杂的语义关系
    • 从 5 个中精选出 3 个最相关的

为什么需要重排序?

向量检索是基于嵌入模型的,可能会漏掉一些语义细节。重排序模型专门训练用于判断查询-文档的相关性,能显著提升最终结果的质量。

检索参数调优

  • similarity_top_k:初筛候选数量,建议 5-10
  • top_n:最终使用数量,建议 3-5
  • 两者比例通常是 2:1 或 3:1

文本 & 图片提取

Docling 的输出格式

Docling 提取 PDF 后可以导出为:

JSON 格式(推荐用于引用溯源):

  • 页面编号 (page_no)
  • 布局信息(边界框 bbox)
  • 字符范围 (charspan)
  • 文档层次结构
  • 图片和表格的完整元数据

Markdown 格式(推荐用于纯文本场景):

  • 文本内容和基本格式化
  • 标题层级
  • 表格结构(简化版)
  • 图片引用

这里选择 JSON 格式,因为它包含完整的位置信息,是实现精准引用溯源的基础。

多模态扩展方向

示例代码未展示图片嵌入,实际应用中有两种方案:

  1. 文本化方案:提取图片 → 多模态 LLM 生成描述 → 与文本一起嵌入
  2. 多模态嵌入:使用多模态嵌入模型直接嵌入图片和文本

实现

环境准备

安装依赖

pip install llama-index-core llama-index-embeddings-jinaai llama-index-llms-openai-like \
            llama-index-postprocessor-jinaai-rerank llama-index-vector-stores-qdrant \
            llama-index-readers-docling llama-index-node-parser-docling \
            qdrant-client python-dotenv tiktoken

配置环境变量.env 文件):

DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 
DEEPSEEK_API_KEY=your_deepseek_key
JINA_API_KEY=your_jina_key

启动 Qdrant

docker run -p 6333:6333 qdrant/qdrant

实现步骤概览

整个实现分为 5 个步骤,每个步骤对应代码中的一个关键部分:

步骤功能核心代码输出
Step 1配置 LLM、嵌入、重排序模型llm = OpenAILike(...)模型实例
Step 2解析 PDF、智能分块、提取元数据process_pdf_to_documents()Document 列表
Step 3创建向量索引、存储到 QdrantVectorStoreIndex.from_documents()VectorStoreIndex
Step 4配置查询引擎、提示词index.as_query_engine()QueryEngine
Step 5执行查询、展示结果query_engine.query()带引用的答案

完整代码

下面是完整的实现代码,包含详细的注释说明每个步骤的作用。代码可以直接运行,建议按照上面的步骤概览逐步理解。

import os
import pathlib
import tiktoken
from typing import List, Tuple
from dotenv import load_dotenv

from llama_index.core import (
    Document,
    PromptTemplate,
    Settings,
    StorageContext,
    VectorStoreIndex,
)
from llama_index.embeddings.jinaai import JinaEmbedding
from llama_index.llms.openai_like import OpenAILike
from llama_index.postprocessor.jinaai_rerank import JinaRerank
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.node_parser.docling import DoclingNodeParser
from llama_index.readers.docling import DoclingReader
from qdrant_client.models import Distance, VectorParams
from qdrant_client import QdrantClient

root_path = pathlib.Path(__file__).parent
load_dotenv()

# ============================================================
# 第一步: 配置大语言模型 (LLM)
# ============================================================
# 使用 Deepseek 作为推理模型,负责根据检索到的文档生成最终答案
llm = OpenAILike(
    api_base=os.getenv("DEEPSEEK_BASE_URL"),
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    model="deepseek-chat",
    temperature=0.0,
    max_tokens=1024,
    context_window=8192,
    is_chat_model=True,
    timeout=60,
)

Settings.llm = llm
Settings.tokenizer = tiktoken.encoding_for_model("gpt-4o").encode

# ============================================================
# 第二步: 配置嵌入模型 (Embedding Model)
# ============================================================
# 文本嵌入模型: 将文档文本转换为 256 维向量,用于存储到向量数据库
text_embed_model = JinaEmbedding(
    api_key=os.getenv("JINA_API_KEY"),
    model="jina-embeddings-v3",
    embed_batch_size=32,
    dimensions=256,
    task="retrieval.passage",  # 针对文档段落优化
)

# 查询嵌入模型: 将用户问题转换为 256 维向量,用于检索相似文档
query_embed_model = JinaEmbedding(
    api_key=os.getenv("JINA_API_KEY"),
    model="jina-embeddings-v3",
    embed_batch_size=32,
    dimensions=256,
    task="retrieval.query",  # 针对查询优化
)

# ============================================================
# 第三步: 配置重排序模型 (Reranker)
# ============================================================
# 重排序模型: 对初步检索结果进行精排,提高最相关文档的排名
jina_rerank = JinaRerank(
    api_key=os.getenv("JINA_API_KEY"),
    model="jina-reranker-v2-base-multilingual",
    top_n=3,  # 重排序后保留前 3 个最相关的文档
)

# ============================================================
# 第四步: 连接向量数据库 (Qdrant)
# ============================================================
# Qdrant: 存储文档向量,支持高效的相似度搜索
qdrant = QdrantClient(host="localhost", port=6333)


# ============================================================
# 核心函数 1: 初始化向量存储
# ============================================================
def init_vector_store(collection_name: str) -> Tuple[QdrantVectorStore, StorageContext]:
    """
    初始化向量存储和存储上下文

    功能说明:
    - 创建 Qdrant 向量存储的连接
    - 配置 LlamaIndex 的存储上下文,用于后续索引创建

    Args:
        collection_name: Qdrant 集合名称

    Returns:
        Tuple[QdrantVectorStore, StorageContext]: 向量存储和存储上下文的元组
    """
    vector_store = QdrantVectorStore(client=qdrant, collection_name=collection_name)
    storage_context = StorageContext.from_defaults(vector_store=vector_store)
    return vector_store, storage_context


# ============================================================
# 核心函数 2: 确保集合存在
# ============================================================
def ensure_collection_exists(collection_name: str) -> bool:
    """
    确保 Qdrant 集合存在,如果不存在则创建

    功能说明:
    - 检查指定的集合是否已存在
    - 如果不存在,创建新集合并配置向量参数:
      * size=256: 向量维度为 256 (与嵌入模型的 dimensions 参数一致)
      * distance=COSINE: 使用余弦相似度计算文档相似性

    Args:
        collection_name: Qdrant 集合名称

    Returns:
        bool: True 表示新创建的集合, False 表示集合已存在
    """
    if not qdrant.collection_exists(collection_name):
        qdrant.create_collection(
            collection_name=collection_name,
            vectors_config=VectorParams(size=256, distance=Distance.COSINE),
        )
        return True
    return False


# ============================================================
# 核心函数 3: PDF 文档处理
# ============================================================
def process_pdf_to_documents(file_path: str) -> List[Document]:
    """
    处理 PDF 文件并转换为 LlamaIndex Document 列表

    功能说明:
    这个函数完成三个关键步骤:
    1. 读取 PDF: 使用 Docling 解析 PDF 的文本、表格、图片等内容
    2. 分块 (Chunking): 将长文档切分为适合嵌入的小块
    3. 元数据提取: 保留页码、位置等信息,用于后续引用溯源

    Args:
        file_path: PDF 文件路径

    Returns:
        List[Document]: Document 对象列表,每个对象包含文本和元数据
    """
    # 步骤 1: 读取 PDF 文件
    # DoclingReader 可以解析复杂的 PDF 结构 (表格、图片、公式等)
    reader = DoclingReader(export_type=DoclingReader.ExportType.JSON)
    docs = reader.load_data(file_path)

    # 步骤 2: 文档分块 (Chunking)
    # DoclingNodeParser 使用 HierarchicalChunker 进行智能分块:
    # - 保持段落完整性
    # - 根据文档结构 (标题、章节) 进行分块
    # - 避免在句子中间切断
    node_parser = DoclingNodeParser()
    nodes = node_parser.get_nodes_from_documents(docs)

    # 打印分块结果,理解文档如何被切分
    print("\n" + "=" * 70)
    print("文档分块结果")
    print("=" * 70)
    print(f"总节点数: {len(nodes)} 个")
    print(f"平均每块长度: {sum(len(n.text) for n in nodes) // len(nodes)} 字符")
    print(f"最小块长度: {min(len(n.text) for n in nodes)} 字符")
    print(f"最大块长度: {max(len(n.text) for n in nodes)} 字符")

    # 展示第一个节点的详细信息
    if nodes:
        first_node = nodes[0]
        print("\n第一个节点详情:")
        print(f"   文本长度: {len(first_node.text)} 字符")
        print(f"   文本预览: {first_node.text[:150]}...")
        print(f"   元数据: {first_node.metadata}")
    print("=" * 70 + "\n")

    # 步骤 3: 转换为 LlamaIndex Document 格式
    documents = []
    for node in nodes:
        # 提取块级元数据 (用于引用溯源)
        # - page: 页码,用于在答案中标注信息来源
        # - bbox: 边界框坐标,标记文本在页面中的位置
        # - headings: 标题层级,帮助理解文本的上下文
        metadata = {
            "page": str(node.metadata["doc_items"][0]["prov"][0]["page_no"]),
            "bbox": node.metadata["doc_items"][0]["prov"][0]["bbox"],
            "headings": node.metadata.get("headings", []),
        }
        llama_document = Document(
            text=node.text,
            metadata=metadata,
            text_template="Metadata: {metadata_str}\n-----\nContent: {content}",
        )
        documents.append(llama_document)

    return documents


# ============================================================
# 核心函数 4: 创建或加载向量索引 (主流程编排)
# ============================================================
def create_or_get_index():
    """
    创建或获取现有的向量存储索引

    功能说明:
    这是整个 RAG 系统的核心流程编排函数,负责:
    1. 初始化向量存储连接
    2. 检查集合是否存在
    3. 如果是新集合:
       - 处理 PDF 文档
       - 进行向量嵌入 (关键步骤)
       - 存储到 Qdrant
    4. 如果集合已存在:
       - 直接加载现有索引

    Returns:
        VectorStoreIndex: 向量存储索引对象,用于后续查询
    """
    collection_name = "chatpdf"

    # 步骤 1: 初始化向量存储
    vector_store, storage_context = init_vector_store(collection_name)

    # 步骤 2: 检查并创建集合
    is_new_collection = ensure_collection_exists(collection_name)

    if is_new_collection:
        # 分支 A: 新集合 - 需要处理文档并创建索引
        print("检测到新集合,开始处理文档...")

        # 这里解析 A 股一家上市公司的季报作为示例
        file_path = f"{str(root_path)}/data/disu.pdf"
        documents = process_pdf_to_documents(file_path)
        print(f"文档处理完成,共 {len(documents)} 个文档块")

        # 关键步骤: 向量嵌入
        # VectorStoreIndex.from_documents() 内部会:
        # 1. 调用 text_embed_model 将每个文档转换为 256 维向量
        # 2. 将向量存储到 Qdrant 的 chatpdf 集合中
        # 3. 建立文本-向量的映射关系
        print("开始向量嵌入...")
        vector_index = VectorStoreIndex.from_documents(
            documents,
            storage_context=storage_context,
            embed_model=text_embed_model,
        )
        print("向量嵌入完成,索引已创建!")
    else:
        # 分支 B: 已存在集合 - 直接加载现有索引
        print("检测到已存在的集合,加载现有索引...")
        vector_index = VectorStoreIndex.from_vector_store(
            vector_store=vector_store,
            embed_model=query_embed_model,
        )
        print("索引加载完成!")

    return vector_index


# ============================================================
# 第五步: 创建向量索引
# ============================================================
index = create_or_get_index()

# ============================================================
# 第六步: 配置查询提示词
# ============================================================
# 通过精心设计的提示词,引导大模型:
# 1. 仅使用检索到的文档内容回答
# 2. 标注信息来源的页码
# 3. 以结构化的方式输出答案
custom_prompt = PromptTemplate(
    "你是一个乐于助人的助手。使用以下内容作为你所学习的知识的来源。请仔细分析提供的文档内容,回答以下问题。避免使用你自己的知识。\n"
    "文档内容:{context_str}\n"
    "问题:{query_str}\n"
    "请遵循以下要求:\n"
    "1. 仅使用上述文档内容回答问题。\n"
    "2. 如果信息分散在多个部分,请将相关信息整合在一起回答。\n"
    "3. 即使信息出现在表格的不同行或列中,也要能够正确关联。\n"
    "4. 如果上下文中没有直接相关信息,进行合理推算或估计,并说明是推算结果。\n"
    "5. 以项目符号(bullet points)的形式列出陈述。\n"
    "6. 每个陈述必须注明信息来源的页码,格式为 [页码]。页码信息位于每段文本的元数据中的 'page' 字段。\n"
    "7. 如果一个陈述来自多个页面,请标注所有相关页码,如 [4][5]。\n"
)

# ============================================================
# 第七步: 创建查询引擎
# ============================================================
# 查询引擎是 RAG 系统的核心,负责:
# 1. 将用户问题转换为向量
# 2. 在 Qdrant 中检索相似文档
# 3. 使用重排序模型精排结果
# 4. 将检索结果和问题一起发送给 LLM
# 5. 生成最终答案
query_engine = index.as_query_engine(
    llm=llm,  # 使用 Deepseek 生成答案
    embed_model=query_embed_model,  # 使用查询嵌入模型
    streaming=True,  # 开启流式输出,实时返回答案
    similarity_top_k=5,  # 初步检索 5 个最相似的文档
    text_qa_template=custom_prompt,  # 使用自定义提示词
    node_postprocessors=[jina_rerank],  # 重排序: 5 个文档 → 精选 3 个
    verbose=True,  # 打印检索和生成的详细过程
)


# ============================================================
# 主函数: RAG 查询流程演示
# ============================================================
def main():
    """
    RAG 查询的完整流程:
    1. 用户提问
    2. 问题向量化
    3. 向量检索 (在 Qdrant 中找相似文档)
    4. 重排序 (精选最相关的文档)
    5. 生成答案 (LLM 基于检索结果回答)
    6. 展示答案和引用来源
    """
    # 步骤 1: 用户提问
    query_str = "李军是谁?"

    # 步骤 2-5: 查询引擎自动完成向量检索、重排序、生成答案
    # 内部流程:
    # 1. query_embed_model 将问题转为 256 维向量
    # 2. Qdrant 检索出 5 个最相似的文档块
    # 3. jina_rerank 重排序,精选出 3 个最相关的
    # 4. 将 3 个文档块 + 问题 + 提示词发送给 Deepseek
    # 5. Deepseek 生成答案并流式返回
    response = query_engine.query(query_str)

    # 步骤 6: 展示答案 (流式输出)
    print("\n=== 模型回答 ===")
    response_text = ""
    for text in response.response_gen:
        print(text, end="", flush=True)
        response_text += text
    print()

    # 步骤 7: 展示引用来源 (用于验证答案的可靠性)
    print("\n=== 参考文本片段 ===")
    for i, node in enumerate(response.source_nodes, 1):
        print(f"\n文本片段 {i}")
        print("=" * 50)
        print(f"相关度分数: {node.score:.3f}")  # 重排序后的相关度分数
        print(f"元数据信息: {node.metadata}")  # 页码、位置等信息
        print("-" * 50)
        print("引用文本内容:")
        # 只显示前 200 个字符,避免输出过长
        print(node.text[:200] + "..." if len(node.text) > 200 else node.text)
        print("=" * 50)


if __name__ == "__main__":
    main()

关键代码解读

如果你觉得上面的完整代码太长,这里提取几个最关键的代码片段进行解读。

1. 文档分块与元数据提取

# 使用 Docling 智能分块
node_parser = DoclingNodeParser()
nodes = node_parser.get_nodes_from_documents(docs)

# 提取元数据用于引用溯源
for node in nodes:
    metadata = {
        "page": str(node.metadata["doc_items"][0]["prov"][0]["page_no"]),  # 页码
        "bbox": node.metadata["doc_items"][0]["prov"][0]["bbox"],          # 位置
        "headings": node.metadata.get("headings", []),                     # 标题
    }

为什么重要:元数据是实现点击页码跳转的基础,没有 pagebbox 就无法溯源。

2. 向量嵌入与存储

# 创建向量索引(自动完成嵌入)
vector_index = VectorStoreIndex.from_documents(
    documents,
    storage_context=storage_context,  # 指向 Qdrant
    embed_model=text_embed_model,     # 使用 Jina 嵌入模型
)

背后发生了什么

  1. LlamaIndex 遍历所有 documents
  2. 调用 text_embed_model 将每个文档转为 256 维向量
  3. 将向量和元数据一起存储到 Qdrant 的 chatpdf 集合

3. 两阶段检索

query_engine = index.as_query_engine(
    similarity_top_k=5,               # 粗排:检索 5 个候选
    node_postprocessors=[jina_rerank], # 精排:重排序后取 Top 3
)

检索流程

用户问题 → 向量化 → Qdrant检索 Top5 → Jina重排序 Top3 → 发送给 LLM

4. 提示词工程

custom_prompt = PromptTemplate(
    "你是一个乐于助人的助手。使用以下内容作为你所学习的知识的来源...\n"
    "6. 每个陈述必须注明信息来源的页码,格式为 [页码]。\n"
)

为什么需要提示词:引导 LLM 输出结构化答案并标注页码,否则 LLM 可能不会主动标注来源。

运行与调试

首次运行

python3 chatpdf.py

预期输出

  1. 文档分块信息(53 个节点,平均 522 字符)
  2. 向量嵌入进度(可能需要 30-60 秒)
  3. 查询结果(带页码引用的答案)
  4. 参考文本片段(3 个最相关的片段)

常见问题

问题原因解决方案
ConnectionError: QdrantQdrant 未启动docker run -p 6333:6333 qdrant/qdrant
AuthenticationError: JinaAPI Key 错误检查 .env 文件中的 JINA_API_KEY
分块结果为空PDF 路径错误确认 file_path 指向正确的 PDF 文件
检索结果不准确参数未调优尝试调整 similarity_top_ktop_n

运行结果验证

可以通过三个维度验证系统的有效性:分块质量、检索准确性、溯源可靠性。

1. 溯源准确性验证

用户问题:"李军是谁?"

LLM 生成答案

根据提供的文档内容,关于“李军”的信息如下:

*   **身份**:李军被列为“境内自然人”。[4]
*   **持股情况**:李军持有“人民币普通股 1,000,000 股”。[4]
*   **持股方式说明**:李军通过信用证券账户持有这1,000,000股。[5]

PDF 原文对照

pdf_ref

pdf_ref

分析结果

页码精准:答案标注 [4][5],与 PDF 截图完全一致

数据准确:1,000,000 股的数字、"境内自然人"的分类、"信用证券账户"的持股方式,全部正确

元数据传递链路完整:

PDF解析 → 分块(保留page/bbox) → 向量存储 → 检索 → LLM → 带页码的答案

这验证了整个 RAG 管道的可靠性。

2. 分块效果验证

======================================================================
总节点数: 53
平均每块长度: 522 字符
最小块长度: 4 字符
最大块长度: 6099 字符

第一个节点详情:
   文本长度: 9 字符
   文本预览: 证券简称:地素时尚...
   元数据: {'schema_name': 'docling_core.transforms.chunker.DocMeta', 'version': '1.0.0', 'doc_items': [{'self_ref': '#/texts/1', 'parent': {'$ref': '#/body'}, 'children': [], 'content_layer': 'body', 'label': 'text', 'prov': [{'page_no': 1, 'bbox': {'l': 174.02, 't': 763.1510439453125, 'r': 536.26, 'b': 752.5910439453125, 'coord_origin': 'BOTTOMLEFT'}, 'charspan': [0, 9]}]}], 'origin': {'mimetype': 'application/pdf', 'binary_hash': 17138581016858007644, 'filename': 'disu.pdf'}}
======================================================================

文档处理完成,共 53 个文档块

分析结果

块大小适中:平均 522 字符,既保留语义完整性,又避免引入过多噪声

元数据完整:每个块都包含 page_nobboxheadings,为引用溯源提供基础

块大小差异大:最小 4 字符,最大 6099 字符,说明 HierarchicalChunker 根据文档结构自适应分块。生产环境建议设置 max_chunk_size 限制上限。

3. 检索准确性验证

检索结果(重排序后的 Top 3):

文本片段 1
==================================================
相关度分数: 0.363
元数据信息: {'page': '4', 'bbox': {'l': 479.14, 't': 763.1510439453125, 'r': 526.42, 'b': 752.5910439453125, 'coord_origin': 'BOTTOMLEFT'}, 'headings': ['二、  股东信息']}
--------------------------------------------------
引用文本内容:
中国太平洋人寿保险股份有限 公司-传统-普通保险产品, 18,922 = 2,272,670. 中国太平洋人寿保险股份有限 公司-传统-普通保险产品, 报告期末表决权恢复的优先股股 东总数(如有) = 2,272,670. 中国太平洋人寿保险股份有限 公司-传统-普通保险产品, 报告期末表决权恢复的优先股股 东总数(如有) = 2,272,670. 中国太平洋人寿保险股份有限 公司-传统-普通保险...
==================================================

文本片段 2
==================================================
相关度分数: 0.358
元数据信息: {'page': '5', 'bbox': {'l': 89.73631286621094, 't': 766.5943450927734, 'r': 532.1719970703125, 'b': 639.1416931152344, 'coord_origin': 'BOTTOMLEFT'}, 'headings': ['二、  股东信息']}
--------------------------------------------------
引用文本内容:
上述股东关联关系或一致行动 的说明, 1 = 上述股东中,马瑞敏和马艺芯为母女关系;马瑞敏和马丽 敏、马姝敏为姐妹关系。马姝敏和上海亿马企业管理合伙企 业(有限合伙)的执行事务合伙人江瀛为夫妻关系。除此之 外,本公司未知上述股东之间是否存在关联关系,也未知其 是否属于《上市公司收购管理办法》规定的一致行动人。. 前 10 名股东及前 10 名无限售 股东参与融资融券及转融通业 务情况说明(如有),...
==================================================

文本片段 3
==================================================
相关度分数: 0.350
元数据信息: {'page': '4', 'bbox': {'l': 479.14, 't': 763.1510439453125, 'r': 526.42, 'b': 752.5910439453125, 'coord_origin': 'BOTTOMLEFT'}, 'headings': ['二、  股东信息']}
--------------------------------------------------
引用文本内容:
中国太平洋人寿保险股份有限 公司-分红-个人分红, 0 = 无. 中国太平洋人寿保险股份有限 公司-分红-个人分红, 0 = . 中国太平洋人寿保险股份有限 公司-传统-普通保险产品, 18,922 = 其他. 中国太平洋人寿保险股份有限 公司-传统-普通保险产品, 报告期末表决权恢复的优先股股 东总数(如有) = 2,272,670. 中国太平洋人寿保险股份有限 公司-传统-普通保险产品, 报告...
==================================================

分析结果

分数接近:0.363、0.358、0.350 差距小,说明重排序模型有效识别了高度相关的片段

语义聚焦:3 个片段都来自"股东信息"章节,且都在第 4-5 页,说明检索定位精准

召回完整:片段 1 包含直接信息(李军持股),片段 2 提供上下文(股东关系),覆盖了问题的多个维度

重排序的价值

初步向量检索返回可能的 5 个文档块,但重排序模型能识别出"李军"这个关键实体,将相关文档块排在前面。

关键成功因素总结

环节关键设计效果
分块HierarchicalChunker + 元数据保留语义完整 + 可溯源
检索两阶段检索(Top5 → Top3)召回率高 + 精度高
生成提示词引导输出页码结构化答案 + 可验证

运行结果分析

提示词工程

代码中的提示词模板引导 LLM:

  1. 仅使用检索到的文档内容回答
  2. 标注信息来源的页码(格式:[页码]
  3. 以项目符号形式输出结构化答案

这是实现点击页码跳转 PDF 页面功能的基础。

生产级分块建议

示例代码使用 HierarchicalChunker 进行智能分块,适合快速原型。生产环境建议:

扩展方向

本文演示了一个可运行的 ChatPDF 原型,要构建生产级应用还需要考虑:

性能优化

  • 文档解析:CPU 解析速度慢,生产环境建议使用 GPU 加速或第三方解析服务
  • 向量检索:调整批量嵌入参数(embed_batch_size)、Qdrant 索引配置以提升吞吐量

功能扩展

  • 跨文档检索:为文档添加唯一标识(document_id),检索时通过元数据过滤实现多文档搜索
  • 多模态支持:提取 PDF 图片后,可用多模态 LLM 生成描述再嵌入,或直接使用多模态嵌入模型
  • 前端实现:前端拿到 LLM 输出的内容(包含引用页码 [4][5]),结合元数据中的 pagebbox 信息,使用 pdf.js 等库渲染 PDF 并实现引用段落高亮显示

总结

通过这篇文章,一起从零搭建了一个类 ChatPDF 的应用。回顾一下,你掌握了:

  1. RAG 系统的四大环节(解析 → 向量化 → 检索 → 生成)
  2. 如何根据成本和性能选择合适的技术栈
  3. 向量嵌入、分块策略、两阶段检索这些核心概念

最后说一句,做 RAG 应用最重要的是快速迭代:先把原型跑起来,再根据实际效果一点点优化。别想着一开始就做到完美,边做边改才是王道。

附件下载

本文使用的示例 PDF 文件(地素时尚季报)可以通过以下链接下载:

上次更新 3/10/2026, 9:06:40 AM
What do you think?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
Comments
  • Latest
  • Oldest
  • Hottest
Powered by Waline v2.15.8