LLM是基于一定训练数据训练成的,如果输入的内容所涉及的知识并不在训练数据范围内,LLM通常是无法准确回答问题的,不过检索增强生成(RAG)方案可以解决这个问题。RAG简单来说就是在外部增加了一个知识库,当输入的内容所涉及的知识不在训练数据范围内时,可以通过检索知识库来获取相关知识,然后与输入的内容进行融合,再通过LLM生成最终的回答。
实现RAG的一个关键点是如何准确的找到和问题语义相关的知识,这通常需要结合向量数据库和Embedding模型来实现。Embedding模型负责将输入的内容映射成向量,向量数据库则负责存储和检索向量。向量数据库有很多种,这里我们以比较简单易用的Chroma为例进行介绍,Embedding模型选择对中文支持较好的bge-large-zh-v1.5。
执行以下命令安装加载Embedding模型和Chroma相关的依赖。
pip install langchain-huggingface sentence_transformers langchain-chroma
其次我们还需要将bge-large-zh-v1.5
模型下载到本地。
LangChain中,文档对应的封装对象是Document
。下面例子中我们手动编写了一些文档并Embedding后存入了Chroma向量数据库中,之后我们又通过向量数据库来检索和问题相关的文档。
from langchain_core.documents import Document
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
embedding_model = HuggingFaceEmbeddings(
model_name='E:/bge-large-zh-v1.5/',
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
documents = [
Document(
page_content='汤姆是一只有蓝色的毛发的英国短毛猫,它经常追逐杰瑞。',
metadata={'source': '宠物介绍'},
),
Document(
page_content='杰瑞是一只褐色的家鼠,它非常聪明和敏捷。',
metadata={'source': '宠物介绍'},
),
Document(
page_content='斯派克是一只又大又笨的狗,他是杰瑞的朋友。',
metadata={'source': '宠物介绍'},
)
]
vector_store = Chroma.from_documents(documents, embedding=embedding_model, persist_directory="./chroma_db")
result = vector_store.similarity_search('斯派克是谁?', k=1)
print(result)
上述代码中,我们首先加载了Embedding模型,随后将文档的特征使用Embedding模型处理后存入了Chroma向量数据库。documents
是我们手写的一组文档,实际开发中,文档可能来源于其它数据源,并且可能是增量更新到向量数据库的,Chroma支持对文档数据的增删改查操作,具体可以参考LangChain和Chroma集成相关的文档。最后,我们调用了similarity_search
方法并输入了一个问题,参数k=1
意味着我们指定检索结果输出1个和问题最相关的文档。
在上面例子中,假如我们的问题和“猫”或“汤姆”有关,程序通常就会检索到第一条数据,因为这条数据和我们的问题是语义相关的,其它词条也是同理。
在LangChain中,将RAG整合到Chain还需要一个Retriever
组件,下面我们使用LangChain编写一个完整的RAG应用程序。
from langchain.globals import set_debug, set_verbose
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.documents import Document
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from langchain_ollama import OllamaLLM
set_debug(True)
set_verbose(True)
embedding_model = HuggingFaceEmbeddings(
model_name='E:/bge-large-zh-v1.5/',
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
documents = [
Document(
page_content='汤姆是一只有蓝色的毛发的英国短毛猫,它经常追逐杰瑞。',
metadata={'source': '宠物介绍'},
),
Document(
page_content='杰瑞是一只褐色的家鼠,它非常聪明和敏捷。',
metadata={'source': '宠物介绍'},
),
Document(
page_content='斯派克是一只又大又笨的狗,他是杰瑞的朋友。',
metadata={'source': '宠物介绍'},
)
]
vector_store = Chroma.from_documents(documents, embedding=embedding_model, persist_directory="./chroma_db")
retriever = vector_store.as_retriever(
search_type="similarity",
search_kwargs={"k": 1},
)
prompt = PromptTemplate.from_template("""
Answer this question using the provided context only.
{question}
Context:
{context}
""")
llm = OllamaLLM(model='llama3.1:8b', temperature=1)
chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser()
result = chain.invoke("介绍斯派克。")
print(result)
RunnablePassthrough()
:LangChain提供的一个工具类,类似一个占位符,用于在链式操作中传递输入数据而不做任何修改。它的作用是将输入数据直接传递给下一个步骤,而不进行任何处理或转换。这里的chain
可能有点难以理解。在这条“链”中,输入内容是字符串"介绍斯派克。"
,这个变量被传递给了retriever
处理并作为链上的context
变量,RunnablePassthrough()
则将输入直接作为question
变量并向后传递,“链”中的下一步接收的参数其实就是{"context": "...", "question": "..."}
。将这些参数传入Prompt模板并拼接为完整的Prompt,之后输入LLM并读取其输出,一个最简单的RAG应用我们就开发完成了。
前面我们编写的程序中,Document(文档)具有十分标准的格式,它是一个数组,数组的每个元素都是一个长度近似的词条,然而实际应用场景中我们可能并没有如此规整的数据,知识库数据可能来自一个大段的文本,数据甚至是包含在一些千奇百怪的格式中的,如CSV、Word、PDF等。加载这些数据我们需要两个组件,一个是对应格式的解析器,另外就是针对大段文本的文本分割器。
下面例子我们将实现从PDF文件中解析数据,并使用递归分割器将原始数据分割成固定大小的块。我们这里打算使用PyPDFLoader
这个加载器,不过它不在LangChain的核心包中,这需要安装一些额外依赖。
pip install langchain-community pypdf cryptography
注:我们实际开发中需要用到的组件可能也是属于langchain-community
包中的,这些组件通常不是特别完善,可能需要我们安装若干额外依赖,并有定位和解决Bug的能力。
from langchain.globals import set_debug, set_verbose
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
from langchain_ollama import OllamaLLM
from langchain_huggingface import HuggingFaceEmbeddings
set_debug(True)
set_verbose(True)
embedding_model = HuggingFaceEmbeddings(
model_name='E:/bge-large-zh-v1.5/',
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
documents = PyPDFLoader("./世界创建指南 - [美]沃尔夫冈·鲍尔.pdf").load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=20,
length_function=len,
is_separator_regex=False,
)
documents = text_splitter.split_documents(documents)
vector_store = Chroma.from_documents(documents, embedding=embedding_model, persist_directory="./chroma_db")
retriever = vector_store.as_retriever(
search_type="similarity",
search_kwargs={"k": 3},
)
prompt = PromptTemplate.from_template("""
Answer this question using the provided context only.
{question}
Context:
{context}
""")
llm = OllamaLLM(model='llama3.1:8b', temperature=1)
chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser()
result = chain.invoke("如何创建国家?")
print(result)
我们这里使用的是RecursiveCharacterTextSplitter
这个文档分割器,其中指定了对长文本的分隔块大小和重叠的长度,当然其效果一定不如之前的结构化数据,这种方式只是针对大段未分割文本处理的一种妥协,如果有条件我们还是尽量将数据创建为结构化的形式,这样RAG才能够达到最好的效果。