首页 > 教程攻略 > ai资讯 >使用高精度RAG实现对表格数据的检索

使用高精度RAG实现对表格数据的检索

来源:互联网 时间:2026-08-26 13:45:14

为什么表格堆砌的文档,RAG表现总是“翻车”?

检索增强生成(RAG)技术已经火了好一阵子,但真正落地的时候,坑其实不少——尤其是当文档里塞满了图片和表格的时候。一个很典型的场景:每次让RAG从表格里提取某个具体数值,结果准确率就直线下降。更让人头疼的是,如果文档里多个表格都和同一主题相关(比如财报里的各种财务数据表),那准确率更是惨不忍睹。那么,问题到底出在哪儿?又该怎么解决?下面就来聊聊一套经过验证的方法。

核心痛点

  1. 检索阶段就掉链子

    :向量搜索算法很难精准定位到正确的表格。特别是当文档中存在多个结构相似的表格时,检索结果往往“跑偏”。

  2. 生成阶段也翻车

    :大语言模型(LLM)经常误解表格里的数值,尤其当表格有嵌套列这种复杂结构的时候,错误率更高。原因大概率出在表格格式的不一致性上——原始表格的文本表述杂乱无章,模型根本没法好好理解。

解决方案:四个关键步骤

要解决这两个问题,逻辑其实很清晰:先得把表格从文档里干干净净地抽出来,再给每个表格配上丰富的上下文说明,然后把它们统一成标准的Markdown格式,最后把这些“加料”的表格块嵌入向量数据库。具体来说,有四个核心环节:

  1. 精确提取

    :老老实实从文档里把表格完整地提取出来,不丢不漏。
  2. 上下文丰富

    :利用LLM分析每个表格以及它周围的文档内容,生成一段有力、相关的描述——让表格“自己介绍自己”。
  3. 格式标准化

    :用LLM把表格都转成统一的Markdown格式。这样一来,无论是嵌入模型还是生成模型,都能轻松理解。
  4. 统一嵌入

    :将上下文的描述和Markdown格式的表格拼在一起,形成一个“表格块”,优化存储和检索效果。

实战:让RAG读懂Meta财报

目标

:针对Meta 2024年第二季度财报,构建一个能同时从文本和多个表格中回答问题的RAG管道。

下面详细拆解每一步,包括完整的代码实现和最终效果对比。注意,本文只展示上下文化表格块的方案;实际在完整版笔记中,还包含了与非上下文化表格块的对比实验,差异相当明显。

第一步:精确提取——从PDF里把表格“抠”出来

用Unstructured.io的partition_pdf函数,并选用hi_res策略(高分辨率模式),能有效识别文档中的表格元素。同时设置chunking_strategy="by_title",确保文本按标题切分,避免不同章节的内容混在一起。

!apt-get -qq install poppler-utils tesseract-ocr
%pip install -q --user --upgrade pillow
%pip install -q --upgrade unstructured["all-docs"]
%pip install kdbai_client
%pip install langchain-openai
%pip install langchain
%pip install langchain-community
%pip install pymupdf
%pip install --upgrade nltk

import os
from getpass import getpass
import openai
from openai import OpenAI
from unstructured.partition.pdf import partition_pdf
from unstructured.partition.auto import partition
from langchain_openai import OpenAIEmbeddings
import kdbai_client as kdbai
from langchain_community.vectorstores import KDBAI
from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI
import fitz
nltk.download('punkt')

设置OpenAI API密钥(需要提前在环境变量中配置,或运行时手动输入):

if "OPENAI_API_KEY" in os.environ:
    KDBAI_API_KEY = os.environ["OPENAI_API_KEY"]
else:
    OPENAI_API_KEY = getpass("请输入 OPENAI API 密钥: ")
    os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY

下载Meta 2024年第二季度财报PDF(文档中表格密集,非常适合做测试):

!wget 'https://s21.q4cdn.com/399680738/files/doc_news/Meta-Reports-Second-Quarter-2024-Results-2024.pdf' -O './doc1.pdf'

partition_pdf提取元素,指定高分辨率策略和分块参数:

elements = partition_pdf(
    './doc1.pdf',
    strategy="hi_res",
    chunking_strategy="by_title",
    max_characters=2500,
    new_after_n_chars=2300,
)

看看提取了什么:

from collections import Counter
display(Counter(element.__class__ for element in elements))

输出:Counter({CompositeElement: 17, Table: 10})。这里17个是文本块,10个是表格元素——还算干净利落。

第二步 & 第三步:上下文丰富 + 格式标准化

先随便看一个表格元素的原始内容,就能理解为什么RAG表现差:

print(elements[-2])

