基于文档布局感知的智能RAG系统:从结构理解到精准检索的工程实践
1. 项目概述:基于文档布局感知的智能检索增强生成
最近在折腾一个文档智能处理的项目,核心目标是把那些结构复杂、图文混排的PDF或扫描件,变成大语言模型(LLM)能高效“理解”和“利用”的知识库。相信很多做企业知识管理、智能客服或者文档分析的朋友都遇到过类似的痛点:传统的文档切分(Chunking)方法,比如按固定字符数或句子分割,在处理包含表格、列表、多级标题的文档时,往往会破坏原有的语义结构和上下文关联。结果就是,当你用向量检索(Vector Search)去查找信息时,要么找不到关键内容,要么返回的片段缺乏必要的背景信息,导致后续的问答或总结效果大打折扣。
这个项目,我称之为“布局感知的文档处理与检索增强生成(RAG)管道”,正是为了解决这个问题。它的核心思路非常直接:在切分文档之前,先理解文档的视觉和逻辑布局。我们不再把文档看作一维的文本流,而是将其视为一个由标题、段落、表格、列表、页眉页脚等元素构成的二维结构。通过识别这些结构元素,我们可以在切分时保留它们的归属关系(比如某个表格属于哪个章节),从而生成带有丰富上下文信息的“智能分块”。当进行检索时,这些分块不仅能基于语义相似度匹配,还能利用其结构元数据(如所属章节标题)进行更精准的筛选和上下文扩充,最终显著提升RAG应用的回答质量。
整个方案构建在AWS云服务之上,主要利用了Amazon Textract的布局分析能力、Amazon SageMaker JumpStart部署的嵌入模型,以及Amazon OpenSearch Service作为向量数据库。接下来,我将拆解整个流程的设计思路、每一步的实操细节,并分享我在搭建和调试过程中踩过的坑和总结的经验。
2. 核心思路与架构设计解析
为什么传统的“暴力分块”在复杂文档上会失效?我们来看一个典型的业务报告页面:它可能有一个主标题(H1),几个副标题(H2/H3),下面跟着几段文字,然后插入一个复杂的合并单元格表格,最后还有一个带项目符号的列表。如果你用固定500个字符去切,很可能一刀把表格从中间劈开,或者把列表的标题和它的项目分隔到两个不同的块里。对于LLM来说,一个没有表头的表格行,或者一个没有说明的列表项,其信息价值是大打折扣的。
因此,本项目的设计哲学是“先理解,后分割”。其整体架构可以分解为两个主要阶段:文档处理与索引、检索与生成。
2.1 文档处理与索引阶段
这个阶段的目标是将原始的非结构化文档,转化为富含语义和结构信息的向量片段,并存入向量数据库。其流程如下图所示(概念图):
文档上传与布局解析:将多页文档(如PDF)上传至Amazon S3。然后,调用Amazon Textract的
StartDocumentAnalysisAPI,并指定FeatureTypes为[“TABLES”, “LAYOUT”]。Textract的布局(Layout)功能非常强大,它能识别出文档中的十大类元素:标题(Titles)、页眉(Headers)、子标题(Sub-headers)、正文文本(Text)、表格(Tables)、图形(Figures)、列表(Lists)、页脚(Footers)、页码(Page Numbers)和键值对(Key-Value pairs)。它返回的不仅是被OCR识别出的文字,更重要的是每个文字块在页面上的坐标(Bounding Box)以及它们之间的层级和阅读顺序关系。这为我们后续的智能分块提供了最基础的数据结构。文本增强与结构化:直接使用Textract的原始响应进行分块仍然比较粗糙。这里我们引入了Amazon Textract Textractor这个Python工具库。它相当于一个高级封装,能更方便地解析Textract的响应,并将其转化为更易编程操作的对象。本项目的关键一步是利用Textractor库,给提取出的文本“穿上XML外衣”。例如,它会用
<title>、<section>、<table>、<list>等标签来包裹对应的内容区域。这一步的“富文本”输出,使得文档的语义边界变得清晰可辨,是进行布局感知分块的前提。布局感知的智能分块:这是整个项目的核心算法部分。分块策略不再是“一刀切”,而是根据元素类型进行差异化处理:
- 按标题章节划分顶级块:首先,文档会按照最高层级的标题(通常是H1或章标题)被划分成几个大的部分(Chapter)。这是第一级分割。
- 在章节内,按元素类型精细化分块:
- 表格:以行为单位进行分块。如果一个表格很大,超过了预设的最大单词数(例如500词),则会被拆分成多个块。关键技巧在于:每个表格块都会附带该表格的列标题(表头),以及文档中紧邻表格上方的那段说明文字。这样,即使表格被拆分,每一部分都知道自己是什么表格、有哪些列。对于复杂的合并单元格表格,算法会先“解合并”,将合并单元格的值复制到拆分后的每一个单元格中,确保数据结构规整,便于后续处理。
- 列表:以列表项为单位进行分块。同样,每个列表项块都会附带整个列表的标题或说明文字。这解决了“只有第一个列表块知道自己在说什么”的经典问题。
- 段落与章节:正文部分以自然段落为最小分块单位。每个段落块都会附加上它所属的子章节标题(Section Header)。这样,一个关于“财务风险”的段落,永远会带着“财务风险”这个标签,为其提供了明确的上下文。
元数据构建与向量化索引:为每个生成的分块创建丰富的元数据(Metadata),这是实现高级检索技巧的基础。元数据至少包括:
chunk_id: 分块唯一标识。parent_section_id: 所属的子章节ID。parent_chapter_id: 所属的顶级章节ID。element_type: 元素类型(如paragraph,table_row,list_item)。raw_table_csv: 如果该分块来自表格,这里会存储整个表格的CSV格式数据(而不仅仅是分块的那几行)。 随后,使用通过Amazon SageMaker JumpStart部署的文本嵌入模型(例如BGE、GTE等),为每个分块的文本内容生成向量(Embedding)。最后,将{向量, 文本内容, 元数据}作为一个整体记录,存入Amazon OpenSearch Service的向量索引中。
注意:元数据中存储完整表格的CSV是一个非常重要的设计。在检索时,我们可能只根据语义相似度召回了一行表格数据。但为了生成更好的答案,我们可以通过元数据轻松地将这个分块所属的整个表格都提取出来,作为补充上下文提供给LLM。这为后续的“小到大”检索模式奠定了基础。
2.2 检索增强生成阶段
当索引构建完成后,就进入了查询和回答阶段。这个过程同样体现了“布局感知”的优势:
混合检索:当用户提出一个问题时,系统首先将问题文本同样向量化,然后在OpenSearch的向量索引中进行近似最近邻搜索,找到最相似的K个分块(例如top 5)。然而,单纯的向量搜索(语义搜索)有时会受到“词汇不匹配”或“语义漂移”的干扰。因此,一个更健壮的方案是采用混合搜索:同时结合向量相似度得分和基于元数据/关键词的文本匹配得分(例如BM25)。OpenSearch原生支持这种混合检索,可以更精准地定位相关片段。
动态上下文组装:检索到的分块携带了丰富的元数据。系统可以根据查询的复杂程度,动态决定提供给LLM的上下文范围。这是本项目的一大亮点:
- 基础模式:只将检索到的分块文本本身作为上下文。
- 扩展模式:如果问题涉及对某个概念的深入理解,系统可以额外附加上该分块所属的
parent_section(子章节)的标题和内容。 - 全景模式:对于需要宏观背景的问题,系统甚至可以附加上整个
parent_chapter(顶级章节)的内容。 这种能力使得系统可以灵活应对从具体事实查询到开放性分析的不同问题类型,避免了固定上下文窗口的局限性。
提示工程与答案生成:将组装好的上下文和用户问题,填入预设的提示词模板中。模板会指示LLM“基于以下文档片段回答问题”,并可能包含一些指令,如“如果信息不足,请说明”。最后,将完整的提示发送给通过Amazon Bedrock或SageMaker接入的LLM,生成最终答案。
3. 核心工具链选型与配置实操
纸上谈兵终觉浅,我们来具体看看如何搭建这个环境。工具链的选择直接决定了方案的可行性、性能和成本。
3.1 Amazon Textract:布局解析的基石
Textract是本项目的起点,它的布局分析精度直接决定了后续所有流程的上限。
- 为什么选Textract?相比于开源的OCR工具(如Tesseract)或某些只提供文本坐标的商用API,Textract的Layout功能是独一无二的。它不仅能识别文字,还能理解“这是一个标题”、“这是一个表格单元格”、“这几项属于同一个列表”。这种语义级别的识别,省去了我们自行开发复杂的版面分析算法,既准确又省心。
- 实操调用与成本控制:
- API选择:对于异步处理多页文档,务必使用
StartDocumentAnalysis异步API,并通过S3 URI指定文档位置。同步APIAnalyzeDocument只适用于单页。 - 功能开关:在请求中明确指定
FeatureTypes=[“TABLES”, “LAYOUT”]。如果不需要表格,可以只开LAYOUT,能稍微降低成本。 - 配额与权限:确保你所在区域的Textract服务有足够的TPS(每秒事务数)配额。同时,执行调用的IAM角色必须对目标S3存储桶有读取权限,并对Textract服务有调用权限。
- 使用Textractor库:强烈建议使用
amazon-textract-textractor库。它提供了Document、Page、Table等高级对象,能让你用几行代码就遍历所有布局元素,远比直接解析JSON响应高效。安装命令:pip install amazon-textract-textractor。
- API选择:对于异步处理多页文档,务必使用
3.2 嵌入模型与向量数据库:检索的核心引擎
检索的效果,一半取决于分块质量,另一半则取决于嵌入模型和向量数据库。
- 嵌入模型选型:通过Amazon SageMaker JumpStart部署嵌入模型是最佳实践。JumpStart提供了大量预训练好的前沿模型,如
BGE-large-en-v1.5、GTE-large等,并提供了现成的推理容器和端点配置。- 部署步骤:
- 在SageMaker控制台进入JumpStart。
- 搜索你心仪的嵌入模型(建议选择在MTEB等基准测试中排名靠前的模型)。
- 点击“部署”,选择一个合适的实例类型(如
ml.g5.2xlarge对于大型模型)。关键点:对于嵌入模型,推理延迟(Latency)比吞吐量更重要,因为通常是同步调用。g5实例的GPU能显著加速。 - 部署成功后,你会得到一个终端节点(Endpoint)名称,通过SageMaker SDK即可调用。
- 调用示例:
import boto3 import json runtime = boto3.client(‘sagemaker-runtime’) response = runtime.invoke_endpoint( EndpointName=‘your-embedding-endpoint-name’, ContentType=‘application/x-text’, Body=chunk_text ) embedding = json.loads(response[‘Body’].read())
- 部署步骤:
- 向量数据库选型:Amazon OpenSearch Service是AWS生态下的自然选择。它从7.x版本开始原生支持向量索引(k-NN索引),并且与IAM集成良好,便于权限管理。
- 集群配置要点:
- 启用k-NN插件:在创建域时,确保“精细访问控制”中启用了OpenSearch原生权限,这通常也意味着k-NN插件可用。
- 索引映射设计:这是重中之重。你需要定义一个同时包含向量字段和元数据字段的映射。
{ “settings”: { “index.knn”: true, “number_of_shards”: 3, “number_of_replicas”: 1 }, “mappings”: { “properties”: { “chunk_vector”: { “type”: “knn_vector”, “dimension”: 1024, // 必须与你的嵌入模型维度一致! “method”: { “name”: “hnsw”, “space_type”: “cosinesimil”, “engine”: “nmslib” } }, “chunk_text”: {“type”: “text”}, “parent_section”: {“type”: “keyword”}, “parent_chapter”: {“type”: “keyword”}, “element_type”: {“type”: “keyword”} // … 其他元数据字段 } } }dimension:必须与你使用的嵌入模型输出维度完全匹配,否则无法插入数据。space_type:cosinesimil(余弦相似度)是最常用的,对于大多数语义搜索任务效果良好。
- 写入与查询:使用OpenSearch的Python客户端
opensearch-py进行数据操作。写入时,将向量、文本和元数据组成一个文档插入。查询时,使用knn查询子句进行向量搜索,并可结合bool查询进行元数据过滤。
- 集群配置要点:
3.3 编排与无服务器架构建议
对于生产环境,建议采用无服务器架构以提高弹性和降低运维成本:
- 文档上传触发:用户上传文档到S3 → 触发AWS Lambda函数。
- 异步处理链:Lambda调用Textract异步API → Textract处理完成后,将结果JSON写入另一个S3路径 → 此事件触发第二个Lambda函数。
- 处理与索引:第二个Lambda函数(或由Step Functions编排的一组Lambda)负责加载Textract结果、调用Textractor库进行分块、调用SageMaker端点生成向量、最后写入OpenSearch索引。
- 查询接口:通过Amazon API Gateway暴露一个REST API,后端连接Lambda函数处理用户查询,执行混合检索并调用Bedrock/SageMaker LLM端点生成答案。
4. 布局感知分块算法的深度实现与调优
理论部分讲完了,我们来深入代码层面,看看“布局感知分块”这个核心算法具体怎么实现,以及有哪些调优空间。
4.1 利用Textractor解析与富文本生成
首先,我们需要从Textract的原始JSON中构建出结构化的文档对象。
from textractcaller import call_textract from textractprettyprinter.t_pretty_print import get_lines_string from textractor import Textractor from textractor.data.constants import TextractFeatures import xml.etree.ElementTree as ET # 初始化Textractor客户端 (假设已配置好AWS凭证) extractor = Textractor(profile_name=‘default’) # 假设document_s3_uri是S3上的文档地址 document_s3_uri = “s3://your-bucket/path/to/document.pdf” # 调用Textract,指定LAYOUT和TABLES response = extractor.start_document_analysis( file_source=document_s3_uri, features=[TextractFeatures.LAYOUT, TextractFeatures.TABLES], s3_upload_path=“s3://your-bucket/textract-output/” # 可选,指定输出位置 ) # 等待处理完成 document = response.get() # 此时,`document`是一个包含所有页面、布局元素的丰富对象 # 我们可以将其转换为带标签的文本 # Textractor的`get_text_with_geometry`等方法可以获取基础文本和位置 # 但为了得到XML风格的富文本,我们需要自己遍历布局元素实际上,Textractor库本身不直接输出XML。我们需要根据document.pages[0].layouts等属性来自行遍历。每个Layout对象有type(如 ‘TITLE’, ‘HEADER’, ‘TABLE’)和text属性。我们可以根据类型手动添加XML标签。
一个更实用的方法是,利用Textractor提取出结构化的块列表,然后用自己的逻辑来组装和分块。
4.2 实现差异化分块策略
假设我们已经有了一个结构化的块列表structured_blocks,每个块包含type,text,page_num,bbox以及从布局分析中推断出的层级关系(例如,通过bbox的包含关系和阅读顺序推断出的chapter_id,section_id)。
下面是分块逻辑的伪代码框架:
def layout_aware_chunking(structured_blocks, max_words_per_chunk=500): “”” 基于布局的结构化分块。 structured_blocks: 列表,每个元素是一个字典,包含type, text, metadata等。 max_words_per_chunk: 每个块允许的最大单词数(用于拆分大表格或长段落)。 “”” final_chunks = [] current_chapter = None current_section = None # 第一遍:建立章节-子章节的映射关系 # 这里需要根据TITLE, HEADER等块的层级来构建文档树。 # 假设我们有一个函数 build_document_tree(blocks) 来完成这个任务。 doc_tree = build_document_tree(structured_blocks) # 第二遍:遍历所有块,根据类型和所属章节进行分块 for block in structured_blocks: if block[‘type’] == ‘TITLE’: current_chapter = block[‘text’] # 标题本身通常作为一个独立的块,或者作为后续块的元数据 chunk_metadata = {‘chapter’: current_chapter, ‘section’: None, ‘element_type’: ‘title’} final_chunks.append({‘text’: block[‘text’], ‘metadata’: chunk_metadata}) elif block[‘type’] == ‘HEADER’ or block[‘type’] == ‘SECTION_HEADER’: current_section = block[‘text’] # 章节标题也作为独立块或元数据 chunk_metadata = {‘chapter’: current_chapter, ‘section’: current_section, ‘element_type’: ‘header’} final_chunks.append({‘text’: block[‘text’], ‘metadata’: chunk_metadata}) elif block[‘type’] == ‘TABLE’: # 处理表格 table_csv = convert_table_to_csv(block[‘table_data’]) # 假设有原始表格数据 table_rows = table_csv.split(‘\n’)[1:] # 去掉标题行 header_row = table_csv.split(‘\n’)[0] # 获取表格前的描述文本(需要根据bbox位置从blocks里查找) preceding_text = find_preceding_text(block, structured_blocks) chunk_text_accumulator = “” for row in table_rows: potential_chunk = f“{preceding_text}\n表头:{header_row}\n本行数据:{row}” # 简单的基于单词数的拆分逻辑 if count_words(chunk_text_accumulator + potential_chunk) > max_words_per_chunk and chunk_text_accumulator: # 保存当前累积的块 final_chunks.append({ ‘text’: chunk_text_accumulator, ‘metadata’: {‘chapter’: current_chapter, ‘section’: current_section, ‘element_type’: ‘table_chunk’, ‘full_table_csv’: table_csv} }) chunk_text_accumulator = potential_chunk else: if chunk_text_accumulator: chunk_text_accumulator += ‘\n’ + row else: chunk_text_accumulator = potential_chunk # 添加最后一个表格块 if chunk_text_accumulator: final_chunks.append({…}) elif block[‘type’] == ‘LIST’: # 处理列表:每个列表项作为一个块,附带列表标题 list_title = find_list_title(block, structured_blocks) for item in block[‘list_items’]: chunk_text = f“{list_title}\n - {item}” final_chunks.append({ ‘text’: chunk_text, ‘metadata’: {‘chapter’: current_chapter, ‘section’: current_section, ‘element_type’: ‘list_item’} }) elif block[‘type’] == ‘PARAGRAPH’: # 处理段落:一个段落一个块,附带当前章节标题 chunk_text = block[‘text’] # 如果段落太长,可以按句子进一步拆分,但尽量保持段落完整性 if count_words(chunk_text) > max_words_per_chunk * 1.5: # 给段落更大的容忍度 sentences = split_into_sentences(chunk_text) temp_accumulator = “” for sent in sentences: if count_words(temp_accumulator + sent) > max_words_per_chunk: final_chunks.append({ ‘text’: f“{current_section}\n{temp_accumulator}”, ‘metadata’: {…} }) temp_accumulator = sent else: temp_accumulator += ‘ ‘ + sent if temp_accumulator: final_chunks.append({…}) else: final_chunks.append({ ‘text’: f“{current_section}\n{chunk_text}”, # 将章节标题前置 ‘metadata’: {‘chapter’: current_chapter, ‘section’: current_section, ‘element_type’: ‘paragraph’} }) return final_chunks关键调优点:
max_words_per_chunk:这个参数需要根据你使用的LLM上下文窗口和嵌入模型性能来权衡。太小会导致上下文碎片化,太大会降低检索精度并增加LLM处理负担。对于类似GPT-4 128K的模型,可以设大一些(如1000-1500)。对于小模型,可能需要在300-500之间。- 段落拆分策略:对于超长段落,按句子拆分是次优选择,因为可能破坏连贯性。更好的方法是尝试按语义边界(如转折词、分号)拆分,或者干脆不拆,作为一个大块处理,并在检索时注意。
- 表格和列表的标题查找:
find_preceding_text和find_list_title函数需要根据布局块的位置(bbox的y坐标和阅读顺序)来实现,确保找到的是真正关联的文本,而不是上一页的无关内容。
4.3 元数据策略与高级检索准备
分块时附带的元数据,是为未来留出的扩展接口。除了基本的章节信息,我还建议添加:
page_number:来源页码,便于溯源和人工核对。confidence:Textract识别该文本块的置信度,可用于在检索结果中加权或过滤低置信度内容。file_source:原始文档标识。
在索引到OpenSearch时,这些元数据字段都应被定义为合适的类型(keyword,integer,text等)。keyword类型适用于精确匹配过滤,text类型可支持全文检索。例如,你可以这样进行混合查询:
# 伪代码:OpenSearch混合查询 query = { “query”: { “bool”: { “must”: [ { “knn”: { “chunk_vector”: { “vector”: question_embedding, “k”: 10 } } } ], “filter”: [ {“term”: {“element_type”: “paragraph”}}, # 可以过滤只检索段落 {“term”: {“parent_chapter”: “财务报告”}} # 可以限定在某个章节 ] } } }5. 实战避坑指南与性能优化
在实际部署和测试这个管道时,我遇到了不少挑战,也总结出一些让系统更稳健、更高效的经验。
5.1 准确性陷阱与应对策略
- Textract布局识别并非100%准确:尤其是对于设计花哨、排版非常规的文档,标题和正文的识别可能会有误。
- 应对:在关键业务场景中,可以引入一个“置信度阈值”。对于识别为标题但置信度低的块,可以结合其字体大小、位置等启发式规则进行二次判断。或者,准备一个小的验证集,对Textract的结果进行抽样人工检查,针对特定类型的文档进行微调(虽然Textract本身不可训练,但你的后处理逻辑可以适配)。
- 分块边界模糊:有时一个逻辑段落会被Textract错误地拆分成两个
PARAGRAPH块。- 应对:在后处理时,可以检查相邻的
PARAGRAPH块。如果它们在页面位置上非常接近(例如y坐标差值小于某个阈值),并且结尾/开头没有句号等明显句子结束标志,可以考虑将它们合并成一个块。
- 应对:在后处理时,可以检查相邻的
- 表格内容提取不完整:复杂表格(如嵌套表头、跨页表格)的提取可能会丢失信息。
- 应对:对于非常重要的表格,可以考虑启用Textract的
QUERIES功能。你可以预先定义一些问题(如“提取第三季度的总收入”),让Textract以键值对的形式返回答案,作为对常规表格提取的补充。
- 应对:对于非常重要的表格,可以考虑启用Textract的
5.2 性能与成本优化
- 异步处理与批处理:处理大量文档时,切勿同步调用Textract。务必使用
StartDocumentAnalysis,并结合S3事件和Lambda或Step Functions构建异步管道。对于大量小文档,可以考虑在Lambda中批量提交(注意Textract的异步API也有批量限制)。 - 向量索引优化:
- 选择合适的k-NN方法:OpenSearch支持
hnsw和ivf两种近似算法。hnsw(Hierarchical Navigable Small World)通常召回率更高,但索引构建慢、内存占用大。ivf(Inverted File Index)构建快、内存占用小,但召回率可能略低。对于文档检索这种对召回率要求较高的场景,通常首选hnsw。 - 调整HNSW参数:
ef_construction和m参数影响索引构建的质量和速度。增加它们可以提高召回率,但也会增加构建时间和内存。需要在质量和效率间权衡。可以从默认值开始,在测试集上调整。 - 分片策略:OpenSearch索引由多个分片组成。分片数在创建索引时设定,之后不能更改。一个经验法则是,确保每个分片的大小在10GB到50GB之间。如果你的向量数据量预计很大,需要提前规划好分片数。
- 选择合适的k-NN方法:OpenSearch支持
- 嵌入模型端点优化:
- 实例自动缩放:为SageMaker端点配置自动缩放策略。根据每分钟的调用次数(InvocationsPerInstance)来动态增加或减少实例数量,可以在流量低谷时节省成本。
- 批量嵌入:如果是在离线构建索引,不要逐条调用端点。将多个文本分块组合成一个批次(Batch)进行推理,可以极大提高吞吐量,降低成本。SageMaker端点支持批量请求。
- 缓存策略:
- 查询缓存:对于频繁出现的、通用的用户查询,可以考虑在Lambda前面加上Amazon ElastiCache(Redis/Memcached)来缓存查询向量和Top K结果。这能显著降低对OpenSearch和LLM的重复调用。
- 嵌入缓存:甚至可以对常见的文档分块文本进行预计算并缓存其向量,避免在索引更新时重复计算。
5.3 可观测性与监控
一个生产级的系统必须可监控。
- 关键指标:
- 流水线延迟:从文档上传到完成索引的总时间。拆分为Textract处理时间、嵌入时间、OpenSearch写入时间。
- Textract API错误率与置信度分布。
- OpenSearch索引延迟与搜索延迟。
- LLM生成延迟与令牌使用量。
- 端到端问答准确率:需要人工或通过验证集定期评估。
- 实现方式:利用Amazon CloudWatch来收集所有服务的原生指标(如Lambda执行时长、SageMaker端点延迟)。在业务代码中,使用
put_metric_dataAPI发送自定义业务指标(如分块数量、平均块大小、检索召回率等)。为所有Lambda函数和Step Functions状态机设置详细的日志记录,并导入CloudWatch Logs以便排查问题。
6. 效果评估与迭代方向
搭建好管道只是第一步,持续评估和迭代才能让系统真正产生价值。
6.1 如何评估RAG系统的好坏?
不能只看最终答案“看起来”对不对,需要多维度评估:
- 检索阶段评估:
- 召回率:对于一个已知答案的问题,系统检索到的Top K个分块中,包含正确答案源文本的比例是多少?这是衡量检索能力的基础。
- 精确率:检索到的Top K个分块中,有多少是真正相关的?可以通过人工标注来判断。
- 对比实验:将“布局感知分块”与“固定大小滑动窗口分块”在同一个测试集上进行对比,看前者的召回率和精确率是否有显著提升。
- 生成阶段评估:
- 忠实度:LLM生成的答案是否严格基于提供的上下文?有没有“胡编乱造”(幻觉)?
- 答案相关性:生成的答案是否直接、完整地回答了问题?
- 可读性与连贯性:答案是否通顺、符合逻辑? 这部分评估通常需要人工进行,或者利用更强大的LLM作为裁判进行打分。
6.2 可能的迭代与扩展方向
- 多模态RAG:当前方案主要处理文本和表格。如果文档中包含大量信息丰富的图表(Figures),可以扩展流程。利用Textract提取出的图形位置信息,调用多模态模型(如CLIP)为图表生成描述性文本,然后将这些描述作为特殊的分块,与文本分块一起索引。在检索时,可以实现图文联合检索。
- 查询理解与重写:在用户查询进入检索之前,可以先用一个轻量级LLM对查询进行理解和重写。例如,将“去年赚了多少钱?”重写为“2023年净利润是多少?”,并提取出可能的过滤条件(如“在财务报告章节中查找”),从而提升检索的精准度。
- 递归检索与智能路由:实现更复杂的检索策略。例如,先进行一轮粗检索,如果返回的分块元数据显示它们属于多个不同的章节,且答案可能需要对不同章节进行综合,则可以触发第二轮检索,专门去获取这些章节的摘要或核心段落,进行“递归”式的信息搜集。
- Agentic RAG:将整个RAG系统嵌入到一个智能体框架中。让LLM不仅生成最终答案,还能决定检索策略(例如,“我需要先查一下定义,再找一个例子”),甚至能对检索结果进行批判性思考,决定是否需要进一步检索或要求用户澄清问题。
这个基于布局感知的文档处理与RAG方案,从一个被忽视的维度——文档结构——入手,显著提升了复杂文档的信息提取和问答质量。它不再是简单的“文本转向量”,而是“理解、重构、再利用”的完整知识工程流程。虽然初始搭建涉及多个云服务和复杂的处理逻辑,但一旦跑通,其带来的精度提升和对业务场景的贴合度,是传统方法难以比拟的。在实际操作中,耐心调试分块策略和检索参数,并建立有效的评估闭环,是成功的关键。
