从零解析RAG(一)

大家好,我终于毕业回来了,虽然暂时还是无业游民一只,在找工作中不断焦虑着,但是终于有了大把的时间干点想干的事情了。最近看了一个关于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架构如下图所示,由三个核心技术阶段组成:

  1. 索引 (Indexing) :这一阶段构建知识库,将非结构化文本转化为可检索的向量表示。
  2. 检索(Retrieval): 当用户提问时,检索与问题相关的文档。
  3. 生成(Generation) : 将检索到的相关文档与原始问题合成提示,输入到模型中,生成最终回答。

接下来,我们将深入基本RAG的每个阶段。

索引 (Indexing)

整个索引阶段围绕一个叫做 Retriever(检索器 )的组件展开,我们有一些外部的文档要加载到系统中, Retriever 接收用户输入的问题 query ,目的是检索与输入问题相关的文档 documents 。

这个过程中,自然会引发一个思考,如何建立 question 与 documents 之间的关联?通常是使用数字表示,将query和documents 都表示为一个数值向量,这样可以便于在大量示例中快速检索。

为什么要将文档转换为数字表示?

文本是离散的符号序列,相对于原始的文本,通常将文档转换为数字表示,比较数值向量之间的相似性要容易得多。

近些年来,已经有大量方法可以实现从文本到数值向量的转换。典型方法包括:

  • 基于统计的方法 :比如TF-IDF,词袋模型等方法,会将文档表示为一个稀疏向量。
  • 基于机器学习的方法,word2vec,glove,BERT等方法,会将文本表示为一个稠密向量。

这里不对文本转换向量的方法做过多赘述,如果想要了解更多可以参考这里

加入将文本转换为数值向量这一步之后,整个 Indexing 阶段的流程应该包括以下步骤:

  1. 文档加载, 文档的加载涉及将原始文档导入到系统的过程中,是整个indexing 的起点。

    ini 复制代码
    # Documents
    question = "What kinds of pets do I like?"
    document = "My favorite pet is a cat."
  2. 分块 ,由于文档最终要作为额外的上下文喂入LLM,要将文档长度其限制在LLM的下文窗口之内。因此,要对文档进行分块,也就是将大的文档切片为小的文本块

  3. 嵌入, 前面已经说过,为了建立输入问题与文档之间的关系,要将其转换为数值向量。这一过程称为嵌入。

  4. 检索,在得到问题与文档的嵌入之后,可以使用不同的衡量向量相关性的方法,用于检索相关文档。检索这一过程将在下一节详述。

到现在为止,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>

然后加载外部文档,需要借助 langchaindocument_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 都是可以被填充的。

我们接下来要做的是

  1. 将 query 和 检索到的组成字典
  2. 用字典的值填充prompt 模板
  3. 得到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的流程

  1. 基于原始的用户 query 生成多个query,分别是Q1, Q2,Q3 ,这些生成的query是不同角度对原始 query 的补充。
  2. 对形成的每个query,Q1, Q2,Q3 ,都去检索一批相关文档。
  3. 所有的相关文档都会被喂给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" , 主要流程包括:

  1. 将原始query分解为多个子问题
  2. 按逻辑顺序解决每个子问题,每一步都利用前面子问题的答案作为上下文,合成最终答案。

以 last-letter-concatenation任务为例,Least-to-Most 会分解为下面的步骤解决问题:

  1. 将输入的列表分解为任意长的子列表,
  2. 针对每个子列表,进行 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) 结合在一起,交替进行检索和推理步骤。具体来说,

  1. 首先从知识库中检索一定数量的段落
  2. 然后使用问题,检索到的段落,已生成的CoT 句子,生成下一个CoT句子。
  3. 使用最后一个生成的CoT句子作为query,检索更多文档并添加到收集的文档中。
  4. 直到输出的CoT 句子中包含"答案"字符串,达到最大推理步数时,终止过程并返回所有收集的段落作为检索结果。

这张图以 "Lost Gravity过山车在哪个国家制造?" 为例,直观展示了IRCoT方法 交替执行链式推理(CoT)与知识检索 的动态过程。

  1. 首先,根据初始问题,问题检索出一些文档。
  2. 将检索到的文档和原始问题输入到LLM中,让LLM推理,输出下一个CoT 例子,就该例而言,推理出Lost Gravity 的制造商是 Mack Rides。
  3. 再次进行检索,搜索到与 Mack Rides 相关的文档
  4. 将这些文档与上一步生成的CoT 例子输入到LLM,再次进行推理,可以直到 Mack Rides 是一家来自德国的公司。
  5. 因此,答案是"德国"。到这里,流程终止。

