大家好,我终于毕业回来了,虽然暂时还是无业游民一只,在找工作中不断焦虑着,但是终于有了大把的时间干点想干的事情了。最近看了一个关于RAG+langchain 的教程,计划使用3-5篇文章来梳理整个课程的内容,在整个过程中,补充一些相关的内容,插入一些个人的看法。本篇将主要介绍RAG解决了什么问题,基本的RAG包括哪些内容,以及高级RAG的第一个阶段-Query Translation。
课程原链接
www.youtube.com/watch?v=wd7...
大模型遇到的挑战
大语言模型(LLM)虽然展现出惊人的能力,但是也面对一些问题
- 知识时效性:模型的训练数据存在截止日期,因此它无法回答最近(也就是LLM发布之后)发生的事情。
- 无法访问私有数据, 世界上大多数据都是私有数据,无法将其用于公开LLM的训练。
这些挑战引发了一个思考:如何将LLM与外部数据库进行连接?早在2020年, 研究人员就提出了**检索增强生成(Retrieval-Augmented Generation, RAG)技术框架,将语言模型与外部知识源连接起来。随着ChatGPT等现代大型语言模型的普及,RAG技术获得了爆发性应用增长。
基本 RAG
2020年,Facebook AI(现Meta AI)团队在论文《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》中首次提出RAG(Retrieval-Augmented Generation)架构。 作为连接语言模型与外部知识的桥梁,基础RAG架构如下图所示,由三个核心技术阶段组成:
- 索引 (Indexing) :这一阶段构建知识库,将非结构化文本转化为可检索的向量表示。
- 检索(Retrieval): 当用户提问时,检索与问题相关的文档。
- 生成(Generation) : 将检索到的相关文档与原始问题合成提示,输入到模型中,生成最终回答。
接下来,我们将深入基本RAG的每个阶段。
索引 (Indexing)
整个索引阶段围绕一个叫做 Retriever(检索器 )的组件展开,我们有一些外部的文档要加载到系统中, Retriever 接收用户输入的问题 query ,目的是检索与输入问题相关的文档 documents 。
这个过程中,自然会引发一个思考,如何建立 question 与 documents 之间的关联?通常是使用数字表示,将query和documents 都表示为一个数值向量,这样可以便于在大量示例中快速检索。
为什么要将文档转换为数字表示?
文本是离散的符号序列,相对于原始的文本,通常将文档转换为数字表示,比较数值向量之间的相似性要容易得多。
近些年来,已经有大量方法可以实现从文本到数值向量的转换。典型方法包括:
- 基于统计的方法 :比如TF-IDF,词袋模型等方法,会将文档表示为一个稀疏向量。
- 基于机器学习的方法,word2vec,glove,BERT等方法,会将文本表示为一个稠密向量。
这里不对文本转换向量的方法做过多赘述,如果想要了解更多可以参考这里。
加入将文本转换为数值向量这一步之后,整个 Indexing 阶段的流程应该包括以下步骤:
-
文档加载, 文档的加载涉及将原始文档导入到系统的过程中,是整个indexing 的起点。
ini# Documents question = "What kinds of pets do I like?" document = "My favorite pet is a cat."
-
分块 ,由于文档最终要作为额外的上下文喂入LLM,要将文档长度其限制在LLM的下文窗口之内。因此,要对文档进行分块,也就是将大的文档切片为小的文本块。
-
嵌入, 前面已经说过,为了建立输入问题与文档之间的关系,要将其转换为数值向量。这一过程称为嵌入。
-
检索,在得到问题与文档的嵌入之后,可以使用不同的衡量向量相关性的方法,用于检索相关文档。检索这一过程将在下一节详述。
到现在为止,Indexing 过程中的基本理论已经掌握了,我们将使用langchain
实现这一过程。
首先需要安装环境
bash
! pip install langchain_community tiktoken langchain-openai langchainhub chromadb langchain
接下来需要配置环境,这里使用langsmith
做debug,如果不需要的话,可以将 langchain
有关的配置信息直接注释掉即可。如果需要开启,需要到平台申请账号,并生成api_key。
python
# 可选的平台配置
import os
os.environ['LANGCHAIN_TRACING_V2'] = 'true'
os.environ['LANGCHAIN_ENDPOINT'] = 'https://api.smith.langchain.com'
os.environ['LANGCHAIN_API_KEY'] = <your-api-key>
# 配置OPEN_AI
os.environ['OPENAI_API_KEY'] = <your-api-key>
然后加载外部文档,需要借助 langchain
的document_loaders
, 这里加载了一个外部网页作为文档。
python
import bs4
from langchain_community.document_loaders import WebBaseLoader
# 1. 加载外部文档
loader = WebBaseLoader(
web_paths=("https://lilianweng.github.io/posts/2023-06-23-agent/",),
bs_kwargs=dict(
parse_only=bs4.SoupStrainer(
class_=("post-content", "post-title", "post-header")
)
),
)
blog_docs = loader.load()
由于嵌入模型的上下文窗口有限,因此要对文档进行分块,这一过程借助text_splitters
, 在这个库里包含多种可以切块的方法,这里采用一种基本的作为示例。
python
# 2. 分块,
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=300,
chunk_overlap=50)
# Make splits
splits = text_splitter.split_documents(blog_docs)
接下来将切块后的chunks转换为向量表示,并存储到 Chroma 中,然后创建了一个检索器,以便后续可以基于语义相似性搜索这些文档。
Chroma 是一个开源的向量数据库(vector database),用于存储和检索文本的向量嵌入(vector embeddings),更多内容可参考这里。
python
# Index
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
vectorstore = Chroma.from_documents(documents=splits,
embedding=OpenAIEmbeddings())
retriever = vectorstore.as_retriever()
整个索引阶段到这里就结束了,目的就是让文档易于搜索。
检索 (Retrival)
回顾前面的 Indexing 阶段,就是将文档加载进来,分成小块,转换为易于搜索的数值向量形式,将其存储在向量数据库中。当给定一个输入的 query 时,我们会使用同样的嵌入方法 query 转换为向量,向量数据库会执行相似性搜索并返回与该问题相关的documents。
如果深入了解该过程,直觉就像是寻找某个点的邻居。
举个例子,假设文档获得的嵌入只有3个维度,每个文档都会成为3D空间中的一个点。点的位置是根据 document 的语义或者内容决定的。也就是说,位置相近的documents 拥有相似的语义或内容。我们将 query 做同样的嵌入,然后在 query 周围的空间去搜索。直观上来理解,就是哪些 documents 距离query 近, 这些documents与query具有相似的语义。
这部分我的理解就像是 word2vec 中提到的分布假说,拥有相似上下文的词在语义上相近。根据这个假说,提出了Word2Vec。按照假设语义空间只有3个维度的情况延伸,语义空间中相近的点,也拥有相似的语义。
现在有很多现成的实现,比如上一节说过的langchain
。在上一小节的代码中,已经实现了检索器Retriver,接下来只需要调用检索器的方法就可以拿到与问题相关的文档。
python
docs = retriever.get_relevant_documents("What is Task Decomposition?")
生成(Generation)
拿到了与query 相关的文档之后, 接下来要做的就是"生成 ", 将检索到的文档填充到LLM的上下文窗口中去,让LLM根据上下文生成最终的答案。
如何将检索与LLM连接起来呢?答案是prompt。 某种程度上,可以直接将prompt理解为具有占位符的一个模板,其中包含一些keys, 每个key 都是可以被填充的。
我们接下来要做的是
- 将 query 和 检索到的组成字典
- 用字典的值填充prompt 模板
- 得到prompt string 后,输入到LLM,得到最终答案,
以上过程可以实现为
python
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
# Prompt
template = """Answer the question based only on the following context:
{context}
Question: {question}
"""
# 定义 prompt 模板
prompt = ChatPromptTemplate.from_template(template)
# 定义 LLM
llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)
# 定义Chain
# 在LangChain 中,有一个名为LCEL的表达式语言,它允许您以一种简洁的方式定义数据处理流程。
chain = prompt | llm
# 调用
chain.invoke({"context":docs,"question":"What is Task Decomposition?"})
这里我们自定义了一个prompt 模板,让LLM根据给定的上下文来回答问题。实际上,在langchain hub 中,有很多已经定义好的prompt可以使用。
python
from langchain import hub
prompt_hub_rag = hub.pull("rlm/rag-prompt")
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
rag_chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
rag_chain.invoke("What is Task Decomposition?")
迈向高级RAG的第一步------ Query Translation
在理解基本RAG的流程后,我们会发现一个关键事实:RAG的最终效果取决于流程中每个环节的协同优化 。任何一个环节的误差都会沿着管道向下传递,模糊的查询会导致检索偏差,低质的索引会污染上下文,未经校准的生成可能放大错误。因此,高级RAG技术本质上是一套分阶段误差控制系统 ,我们将其拆解为四个优化方向:
-
检索前优化:让问题更"容易被正确回答"(例如提问方式修正)
- Query Translation, 通过对原始问题进行翻译提高检索效率。
- Query 构建,将自然语言问题转换为结构化查询。
- 路由决策, 根据原始问题导向不同的数据源
-
检索优化:让数据更"容易被准确找到"(例如索引结构增强)
-
检索后优化:让证据更"容易被有效利用"(例如上下文净化)
-
生成优化:让答案更"容易被安全信赖"(例如结果验证)
接下来,我们将沿着这个"问题→数据→证据→答案"的优化链路,揭示每个环节的核心技术逻辑。
Query Translation
问题的陈述至关重要,如果用户输入的问题是含糊不清的话,那么只能得到一个含糊的答案。 因此,研究人员提出对原始的用户输入进行优化。这就是Query Translation, 是高级RAG的第一个阶段,目的是通过对用户输入进行翻译来提高检索效率。
从更高维的角度来看对 query的优化,可以朝着3个方向进行
- 抽象化: 通过将具体问题抽象化,揭示问题的本质,也就是从更高的维度来看待问题。
- 具象化: 通过拆解复杂问题,让问题变得更具体。典型方法是将原始问题分解为多个有序的子问题,逐步解决,最终达成解决原始问题的任务。
- 重写: 从多个视角看待问题,同一个问题可能有100中写法,用不同的措辞来表达同一个问题。
Multi-Query(多重查询)
原理解析
Multi-Query 的基本思想是:针对不同的角度看待一个问题,从不同的视角,可以生成多个不同表述的 query,分别去做检索,将检索到的所有文档汇总后,输入到LLM中。
其背后的直觉是,根据原始question去检索,可能无法命中相关的文档。但是形成多个query之后,某一个query可能距离需要的文档很近。
Multi-Query的流程
- 基于原始的用户 query 生成多个query,分别是Q1, Q2,Q3 ,这些生成的query是不同角度对原始 query 的补充。
- 对形成的每个query,Q1, Q2,Q3 ,都去检索一批相关文档。
- 所有的相关文档都会被喂给LLM,这样LLM就会生成比较完整和全面的答案。
代码实现
其核心实现如下,主要是让LLM根据给定的用户原始query,生成多个不同的视角的query。
python
#配置环境,构建索引, 构建检索器。
...
from langchain.prompts import ChatPromptTemplate
# Multi Query: Different Perspectives
template = """You are an AI language model assistant. Your task is to generate five
different versions of the given user question to retrieve relevant documents from a vector
database. By generating multiple perspectives on the user question, your goal is to help
the user overcome some of the limitations of the distance-based similarity search.
Provide these alternative questions separated by newlines. Original question: {question}"""
prompt_perspectives = ChatPromptTemplate.from_template(template)
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
generate_queries = (
prompt_perspectives
| ChatOpenAI(temperature=0)
| StrOutputParser()
| (lambda x: x.split("\n"))
)
对于每个query,都要去检索相关文档,最终形成的文档可能会有重复,进行去重。
python
from langchain.load import dumps, loads
def get_unique_union(documents: list[list]):
""" Unique union of retrieved docs """
# Flatten list of lists, and convert each Document to string
flattened_docs = [dumps(doc) for sublist in documents for doc in sublist]
# Get unique documents
unique_docs = list(set(flattened_docs))
# Return
return [loads(doc) for doc in unique_docs]
# Retrieve
question = "What is task decomposition for LLM agents?"
retrieval_chain = generate_queries | retriever.map() | get_unique_union
docs = retrieval_chain.invoke({"question":question})
len(docs)
最终要将去重后的文档作为上下文,输入到LLM中,生成最终答案。
python
from operator import itemgetter
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnablePassthrough
# RAG
template = """Answer the following question based on this context:
{context}
Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
llm = ChatOpenAI(temperature=0)
final_rag_chain = (
{"context": retrieval_chain,
"question": itemgetter("question")}
| prompt
| llm
| StrOutputParser()
)
final_rag_chain.invoke({"question":question})
RAG Fustion(RAG融合)
原理解析
RAG-fustion是在multi-query基础上,对检索进行重新排序,输出top k 个最相关文档。
RAG-Fustion 使用了Reciprocal Rank Fusion 倒排相关性 (RRF) 用于文档的重新排序。
- 原理 :如果一个文档被多个查询同时命中,则排名提升(类似投票机制)。
- 公式:
(n
=查询数量,k
=平滑常数,rank(d,q)
=文档d在第q个查询中的排名)
一句话总结,RAG-Fusion 是 Multi-Query 的"智能版"------它不只追求检索数量 ,而是通过投票加权 +去重过滤,进一步提升检索质量。
核心实现
RAG-Fusion 的核心实现在于RRF代码的实现,其余的代码与Multi-Query 基本一样。
python
from langchain.load import dumps, loads
def reciprocal_rank_fusion(results: list[list], k=60):
""" Reciprocal_rank_fusion that takes multiple lists of ranked documents
and an optional parameter k used in the RRF formula """
# Initialize a dictionary to hold fused scores for each unique document
fused_scores = {}
# Iterate through each list of ranked documents
for docs in results:
# Iterate through each document in the list, with its rank (position in the list)
for rank, doc in enumerate(docs):
# Convert the document to a string format to use as a key (assumes documents can be serialized to JSON)
doc_str = dumps(doc)
# If the document is not yet in the fused_scores dictionary, add it with an initial score of 0
if doc_str not in fused_scores:
fused_scores[doc_str] = 0
# Retrieve the current score of the document, if any
previous_score = fused_scores[doc_str]
# Update the score of the document using the RRF formula: 1 / (rank + k)
fused_scores[doc_str] += 1 / (rank + k)
# Sort the documents based on their fused scores in descending order to get the final reranked results
reranked_results = [
(loads(doc), score)
for doc, score in sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
]
# Return the reranked results as a list of tuples, each containing the document and its fused score
return reranked_results
retrieval_chain_rag_fusion = generate_queries | retriever.map() | reciprocal_rank_fusion
docs = retrieval_chain_rag_fusion.invoke({"question": question})
len(docs)
更多内容可参考:
Decompsition(分解)
从让原始更加问题具象化的角度来说,这一节主要是涉及分解,将query分解为子问题。
分解子问题
这部分有两个相关的工作,一个是来自于Google 的 "Least-to-most" , 主要流程包括:
- 将原始query分解为多个子问题
- 按逻辑顺序解决每个子问题,每一步都利用前面子问题的答案作为上下文,合成最终答案。
以 last-letter-concatenation任务为例,Least-to-Most 会分解为下面的步骤解决问题:
- 将输入的列表分解为任意长的子列表,
- 针对每个子列表,进行 last-letter-concatenation 任务,前一步的最终答案作为下一步输入的一部分。
Last-letter-concatenation 是什么任务? 这个任务要求模型将多个单词的最后一个字母连接起来形成新单词。例如,给定单词列表["apple", "banana", "cherry"],正确输出应该是"eay"(每个单词最后一个字母是e, a, y,组合起来是eay)。
动态检索
另一项工作来自于IR-CoT (Interleaved Retrieval with Chain-of-Thought Reasoning), 将检索和 cot(Chain-of-Thought) 结合在一起,交替进行检索和推理步骤。具体来说,
- 首先从知识库中检索一定数量的段落
- 然后使用问题,检索到的段落,已生成的CoT 句子,生成下一个CoT句子。
- 使用最后一个生成的CoT句子作为query,检索更多文档并添加到收集的文档中。
- 直到输出的CoT 句子中包含"答案"字符串,达到最大推理步数时,终止过程并返回所有收集的段落作为检索结果。
这张图以 "Lost Gravity过山车在哪个国家制造?" 为例,直观展示了IRCoT方法 交替执行链式推理(CoT)与知识检索 的动态过程。
- 首先,根据初始问题,问题检索出一些文档。
- 将检索到的文档和原始问题输入到LLM中,让LLM推理,输出下一个CoT 例子,就该例而言,推理出Lost Gravity 的制造商是 Mack Rides。
- 再次进行检索,搜索到与 Mack Rides 相关的文档
- 将这些文档与上一步生成的CoT 例子输入到LLM,再次进行推理,可以直到 Mack Rides 是一家来自德国的公司。
- 因此,答案是"德国"。到这里,流程终止。
将"分解为子问题 " 和 "动态检索" 这两种想法结合起来,就变成了一个新的架构。
- 首先,将原始 query 分解为多个子问题
- 使用Q1去检索相关文档,让LLM去回答Q1,生成答案 A1
- 用 A1 和 Q2 去检索相关文档,让LLM回答 Q2, 生成答案 A2
- 以此类推,直至所有子问题都解决,也就是解决了原始的 query 。
代码实现
Decomposition 代码的核心逻辑分为两个阶段:
- 子问题生成:将原始复杂问题分解为多个独立的子问题。
- 子问题求解:依次回答每个子问题,最终汇总。
实现该架构的第一步就是将原始query分解为子问题,这一步可以通过LLM来完成。
python
from langchain.prompts import ChatPromptTemplate
# Decomposition 模板:要求生成3个相关子问题
template = """You are a helpful assistant that generates multiple sub-questions related to an input question. \n
The goal is to break down the input into a set of sub-problems / sub-questions that can be answers in isolation. \n
Generate multiple search queries related to: {question} \n
Output (3 queries):"""
prompt_decomposition = ChatPromptTemplate.from_template(template)
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
# LLM
llm = ChatOpenAI(temperature=0)
# 定义LLM链:生成子问题列表
generate_queries_decomposition = ( prompt_decomposition | llm | StrOutputParser() | (lambda x: x.split("\n")))
# Run
question = "What are the main components of an LLM-powered autonomous agent system?"
questions = generate_queries_decomposition.invoke({"question":question})
得到子问题列表后,在回答每个子问题的时候可以采用两种策略:
- Answer recursively 递归回答, 当前子问题的答案会作为下一个子问题的上下文。
- Answer individually 独立回答,每个子问题独立地进行文档检索,输入到LLM中分别生成答案,最后将所有子问题的答案放到LLM中汇总,生成最终答案。这种方法的好处是可以并行进行,缺点是无法利用前面的子问题答案。
首先是递归回答, 通过顺序执行与上下文传递,将当前子问题的答案注入后续子问题的求解流程,形成链式推理。
-
问题分解:生成有序子问题序列 ,其中 的表述或求解依赖 的答案。
-
迭代求解:
- 步骤1:求解 ,得到答案 。
- 步骤2:将 与 拼接为增强问题 ,求解得 。
- 重复直至所有子问题完成。
-
答案生成:将最终子问题答案 作为原始问题的解,或基于所有 进一步合成。
其代码实现首先定义了为回答子问题定义了一个新的prompt 模板,接收当前步子问题,之前的子问题与答案,以及与当前子问题相关的的上下文。对于每个子问题,将对应的问答对与文档作为上下文,输入到 LLM中推理出答案。
python
# Prompt
template = """Here is the question you need to answer:
\n --- \n {question} \n --- \n
Here is any available background question + answer pairs:
\n --- \n {q_a_pairs} \n --- \n
Here is additional context relevant to the question:
\n --- \n {context} \n --- \n
Use the above context and any background question + answer pairs to answer the question: \n {question}
"""
decomposition_prompt = ChatPromptTemplate.from_template(template)
from operator import itemgetter
from langchain_core.output_parsers import StrOutputParser
def format_qa_pair(question, answer):
"""Format Q and A pair"""
formatted_string = ""
formatted_string += f"Question: {question}\nAnswer: {answer}\n\n"
return formatted_string.strip()
# llm
llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)
q_a_pairs = ""
for q in questions:
rag_chain = (
{"context": itemgetter("question") | retriever,
"question": itemgetter("question"),
"q_a_pairs": itemgetter("q_a_pairs")}
| decomposition_prompt
| llm
| StrOutputParser())
answer = rag_chain.invoke({"question":q,"q_a_pairs":q_a_pairs})
q_a_pair = format_qa_pair(q,answer)
q_a_pairs = q_a_pairs + "\n---\n"+ q_a_pair
另一种方式是使用独立回答,
-
问题分解:生成独立子问题集 ,确保子问题间无显式依赖。
-
并行求解:
- 同时处理所有 ,利用分布式计算资源加速。
- 每个 的求解仅依赖原始问题与自身相关检索内容。
-
答案聚合:将 输入模型进行汇总,生成连贯的最终答案。
其核心代码实现如下:
python
# Answer each sub-question individually
from langchain import hub
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
# RAG prompt
prompt_rag = hub.pull("rlm/rag-prompt")
def retrieve_and_rag(question,prompt_rag,sub_question_generator_chain):
"""RAG on each sub-question"""
# Use our decomposition /
sub_questions = sub_question_generator_chain.invoke({"question":question})
# Initialize a list to hold RAG chain results
rag_results = []
for sub_question in sub_questions:
# Retrieve documents for each sub-question
retrieved_docs = retriever.get_relevant_documents(sub_question)
# Use retrieved documents and sub-question in RAG chain
answer = (prompt_rag | llm | StrOutputParser()).invoke({"context": retrieved_docs,
"question": sub_question})
rag_results.append(answer)
return rag_results,sub_questions
# Wrap the retrieval and RAG process in a RunnableLambda for integration into a chain
answers, questions = retrieve_and_rag(question, prompt_rag, generate_queries_decomposition)
# Prompt
template = """Here is a set of Q+A pairs:
{context}
Use these to synthesize an answer to the question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
final_rag_chain = (
prompt
| llm
| StrOutputParser()
)
final_rag_chain.invoke({"context":context,"question":question})
更多内容可参考:
- arxiv.org/pdf/2205.10...
- arxiv.org/abs/2212.10...
- smith.langchain.com/public/d8f2...
- github.com/langchain-a...
- docs.google.com/presentatio...
Step-back Prompting (后退提示)
之前已经介绍了针对query优化的两个方向,
- 一类是重写,具体讨论了两种实现,RAG-fustion 和 Multi-Query
- 一类是具象化,将 query 分解为子问题,让其更加具象化。
最后一类就是本小节要讲的内容,Step-back prompt,它采取了与具象化相反的策略,从原始的 query中抽象出更高层次的概念与原理,关注问题的本质,从而解决具体的问题。
原理解析
具体来说,Step-back prompting 包括两步:
- 抽象: 通过prompt LLM从原始问题中抽象出更高层次的概念或原理。这一步骤的目的是让模型理解问题的本质,从而更容易找到相关的信息和事实。
- 推理:其次,基于高层次概念或原理的事实,LLMs进行推理以解决原始问题。这一步骤的目的是利用高层次概念来指导低层次细节的推理过程,从而减少中间推理步骤中的错误
简单来说,
抽象推理
其中,推理的步骤与前面介绍的并无二致,主要关注如何将问题抽象。在原始工作的实现中,主要使用少样本提示(few-shot prompt)来产生所谓的 step-back 或者更抽象的问题。
如下图所示,核心步骤包括:
- 让LLM 生成 step-back 问题
- 使用原始的query 和 step-back query 分别去检索相关文档
- 促使LLM根据所有的相关文档,回答原本的问题
其中,每个样本都要包含原始问题,以及所谓的 Step-back 问题。举例来说,如果要询问一个人出生在哪个国家。这个问题的抽象问题可以写成"一个人的个人历史有哪些?",这时,检索就更加关注与个人历史相关的文档,而无需关注具体的问题。
代码实现
首先给定一些样本,每个样本都包含原始的问题,和抽象之后的问题,主要为LLM提供示例。
python
# Few Shot Examples
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
examples = [
{
"input": "Could the members of The Police perform lawful arrests?",
"output": "what can the members of The Police do?",
},
{
"input": "Jan Sindel's was born in what country?",
"output": "what is Jan Sindel's personal history?",
},
]
# We now transform these to example messages
example_prompt = ChatPromptTemplate.from_messages(
[
("human", "{input}"),
("ai", "{output}"),
]
)
few_shot_prompt = FewShotChatMessagePromptTemplate(
example_prompt=example_prompt,
examples=examples,
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"""You are an expert at world knowledge. Your task is to step back and paraphrase a question to a more generic step-back question, which is easier to answer. Here are a few examples:""",
),
# Few shot examples
few_shot_prompt,
# New question
("user", "{question}"),
]
)
根据少量样本生成当前query的抽象问题
python
generate_queries_step_back = prompt | ChatOpenAI(temperature=0) | StrOutputParser()
question = "What is task decomposition for LLM agents?"
generate_queries_step_back.invoke({"question": question})
原始query 与其抽象问题依次去检索相关文档,并将这些文档都当做LLM的上下文,用于生成最终的答案。
python
# Response prompt
response_prompt_template = """You are an expert of world knowledge. I am going to ask you a question. Your response should be comprehensive and not contradicted with the following context if they are relevant. Otherwise, ignore them if they are not relevant.
# {normal_context}
# {step_back_context}
# Original Question: {question}
# Answer:"""
response_prompt = ChatPromptTemplate.from_template(response_prompt_template)
chain = (
{
# Retrieve context using the normal question
"normal_context": RunnableLambda(lambda x: x["question"]) | retriever,
# Retrieve context using the step-back question
"step_back_context": generate_queries_step_back | retriever,
# Pass on the question
"question": lambda x: x["question"],
}
| response_prompt
| ChatOpenAI(temperature=0)
| StrOutputParser()
)
chain.invoke({"question": question})
HyDE (假说文档嵌入)
HyDE (Hypothetical Document Embedding, 假说文档嵌入)同样是将具体的问题变得更抽象的一种方法。
回顾基础的RAG流程,将初始的query与documents 使用到同一embedding方法嵌入到同一特征空间中去,然后基于二者的相似性,检索到相关的文档,让LLM根据上下文回答问题。
然而, 这里存在的问题是:query 和 document 是两种不同的文本对象 ,一般来说,document 来自各种不同的来源,可以非常长,但是query 通常是非常简短的。这就引发一个思考,如果将query转换为一个document,然后再做嵌入不就行了吗?
原理解析
HyDE就是这样出现的, 具体来说, 包括以下步骤:
- 生成假设文档: 给定一个 query, 让模型针对其生成一个假设文档。这个文档的目的在于捕捉与 query 相关的模式,它不是真实的文档,可能包含一些错误的信息。
- 编码假设文档: 将生成的假设文档编码为一个嵌入向量
- 检索真实文档: 使用假设文档的嵌入向量在预料库中进行检索,基于向量相似性,检索到与假设文档相似的真实文档。
在HyDE 背后的直觉是:比起初始的query,原始 query 生成假设文档,可能离想要检索到的文档更近一些。
HyDE 和 Multi-Query 也有一点类似,Multi-Query 同样认为原始的query可能离真正检索的文档较远,它通过生成不同角度的多种 query 扩大目标,这多个query中总有一个能命中真正想要检索的文档。HyDE 同样这样认为,它采用的方式是,既然query离文档比较远,那就让 query 变成一个文档,能加大命中目标的概率。
代码实现
HyDE 的核心实现在于基于原始的query生成假设文档,然后使用假设文档在知识库中进行检索。
python
from langchain.prompts import ChatPromptTemplate
# HyDE document genration
template = """Please write a scientific paper passage to answer the question
Question: {question}
Passage:"""
prompt_hyde = ChatPromptTemplate.from_template(template)
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
generate_docs_for_retrieval = (
prompt_hyde | ChatOpenAI(temperature=0) | StrOutputParser()
)
# Run
question = "What is task decomposition for LLM agents?"
generate_docs_for_retrieval.invoke({"question":question})
总结
本文从LLM存在的问题开始,引出了RAG。系统拆解了RAG的基础流程,以及改进RAG的一类基础方法:Query Translation ,主要通过对用户原始输入进行某种形式的翻译来提高检索质量。详细介绍了以下方法:
- Multi-Query, 同一个问题从不同的角度来看,可以用不同的表述方法,因此提出将用户原本的query进行改写,形成多个query之后,每个query都去检索相关文档,汇总起来作为LLM的上下文。
- RAG-Fusion, 该方法在Mulit-Query 的基础上提出要将所有检索出来的文档进行排序,其背后的基本思想是,如果一个文档对应多个query,也就是说多个query去命中目标的时候,都有它,那就证明它的权重应该更高一点,因此在实现中,使用倒排相关性对所有的文档进行重新排序,选择K个,作为用于生成的文档。
- Decomposition, 如果遇到一个复杂的问题,我们倾向于将其分解为一个个的子问题,然后一步步去解决。而且,在解决当前子问题的时候,要将前面的子问题的答案也用进来。这就是分解的基本思想。
- Step-back, 抓住问题的本质就是解决问题的第一步,Step-back 采用的就是这样的思想。从用户的原始query出发,生成更加抽象的问题,也就是从高级的层面去尽可能抓住问题的本质。用抽象化之后的问题去检索相关文档。
- HyDE, 基本的RAG流程使用同样的方法将query和文档进行嵌入之后,再使用相似度检索来查找与query相关的文档。HyDE 则认为这是不合理的,原因是 query 和 document 本质上是两种不同的文本对象,一般来说,query 简短,文档较长。因此,HyDE 提出根据query去生成假设文档,然后将假设文档与真实文档进行嵌入,之后再进行检索。
以上这些Query Translation 方法。本质上都是通过优化 query, 提高检索质量。下一篇文章里,将会涉及路由,构建结构化查询,以及检索阶段的优化手段。下一篇文章见。