0%

大模型应用系列(十七) 大模型RAG项目实战:Llamaindex文档切分与重排序

介绍RAG中常用的文档切分方式,如何提升召回率,以及简要介绍重排序。

本文用到的文档可以在【免费】RAG文档资料,用于用llamaindex构建RAG的测试程序资源-CSDN下载 下载。

一. 文档解析

参考: SimpleDirectoryReader 的并行处理 - LlamaIndex 框架

1.1 什么是文档解析?

文档解析实际上就是读取文件,就像把不同包装的食品拆开处理:

  • PDF文件:罐头食品(需要用专用工具打开)

  • Word文档:盒装饼干(容易拆但可能有碎屑)

  • 扫描件/图片:真空包装(需要剪刀才能打开)
1.2 基础解析

1.本地文档

常见的有pdf, txt, md, word,都可以用llamaindex自带的simple_direct_reader 读取。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from llama_index.core import SimpleDirectoryReader 
# 加载的文本
# 加载单个或多个文档
reader = SimpleDirectoryReader(
input_files=["/root/autodl-tmp/day19/data/report_with_table.pdf"]
)
# # 批量加载,加载文件夹下的所有文件
# reader = SimpleDirectoryReader(
# "/root/autodl-tmp/day19/data"
# )
# 取出加载的文本
docs = reader.load_data()
# 打印文本内容
print(docs)

每个文档读取完就是一个node, 但它的解析功能比较简单,比如对于表格信息,并不能保持它的格式,如下:

读取后表格变成文本形式了,同时可以看到,llamaindex解析文档后默认情况下把整个文档作为一个node,同时会保存文件名,路径,类型,大小等元数据。

image-20250615233326960

2.读取本地html文件

llamaindex同样自带html的解析包,对于,网页,可以使用如下方式解析

安装解析包:

1
2
pip install llama-index-readers-file
pip install html2text

使用SimpleWebPageReader

1
2
3
4
5
6
7
8
9
10
11
from llama_index.readers.file import HTMLTagReader

reader = HTMLTagReader(tag="section", ignore_no_id=True)
docs = reader.load_data(
"/root/autodl-tmp/day19/data/document.html"
)

for doc in docs:
print(doc.metadata)

print(docs)

但它只能加载本地缓存的html文件。如果要很好地爬取数据,还是用传统的爬虫(beautiful soup)效果比较好。

3.读取在线网页

可以使用SimpleWebPageReader读取在线网页:安装

1
pip install llama-index llama-index-readers-web

加载网页

1
2
3
4
5
from llama_index.readers.web import SimpleWebPageReader
documents = SimpleWebPageReader(html_to_text=True).load_data(
["http://paulgraham.com/worked.html"]
)
print(documents[0])

结果如下:但这个不会进入网页中的超链接爬取更多数据:

image-20250615234343786

也可以用Spider,Spider是最快的爬虫,可以将任何网站转换为html, markdown, 元数据或文本,同时支持使用AI进行自定义操作来爬取

Spider 允许您使用高性能代理来防止检测,缓存 AI 操作,提供爬取状态的 webhook,支持计划爬取等。

先决条件:您需要有一个 Spider API 密钥才能使用此加载器。可以在 spider.cloud 获取。但这个收费,我没有尝试。

1
2
3
4
5
6
7
8
# 抓取单个 URL from llama_index.readers.web 
import SpiderWebReader
spider_reader = SpiderWebReader(
api_key="YOUR_API_KEY", # 在 https://spider.cloud 获取
mode="scrape",
# params={} # 可选参数,更多信息请参阅 https://spider.cloud/docs/api
)
documents = spider_reader.load_data(url="https://spider.cloud") print(documents)

总的来说,llamaindex提供的解析工具不能很好地满足要求,所以一般用更好的第三方工具。对于网页,最好的还是网页爬虫。

1.3 高级解析

初级解析的优点是方便使用,缺点是功能简单,像上面的表格数据就无法很好解析。很多第三方包提供了很好的解析工具,比如为了更好地解析表格,可以使用pdfplumber, 示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pdfplumber
with pdfplumber.open("报告.pdf") as pdf:
# 提取所有文本
text = ""
for page in pdf.pages:
text += page.extract_text()
print(text[:200]) # 打印前200字符
# 提取表格(自动检测)
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
print("\n表格内容:")
for row in table:
print(row)

如果文件中有图片,要提取图片中的文字,则可以用OCR。魔塔社区直接搜OCR就有开源的OCR模型。

如下, 提取后数据保持表格形式。

image-20250615234444062

1.4 总结

如果文档简单(只有文字),则可以用llamaindex自带的解析器,如果文档复杂(涉及表格,图片,html等),则用第三方工具。

二. 文本切分

2.1 为什么需要切分

使用dataconnector加载数据时,会默认一个文档就是一个document,也就是一个节点,在检索的时候就会检索到整个文档,这样不仅prompt太长,还可能很多噪音。