将"分解为子问题 " 和 "动态检索" 这两种想法结合起来,就变成了一个新的架构。

  1. 首先,将原始 query 分解为多个子问题
  2. 使用Q1去检索相关文档,让LLM去回答Q1,生成答案 A1
  3. 用 A1 和 Q2 去检索相关文档,让LLM回答 Q2, 生成答案 A2
  4. 以此类推,直至所有子问题都解决,也就是解决了原始的 query 。
代码实现

Decomposition 代码的核心逻辑分为两个阶段:

  1. 子问题生成:将原始复杂问题分解为多个独立的子问题。
  2. 子问题求解:依次回答每个子问题,最终汇总。

实现该架构的第一步就是将原始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. 迭代求解

    1. 步骤1:求解 ,得到答案 。
    2. 步骤2:将 与 拼接为增强问题 ,求解得 。
    3. 重复直至所有子问题完成。
  3. 答案生成:将最终子问题答案 ​ 作为原始问题的解,或基于所有 ​ 进一步合成。

其代码实现首先定义了为回答子问题定义了一个新的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

另一种方式是使用独立回答,

  1. 问题分解:生成独立子问题集 ​,确保子问题间无显式依赖。

  2. 并行求解

    1. 同时处理所有 ,利用分布式计算资源加速。
    2. 每个 的求解仅依赖原始问题与自身相关检索内容。
  3. 答案聚合:将 ​ 输入模型进行汇总,生成连贯的最终答案。

其核心代码实现如下:

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})

更多内容可参考:

Step-back Prompting (后退提示)

之前已经介绍了针对query优化的两个方向,

  • 一类是重写,具体讨论了两种实现,RAG-fustion 和 Multi-Query
  • 一类是具象化,将 query 分解为子问题,让其更加具象化。

最后一类就是本小节要讲的内容,Step-back prompt,它采取了与具象化相反的策略,从原始的 query中抽象出更高层次的概念与原理,关注问题的本质,从而解决具体的问题。

原理解析

具体来说,Step-back prompting 包括两步:

  1. 抽象: 通过prompt LLM从原始问题中抽象出更高层次的概念或原理。这一步骤的目的是让模型理解问题的本质,从而更容易找到相关的信息和事实。
  2. 推理:其次,基于高层次概念或原理的事实,LLMs进行推理以解决原始问题。这一步骤的目的是利用高层次概念来指导低层次细节的推理过程,从而减少中间推理步骤中的错误

简单来说,

抽象推理​

其中,推理的步骤与前面介绍的并无二致,主要关注如何将问题抽象。在原始工作的实现中,主要使用少样本提示(few-shot prompt)来产生所谓的 step-back 或者更抽象的问题。

如下图所示,核心步骤包括:

  1. 让LLM 生成 step-back 问题
  2. 使用原始的query 和 step-back query 分别去检索相关文档
  3. 促使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就是这样出现的, 具体来说, 包括以下步骤:

  1. 生成假设文档: 给定一个 query, 让模型针对其生成一个假设文档。这个文档的目的在于捕捉与 query 相关的模式,它不是真实的文档,可能包含一些错误的信息。
  2. 编码假设文档: 将生成的假设文档编码为一个嵌入向量
  3. 检索真实文档: 使用假设文档的嵌入向量在预料库中进行检索,基于向量相似性,检索到与假设文档相似的真实文档。

在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, 提高检索质量。下一篇文章里,将会涉及路由,构建结构化查询,以及检索阶段的优化手段。下一篇文章见。

相关推荐
雪语.17 小时前
AI大模型学习(五): LangChain(四)
数据库·学习·langchain
ILUUSION_S17 小时前
结合RetrievalQA和agent的助手
python·学习·langchain
neoooo3 天前
LangChain与Ollama构建本地RAG知识库
人工智能·langchain·aigc
charles_vaez3 天前
开源模型应用落地-LangGraph101-探索 LangGraph人机交互-添加断点(一)
深度学习·自然语言处理·langchain
牛奶3 天前
前端学AI:LangChain、LangGraph和LangSmith的核心区别及定位
前端·langchain·ai 编程
牛奶3 天前
前端学AI:基于Node.js的Langchain开发-简单实战应用
前端·langchain·node.js
星星点点洲3 天前
【LangChain.js】Python版LangChain 的姊妹项目
javascript·langchain
星星点点洲3 天前
【智能体架构:Agent】LangChain智能体类型ReAct、Self-ASK的区别
langchain
星星点点洲4 天前
【智能体Agent】ReAct智能体的实现思路和关键技术
langchain