首页 / 文章

利用RAG技术基于央行PDF文档构建的引用式问答系统

基于季度央行报告构建的实操型RAG系统:数据导入、基于时间新鲜度的检索、引用答案,以及开发过程中遇到的实际问题。

发布者:REACTAPP.TOP阅读约 8 分钟1827 词

来源: I Learned RAG by Building a Q&A System

大多数RAG教程使用的是整理过的数据。真实文档的表现则有所不同,只有当人们在杂乱无章的语料库上构建整个系统时,才会显现出对检索增强生成技术的理解与实际应用之间的差距。这正是bspQA诞生的动机——它是一个基于菲律宾中央银行发布的季度货币政策报告的问答系统。

该语料库是极佳的学习对象。每份报告都包含60到70页详实的经济分析内容,其中17份的报告时间跨度达数年,且全部公开可获取。这些文本属于专业领域用语,文件数量众多,检索难度很大,因为根本无法将17份报告全部放入上下文窗口中。我们的目标很简单:只需提出诸如“2026年的通胀前景如何?”这样的问题,就能在几秒钟内得到有依据、有引用的答案,而无需在一堆PDF文件中手动使用Ctrl+F搜索。

单段文字中的模式

RAG将大语言模型的回答与预先选定的文档集关联起来,而非其训练时吸收的所有内容。这些文档会提前被转换为向量嵌入。当有问题出现时,系统会获取与之最相关的文本片段,将其作为上下文传递给模型,并要求模型仅基于该上下文进行回答。这样一来,系统就不会编造文档中不存在的信息。

两个相互独立的处理流程

该设计刻意将两项任务分开处理。

  • 数据摄取在离线状态下进行,每有新文档时会执行一次。它会下载PDF文件,将其解析为纯文本,再将文本分割成片段,生成嵌入向量,并将所有数据存储在Qdrant中。
  • 查询则在每次请求时实时执行。系统会先对问题进行嵌入处理,检索相关的文本片段,进而生成答案。在此阶段不会触碰原始PDF文件。

构建数据摄取功能

下载步骤

manifest.json 文件是权威依据。每份报告都会有一条记录,其中包含其 URL、周期标签以及发布日期。这些 URL 是手动粘贴的,正如听起来那样并不美观。

{
  "doc_id": "bsp-mpr-2023-q3",
  "title": "Monetary Policy Report Q3 2023",
  "period_label": "Q3 2023",
  "publication_date": "2023-08-17",
  "url": "https://www.bsp.gov.ph/..."
}

该工具会跳过磁盘上已存在的文件,因此重新运行不会造成影响;而添加报告只是会在文档列表中多出一个条目。

解析步骤

Microsoft的MarkItDown工具可将PDF文件转换为文本。对于内容以文本为主的政策报告,它的处理效果相当不错:标题能够保留,段落分隔清晰,表格也能勉强保持完整。

MarkItDown也是一个体积较大的依赖项,会带来许多附加包。由于解析仅在数据导入阶段进行,而在查询响应时不会执行,因此它被归类为可选依赖项,这样由Render托管的后端就不必承担其带来的负担。尽早养成一个好习惯:明确哪些包属于数据导入环境,哪些属于服务环境,因为两者无需保持一致。在解析和分块之间还有一个清理步骤,其作用将在下文中说明。

分块、嵌入与插入更新

真正的决策从这里开始。分块来自一个512个标记的滑动窗口,窗口间有64个标记的重叠部分,计数使用tiktoken。

def chunk_text_by_tokens(text, enc, chunk_size=512, overlap=64):
    tokens = enc.encode(text)
    stride = chunk_size - overlap
    chunks = []
    start = 0
    while start < len(tokens):
        window = tokens[start : start + chunk_size]
        chunks.append(enc.decode(window))
        if start + chunk_size >= len(tokens):
            break
        start += stride
    return chunks

重叠机制确保在分块边界处被截断的句子至少仍会在一个分块中保持完整。

嵌入模型来自 OpenAI 的 text-embedding-3-small,该模型成本较低,且非常适合处理密集的政策性文本。整个项目大约花费了十美元的积分,尽管当时的最低充值金额为五美元,但这对于小型项目来说已经足够使用了。

每个向量都会根据源文件名和分块索引生成一个 UUID5 标识符。这样一来,重新处理数据时就不会出现问题,因为再次运行时会覆盖现有记录,而不会产生重复数据。

每个存储的负载数据都包含源文件、分块索引、文本内容、嵌入模型、时间周期标签、如q3_2023这样的标准化时间周期键,以及符合ISO 8601标准的发布日期。Qdrant会为源文件和时间周期键建立关键词索引,同时为发布日期建立时间戳索引,从而确保在查询时能够快速过滤元数据。

检索与最新性陷阱

一种简单的实现方式是通过余弦相似度返回排名靠前的K个结果。但当数据集涵盖数年内容且有人询问当前的通胀预期时,这种做法就存在问题。如果银行在2022年第一季度和2024年第三季度使用几乎相同的措辞来描述通胀,那么由于表述稍好,2022年的相关分块可能会获得更高的评分,尽管用户显然希望获取最新的观点。

解决办法是超额获取候选项后再进行重新排序。系统会获取三倍于所需数量的候选项,然后结合余弦相似度与标准化最新度值来为每个候选项重新评分。

RECENCY_WEIGHT = 0.85

def combined_score(hit):
    relevance = hit.score / max_score
    recency = (pub_date - min_date).days / date_span_days  # 0.0 to 1.0
    return (1 - RECENCY_WEIGHT) * relevance + RECENCY_WEIGHT * recency

0.85的近期权重意味着除非更早的文档相关性更高,否则较新的文档会获得更高优先级。在每份报告实际上都取代前一份的报告集中,这种权衡是合理的。