2.2 切分三要素
要素 说明 推荐值
块大小 每段文字的长度 200-500字
块重叠 相邻块重复内容,用于表示上下文 10%-20%
切分依据 按句子/段落/语义切分 语义分割最优
2.3 分块策略对比
策略类型 优点 缺点 适用场景
固定大小 实现简单 可能切断完整语义 古诗,对联等句式整齐的
按段落分割 保持逻辑完整性 段落长度差异大 文学小说·
语义分割 确保内容完整性 计算资源消耗大 专业领域文档
正则匹配 效果好 需要写正则表达式 基本都可以
2.4 分块常见问题
2.4.1 如何确定最佳块大小

测试不同尺寸查看检索效果

1
2
3
4
5
# 测试块大小对召回率的影响
sizes = [128, 256, 512]
for size in sizes:
rest_call = evaluate_chunk_size(size)
print(f"块大小{size} 召回率{rest_recall:.2f%}")
2.4.2 分块是否重叠越多越好

适当重叠(10%-20%)可以防止信息断裂,但过多会造成冗余。

2.5 切分示例: 固定分块vs语句切分vs语义切分

固定分块: 到达chunk_size马上切分

语句切分: 尽量让句子保持完整,如果开始下个句子后会超出chunk_size,则从当前句子切分

语义切分: 利用大模型的语义理解能力,将表达完整的语句作为一个分块。

2.5.1 固定分块
1
2
3
4
5
6
7
8
9
10
11
12
13
from llama_index.core import SimpleDirectoryReader

# 加载所有文档
documents = SimpleDirectoryReader(input_files=["/home/cw/projects/demo_20/data/ai.txt"]).load_data()

#使用固定分块大小进行切分
from llama_index.core.node_parser import TokenTextSplitter

fixed_splitter = TokenTextSplitter(chunk_size=256, chunk_overlap=20) # chunk_size到了就切分, chunk_overlap重叠度
fixed_nodes = fixed_splitter.get_nodes_from_documents(documents)
print("固定分块示例:", [len(n.text) for n in fixed_nodes[:3]]) # 输出:[200, 200, 200]
print(print("首个节点内容:\n", fixed_nodes[0].text))
print(print("第二个节点内容:\n", fixed_nodes[1].text))

结果如下:

image-20250615235115768

2.5.2 语句切分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core import SimpleDirectoryReader

# 加载所有文档
documents = SimpleDirectoryReader(input_files=["/root/autodl-tmp/day19/data/ai.txt"]).load_data()

# 使用句子分割器:按照句子进行切分
splitter = SentenceSplitter(chunk_size=128, chunk_overlap=50) # 如果接下面的句子会超过chunk_size,则提前终止
nodes = splitter.get_nodes_from_documents(documents)

# 查看结果
print(f"生成节点数: {len(nodes)}")
print("首个节点内容:\n", nodes[0].text)
print("第二个节点内容:\n", nodes[1].text)

结果如下: 根据语义第一个节点中”1. 起源阶段…”部分文字被划分到第二个节点, 因为加上这部分就会超了,句子说不完整。

image-20250615235835463

2.5.3 语义切分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from llama_index.core import SimpleDirectoryReader
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core.node_parser import SemanticSplitterNodeParser
import os


# 2. 加载文档
documents = SimpleDirectoryReader(input_files=["/home/cw/projects/demo_20/data/test.txt"]).load_data()

# # 3. 筛选Markdown文档
# md_docs = [d for d in documents if d.metadata["file_path"].endswith(".md")]