输出是一长串混杂了数字和文字的字符串,没有任何结构。如果直接用这个去嵌入、检索,向量搜索算法根本分不清这个表格到底在描述什么。

所以,需要两件事:第一,给每个表格生成一段详细的描述性上下文;第二,把表格转成漂亮的Markdown格式。

首先,提取整个PDF的文本作为文档上下文:

def extract_text_from_pdf(pdf_path):
    text = ""
    with fitz.open(pdf_path) as doc:
        for page in doc:
            text += page.get_text()
    return text

pdf_path = './doc1.pdf'
document_content = extract_text_from_pdf(pdf_path)

然后,定义一个函数,传入表格的原始内容和文档上下文,让LLM同时输出表格描述和Markdown格式的表格:

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def get_table_description(table_content, document_context):
    prompt = f"""
Given the following table and its context from the original document,
provide a detailed description of the table. Then, include the table in markdown format.

Original Document Context:
{document_context}

Table Content:
{table_content}

Please provide:
1. A comprehensive description of the table.
2. The table in markdown format.
"""
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "You are a helpful assistant that describes tables and formats them in markdown."},
            {"role": "user", "content": prompt}
        ]
    )
    return response.choices[0].message.content

接下来,遍历所有表格元素,用上述函数替换原始文本:

for element in elements:
    if element.to_dict()['type'] == 'Table':
        table_content = element.to_dict()['text']
        result = get_table_description(table_content, document_content)
        element.text = result
print("Processing complete.")

处理之后的表格块会长什么样?以下是一个示例输出(为方便阅读已转成Markdown):

### Detailed Description of the Table
The table presents segment information from Meta Platforms, Inc. for both revenue and income (loss) from operations. The data is organized into two main sections: 
1. **Revenue**: This section is subdivided into two categories: "Advertising" and "Other revenue". The total revenue generated from these subcategories is then summed up for two segments: "Family of Apps" and "Reality Labs". The table provides the revenue figures for three months and six months ended June 30, for the years 2024 and 2023.
2. **Income (loss) from operations**: This section shows the income or loss from operations for the "Family of Apps" and "Reality Labs" segments, again for the same time periods.

The table allows for a comparison between the two segments of Meta's business over time, illustrating the performance of each segment in terms of revenue and operational income or loss.

### The Table in Markdown Format
```markdown
### Segment Information (In millions, Unaudited)
|| Three Months Ended June 30, 2024 | Three Months Ended June 30, 2023 | Six Months Ended June 30, 2024 | Six Months Ended June 30, 2023 |
|----------------------------|----------------------------------|----------------------------------|------------------------------- |-------------------------------|
| **Revenue:** ||| | |
| Advertising| $38,329| $31,498| $73,965 | $59,599 |
| Other revenue| $389 | $225 | $769| $430|
| **Family of Apps** | $38,718| $31,723| $74,734 | $60,029 |
| Reality Labs | $353 | $276 | $793| $616|
| **Total revenue**| $39,071| $31,999| $75,527 | $60,645 |
|||| | |
| **Income (loss) from operations:** ||| | |
| Family of Apps | $19,335| $13,131| $36,999 | $24,351 |
| Reality Labs | $(4,488) | $(3,739) | $(8,334)| $(7,732)|
| **Total income from operations** | $14,847| $9,392 | $28,665 | $16,619 |
```

对比一下最初的纯文本,现在表格有了清晰的描述和结构化的Markdown格式。嵌入模型能从中获取到表格的主题、列含义以及数值的上下文关系,向量检索的命中率会大幅提升。

第四步:统一嵌入——把“加料”的表格存进向量库

现在所有元素(包括文本块和增强后的表格块)都准备好了,接下来要把它们嵌入并存入向量数据库。这里选用KDB.AI作为向量数据库,因为它速度快、对复杂查询支持好。

首先,生成所有元素的嵌入:

from unstructured.embed.openai import OpenAIEmbeddingConfig, OpenAIEmbeddingEncoder

embedding_encoder = OpenAIEmbeddingEncoder(
    config=OpenAIEmbeddingConfig(
        api_key=os.getenv("OPENAI_API_KEY"),
        model_name="text-embedding-3-small",
    )
)
elements = embedding_encoder.embed_documents(elements=elements)

接着,把元素整理成Pandas DataFrame,方便存入KDB.AI:

import pandas as pd

data = []
for c in elements:
    row = {}
    row['id'] = c.id
    row['text'] = c.text
    row['metadata'] = c.metadata.to_dict()
    row['embedding'] = c.embeddings
    data.append(row)

df = pd.DataFrame(data)

连接到KDB.AI云服务(需要先在KDB.AI申请API密钥和端点):