可选的筛选功能允许用户将搜索限定在某个特定时期或日期范围内:

# scope to Q1 2023 only
retrieve_chunks(query, period_label_key="q1_2023")

# scope to a date range
retrieve_chunks(query, publication_date_from="2023-01-01", publication_date_to="2023-12-31")

在没有过滤器的状态下,该系统表现为“最新浏览”助手;而启用过滤器后,则转变为历史研究工具。

生成过程

该生成器被刻意设计得极为简单。检索到的内容会被格式化为带编号的资料来源,然后通过一条系统提示传递给gpt-4o-mini,这条提示的实质是:仅使用所提供的资料,不得引入外部知识,每项事实性陈述都需在文中标注出处,若资料不足则需明确说明。

最后这条要求值得特别重视。严格的提示约束是防止幻觉出现的最有效手段。如果检索结果中没有相关内容,系统会直接回复预设的默认信息,而不会自行编造答案。

API响应会返回答案文本以及结构化的来源列表,每条记录都会注明其所属时期、发布时间以及源PDF的存放位置。在前端,这些内容会转化为可点击的引用链接,从而确保每条陈述都可追溯。

实际使用中出现的问题

页面元素污染数据块

每份报告的每一页都包含标题“分类:综合”,以及底部信息“货币政策报告 - 2023年第三季度 | 47”。在解析后,这些行会被视为普通文本并纳入数据块中,导致不必要的资源浪费。实际上,底部的页码本可以用于实现更精确的引用,但这一想法直到后期才被提出。

解决办法是在数据块划分之前进行一次清洗处理:

def _clean_parsed_text(text):
    text = text.replace("\x0c", "\n\n")
    text = re.sub(r"Classification:\s*GENERAL\s*\n", "", text)
    text = re.sub(r"Monetary Policy Report\s*[--][^\n]+\|\s*\d+\s*\n", "", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()

事后看来这很明显,但如果在嵌入之前从未检查过解析后的文本,就很容易忽略。其症状很奇怪:在答案测试期间,检索器似乎会使用其他片段而非排名最高的那个。页脚会在思考过程中将段落分割开,从而悄悄改变每个片段所代表的上下文。

意外清除

Qdrant 提供了一个“重新创建集合”标志,可用于删除并重建整个集合。在开发环境中很方便,但在生产环境中则会造成灾难性后果。现在,当环境看起来像是生产环境时,防护机制会忽略该标志:

def _should_recreate_qdrant_collection():
    if _is_production_environment():
        return False
    explicit = os.environ.get("QDRANT_RECREATE_COLLECTION", "").lower()
    if explicit in ("1", "true", "yes"):
        return True
    app_env = (os.environ.get("ENVIRONMENT") or "").lower()
    return app_env in ("development", "dev", "local")

对于只有单一用户的项目而言,这种保护机制或许有些过度设计,但其成本几乎为零。

重新导入后的重复数据

在编辑了清单条目并重新运行导入流程后,同一个PDF会出现两套数据块。解决方法是,在插入新数据块之前先删除源文件中现有的数据块:

client.delete(
    collection_name=collection,
    points_selector=models.Filter(must=[
        models.FieldCondition(
            key="source_file",
            match=models.MatchValue(value=source_file)
        )
    ]),
)

由于有了稳定的UUID5标识符,重新导入操作现在具备了幂等性。

免费套餐下的冷启动问题

后端运行在Render的免费套餐上,空闲时会自动关闭。冷启动后的第一个请求可能需要30到60秒才能响应,这段时间足够让访问者认为应用出现了故障。一个简单的/health接口可以解决大部分问题:前端在页面加载时向该接口发送请求,从而在用户提交第一个问题之前唤醒服务器。这样每个会话就会多一个HTTP请求。此外,用户界面还会显示简短说明,告知用户由于后端正在启动,第一个查询可能需要半分钟到一分钟的时间。

块大小的选择

512个标记的值是通过实验确定的。128到256个标记的片段虽然更精确,但往往无法单独理解其含义所需的上下文。1024个标记或更多的片段则会牵扯到无关的周围文本。64个标记的重叠部分可以缓和边界处的生硬分割,因此值得为此多占用一些存储空间。

下次应做哪些调整

  • 首先构建一个评估集。虽然已有evals目录,但在调整最新性权重或片段大小之前并未制定标准问题集,因此这两种选择都只是基于经验的猜测。真正的评估集能为这些决策提供依据。
  • 添加重新排序模块。将最新性因素纳入评分体系仅是一种启发式方法。使用如Cohere Rerank这样的交叉编码器或本地模型,可以同时对查询和片段进行评分,从而更可靠地确定相关性排序。
  • 尝试混合搜索方式。密集向量可能无法准确匹配特定词汇。如果查询词略有不同,像“7.00%的逆回购利率”这样的短语就很难被检索到。将BM25关键词匹配与向量搜索结合使用可以弥补这一缺陷,而Qdrant可直接实现这一功能。
  • 流式响应。gpt-4o-mini需要两到五秒才能给出回答。通过实时输出生成的标记,即便总处理时间不变,界面也会显得更快。

设计复用

该架构具有通用性。只需用另一份文档语料库替换现有清单,更新系统提示中的领域描述,即可构建适用于任意PDF集合的实用问答系统。那些较为复杂的环节,如文本分割、基于最新性的检索、元数据过滤以及安全的数据重新导入,都已由系统自动处理。

相关阅读

  • 大规模混合图像搜索:SigLIP、SPLADE、YOLOv8与Gemini描述功能 —— 设计一个可处理百万级图像的搜索系统,该系统将密集嵌入与稀疏嵌入相结合,并结合物体检测、实体识别、安全搜索以及由大语言模型生成的描述功能。