1. 引言
1.1 什么是RAG
大模型具有以下几方面问题:
- 知识局限性
- 数据安全性
- 大模型幻觉
RAG:检索增强生成(Retrieval Augmented Generation)。通过引入外部知识,使大模型能够生成准确且符合上下文的答案,同时能够减少模型幻觉的出现。
- 索引:将文档库分割成较短的Chunk,即文本块或者文本片段,然后构建成向量索引
- 检索:计算问题和Chunks的相似度,检索出若干个相关的Chunk
- 生成:将检索到的Chunks作为背景信息,生成问题的回答。
1.2 一个完整的RAG链路
从上图可以看到,线上接收到用户query
后,RAG会先进行检索,然后将检索到的 Chunks
和 query
一并输入到大模型,进而回答用户的问题。
为了完成检索,需要离线将文档(ppt、word、pdf等)经过解析、切割甚至OCR转写,然后进行向量化存入数据库中。
1.2.1 离线计算
首先,知识库中包含了多种类型的文件,如pdf、word、ppt等,这些 文档(Documents)需要提前被解析,然后切割成若干个较短的 Chunk
,并且进行清洗和去重。
由于知识库中知识的数量和质量决定了RAG的效果,因此这是非常关键且必不可少的环节。
然后,我们会将知识库中的所有 Chunk
都转成向量,这一步也称为 向量化
(Vectorization)或者 索引
(Indexing)。
向量化
需要事先构建一个 向量模型
(Embedding Model),它的作用就是将一段 Chunk 转成 向量(Embedding)。如下图所示:
一个好的向量模型,会使得具有相同语义的文本的向量表示在语义空间中的距离会比较近,而语义不同的文本在语义空间中的距离会比较远。
由于知识库中的所有 Chunk
都需要进行 向量化,这会使得计算量非常大,因此这一过程通常是离线完成的。
随着新知识的不断存储,向量的数量也会不断增加。这就需要将这些向量存储到 数据库
(DataBase)中进行管理,例如 Milvus 中。
1.2.2 在线计算
在实际使用RAG系统时,当给定一条用户查询
(Query),需要先从知识库中找到所需的知识,这一步称为 检索
(Retrieval)。
在 检索
过程中,用户查询首先会经过向量模型得到相应的向量,然后与 数据库
中所有 Chunk
的向量计算相似度,最简单的例如 余弦相似度
,然后得到最相近的一系列Chunk
。
由于向量相似度的计算过程需要一定的时间,尤其是 数据库
非常大的时候。
这时,可以在检索之前进行召回
(Recall),即从 数据库
中快速获得大量大概率相关的 Chunk
,然后只有这些Chunk
会参与计算向量相似度。这样,计算的复杂度就从整个知识库降到了非常低。
召回
步骤不要求非常高的准确性,因此通常采用简单的基于字符串的匹配算法。由于这些算法不需要任何模型,速度会非常快,常用的算法有 TF-IDF
,BM25
等。
另一方面,随着知识库的增大,除了检索速度变慢外,检索效果也会退化。
这是由于 能力有限
,而随着知识库的增大,已经超出了其容量,因此准确性就会下降。在这种情况下,相似度最高的结果可能并不是最优的。
为了解决这一问题,提升RAG效果,研究者提出增加一个二阶段检索——重排
(Rerank),即利用 重排模型
(Reranker),使得越相似的结果排名更靠前。这样就能实现准确率稳定增长,即数据越多,效果越好。
通常,为了与 重排
进行区分,一阶段检索有时也被称为 精排
。而在一些更复杂的系统中,在 召回
和 精排
之间还会添加一个粗排
步骤,这里不再展开,感兴趣的同学可以自行搜索。
综上所述,在整个 检索
过程中,计算量的顺序是 召回 > 精排 > 重排,而检索效果的顺序则是 召回 < 精排 < 重排 。
当这一复杂的 检索
过程完成后,我们就会得到排好序的一系列 检索文档
(Retrieval Documents)。
然后我们会从其中挑选最相似的 k
个结果,将它们和用户查询拼接成prompt的形式,输入到大模型。
最后,大型模型就能够依据所提供的知识来生成回复,从而更有效地解答用户的问题。
至此,一个完整的RAG链路就构建完毕了。
1.3 开源RAG框架
目前,开源社区中已经涌现出了众多RAG框架,例如:
- TinyRAG:DataWhale成员宋志学精心打造的纯手工搭建RAG框架。
- LlamaIndex:一个用于构建大语言模型应用程序的数据框架,包括数据摄取、数据索引和查询引擎等功能。
- LangChain:一个专为开发大语言模型应用程序而设计的框架,提供了构建所需的模块和工具。
- QAnything:网易有道开发的本地知识库问答系统,支持任意格式文件或数据库。
- RAGFlow:InfiniFlow开发的基于深度文档理解的RAG引擎。
- ···
因此,本节课将以 Yuan2-2B-Mars
模型为基础,进行RAG实战。希望通过构建一个简化版的RAG系统,来帮助大家掌握RAG的核心技术,从而进一步了解一个完整的RAG链路。
2. 源2.0-2B RAG实战
2.1 环境准备
git lfs install
git clone https://www.modelscope.cn/datasets/Datawhale/AICamp_yuan_baseline.git
cp AICamp_yuan_baseline/Task\ 3:源大模型RAG实战/* .
2.2 模型下载
在RAG实战中,我们需要构建一个向量模型。
向量模型通常采用BERT架构,它是一个Transformer Encoder。
输入向量模型前,首先会在文本的最前面额外加一个[CLS]
token,然后将该token最后一层的隐藏层向量作为文本的表示。如下图所示:
在本次学习中,我们选用基于BERT架构的向量模型 bge-small-zh-v1.5
,它是一个4层的BERT模型,最大输入长度512,输出的向量维度也为512。
3. 代码详解
模型下载
# 向量模型下载
from modelscope import snapshot_download
model_dir = snapshot_download("AI-ModelScope/bge-small-zh-v1.5", cache_dir='.')
# 源大模型下载
from modelscope import snapshot_download
model_dir = snapshot_download('IEITYuan/Yuan2-2B-Mars-hf', cache_dir='.')
RAG实战
# 导入所需的库
from typing import List
import numpy as np
import torch
from transformers import AutoModel, AutoTokenizer, AutoModelForCausalLM
- AutoTokenizer:
AutoTokenizer
是一个自动化的类,它可以加载与预训练模型相对应的分词器。分词器负责将原始文本转换为模型可以理解的数字表示(即 token IDs),并且能够处理文本的填充、截断等预处理步骤。- 使用
AutoTokenizer.from_pretrained
方法,可以根据模型的名称或路径自动下载并初始化正确的分词器。
- AutoModel:
AutoModel
是一个自动化的类,它可以加载任何预训练的模型架构,这些模型可以是用于各种任务的模型,如文本分类、命名实体识别、句子相似度等。- 通过
AutoModel.from_pretrained
方法,可以根据模型的名称或路径自动下载并初始化相应的模型结构。 AutoModel
通常用于提取特征或执行任何没有特定输出的任务。
- AutoModelForCausalLM:
AutoModelForCausalLM
是专门为因果语言模型(Causal Language Modeling)设计的自动化类。因果语言模型是自回归模型,它们通常用于生成文本,如 GPT-2、GPT-3、或其他基于 Transformer 的语言模型。- 这种模型在文本生成任务中非常有用,因为它们能够预测下一个词,而不依赖于未来的上下文(即,它们是单向的)。
- 使用
AutoModelForCausalLM.from_pretrained
方法,可以加载预训练的因果语言模型,并用于文本生成任务。
a 索引
# 定义向量模型类
class EmbeddingModel:
"""
class for EmbeddingModel
"""
def __init__(self, path: str) -> None:
self.tokenizer = AutoTokenizer.from_pretrained(path)
self.model = AutoModel.from_pretrained(path).cuda()
print(f'Loading EmbeddingModel from {path}.')
def get_embeddings(self, texts: List) -> List[float]:
"""
calculate embedding for text list
"""
encoded_input = self.tokenizer(texts, padding=True, truncation=True, return_tensors='pt')
encoded_input = {k: v.cuda() for k, v in encoded_input.items()}
with torch.no_grad():
model_output = self.model(**encoded_input)
sentence_embeddings = model_output[0][:, 0]
sentence_embeddings = torch.nn.functional.normalize(sentence_embeddings, p=2, dim=1)
return sentence_embeddings.tolist()
def init()
-
def __init__
: 这是定义一个方法的开始,其中__init__
是一个特殊的方法名,称为构造函数。构造函数在创建对象时自动调用,用于初始化对象的状态。 -
self
: 这是一个类实例的方法的第一个参数,它代表当前对象的实例。self
用于访问实例的属性和其他方法。 -
path: str
: 这是一个参数path
,它的类型提示为str
,表示path
应该是一个字符串。 -
-> None
: 这是方法的返回类型提示,表示该方法没有返回值。None
是Python中的一个特殊类型,表示没有值。
【其中】在Python中,self
是一个约定的名称,用于表示类的实例本身。它是实例方法的第一个参数,允许你访问和操作实例的属性和其他方法。下面是一些关于 self
的具体解释:
-
表示实例本身:
self
代表类的当前实例。例如,当你创建一个类的对象时,self
就指向那个对象。
-
访问实例属性:
- 通过
self
,你可以访问实例的属性。例如,在构造函数中,你可以用self.path = path
来将传入的path
参数存储在实例的属性中。
- 通过
-
调用实例方法:
self
允许你调用同一个类中的其他实例方法。例如,你可以在一个方法中调用另一个方法:self.other_method()
。
-
与类变量区分:
self
只在实例方法中使用,而类变量则通过类名访问。例如,如果你有一个类变量class_variable
,你会使用ClassName.class_variable
来访问,而不是通过self
。
-
不需要显式传递:
- 在调用实例方法时,
self
是自动传递的。你不需要在调用方法时显式地传递self
,Python 会自动处理。
- 在调用实例方法时,
def get_embeddings()
get_embeddings
方法的目的是为输入的文本列表计算嵌入向量。以下是该方法中每一步的详细解释:
-
编码输入文本:
encoded_input = self.tokenizer(texts, padding=True, truncation=True, return_tensors='pt')
self.tokenizer
是一个分词器对象,它负责将文本字符串转换为模型可以理解的数字表示。texts
是一个文本字符串列表。padding=True
表示对文本进行填充,以确保所有文本序列的长度一致。truncation=True
表示如果文本序列过长,则进行截断。return_tensors='pt'
表示返回 PyTorch 张量格式的编码结果。
-
将编码的输入移动到 GPU:
encoded_input = {k: v.cuda() for k, v in encoded_input.items()}
- 这一步将编码后的输入数据移动到 GPU 上,如果 GPU 可用的话。
cuda()
是 PyTorch 中的一个方法,用于将张量移动到 GPU。 - 这可以加速模型的计算过程,特别是对于大型模型和大量数据。
- 这一步将编码后的输入数据移动到 GPU 上,如果 GPU 可用的话。
-
计算模型输出:
with torch.no_grad(): model_output = self.model(**encoded_input) sentence_embeddings = model_output[0][:, 0]
with torch.no_grad():
确保在计算过程中不计算梯度,因为我们只是进行前向传播,而不进行反向传播。self.model
是加载的预训练模型。**encoded_input
是将编码后的输入解包并传递给模型。model_output[0][:, 0]
提取模型输出的第一部分(通常是嵌入向量)的第一个元素(通常是 [CLS] 标记的嵌入向量),它通常用于表示整个序列的嵌入。
-
归一化嵌入向量:
sentence_embeddings = torch.nn.functional.normalize(sentence_embeddings, p=2, dim=1)
torch.nn.functional.normalize
是一个函数,用于对嵌入向量进行归一化处理。p=2
表示使用 L2 归一化(欧几里得范数)。dim=1
表示沿着嵌入向量的第二个维度(通常是嵌入向量的长度)进行归一化。
-
返回嵌入向量列表:
return sentence_embeddings.tolist()
-
最后,将归一化后的嵌入向量转换为 Python 列表格式,并返回。
总结来说,
get_embeddings
方法接受一个文本列表,使用分词器编码这些文本,通过模型计算它们的嵌入向量,对嵌入向量进行归一化,然后返回归一化后的嵌入向量列表。这些嵌入向量可以用于各种下游任务,如文本相似度计算、文本分类、聚类等。
-
通过传入模型路径,新建一个 EmbeddingModel
对象 embed_model
。
初始化时自动加载向量模型的tokenizer和模型参数。
print("> Create embedding model...")
embed_model_path = './AI-ModelScope/bge-small-zh-v1___5'
embed_model = EmbeddingModel(embed_model_path)
EmbeddingModel
类还有一个 get_embeddings()
函数,它可以获得输入文本的向量表示。
注意,这里为了充分发挥GPU矩阵计算的优势,输入和输出都是一个 List
,即多条文本和他们的向量表示。
b 检索
# 定义向量库索引类
class VectorStoreIndex:
"""
class for VectorStoreIndex
"""
def __init__(self, doecment_path: str, embed_model: EmbeddingModel) -> None:
self.documents = []
for line in open(doecment_path, 'r', encoding='utf-8'):
line = line.strip()
self.documents.append(line)
self.embed_model = embed_model
self.vectors = self.embed_model.get_embeddings(self.documents)
print(f'Loading {len(self.documents)} documents for {doecment_path}.')
def get_similarity(self, vector1: List[float], vector2: List[float]) -> float:
"""
calculate cosine similarity between two vectors
"""
dot_product = np.dot(vector1, vector2)
magnitude = np.linalg.norm(vector1) * np.linalg.norm(vector2)
if not magnitude:
return 0
return dot_product / magnitude
def query(self, question: str, k: int = 1) -> List[str]:
question_vector = self.embed_model.get_embeddings([question])[0]
result = np.array([self.get_similarity(question_vector, vector) for vector in self.vectors])
return np.array(self.documents)[result.argsort()[-k:][::-1]].tolist()
通过传入知识库文件路径,新建一个 VectorStoreIndex
对象 index
。
初始化时会自动读取知识库的内容,然后传入向量模型,获得向量表示。
print("> Create index...")
doecment_path = './knowledge.txt'
index = VectorStoreIndex(doecment_path, embed_model)
上文提到 get_embeddings()
函数支持一次性传入多条文本,但由于GPU的显存有限,输入的文本不宜太多。
所以,如果知识库很大,需要将知识库切分成多个batch,然后分批次送入向量模型。
这里,因为我们的知识库比较小,所以就直接传到了 get_embeddings()
函数。
其次,VectorStoreIndex
类还有一个 get_similarity()
函数,它用于计算两个向量之间的相似度,这里采用了余弦相似度。
最后,我们介绍一下 VectorStoreIndex
类的入口,即查询函数 query()
。传入用户的提问后,首先会送入向量模型获得其向量表示,然后与知识库中的所有向量计算相似度,最后将 k
个最相似的文档按顺序返回,k
默认为1。
question = '介绍一下广州大学'
print('> Question:', question)
context = index.query(question)
print('> Context:', context)
c 生成
为了实现基于RAG的生成,我们还需要定义一个大语言模型类 LLM
:
# 定义大语言模型类
class LLM:
"""
class for Yuan2.0 LLM
"""
def __init__(self, model_path: str) -> None:
print("Creat tokenizer...")
self.tokenizer = AutoTokenizer.from_pretrained(model_path, add_eos_token=False, add_bos_token=False, eos_token='<eod>')
self.tokenizer.add_tokens(['<sep>', '<pad>', '<mask>', '<predict>', '<FIM_SUFFIX>', '<FIM_PREFIX>', '<FIM_MIDDLE>','<commit_before>','<commit_msg>','<commit_after>','<jupyter_start>','<jupyter_text>','<jupyter_code>','<jupyter_output>','<empty_output>'], special_tokens=True)
print("Creat model...")
self.model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.bfloat16, trust_remote_code=True).cuda()
print(f'Loading Yuan2.0 model from {model_path}.')
def generate(self, question: str, context: List):
if context:
prompt = f'背景:{context}\n问题:{question}\n请基于背景,回答问题。'
else:
prompt = question
prompt += "<sep>"
inputs = self.tokenizer(prompt, return_tensors="pt")["input_ids"].cuda()
outputs = self.model.generate(inputs, do_sample=False, max_length=1024)
output = self.tokenizer.decode(outputs[0])
print(output.split("<sep>")[-1])
这里我们传入 Yuan2-2B-Mars
的模型路径,新建一个 LLM
对象 llm
。
初始化时自动加载源大模型的tokenizer和模型参数。
print("> Create Yuan2.0 LLM...")
model_path = './IEITYuan/Yuan2-2B-Mars-hf'
llm = LLM(model_path)
LLM
类的入口是生成函数 generate()
,它有两个参数:
question
: 用户提问,是一个strcontext
: 检索到的上下文信息,是一个List,默认是[],代表没有使用RAG
运行下面的代码,即可体验使用RAG技术之后 Yuan2-2B-Mars
模型的回答效果:
print('> Without RAG:')
llm.generate(question, [])
print('> With RAG:')
llm.generate(question, context)
AI 科研助手案例
# 导入所需的库
import torch
import streamlit as st
from transformers import AutoTokenizer, AutoModelForCausalLM
from langchain.prompts import PromptTemplate
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import PyPDFLoader
from langchain_huggingface import HuggingFaceEmbeddings
from langchain.chains import LLMChain
from langchain.chains.question_answering import load_qa_chain
from langchain.llms.base import LLM
from langchain.callbacks.manager import CallbackManagerForLLMRun
from langchain.text_splitter import RecursiveCharacterTextSplitter
from typing import Any, List, Optional
# 向量模型下载
from modelscope import snapshot_download
model_dir = snapshot_download('AI-ModelScope/bge-small-en-v1.5', cache_dir='./')
# 源大模型下载
from modelscope import snapshot_download
model_dir = snapshot_download('IEITYuan/Yuan2-2B-Mars-hf', cache_dir='./')
# 定义模型路径
model_path = './IEITYuan/Yuan2-2B-Mars-hf'
# 定义向量模型路径
embedding_model_path = './AI-ModelScope/bge-small-en-v1___5'
# 定义模型数据类型
torch_dtype = torch.bfloat16 # A10
# torch_dtype = torch.float16 # P100
# 定义源大模型类
class Yuan2_LLM(LLM):
"""
class for Yuan2_LLM
"""
tokenizer: AutoTokenizer = None
model: AutoModelForCausalLM = None
def __init__(self, mode_path :str):
super().__init__()
# 加载预训练的分词器和模型
print("Creat tokenizer...")
self.tokenizer = AutoTokenizer.from_pretrained(mode_path, add_eos_token=False, add_bos_token=False, eos_token='<eod>')
self.tokenizer.add_tokens(['<sep>', '<pad>', '<mask>', '<predict>', '<FIM_SUFFIX>', '<FIM_PREFIX>', '<FIM_MIDDLE>','<commit_before>','<commit_msg>','<commit_after>','<jupyter_start>','<jupyter_text>','<jupyter_code>','<jupyter_output>','<empty_output>'], special_tokens=True)
print("Creat model...")
self.model = AutoModelForCausalLM.from_pretrained(mode_path, torch_dtype=torch.bfloat16, trust_remote_code=True).cuda()
def _call(
self,
prompt: str,
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> str:
prompt = prompt.strip()
prompt += "<sep>"
inputs = self.tokenizer(prompt, return_tensors="pt")["input_ids"].cuda()
outputs = self.model.generate(inputs,do_sample=False,max_length=4096)
output = self.tokenizer.decode(outputs[0])
response = output.split("<sep>")[-1].split("<eod>")[0]
return response
@property
def _llm_type(self) -> str:
return "Yuan2_LLM"
# 定义一个函数,用于获取llm和embeddings
@st.cache_resource
def get_models():
llm = Yuan2_LLM(model_path)
model_kwargs = {'device': 'cuda'}
encode_kwargs = {'normalize_embeddings': True} # set True to compute cosine similarity
embeddings = HuggingFaceEmbeddings(
model_name=embedding_model_path,
model_kwargs=model_kwargs,
encode_kwargs=encode_kwargs,
)
return llm, embeddings
summarizer_template = """
假设你是一个AI科研助手,请用一段话概括下面文章的主要内容,200字左右。
{text}
"""
# 定义Summarizer类
class Summarizer:
"""
class for Summarizer.
"""
def __init__(self, llm):
self.llm = llm
self.prompt = PromptTemplate(
input_variables=["text"],
template=summarizer_template
)
self.chain = LLMChain(llm=self.llm, prompt=self.prompt)
def summarize(self, docs):
# 从第一页中获取摘要
content = docs[0].page_content.split('ABSTRACT')[1].split('KEY WORDS')[0]
summary = self.chain.run(content)
return summary
chatbot_template = '''
假设你是一个AI科研助手,请基于背景,简要回答问题。
背景:
{context}
问题:
{question}
'''.strip()
# 定义ChatBot类
class ChatBot:
"""
class for ChatBot.
"""
def __init__(self, llm, embeddings):
self.prompt = PromptTemplate(
input_variables=["text"],
template=chatbot_template
)
self.chain = load_qa_chain(llm=llm, chain_type="stuff", prompt=self.prompt)
self.embeddings = embeddings
# 加载 text_splitter
self.text_splitter = RecursiveCharacterTextSplitter(
chunk_size=450,
chunk_overlap=10,
length_function=len
)
def run(self, docs, query):
# 读取所有内容
text = ''.join([doc.page_content for doc in docs])
# 切分成chunks
all_chunks = self.text_splitter.split_text(text=text)
# 转成向量并存储
VectorStore = FAISS.from_texts(all_chunks, embedding=self.embeddings)
# 检索相似的chunks
chunks = VectorStore.similarity_search(query=query, k=1)
# 生成回复
response = self.chain.run(input_documents=chunks, question=query)
return chunks, response
def main():
# 创建一个标题
st.title('💬 Yuan2.0 AI科研助手')
# 获取llm和embeddings
llm, embeddings = get_models()
# 初始化summarizer
summarizer = Summarizer(llm)
# 初始化ChatBot
chatbot = ChatBot(llm, embeddings)
# 上传pdf
uploaded_file = st.file_uploader("Upload your PDF", type='pdf')
if uploaded_file:
# 加载上传PDF的内容
file_content = uploaded_file.read()
# 写入临时文件
temp_file_path = "temp.pdf"
with open(temp_file_path, "wb") as temp_file:
temp_file.write(file_content)
# 加载临时文件中的内容
loader = PyPDFLoader(temp_file_path)
docs = loader.load()
st.chat_message("assistant").write(f"正在生成论文概括,请稍候...")
# 生成概括
summary = summarizer.summarize(docs)
# 在聊天界面上显示模型的输出
st.chat_message("assistant").write(summary)
# 接收用户问题
if query := st.text_input("Ask questions about your PDF file"):
# 检索 + 生成回复
chunks, response = chatbot.run(docs, query)
# 在聊天界面上显示模型的输出
st.chat_message("assistant").write(f"正在检索相关信息,请稍候...")
st.chat_message("assistant").write(chunks)
st.chat_message("assistant").write(f"正在生成回复,请稍候...")
st.chat_message("assistant").write(response)
if __name__ == '__main__':
main()