复杂结构化文档RAG训练集清洗策略:避免召回偏移
大家好,今天我们来探讨一个在构建基于复杂结构化文档的RAG(Retrieval-Augmented Generation)系统时经常遇到的问题:复杂结构化文档进入训练集后导致的召回偏移。这个问题直接影响RAG系统的性能,轻则召回结果不相关,重则导致生成内容错误。
RAG系统依赖于一个有效的检索机制,从大量文档中找到与用户查询最相关的片段,并将这些片段作为上下文提供给生成模型。如果训练集中的文档结构复杂,例如包含表格、列表、嵌套段落等,未经处理直接用于索引,就会导致检索系统难以准确理解文档的语义,从而产生召回偏移。
本文将以讲座的形式,深入分析复杂结构化文档导致召回偏移的原因,并提出一系列工程化的清洗策略,帮助大家构建更可靠的RAG系统。
一、召回偏移的原因分析
在深入清洗策略之前,我们需要理解为什么复杂结构化文档会导致召回偏移。主要原因有以下几个方面:
-
语义理解困难: 传统的文本检索方法(如基于关键词匹配的BM25、基于向量相似度的 embeddings 等)在处理结构化文档时,难以捕捉文档内部的语义关系。例如,表格中的单元格与标题的关联、列表项之间的层次关系等,都会被忽略。
-
信息冗余与噪声: 结构化文档中常常包含大量的重复信息、格式化的标记,以及与核心内容无关的辅助信息(如页眉页脚、版权声明等)。这些信息会干扰检索系统的判断,降低召回的准确率。
-
索引粒度不合理: 如果将整个文档作为一个索引单元,会导致检索结果过于宽泛,包含大量无关信息。反之,如果将文档拆分成过小的单元(如每个句子),又可能丢失上下文信息,导致语义不完整。
-
Embedding空间错位: 如果使用预训练的语言模型生成文档片段的 embeddings,而这些片段的结构与预训练数据分布差异较大,就会导致 embeddings 空间错位,从而影响相似度计算的准确性。例如,预训练模型可能不擅长处理表格数据,因此生成的表格片段 embeddings 质量不高。
二、工程化清洗策略:分而治之
针对以上问题,我们需要采取一系列工程化的清洗策略,对复杂结构化文档进行预处理,提高检索系统的召回准确率。整体思路是分而治之,将文档拆解成更易于理解和处理的单元,并针对不同类型的结构化元素,采用不同的清洗方法。
-
文档解析与结构化表示:
首先,我们需要将原始文档解析成结构化的表示形式,例如使用
BeautifulSoup或lxml解析 HTML 文档,使用pdfminer或PyPDF2解析 PDF 文档,使用python-docx解析 Word 文档。解析完成后,我们可以得到文档的层次结构、文本内容、表格、列表等信息。from bs4 import BeautifulSoup import pdfminer.high_level import docx def parse_html(html_file): with open(html_file, 'r', encoding='utf-8') as f: soup = BeautifulSoup(f, 'html.parser') return soup def parse_pdf(pdf_file): text = pdfminer.high_level.extract_text(pdf_file) return text def parse_docx(docx_file): doc = docx.Document(docx_file) text = 'n'.join([paragraph.text for paragraph in doc.paragraphs]) return text # 示例 # html_content = parse_html("example.html") # pdf_content = parse_pdf("example.pdf") # docx_content = parse_docx("example.docx") -
文本清洗与标准化:
对解析后的文本内容进行清洗和标准化,包括:
- 去除 HTML 标签、XML 标签等格式化标记。
- 去除特殊字符、控制字符、多余的空格和换行符。
- 转换大小写、统一编码格式。
- 进行词干提取或词形还原,将单词转换为其基本形式。
import re import nltk from nltk.stem import PorterStemmer from nltk.stem import WordNetLemmatizer from nltk.corpus import stopwords nltk.download('stopwords') nltk.download('wordnet') def clean_text(text): # 去除 HTML 标签 text = re.sub(r'<[^>]+>', '', text) # 去除特殊字符和控制字符 text = re.sub(r'[^a-zA-Z0-9s]', '', text) # 去除多余空格 text = re.sub(r's+', ' ', text).strip() # 转换为小写 text = text.lower() return text def standardize_text(text): # 停用词去除 stop_words = set(stopwords.words('english')) words = text.split() words = [word for word in words if word not in stop_words] # 词干提取 stemmer = PorterStemmer() stemmed_words = [stemmer.stem(word) for word in words] # 或者 词形还原 # lemmatizer = WordNetLemmatizer() # lemmatized_words = [lemmatizer.lemmatize(word) for word in words] return ' '.join(stemmed_words) # 或 ' '.join(lemmatized_words) # 示例 # cleaned_text = clean_text("<h1>This is a sample text with <b>HTML</b> tags.</h1>") # standardized_text = standardize_text(cleaned_text) -
表格处理:
表格是结构化文档中常见的元素,处理表格需要特别注意以下几点:
- 识别表格结构: 使用
pandas等库识别表格的行、列、标题等信息。 - 填充缺失值: 对表格中的缺失值进行填充,可以使用平均值、中位数、众数等方法。
- 提取表格元数据: 提取表格的标题、描述等信息,作为表格内容的补充。
- 将表格转换为文本: 将表格转换为文本形式,方便检索系统处理。常用的方法有:
- 线性化: 将表格的每一行或每一列转换为一个字符串。
- 关系抽取: 提取表格中单元格之间的关系,例如 "单元格 A 包含 属性 B 的值 C"。
import pandas as pd def process_table(table): # 将 BeautifulSoup 的 table 对象转换为 pandas DataFrame df = pd.read_html(str(table))[0] # 填充缺失值 df = df.fillna('') # 将表格转换为文本 (线性化) text = '' for index, row in df.iterrows(): text += ' '.join(row.astype(str).tolist()) + 'n' return text # 示例 (需要先用 BeautifulSoup 解析 HTML) # soup = BeautifulSoup(html_content, 'html.parser') # tables = soup.find_all('table') # for table in tables: # table_text = process_table(table) # print(table_text)更高级的表格处理:关系抽取示例
def extract_table_relations(df): """ 从 pandas DataFrame 中提取表格单元格之间的关系。 返回一个包含关系描述的列表。 """ relations = [] header = df.columns.tolist() for index, row in df.iterrows(): for i, value in enumerate(row): relation = f"The {header[i]} is {value}" relations.append(relation) return relations # 示例 # relations = extract_table_relations(df) # for relation in relations: # print(relation) - 识别表格结构: 使用
-
列表处理:
列表也是结构化文档中常见的元素,处理列表需要注意以下几点:
- 识别列表结构: 识别列表的类型(有序列表、无序列表)、层级关系。
- 提取列表项内容: 提取每个列表项的文本内容。
- 保留列表上下文: 将列表的标题、描述等信息与列表项内容关联起来。
def process_list(list_element): """ 处理 HTML 列表元素 (<ul> 或 <ol>)。 返回一个包含列表项文本的字符串。 """ list_items = list_element.find_all('li') text = '' for item in list_items: text += item.text.strip() + 'n' return text # 示例 # soup = BeautifulSoup(html_content, 'html.parser') # lists = soup.find_all(['ul', 'ol']) # for list_element in lists: # list_text = process_list(list_element) # print(list_text) -
文档分块与索引:
在完成文本清洗和结构化元素处理后,我们需要将文档分割成更小的块,作为索引单元。分块策略的选择至关重要,它直接影响检索系统的召回准确率。常用的分块策略有:
- 固定大小分块: 将文档按照固定的字符数或单词数进行分割。
- 基于句子分块: 将文档按照句子进行分割。
- 基于段落分块: 将文档按照段落进行分割。
- 语义分块: 使用语义分割模型(例如 SentenceTransformers)将文档分割成语义相关的块。
选择合适的分块策略需要根据文档的特点和应用场景进行权衡。一般来说,对于结构复杂的文档,语义分块的效果更好,但计算成本也更高。
from sentence_transformers import SentenceTransformer, util def semantic_chunking(text, model_name='all-MiniLM-L6-v2', chunk_size=500): """ 使用 SentenceTransformers 进行语义分块。 返回一个包含文本块的列表。 """ model = SentenceTransformer(model_name) sentences = nltk.sent_tokenize(text) chunks = [] current_chunk = "" for sentence in sentences: if len(current_chunk) + len(sentence) + 1 <= chunk_size: current_chunk += sentence + " " else: chunks.append(current_chunk.strip()) current_chunk = sentence + " " if current_chunk: chunks.append(current_chunk.strip()) return chunks # 示例 # chunks = semantic_chunking(cleaned_text) # for chunk in chunks: # print(chunk)索引建立:
在完成分块后,我们需要将文本块及其元数据(例如文档 ID、标题、段落号等)存储到向量数据库中,并建立索引。常用的向量数据库有:
- FAISS: Facebook AI Similarity Search,高性能的向量相似度搜索库。
- Annoy: Approximate Nearest Neighbors Oh Yeah, Spotify 开源的近似最近邻搜索库。
- Milvus: 开源的向量数据库,支持多种索引类型和查询方式。
- Pinecone: 云原生的向量数据库,提供高可用性和可扩展性。
from sentence_transformers import SentenceTransformer import faiss import numpy as np def create_index(chunks, model_name='all-MiniLM-L6-v2', index_path='my_index.faiss'): """ 创建 FAISS 索引。 """ model = SentenceTransformer(model_name) embeddings = model.encode(chunks) dimension = embeddings.shape[1] index = faiss.IndexFlatL2(dimension) # L2距离 index.add(embeddings) faiss.write_index(index, index_path) return index_path def load_index(index_path): """ 加载 FAISS 索引。 """ index = faiss.read_index(index_path) return index def search_index(query, index, model_name='all-MiniLM-L6-v2', top_k=5): """ 在 FAISS 索引中搜索与查询最相关的文本块。 """ model = SentenceTransformer(model_name) query_embedding = model.encode(query) query_embedding = np.expand_dims(query_embedding, axis=0) # Reshape for FAISS distances, indices = index.search(query_embedding, top_k) return distances, indices # 示例 # index_path = create_index(chunks) # index = load_index(index_path) # distances, indices = search_index("What is the capital of France?", index) # print(distances, indices) -
元数据增强:
为了提高检索的准确率,我们可以为每个文本块添加元数据,例如:
- 文档标题: 文本块所属的文档标题。
- 段落标题: 文本块所属的段落标题。
- 关键词: 文本块的关键词。
- 实体: 文本块中包含的实体。
- 文档结构信息: 文本块在文档中的位置、层级关系等。
这些元数据可以帮助检索系统更好地理解文本块的语义,从而提高召回的准确率。例如,我们可以使用关键词或实体作为过滤条件,缩小检索范围。
-
评估与优化:
完成以上步骤后,我们需要对清洗后的训练集进行评估,并根据评估结果进行优化。常用的评估指标有:
- 召回率: 检索系统返回的相关文档占所有相关文档的比例。
- 准确率: 检索系统返回的文档中,相关文档占所有返回文档的比例。
- F1 值: 召回率和准确率的调和平均值。
如果评估结果不理想,我们需要回过头来检查清洗策略的各个环节,例如:
- 文档解析是否正确?
- 文本清洗是否彻底?
- 分块策略是否合理?
- 元数据是否有效?
通过不断迭代和优化,我们可以构建一个高质量的 RAG 训练集,提高 RAG 系统的性能。
三、代码示例:一个完整的流程
下面是一个完整的代码示例,演示了如何使用上述策略清洗一个 HTML 文档,并建立 FAISS 索引:
from bs4 import BeautifulSoup
import re
import nltk
from nltk.stem import PorterStemmer
from nltk.corpus import stopwords
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
nltk.download('stopwords')
nltk.download('wordnet')
nltk.download('punkt') # 下载 sent_tokenize 所需资源
def parse_html(html_file):
with open(html_file, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f, 'html.parser')
return soup
def clean_text(text):
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'[^a-zA-Z0-9s]', '', text)
text = re.sub(r's+', ' ', text).strip()
text = text.lower()
return text
def standardize_text(text):
stop_words = set(stopwords.words('english'))
words = text.split()
words = [word for word in words if word not in stop_words]
stemmer = PorterStemmer()
stemmed_words = [stemmer.stem(word) for word in words]
return ' '.join(stemmed_words)
def process_table(table):
df = pd.read_html(str(table))[0]
df = df.fillna('')
text = ''
for index, row in df.iterrows():
text += ' '.join(row.astype(str).tolist()) + 'n'
return text
def process_list(list_element):
list_items = list_element.find_all('li')
text = ''
for item in list_items:
text += item.text.strip() + 'n'
return text
def semantic_chunking(text, model_name='all-MiniLM-L6-v2', chunk_size=500):
model = SentenceTransformer(model_name)
sentences = nltk.sent_tokenize(text)
chunks = []
current_chunk = ""
for sentence in sentences:
if len(current_chunk) + len(sentence) + 1 <= chunk_size:
current_chunk += sentence + " "
else:
chunks.append(current_chunk.strip())
current_chunk = sentence + " "
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
def create_index(chunks, model_name='all-MiniLM-L6-v2', index_path='my_index.faiss'):
model = SentenceTransformer(model_name)
embeddings = model.encode(chunks)
dimension = embeddings.shape[1]
index = faiss.IndexFlatL2(dimension)
index.add(embeddings)
faiss.write_index(index, index_path)
return index_path
def load_index(index_path):
index = faiss.read_index(index_path)
return index
def search_index(query, index, model_name='all-MiniLM-L6-v2', top_k=5):
model = SentenceTransformer(model_name)
query_embedding = model.encode(query)
query_embedding = np.expand_dims(query_embedding, axis=0)
distances, indices = index.search(query_embedding, top_k)
return distances, indices
# 假设我们有一个名为 example.html 的 HTML 文件
if __name__ == '__main__':
html_file = "example.html" # 替换为你的 HTML 文件名
# 1. 解析 HTML 文档
soup = parse_html(html_file)
# 2. 提取文本内容、表格和列表
text_content = clean_text(soup.get_text())
tables = soup.find_all('table')
lists = soup.find_all(['ul', 'ol'])
# 3. 处理表格和列表
table_texts = [process_table(table) for table in tables]
list_texts = [process_list(list_element) for list_element in lists]
# 4. 合并所有文本内容
all_text = text_content + 'n'.join(table_texts) + 'n'.join(list_texts)
# 5. 标准化文本
standardized_text = standardize_text(all_text)
# 6. 语义分块
chunks = semantic_chunking(standardized_text)
# 7. 创建 FAISS 索引
index_path = create_index(chunks)
# 8. 加载 FAISS 索引
index = load_index(index_path)
# 9. 搜索索引
query = "What are the key features?"
distances, indices = search_index(query, index)
# 10. 打印搜索结果
print("Query:", query)
for i, index_val in enumerate(indices[0]):
print(f"Rank {i+1}: Distance = {distances[0][i]}, Chunk = {chunks[index_val]}")
# 创建一个示例 HTML 文件 (example.html)
with open("example.html", "w", encoding="utf-8") as f:
f.write("""
<!DOCTYPE html>
<html>
<head>
<title>Example Document</title>
</head>
<body>
<h1>Introduction</h1>
<p>This is a sample HTML document with tables and lists.</p>
<h2>Table Example</h2>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1, Cell 1</td>
<td>Row 1, Cell 2</td>
</tr>
<tr>
<td>Row 2, Cell 1</td>
<td>Row 2, Cell 2</td>
</tr>
</tbody>
</table>
<h2>List Example</h2>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<h2>Another Section</h2>
<p>This section contains more text and information about key features.</p>
<p>Key features include: functionality, scalability, and ease of use.</p>
</body>
</html>
""")
这段代码提供了一个完整的示例,从HTML解析,数据清洗,到向量索引建立和搜索,展示了整个流程。确保安装了所需的库: pip install beautifulsoup4 pandas nltk sentence-transformers faiss-cpu
四、一些额外的建议
除了以上策略,还有一些额外的建议可以帮助大家提高 RAG 系统的性能:
- 领域知识融合: 将领域知识融入到清洗策略中,例如使用领域词典进行实体识别和关键词提取。
- 数据增强: 使用数据增强技术扩充训练集,例如对文本进行同义词替换、句子重组等。
- 持续学习: 随着用户查询的积累,我们可以使用持续学习的方法不断优化 RAG 系统,提高其适应性和准确性。
- Prompt 工程: 好的 prompt 可以引导模型更好地利用检索到的上下文信息。
提升召回率和准确率的工程化思路
复杂结构化文档的清洗是一个复杂而精细的过程,需要根据文档的特点和应用场景进行定制化的设计。希望本文提出的策略能够帮助大家构建更可靠的 RAG 系统,解决实际问题。