目录
LLM面临的问题
在大语言模型(LLM)飞速发展的今天,LLMs 正不断地充实和改进我们周边的各种工具和应用。如果说现在基于 LLM 最火热的应用技术是什么,**检索增强生成(RAG,Retrieval Augmented Generation)**技术必占据重要的一席。
RAG 最初是为了解决 LLM 的各类问题的产生的,但后面大家发现在现阶段的很多企业痛点上,使用RAG好像是更好的解决方案。在介绍 RAG 之前,我们先来看一下现在LLM存在的问题。
尽管LLM拥有令人印象深刻的能力,但是它们还面临着一些问题和挑战:
-
幻觉问题:大模型的底层原理是基于概率,在没有答案的情况下经常会胡说八道,提供虚假信息。
-
时效性问题:规模越大(参数越多、tokens 越多),大模型训练的成本越高。类似 ChatGPT3.5,起初训练数据是截止到 2021 年的,对于之后的事情就不知道了。而且对于一些高时效性的事情,大模型更加无能为力,比如帮我看看今天晚上有什么电影值得去看?这种任务是需要去淘票票、猫眼等网站先去获取最新电影信息的,大模型本身无法完成这个任务。
-
数据安全:OpenAI 已经遭到过几次隐私数据的投诉,而对于企业来说,如果把自己的经营数据、合同文件等机密文件和数据上传到互联网上的大模型,那想想都可怕。既要保证安全,又要借助 AI 能力,那么最好的方式就是把数据全部放在本地,企业数据的业务计算全部在本地完成。而在线的大模型仅仅完成一个归纳的功能,甚至,LLM 都可以完全本地化部署。
解决这些挑战对于 LLMs 在各个领域的有效利用至关重要。一个有效的解决方案是集成检索增强生成(RAG)技术,该技术通过获取外部数据来响应查询来补充模型,从而确保更准确和最新的输出。主要表现方面如下:
-
有效避免幻觉问题:虽然无法 100% 解决大模型的幻觉问题,但通过 RAG 技术能够有效的降低幻觉,在软件系统中结合大模型提供幂等的API接口就可以发挥大模型的重要作用。
-
经济高效的处理知识&开箱即用:只需要借助信息检索和向量技术,将用户的问题和知识库进行相关性搜索结合,就能高效的提供大模型不知道的知识,同时具有权威性。
-
数据安全:企业的数据可以得到有效的保护,通过私有化部署基于 RAG 系统开发的AI产品,能够在体验AI带来的便利性的同时,又能避免企业隐私数据的泄漏。
RAG
RAG 是检索增强生成(Retrieval Augmented Generation )的简称,它为大语言模型 (LLMs) 提供了从数据源检索信息的能力,并以此为基础生成回答。
简而言之,RAG 结合了信息检索技术和大语言模型的提示功能,即模型根据搜索算法找到的信息作为上下文来查询回答问题。无论是查询还是检索的上下文,都会被整合到发给大语言模型的提示中。
RAG 的架构如图中所示。完整的 RAG 应用流程主要包含两个阶段:
数据准备阶段:(A)数据提取–> (B)分块(Chunking)–> (C)向量化(embedding)–> (D)数据入库
检索生成阶段:(1)问题向量化–> (2)根据问题查询匹配数据–> (3)获取索引数据 --> (4)将数据注入Prompt–> (5)LLM生成答案
数据准备阶段
数据准备一般是一个离线的过程,主要是将私有数据向量化后构建索引并存入数据库的过程。主要包括:数据提取、数据清洗、文本分割、向量化、数据入库等环节。
-
数据提取:将 PDF、word、markdown、数据库和API等多种格式的数据,进行过滤、压缩、格式化等处理为同一个范式。
-
分块(Chunking):将初始文档分割成一定大小的块,尽量不要失去语义含义。将文本分割成句子或段落,而不是将单个句子分成多部分。有多种文本分割器实现能够完成此任务。比如根据换行、句号、问号、感叹号等切分文本,或者以其他的合适大小的 chunk 为原则进行分割。最终将语料分割成 chunk 块,在检索时会取相关性最高的 top_n。
-
向量化(embedding):将文本数据转化为向量矩阵的过程,该过程会直接影响到后续检索的效果。常用的 embedding 模型:moka-ai/m3e-base、GanymedeNil/text2vec-large-chinese,也可以参考 Hugging Face 推出的嵌入模型排行榜 MTEB Leaderboard。
-
数据入库:数据向量化后构建索引,并写入向量数据库的过程可以概述为数据入库,适用于 RAG 场景的向量数据库包括:facebookresearch/faiss(本地)、Chroma、Elasticsearch、Milvus 等。一般可以根据业务场景、硬件、性能需求等多因素综合考虑,选择合适的数据库。
检索生成阶段
在应用阶段,根据用户的提问,将提问问题向量化处理,然后通过高效的检索方法,从向量数据库中召回与提问最相关的知识,并融入 Prompt,大模型参考当前提问和相关知识,生成相应的答案。
关键环节包括:数据检索、Rerank、注入 Prompt 等。
-
数据检索:常见的数据检索方法包括:相似性检索、全文检索等。以及可以结合多种检索方式,提升召回率。
-
相似性检索:即计算查询向量与所有存储向量的相似性得分,返回得分高的记录。常见的相似性计算方法包括:余弦相似性、欧氏距离、曼哈顿距离等。
-
全文检索:全文检索是一种比较经典的检索方式,在数据存入时,通过关键词构建倒排索引;在检索时,通过关键词进行全文检索,找到对应的记录。
-
RAG 文本检索环节中的主流方法是相似性检索(向量检索),即语义相关度匹配的方式。想了解更多检索方式和检索的优化请查看文章,综述等文章。
-
-
rerank:我们可以在许多文本文档中执行语义搜索,相关文档可能有数万到数百亿个。但由于大语言模型对于传递文本量有限制,我们需要对文档质量进行排序,然后返回top-k文档用于下一步检索生成。在重排器中,给定查询和文档对,将输出相似性得分。我们使用这个分数根据与我们的查询的相关性对文档进行重新排序。
-
注入 Prompt:Prompt 作为大模型的直接输入,是影响模型输出准确率的关键因素之一。在 RAG 场景中,Prompt 一般包括任务描述、背景知识(检索得到)、任务指令(一般是用户提问)等,根据任务场景和大模型性能,也可以在 Prompt 中适当加入其他指令优化大模型的输出。一个简单知识问答场景的 Prompt 如下所示:
def question_answering(context, query): prompt = f""" Give the answer to the user query delimited by triple backticks ```{query}```\ using the information given in context delimited by triple backticks ```{context}```.\ If there is no relevant information in the provided context, try to answer yourself, but tell user that you did not have any relevant context to base your answer on. Be concise and output the answer of size less than 80 tokens. """ response = get_completion(instruction, prompt, model="gpt-3.5-turbo") answer = response.choices[0].message["content"] return answer
Prompt 的设计只有方法、没有语法,比较依赖于个人经验,在实际应用过程中,往往需要根据大模型的实际输出进行针对性的 Prompt 调优。
RAG实战
数据准备阶段
数据提取
我们准备一个PDF作为RAG的知识库,并且通过OCR等技术将其内容提取出来,保存到txt文件中备用,具体方法可以参考:python操作PDF中各类文本内容的方法。
或者也可以直接下载一个txt作为知识库,比如:西游记txt下载
import os
# pdf_parser是我另一篇文章的pdf解析器
from pdf_parser import PDFParser
def parse(filepath:str):
# 已经解析过的不做二次解析
if os.path.exists(filepath):
return
parser = PDFParser("F:\\Model\\GOT-OCR2_0", filepath)
with open("result.txt","w",encoding="utf-8") as f:
f.write(parser.parse().result)
于是我们得到了一个最原始的知识库:
数据清洗
在获取数据后,需要进行数据清洗和预处理工作,以确保数据一致性和高质量。这个步骤的目标是去除噪声数据、冗余数据,并确保数据格式适合后续处理。
- 去重:确保文档内容没有重复,避免在检索阶段返回多个相同的结果。
- 去噪:清理文档中的无关信息,如广告、版权声明、无效字符等。
- 结构化与格式化:将数据转换为统一的格式,常见的格式包括纯文本、JSON、CSV等。如果是HTML等网页数据,需要提取出有效内容并去除标签。
- 语言处理:如果系统处理的是多语言数据,可能需要进行分词、语言检测、翻译等工作,确保生成模型能够处理并理解这些内容。
由于西游记是中文著作,因此一些英文字符就是无效字符
,再去除掉额外空白字符即可:
def clean(filepath:str):
content = []
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
# 清除空白字符
line = line.strip()
# 删除英文字符
new_line = ""
for char in line:
if char.isascii():
continue
new_line += char
if new_line!="":
content.append(new_line)
with open(filepath, "w", encoding="utf-8") as f:
f.write("\n".join(content))
补充:去除停用词
去除停用词(Stop Words Removal)是自然语言处理中的一个常见预处理步骤。停用词通常是对语义贡献较小的高频词(如 “the”, “is”, “at”, “which”),去除这些词可以帮助减少文本的冗余,提升文本表示的有效性。
下面详细介绍如何在 Python 中去除停用词,并列举几种常用的库和方法。
-
使用 NLTK 去除停用词
NLTK 是 Python 中著名的自然语言处理库,它自带了英文的停用词列表,当然你也可以自定义列表。可以通过以下步骤去除文本中的停用词:
import nltk from nltk.corpus import stopwords from nltk.tokenize import word_tokenize # 下载停用词列表 nltk.download('stopwords') nltk.download('punkt') # 获取英文停用词表 stop_words = set(stopwords.words('english')) # 你的文本 text = "This is a sample sentence, showing off the stop words filtration." # 分词 words = word_tokenize(text) # 去除停用词 filtered_sentence = [w for w in words if not w.lower() in stop_words] print(filtered_sentence)
解释:
stopwords.words('english')
:获取英文停用词表。word_tokenize
:对文本进行分词。- 过滤逻辑:通过列表推导式,过滤掉在停用词表中的单词。
输出结果:
['This', 'sample', 'sentence', ',', 'showing', 'stop', 'words', 'filtration', '.']
-
使用 SpaCy 去除停用词
SpaCy 是另一个流行的自然语言处理库,它内置了更加丰富的语言模型和停用词列表。你可以通过以下方式去除停用词:
import spacy # 加载语言模型 nlp = spacy.load("en_core_web_sm") # 你的文本 text = "This is a sample sentence, showing off the stop words filtration." # 处理文本 doc = nlp(text) # 去除停用词 filtered_sentence = [token.text for token in doc if not token.is_stop] print(filtered_sentence)
解释:
is_stop
:判断每个词是否为停用词,True
表示停用词,False
表示非停用词。- 语言模型:SpaCy 使用预训练语言模型,可以根据不同语言提供对应的停用词表。
输出结果:
['This', 'sample', 'sentence', ',', 'showing', 'stop', 'words', 'filtration', '.']
-
使用 Gensim 去除停用词
Gensim 是一个专注于主题建模和文档相似度的库,它同样提供了内置的停用词表。
from gensim.parsing.preprocessing import remove_stopwords # 你的文本 text = "This is a sample sentence, showing off the stop words filtration." # 去除停用词 filtered_sentence = remove_stopwords(text) print(filtered_sentence)
解释:
remove_stopwords
:Gensim 提供的简单方法,可以从文本中直接去除停用词。
输出结果:
'This sample sentence, showing stop words filtration.'
-
自定义停用词列表
你可以根据具体的任务场景自定义停用词列表,以满足不同领域的需求。例如,在某些场景下,特定的高频词(如 “data”, “model”)可能需要被去除。
# 自定义停用词表 custom_stop_words = ["sample", "showing", "off"] # 你的文本 text = "This is a sample sentence, showing off the stop words filtration." # 分词 words = word_tokenize(text) # 去除自定义停用词 filtered_sentence = [w for w in words if not w.lower() in custom_stop_words] print(filtered_sentence)
输出结果:
['This', 'is', 'a', 'sentence', ',', 'the', 'stop', 'words', 'filtration', '.']
-
其他语言的停用词
不同的库通常也支持多语言的停用词。例如,在 NLTK 中,你可以通过以下方式获取不同语言的停用词表:
# 法语的停用词表 french_stopwords = set(stopwords.words('french')) print(french_stopwords)
你也可以通过
SpaCy
加载不同语言的模型,并获取对应的停用词表。
- NLTK 和 SpaCy 都非常适合处理复杂的文本清洗和停用词去除任务。它们提供了现成的停用词表,并支持多语言处理。
- Gensim 提供了更加简化的停用词去除方法,适合处理较为基础的任务。
- 你也可以自定义停用词表,根据不同的应用场景对文本进行更精细化的清理。
去除中文停用词与英文类似,也可以通过分词和停用词表来实现。不过由于中文的特殊性(如没有空格区分单词),首先需要对中文文本进行分词。在中文自然语言处理中,常用的分词工具是 Jieba,同时,我们也可以使用常见的中文停用词表进行过滤。
-
中文停用词表的准备
中文停用词表可以从一些公共的停用词列表获取,例如中文 NLP 研究中常用的停用词表。这些表格通常包含大量常见的无意义词语,如 “的”, “是”, “在” 等。
以下代码展示了如何加载中文停用词列表和使用 Jieba 进行分词,再去除停用词。
-
使用 Jieba 和停用词表去除中文停用词
-
安装 Jieba
如果还没有安装 Jieba,可以通过以下命令安装:
pip install jieba
-
中文停用词去除示例代码
import jieba # 加载停用词表 def load_stopwords(filepath): with open(filepath, 'r', encoding='utf-8') as file: stopwords = set([line.strip() for line in file.readlines()]) return stopwords # 假设我们有一个停用词表文件 stopwords.txt stopwords = load_stopwords("stopwords.txt") # 示例文本 text = "这是一个用于测试停用词去除的简单例子。我们希望去除其中的无意义词语。" # 使用 Jieba 进行分词 words = jieba.lcut(text) # 去除停用词 filtered_words = [word for word in words if word not in stopwords] # 输出结果 print("原始文本:", text) print("去除停用词后的文本:", " ".join(filtered_words))
-
2.3 停用词表的格式
停用词表通常是一个文本文件,每行一个词。例如:的 是 在 ...
输出结果:
原始文本: 这是一个用于测试停用词去除的简单例子。我们希望去除其中的无意义词语。 去除停用词后的文本: 测试 停用词 去除 简单 例子 希望 去除 无意义 词语
-
-
常见的中文停用词表来源
- 百度停用词表:百度 NLP 开发的中文停用词表,内容覆盖广泛。
- 哈工大停用词表:哈尔滨工业大学发布的中文停用词表,也是许多中文 NLP 项目中常用的选择。
- SCU 停用词表:四川大学发布的中文停用词表。
你可以从网络上获取这些停用词表并加载使用。
-
改进和定制化
如果你有特定的领域需求,可以在通用停用词表的基础上添加或删除词语,形成一个更符合你应用场景的停用词表。例如,你可能希望保留一些常见词汇,但在你的应用中具有特定意义的词(如“数据”,“模型”)。
-
处理标点符号
你也可以在过滤停用词时同时去除标点符号。可以通过正则表达式来实现:
import re import jieba # 示例文本 text = "这是一个用于测试停用词去除的简单例子。我们希望去除其中的无意义词语。" # 使用正则表达式去除标点符号 text = re.sub(r'[^\w\s]', '', text) # 使用 Jieba 进行分词 words = jieba.lcut(text) # 去除停用词 filtered_words = [word for word in words if word not in stopwords] print("去除停用词和标点符号后的文本:", " ".join(filtered_words))
结果:
去除停用词和标点符号后的文本: 测试 停用词 去除 简单 例子 希望 去除 无意义 词语
-
使用 SpaCy 处理中文
除了 Jieba,SpaCy 也支持中文处理(通过外部模型),但是分词效果和性能上不如 Jieba。在处理中文时,Jieba 还是首选工具。
去除中文停用词的步骤:
- 分词:使用 Jieba 或其他分词工具将中文文本进行分词。
- 加载停用词表:准备一个中文停用词表。
- 去除停用词:通过过滤操作去除停用词表中的词汇。
Jieba 是处理中文文本的常用工具,结合停用词表和正则表达式,你可以轻松完成中文文本的清洗与预处理。如果你有更复杂的需求或想定制停用词表,也可以进行调整。
分块(Chunking)
RAG是一个考验技术的工作:RAG涉及的内容其实广泛,包括Embedding、分词分块、检索召回(相似度匹配)、chat系统、ReAct和Prompt优化等,最后还有与LLM的交互,整个过程技术复杂度很高。如果你用的LLM非常好,反而大模型这一块是你最不需要关心的。而这些环节里面我们每个都没达到1(比如0.9、0.7…),那么最终的结果可能是这些小数点的乘积。如果我们每个环节都可以做到>1.0,那么最终的结果会比上一个结果高出很多。
在构建RAG这类基于LLM的应用程序中,分块(chunking)是将大块文本分解成小段的过程。当我们使用LLM embedding内容时,这是一项必要的技术,可以帮助我们优化从向量数据库被召回的内容的准确性。
在向量数据库(如:Pinecone)中索引的任何内容都需要首先Embedding。分块的主要原因是尽量减少我们Embedding内容的噪音。
例如,在语义搜索中,我们索引一个文档语料库,每个文档包含一个特定主题的有价值的信息。通过使用有效的分块策略,我们可以确保搜索结果准确地捕获用户查询的需求本质。如果我们的块太小或太大,可能会导致不精确的搜索结果或错过展示相关内容的机会。根据经验,如果文本块尽量是语义独立的,也就是没有对上下文很强的依赖,这样子对语言模型来说是最易于理解的。因此,为语料库中的文档找到最佳块大小对于确保搜索结果的准确性和相关性至关重要。
在确定最佳分块策略时,有几个因素会对我们的选择起到至关重要的影响。以下是一些事实我们需要首先记在心里:
-
被索引内容的性质是什么? 这可能差别会很大,是处理较长的文档(如文章或书籍),还是处理较短的内容(如微博或即时消息)?答案将决定哪种模型更适合您的目标,从而决定应用哪种分块策略。
-
使用的是哪种Embedding模型,它在多大的块大小上表现最佳?例如,sentence-transformer[1]模型在单个句子上工作得很好,但像text- embedt-ada -002[2]这样的模型在包含256或512个tokens的块上表现得更好。
-
对用户查询的长度和复杂性有什么期望?用户输入的问题文本是简短而具体的还是冗长而复杂的?这也直接影响到我们选择分组内容的方式,以便在嵌入查询和嵌入文本块之间有更紧密的相关性。
-
如何在特定应用程序中使用检索结果? 例如,它们是否用于语义搜索、问答、摘要或其他目的?例如,和底层连接的LLM是有直接关系的,LLM的tokens限制会让你不得不考虑分块的大小。
没有最好的分块策略,只有适合的分块策略,为了确保查询结果更加准确,有时候我们甚至需要选择性的使用几种不同的策略。
分块的方法
分块有不同的方法,每种方法都可能适用于不同的情况,为了快速高效,我们使用langchain造好的分块轮子。
固定大小分块
这是最常见、最直接的分块方法。
我们只需决定块中的tokens的数量,以及它们之间是否应该有任何重叠。一般来说,我们会在块之间保持一些重叠,以确保语义上下文不会在块之间丢失。
在大多数情况下,固定大小的分块将是最佳方式。与其他形式的分块相比,固定大小的分块在计算上更加经济且易于使用,因为它在分块过程中不需要使用任何NLP库。
from langchain.text_splitter import CharacterTextSplitter
def fixed_size_splitter(text:str):
text_splitter = CharacterTextSplitter(
separator = "\n\n",
chunk_size = 256,
chunk_overlap = 20
)
docs = text_splitter.create_documents([text])
return docs
Sentence splitting(句分割)
句分割可以确保每个生成的文本块都是一个完整的句子,保持语义的完整性和连贯性。这对于需要理解和分析文本内容的任务(如自然语言理解、问答系统等)尤为重要。
Naive splitting: 最幼稚的方法是用句号(。)和“换行”来分割句子。虽然这可能是快速和简单的,但这种方法不会考虑到所有可能的边缘情况。
def native_splitter(text:str)->list[str]:
return text.split(".")
NLTK: 自然语言工具包(NLTK)是一个流行的Python库,用于处理自然语言数据。它提供了一个句子标记器,可以将文本分成句子,帮助创建更有意义的分块:
from langchain.text_splitter import CharacterTextSplitter,NLTKTextSplitter
def nltk_text_splitter(text:str)->list[str]:
text_splitter = NLTKTextSplitter()
docs = text_splitter.split_text(text)
return docs
spaCy: spaCy是另一个用于NLP任务的强大Python库。它提供了一个复杂的句子分割功能,可以有效地将文本分成单独的句子,从而在生成的块中更好地保存上下文:
from langchain.text_splitter import CharacterTextSplitter,NLTKTextSplitter,SpacyTextSplitter
def spacy_text_splitter(text:str)->list[str]:
text_splitter = SpacyTextSplitter()
docs = text_splitter.split_text(text)
return docs
递归分割
递归分块使用一组分隔符以分层和迭代的方式将输入文本分成更小的块。如果分割文本开始的时候没有产生所需大小或结构的块,那么这个方法会使用不同的分隔符或标准对生成的块递归调用,直到获得所需的块大小或结构。这意味着虽然这些块的大小并不完全相同,但它们仍然会逼近差不多的大小。
from langchain.text_splitter import CharacterTextSplitter,NLTKTextSplitter,SpacyTextSplitter,RecursiveCharacterTextSplitter
def recursive_text_splitter(text:str)->list[str]:
text_splitter = RecursiveCharacterTextSplitter(
# 设置一个非常小的块大小。
chunk_size=256,
chunk_overlap=20
)
docs = text_splitter.split_text(text)
return docs
补充:特殊分块
Markdown: Markdown是一种轻量级的标记语言,通常用于格式化文本。通过识别Markdown语法(例如,标题、列表和代码块),您可以根据其结构和层次结构智能地划分内容,从而生成语义更连贯的块。例如:
from langchain.text_splitter import MarkdownTextSplitter
markdown_text = "..."
markdown_splitter = MarkdownTextSplitter(chunk_size=100, chunk_overlap=0)
docs = markdown_splitter.create_documents([markdown_text])
LaTex: LaTeX是一种文档准备系统和标记语言,通常用于学术论文和技术文档。通过解析LaTeX命令和环境,您可以创建尊重内容逻辑组织的块(例如,节、子节和方程),从而产生更准确和上下文相关的结果。例如:
from langchain.text_splitter import LatexTextSplitter
latex_text = "..."
latex_splitter = LatexTextSplitter(chunk_size=100, chunk_overlap=0)
docs = latex_splitter.create_documents([latex_text])
向量化(embedding)
Transformer 模型的输入序列长度是固定的,即使输入上下文窗口很大,用一个句子或几个句子的向量来代表它们的语义含义,通常比对几页文本进行平均向量化更为有效。因此,需要对数据进行分块处理,将文档切分成合适大小的段落,同时保持其原有意义不变(例如,将文本划分为句子或段落,而不是将单个句子切割成两部分)。市面上有多种文本分割工具可用于此任务。
需要考虑的一个参数是块的大小,这取决于你使用的嵌入模型及其处理token的能力。例如,基于BERT的标准Transformer编码器模型最多处理512个token,而OpenAI ada-002能处理更长的序列,如8191个token。但这里需要权衡的是为大语言模型提供足够上下文以进行推理,与实现高效搜索所需的足够具体的文本嵌入。
接下来的步骤是选择一个模型来嵌入这些文本块。有很多种模型可以选择,推荐使用像bge-large或E5嵌入系列这样的搜索优化模型。