# 4. 初始化模型和解析器
embed_model = HuggingFaceEmbedding(
#指定了一个预训练的sentence-transformer模型的路径
model_name="/home/cw/llms/embedding_model/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

semantic_parser = SemanticSplitterNodeParser(
buffer_size=1,
breakpoint_percentile_threshold=90, # 给的越高语句越完整
embed_model=embed_model
)

# 5. 执行语义分割
semantic_nodes = semantic_parser.get_nodes_from_documents(documents)

# 6. 打印结果
print(f"语义分割节点数: {len(semantic_nodes)}")
for i, node in enumerate(semantic_nodes[:2]): # 只打印前两个节点
print(f"\n节点{i+1}:\n{node.text}")
print("-"*50)

对比三种分块方法,第三种最好,因为它借助了大模型的语义理解能力,不会造成句子语义不完整:

但第三种同样存在问题,比如在分割法律条款时,它将所有的法律条款视为一个分块,而我们在实际中一般需要它按章节以及条目进行分块。

实际上文档切分并没有固定的处理方式,需要根据知识文档的数据格式选择切分方式,所以还需要结合正则匹配,甚至人工划分。

三. 召回率提升方案

3.1 什么是召回率

召回率涉及的是检索阶段。检索逻辑太粗会导致一些信息没有被召回,召回率低。检索逻辑太细会导致一些冗余信息被召回,导致准确率低。

首先得把召回率提上来,后面再把准确率提高,比如有8个node个查询有关,如果一开始只准确召回4个(召回率低,准确率高),那后面再怎么处理都没用了;如果一卡是召回12个,里面有8个正确4个错误(召回率高,准确率低),那么召回后可以通过重排提高准确率。

3.2 提升召回率的三大策略:
3.2.1 查询扩展: 给问题加修饰词
  • 原始问题: 如何做番茄炒蛋
  • 扩展后: 家常番茄炒蛋做法步骤,厨房新手教程,简单易学。
3.2.2 混合检索: 结合两种搜索方式
1
2
3
graph LR
A[用户问题]-->B[关键词搜索]-->C[初步结果]-->D[合并去重]
A-->E[语义搜索]-->C

用户问题关键词搜索语义搜索初步结果合并去重。

但是混合检索也有弊端: 响应慢。关键词搜索的前提是用户规范提问,能问到点子上(关键词)

3.2.3 向量优化

微调大模型型,如果数据集中含有专有名词,大模型不能理解,则需要微调。再RAG中成为“向量优化”。

微调前: Transformer 理解为 “变形金刚”

微调后: Transformer 理解为”深度学习模型”。

3.2.4 基础检索vs混合检索

下面用一个完整的RAG代码展示向量检索和关键词检索的效果对比:代码流程未: 加载本地Embedding模型和LLM模型—>加载并解析数据—>检索—>查询,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core import Settings, VectorStoreIndex
from llama_index.llms.huggingface import HuggingFaceLLM
from llama_index.core.schema import TextNode
import json
import torch

# 1. 初始化本地模型
def setup_local_models():
# 设置本地embedding模型
embed_model = HuggingFaceEmbedding(
model_name="/home/cw/llms/embedding_model/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
device="cuda" if torch.cuda.is_available() else "cpu"
)

# 设置本地LLM模型
llm = HuggingFaceLLM(
model_name="/home/cw/llms/Qwen/Qwen1.5-1.8B-Chat",
tokenizer_name="/home/cw/llms/Qwen/Qwen1.5-1.8B-Chat",
model_kwargs={"trust_remote_code": True},
tokenizer_kwargs={"trust_remote_code": True},
device_map="auto",
generate_kwargs={"temperature": 0.3, "do_sample": True} # 修改为do_sample=True避免警告
)

# 全局设置
Settings.embed_model = embed_model
Settings.llm = llm
Settings.chunk_size = 512

# 2. 加载数据并处理格式
def load_data(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)

nodes = []
for item in data:
if isinstance(item, dict):
# 处理DPR格式数据
if 'query' in item and 'positive_passages' in item:
text = f"查询: {item['query']}\n相关文档: {item['positive_passages'][0]['text']}"
# 处理QA对格式
elif 'question' in item and 'answer' in item:
text = f"问题: {item['question']}\n答案: {item['answer']}"
else:
continue
elif isinstance(item, str):
text = item
else:
continue

node = TextNode(text=text)
nodes.append(node)

return nodes

# 3. 初始化本地模型
setup_local_models()

# 4. 加载数据
data_path = "/home/cw/projects/demo_19/data/qa_pairs.json"
nodes = load_data(data_path)

# 5. 示例查询
query = "如何预防机器学习模型过拟合?"

# 案例1:向量检索(使用本地embedding模型)
vector_index = VectorStoreIndex(nodes)
vector_retriever = vector_index.as_retriever(similarity_top_k=3)
print("向量检索结果:", [node.text[:50] + "..." for node in vector_retriever.retrieve(query)])

# 案例2:关键词检索(不使用bm25模式)
from llama_index.core import KeywordTableIndex
keyword_index = KeywordTableIndex(nodes)
keyword_retriever = keyword_index.as_retriever(similarity_top_k=3) # 使用默认模式
print("关键词检索结果:", [node.text[:50] + "..." for node in keyword_retriever.retrieve(query)])

# 案例3:查询引擎(使用本地LLM生成回答)
query_engine = keyword_index.as_query_engine()
response = query_engine.query(query)
print("LLM生成回答:", response)

使用的数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"query": "如何预防机器学习模型过拟合?",
"positive_passages": [
"正则化方法通过添加L1/L2惩罚项控制模型复杂度...",
"交叉验证将数据划分为训练集和验证集...",
"早停法(Early Stopping)监控验证集损失..."
],
"negative_passages": [
"GPU加速训练的技术方案...",
"数据标注的质量控制方法...",
"卷积神经网络结构解析..."
]
}
3.3 效果验证方法
  1. 准备测试问题集(至少50个典型问题)。
  2. 记录基础方案召回率。
  3. 应用优化策略后再次测试。
  4. 对比提升幅度。

四. 检索结果重排序

4.1 为什么要重排序

在召回期间,我们是尽量提升召回率,所以和问题沾边的都召回了,导致召回的数据中很多冗余的,要经过重排序把这些冗余的排除。重排序的目的是提高模型响应的精度。排序需要用到重排序模型。

4.2 常见排序模型对比
模型名称 速度 精度 硬件要求 使用场景
BM25 关键词匹配
Cross-Encoder 小规模精准排序
ColBERT 平衡速度与精度

重排序的效果要在实际项目中展示,在后面项目中再详细说明。

如果您读文章后有收获,可以打赏我喝咖啡哦~