用 LLM 实现大型文档摘要的方法
大型语言模型(LLM)使许多任务变得更加简单,如创建聊天机器人、语言翻译、文本摘要等。目前,有一些 LLM 宣称允许用户上传“大文本”,但实际测试发现,其所谓“大”也是有限制的。本文将提供一种方法,试图打破直接的限制。
大型语言模型在大型文档摘要中的限制
在大型语言模型中,情境限制或上下文长度指的是模型可以处理的 token 的数量。每个模型都有其特定的上下文长度,也称为最大 token 数或 token 限制。例如,标准的 GPT-4 模型的上下文长度为 12.8 万个 tokens。超过此数量的 token 信息将会丢失。其他各种 LLM 皆有类似情况,只不过上下文限制的 tokens 量的不同。
此外,还有另外一个不可忽视的问题,随着上下文限制的增加,LLM 会受到如“近因效应”(recency)和“首因效应”(primacy)等限制的影响。
- LLM 中的首因效应指的是模型对序列开头的信息给予更高的重要性。
- 近因效应指的是模型强调最近处理的信息。
这两种效应会使模型偏向输入数据的特定部分,可能会跳过序列中间的重要信息。
第二个问题是成本。我们可以通过分割文本来解决上下文限制的问题,但我们不能直接将整本书传递给模型,这样做成本会很高。例如,如果我们有一本书的 100 万个 tokens,并直接将其传递给 GPT-4 模型,总成本大约为 90 美元(包括提示词和返回信息的 tokens)。我们必须综合考虑价格、上下文限制和保持整本书完整性,在此基础上确定一个最优的方法。
使用 LangChain 和 OpenAI 总结大型文档
如果读者使用其他的模型,可以仿照本文的代码给予修改。
环境设置
本文采用如下开发环境:
- 安装 Python(推荐学习教程:Python与AI同行:从小白到专家)
- 一个集成开发环境(例如:VS Code,在上述推荐的学习教程中,对此编辑工具做了详细介绍)
安装依赖项:
pip install langchain openai tiktoken fpdf2 pandas
加载书籍
下面的举例中使用查尔斯·狄更斯的《大卫·科波菲尔》一书,该书已经公开,没有版权问题。使用 LangChain 提供的 PyPDFLoader
工具加载这本书。
from langchain.document_loaders import PyPDFLoader
# 加载书籍
loader = PyPDFLoader("David-Copperfield.pdf")
pages = loader.load_and_split()
这样操作的结果是加载了整本书,但如果实际上,在对书的内容进行总结提炼时,常常要跳过前言、介绍、目录等页面,所以,还要做如下操作。
# 剪掉开头和结尾部分
pages = pages[6:1308]
# 将各页面合并为一个文本字符串,并将制表符替换为空格
text = ' '.join([page.page_content.replace('\t', ' ') for page in pages])
现在,已经有了内容。查看一下出前 200 个字符。
text[0:200]
预处理
删除文本中不必要的内容,如不可打印字符、多余的空格等。
import re
def clean_text(text):
# 删除特定短语 'Free eBooks at Planet eBook.com' 和周围的空白
cleaned_text = re.sub(r'\s*Free eBooks at Planet eBook\.com\s*', '', text, flags=re.DOTALL)
# 删除多余的空格
cleaned_text = re.sub(r' +', ' ', cleaned_text)
# 删除不可打印字符,以 'David Copperfield' 开头
cleaned_text = re.sub(r'(David Copperfield )?[\x00-\x1F]', '', cleaned_text)
# 将换行符替换为空格
cleaned_text = cleaned_text.replace('\n', ' ')
# 移除连字符周围的空格
cleaned_text = re.sub(r'\s*-\s*', '', cleaned_text)
return cleaned_text
clean_text=clean_text(text)
这里演示的数据清洗比较简单,因为我们所用选用的文档是以文字为主,在实际项目中可能会遇到更复杂的场景,届时要具体问题具体分析。但是,对文本内容进行预处理是必须的。
《数据准备和特征工程》(齐伟,电子工业出版社)中有这样一句话,对现在 LLM 下解决问题依然适用:
数据和特征决定上限,模型和算法只是接近这个上限而已。
在清理数据后,即将开始解决大文本摘要问题了。
加载 OpenAI API
在使用 OpenAI API 之前,需要做一些必要的配置(读者也可以使用其他 LLM,多数的 LLM 也都支持 OpenAI 格式,当然,采用该 LLM 所指定的格式亦可)。
import os
os.environ["OPENAI_API_KEY"] = "your-openai-key-here"
输入 API 密钥,将其设置为环境变量。
下面计算上述文本的 tokens 数量:
from langchain import OpenAI
llm = OpenAI()
Tokens = llm.get_num_tokens(clean_text)
print (f"We have {Tokens} tokens in the book")
从输出结果可知,文本 clean_text
的 tokens 数量超过 46.6 万,如果将它们全部直接传递给 LLM,不仅会被“截取”,而且费用会很高。因此,要对该文本先做必要的处理。
首先,对其实施 K 均值聚类,以提取其中的重要部分。为此,必须要先将文本分成不同的块。
分割文本
这里使用 LangChain 的 SemanticChunker
工具将文本内容分割。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings
text_splitter = SemanticChunker(
OpenAIEmbeddings(), breakpoint_threshold_type="interquartile"
)
docs = text_splitter.create_documents([clean_text])
SemanticChunker
接收两个参数,第一个是嵌入模型。该模型生成的嵌入用于根据语义分割文本。第二个是 breakpoint_threshold_type
,它决定了文本应该在语义相似性基础上分割成不同块的点。
注意:通过处理这些较小的语义相似块,旨在将 LLM 中的近因效应和首因效应降至最低。这种策略允许模型更有效地处理每个小上下文,确保更平衡的解释和响应生成。
将原来的文本分割之后,所得到的称之为“文档”,即 docs
所引用的对象。
获取每个文档的嵌入
用 OpenAI
的默认方法获取每个文档的嵌入。关于文本嵌入问题,请阅读:文本嵌入的全面指南
import numpy as np
import openai
def get_embeddings(text):
response = openai.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data
embeddings=get_embeddings([doc.page_content for doc in docs]
)
用 get_embeddings
方法得到了所有文档的嵌入,即将所有文档向量化。
重新整理数据
接下来,我们将文档内容及文档嵌入的列表转换为 pandas 的 DataFrame 格式,以便于数据处理和分析。
import pandas as pd
content_list = [doc.page_content for doc in docs]
df = pd.DataFrame(content_list, columns=['page_content'])
vectors = [embedding.embedding for embedding in embeddings]
array = np.array(vectors)
embeddings_series = pd.Series(list(array))
df['embeddings'] = embeddings_series
这是一个很重要的技巧。
使用 Faiss 进行高效聚类
现在,我们将文档向量转换为与 Faiss 兼容的格式,使用 K 均值将文档的向量聚类成 50 组,然后创建一个 Faiss 索引以便在文档之间进行高效的相似性搜索。
import numpy as np
import faiss
# 如果尚未转换为 float32,则进行转换
array = array.astype('float32')
num_clusters = 50
# 向量的维度
dimension = array.shape[1]
# 使用 Faiss 训练 KMeans
kmeans = faiss.Kmeans(dimension, num_clusters, niter=20, verbose=True)
kmeans.train(array)
# 直接访问类的质心
centroids = kmeans.centroids
# 为原始数据集创建一个新的索引
index = faiss.IndexFlatL2(dimension)
# 将原始数据集添加到索引中
index.add(array)
上述 K 均值聚类将所有文档分为了 50 组。
上述代码中的“质心” centroids
,可以作为每组的代表。
注意:选择 K 均值聚类的原因在于每个簇将具有相似的内容或相似的上下文,因为该簇中的所有文档具有相关的嵌入,我们将选择最接近中心的一个。
选择重要文档
现在,从每个簇中选择最重要的文档。为此,我们只选择最接近质心的第一个向量。
D, I = index.search(centroids, 1)
此代码在索引上使用搜索方法,以查找距离质心最接近的文档。它返回两个数组:
D
,包含最接近文档与各自质心之间的距离;I
,包含这些最近文档的索引。
search
中的第二个参数 1
的意思是找到距离每个质心最近的一个文档。
然后对选定的文档索引进行排序,因为这些文档是按书的顺序排列的。
sorted_array = np.sort(I, axis=0)
sorted_array=sorted_array.flatten()
extracted_docs = [docs[i] for i in sorted_array]
生成每个文档的摘要
下一步是使用 GPT-4 模型生成每个文档的摘要,这样就节省了开支。
首先定义模型:
model = ChatOpenAI(temperature=0,model="gpt-4")
然后创建提示词,并使用 LangChain 创建提示模板以传给模型。
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template("""
You will be given different passages from a book one by one. Provide a summary of the following text. Your result must be detailed and atleast 2 paragraphs. When summarizing, directly dive into the narrative or descriptions from the text without using introductory phrases like 'In this passage'. Directly address the main events, characters, and themes, encapsulating the essence and significant details from the text in a flowing narrative. The goal is to present a unified view of the content, continuing the story seamlessly as if the passage naturally progresses into the summary
Passage:
```{text}```
SUMMARY:
"""
)
这个提示模板将帮助模型更有效地总结文档。
下一步是使用 LangChain 表达式语言(LCEL)定义一个链。
chain= (
prompt
| model
|StrOutputParser() )
总结链使用 StrOutputParser 来解析输出。还有其他输出解析器可以探索。
然后,将定义的链应用于每个文档以生成摘要。
from tqdm import tqdm
final_summary = ""
for doc in tqdm(extracted_docs, desc="Processing documents"):
# 获取新的摘要。
new_summary = chain.invoke({"text": doc.page_content})
# 更新最近两个摘要的列表:删除第一个,并在末尾添加新的一个。
final_summary += new_summary
上面的代码将链一个一个地应用到每个文档,并将每个摘要追加到 final_summary
。
将摘要保存为 PDF
接下来的步骤是格式化摘要并将其保存为 PDF 格式。
from fpdf import FPDF
class PDF(FPDF):
def header(self):
# 选择 Arial 粗体 15
self.set_font('Arial', 'B', 15)
# 向右移动
self.cell(80)
# 带框标题
self.cell(30, 10, 'Summary', 1, 0, 'C')
# 换行
self.ln(20)
def footer(self):
# 距底部 1.5 厘米
self.set_y(-15)
# 选择 Arial 斜体 8
self.set_font('Arial', 'I', 8)
# 页码
self.cell(0, 10, 'Page %s' % self.page_no(), 0, 0, 'C')
# 实例化 PDF 对象并添加页面
pdf = PDF()
pdf.add_page()
pdf.set_font("Arial", size=12)
# 确保 'last_summary' 文本按 UTF-8 处理
# 用您的实际文本变量替换 'last_summary'(如果不同)
# 确保您的文本是一个 utf-8 编码的字符串
last_summary_utf8 = last_summary.encode('latin-1', 'replace').decode('latin-1')
pdf.multi_cell(0, 10, last_summary_utf8)
# 将 PDF 保存到文件
pdf_output_path = "s_output1.pdf"
pdf.output(pdf_output_path)
最后,得到了整本书的完整摘要,并以 PDF 格式输出。
总结
在文中,我们解决了使用 LLM 总结整个书籍等大型文本的复杂性,同时解决了与上下文限制和成本相关的挑战。学习了文本预处理的步骤,并实现了一种结合语义分块和 K 均值聚类的策略,以有效管理模型的上下文限制。通过使用高效的聚类,有效地提取了关键段落,减少了直接处理大量文本的开销。这种方法不仅通过减少处理的标记数量显著降低了成本,还减轻了 LLM 固有的近因效应和首因效应,确保对所有文本段落的平衡考虑。