KDBAI_ENDPOINT = (
    os.environ["KDBAI_ENDPOINT"]
    if "KDBAI_ENDPOINT" in os.environ
    else input("KDB.AI endpoint: ")
)
KDBAI_API_KEY = (
    os.environ["KDBAI_API_KEY"]
    if "KDBAI_API_KEY" in os.environ
    else getpass("KDB.AI API key: ")
)
session = kdbai.Session(api_key=KDBAI_API_KEY, endpoint=KDBAI_ENDPOINT)

定义向量表的模式。嵌入列需要设置向量索引参数:维度数(text-embedding-3-small输出1536维)、索引类型(这里用最简单的flat)、距离度量(L2欧几里得距离)。

schema = {
    'columns': [
        {'name': 'id', 'pytype': 'str'},
        {'name': 'text', 'pytype': 'str'},
        {'name': 'metadata', 'pytype': 'dict'},
        {'name': 'embedding', 'vectorIndex': {'dims': 1536, 'type': 'flat', 'metric': 'L2'}}
    ]
}

删除可能已存在的同名表,然后创建新表:

KDBAI_TABLE_NAME = "Table_RAG"
if KDBAI_TABLE_NAME in session.list():
    session.table(KDBAI_TABLE_NAME).drop()
table = session.create_table(KDBAI_TABLE_NAME, schema)

将DataFrame插入表中:

table.insert(df)

现在,所有元素已经安家在向量数据库,随时可以检索。

打造RAG管道——用LangChain把一切串起来

定义嵌入模型和向量检索器:

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import KDBAI

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vecdb_kdbai = KDBAI(table, embeddings)

构建RAG链,使用gpt-4o作为生成模型,检索时返回前5个相似文档:

qabot = RetrievalQA.from_chain_type(
    chain_type="stuff",
    llm=ChatOpenAI(model="gpt-4o"),
    retriever=vecdb_kdbai.as_retriever(search_kwargs=dict(k=5)),
    return_source_documents=True,
)

def RAG(query):
    print(query)
    print("-----")
    return qabot.invoke(dict(query=query))["result"]

效果实测——真金不怕火炼

以下是五个有代表性的提问和结果:

示例1:简单数值提取

RAG("what is the 2024 GAAP advertising Revenue in the three months  ended June 30th? What about net cash by operating activities")

结果

截至2024年6月30日:GAAP广告收入为383.29亿美元;经营活动净现金流为193.70亿美元。

正确。

示例2:跨表格查询

RAG("what is the three month costs and expenses for 2023?")

结果

2023年第二季度,Meta三个月成本和支出为226.07亿美元。

正确。

示例3:资产负债表数据

RAG("At the end of 2023, what was the value of Meta's Goodwill assets?")

结果

截至2023年底,Meta商誉资产价值为206.54亿美元。

正确。

示例4:复杂嵌套表格(此处若用非上下文化表格块,几乎必然出错)

RAG("What is the research and development costs for six months ended in June 2024")

结果

截至2024年6月结束的六个月研发成本为205.15亿美元。

注意,这个表格在原始PDF中具有嵌套列,如果没有上下文描述和Markdown格式,LLM很容易混淆列标签。上下文化表格块在此类场景下优势明显。

示例5:开放性推理问答

RAG("Given a sentiment score between 1 and 10 for the outlook? Explain your reasoning")

结果

:模型给出了8分,并引用了多个表格中的数值——每股收益从3.03涨到5.31,收入增长22%,运营利润率从29%提升到38%等。可以看到,LLM能够准确引用嵌入式表格中的数字来进行推理和论证。

一些权衡与思考

当然,为表格增加上下文描述不是免费的午餐。每张表格都需要额外调用一次LLM来生成描述,这会增加成本和延迟。对于只有少数简单表格的场景,或许并不值得这么做。实验表明,当表格结构简单(仅有几行几列)时,直接用原始表格文本效果也不差。但当表格开始出现嵌套列、跨页合并单元格、或数值含义依赖上下文时,非上下文化的表格块就会频频翻车。示例4就是一个典型。

所以,是否要采用上下文化表格块,取决于你的文档中表格的复杂度和数量。如果表格是核心信息载体,而且数据量大、结构复杂,那么这个方案绝对值得投入。

总结

表格丰富的文档对RAG管道是一块硬骨头,但并非啃不动。通过精确提取、上下文丰富、格式标准化和统一嵌入这一组合拳,能有效解决检索不一致和生成不准确两大痛点。Meta财报的例子清晰展示了使用增强表格块之后,生成响应的质量和可靠性显著提升。随着RAG技术不断演进,这种将表格“拟人化”的预处理思路,或许会成为处理结构化数据类文档的标配方法。