LangChain——RAG 第二部分:与你的数据对话

63 阅读34分钟

在上一章中,您学习了如何处理数据并在向量存储中创建和存储嵌入。在本章中,您将学习如何根据用户的查询高效地检索最相关的嵌入和文档块。这使您能够构建一个包含相关文档作为上下文的提示,从而提高 LLM 最终输出的准确性。

这个过程——包括嵌入用户的查询、从数据源中检索相似文档,然后将它们作为上下文传递给发送到 LLM 的提示——正式被称为检索增强生成(RAG)。

RAG 是构建准确、高效且最新的聊天启用 LLM 应用程序的关键组成部分。在本章中,您将从基础到高级策略,学习如何为各种数据源(如向量存储和数据库)和数据结构(结构化和非结构化)构建有效的 RAG 系统。

但首先,让我们定义 RAG 并讨论它的好处。

介绍检索增强生成(RAG)

RAG 是一种通过提供来自外部来源的上下文来增强 LLM 输出准确性的技术。这个术语最早是由 Meta AI 的研究人员提出的,他们发现启用 RAG 的模型比非 RAG 模型更具事实性和具体性。

在没有 RAG 的情况下,LLM 仅依赖于其预训练的数据,这些数据可能是过时的。例如,让我们向 ChatGPT 提问一个当前事件的问题,看看它的回答:

输入
哪个国家是最新的男子 FIFA 世界杯冠军?

输出
最近的 FIFA 世界杯冠军是法国,他们在 2018 年赢得了比赛。

LLM 的回答是事实错误且过时的。根据本书的出版时间,最新的冠军是阿根廷,他们在 2022 年赢得了世界杯。虽然这个示例问题可能是微不足道的,但如果 LLM 的答案被用于事实核查或重要决策,幻觉(错误回答)可能带来灾难性的后果。

为了解决这个问题,我们需要为 LLM 提供事实性、最新的信息,使其能够生成准确的回答。继续以上的示例,我们可以访问维基百科的 FIFA 世界杯页面,复制引言段落,并将其附加为上下文传递给 ChatGPT:

输入
哪个国家是最新的男子 FIFA 世界杯冠军?

上下文如下:

FIFA 世界杯,通常称为世界杯,是一个国际足球协会比赛,参加者为国际足球联合会(FIFA)成员国的男子国家队。自 1930 年首次举办以来,比赛每四年举行一次,除了由于第二次世界大战而取消的 1942 年和 1946 年。现任冠军是阿根廷,他们在 2022 年的比赛中赢得了第三个冠军。

注意,最后一句包含了 LLM 可以使用的必要上下文,以提供准确的回答。以下是 LLM 的回答:

输出
最新的男子 FIFA 世界杯冠军是阿根廷,他们在 2022 年的比赛中赢得了第三个冠军。

由于提供了最新的附加上下文,LLM 能够生成准确的回答。但手动复制并粘贴相关信息作为上下文并不实际,也无法扩展到生产环境中的 AI 应用程序。我们需要一个自动化系统,根据用户的查询获取相关信息,将其作为上下文附加到提示中,然后执行生成请求给 LLM。

检索相关文档

一个 AI 应用的 RAG 系统通常包括三个核心阶段:

索引

这个阶段涉及预处理外部数据源,并将表示数据的嵌入存储在向量存储中,以便可以轻松检索。

检索

这个阶段涉及根据用户的查询,从向量存储中检索相关的嵌入和数据。

生成

这个阶段涉及将原始提示与检索到的相关文档合成,作为最终提示发送给模型进行预测。

这三个基本阶段如图 3-1 所示。

image.png

这个过程的索引阶段在第 2 章中已经详细介绍,您学会了如何使用文档加载器、文本拆分器、嵌入和向量存储。

让我们再次从头开始执行一个示例,首先是索引阶段:

Python 示例

from langchain_community.document_loaders import TextLoader
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_postgres.vectorstores import PGVector

# 加载文档,拆分成块
raw_documents = TextLoader('./test.txt').load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
documents = text_splitter.split_documents(raw_documents)

# 嵌入每个块并将其插入向量存储
model = OpenAIEmbeddings()
connection = 'postgresql+psycopg://langchain:langchain@localhost:6024/langchain'
db = PGVector.from_documents(documents, model, connection=connection)

JavaScript 示例

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { OpenAIEmbeddings } from "@langchain/openai";
import { PGVectorStore } from "@langchain/community/vectorstores/pgvector";

// 加载文档,拆分成块
const loader = new TextLoader("./test.txt");
const raw_docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const docs = await splitter.splitDocuments(docs)

// 嵌入每个块并将其插入向量存储
const model = new OpenAIEmbeddings();
const db = await PGVectorStore.fromDocuments(docs, model, {
  postgresConnectionOptions: {
    connectionString: 'postgresql://langchain:langchain@localhost:6024/langchain'
  }
})

第 2 章有更多关于索引阶段的详细内容。

现在索引阶段已经完成。为了执行检索阶段,我们需要进行相似性搜索计算——例如余弦相似度——来比较用户的查询和我们存储的嵌入,这样就可以检索到我们索引文档的相关块(见图 3-2)。

image.png

图 3-2 展示了检索过程中的步骤:

  1. 将用户的查询转换为嵌入。
  2. 计算在向量存储中最与用户查询相似的嵌入。
  3. 检索相关的文档嵌入及其对应的文本块。

我们可以通过 LangChain 使用以下方式以编程方式表示这些步骤:

Python 示例

# 创建检索器
retriever = db.as_retriever()

# 获取相关文档
docs = retriever.invoke("""Who are the key figures in the ancient greek 
    history of philosophy?""")

JavaScript 示例

// 创建检索器
const retriever = db.asRetriever()

// 获取相关文档
const docs = await retriever.invoke(`Who are the key figures in the ancient 
  greek history of philosophy?`)

请注意,我们使用了一个您之前没有见过的向量存储方法:as_retriever。这个函数抽象了嵌入用户查询和由向量存储执行的基础相似性搜索计算逻辑,从而检索相关文档。

还有一个参数 k,它决定了从向量存储中获取的相关文档数量。例如:

Python 示例

# 创建检索器,k=2
retriever = db.as_retriever(search_kwargs={"k": 2})

# 获取最相关的 2 个文档
docs = retriever.invoke("""Who are the key figures in the ancient greek history 
    of philosophy?""")

JavaScript 示例

// 创建检索器,k=2
const retriever = db.asRetriever({k: 2})

// 获取最相关的 2 个文档
const docs = await retriever.invoke(`Who are the key figures in the ancient 
  greek history of philosophy?`)

在这个示例中,参数 k 被指定为 2。这告诉向量存储根据用户的查询返回两个最相关的文档。

使用较低的 k 值可能看起来不直观,但检索更多文档并不总是更好。检索更多的文档会导致应用程序性能变慢,提示的大小(以及生成的相关成本)增加,而且更有可能检索到包含不相关信息的文本块,这会导致 LLM 产生幻觉。

现在我们已经完成了 RAG 系统的检索阶段,接下来让我们进入最终的生成阶段。

使用相关文档生成 LLM 预测

一旦我们根据用户的查询检索到相关文档,最后一步就是将它们作为上下文添加到原始提示中,然后调用模型生成最终输出(见图 3-3)。

image.png

以下是从我们之前的示例继续的代码示例:

Python 示例

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

retriever = db.as_retriever()

prompt = ChatPromptTemplate.from_template("""Answer the question based only on 
    the following context:
{context}

Question: {question}
""")

llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

chain = prompt | llm

# 获取相关文档 
docs = retriever.get_relevant_documents("""Who are the key figures in the 
    ancient greek history of philosophy?""")

# 执行
chain.invoke({"context": docs,"question": """Who are the key figures in the 
    ancient greek history of philosophy?"""})

JavaScript 示例

import {ChatOpenAI} from '@langchain/openai'
import {ChatPromptTemplate} from '@langchain/core/prompts'

const retriever = db.asRetriever()

const prompt = ChatPromptTemplate.fromTemplate(`Answer the question based only 
  on the following context:
{context}

Question: {question}
`)

const llm = new ChatOpenAI({temperature: 0, modelName: 'gpt-3.5-turbo'})

const chain = prompt.pipe(llm)

// 获取相关文档
const docs = await retriever.invoke(`Who are the key figures in the ancient 
  greek history of philosophy?`)

await chain.invoke({context: docs, question: `Who are the key figures in the 
  ancient greek history of philosophy?`})

注意以下变化:

  • 我们在提示中实现了动态的上下文和问题变量,这使我们能够定义一个 ChatPromptTemplate,模型可以使用它生成响应。
  • 我们定义了一个 ChatOpenAI 接口来充当我们的 LLM。将温度设置为 0,消除模型输出中的创意。
  • 我们创建了一个链来组合提示和 LLM。提醒:在 Python 中,| 操作符(在 JavaScript 中是 pipe 方法)将提示的输出作为输入传递给 LLM。
  • 我们调用链,传入上下文变量(我们检索到的相关文档)和用户的问题来生成最终的输出。

我们还可以将这个检索逻辑封装在一个单独的函数中:

Python 示例

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import chain

retriever = db.as_retriever()

prompt = ChatPromptTemplate.from_template("""Answer the question based only on 
    the following context:
{context}

Question: {question}
""")

llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

@chain
def qa(input):
    # 获取相关文档 
    docs = retriever.get_relevant_documents(input)
    # 格式化提示
    formatted = prompt.invoke({"context": docs, "question": input})
    # 生成答案
    answer = llm.invoke(formatted)
    return answer

# 执行
qa.invoke("Who are the key figures in the ancient greek history of philosophy?")

JavaScript 示例

import {ChatOpenAI} from '@langchain/openai'
import {ChatPromptTemplate} from '@langchain/core/prompts'
import {RunnableLambda} from '@langchain/core/runnables'

const retriever = db.asRetriever()

const prompt = ChatPromptTemplate.fromTemplate(`Answer the question based only 
  on the following context:
{context}

Question: {question}
`)

const llm = new ChatOpenAI({temperature: 0, modelName: 'gpt-3.5-turbo'})

const qa = RunnableLambda.from(async input => {
  // 获取相关文档
  const docs = await retriever.invoke(input)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return answer
})

await qa.invoke(`Who are the key figures in the ancient greek history of 
  philosophy?`)

请注意,我们现在有了一个新的可调用的 qa 函数,只需输入一个问题,它会先获取相关文档作为上下文,格式化它们到提示中,最后生成答案。在 Python 代码中,@chain 装饰器将函数转化为一个可调用的链。将多个步骤封装到一个函数中将是构建有趣的 LLM 应用的关键。

您还可以返回检索到的文档以供进一步检查:

Python 示例

@chain
def qa(input):
    # 获取相关文档 
    docs = retriever.get_relevant_documents(input)
    # 格式化提示
    formatted = prompt.invoke({"context": docs, "question": input})
    # 生成答案
    answer = llm.invoke(formatted)
    return {"answer": answer, "docs": docs}

JavaScript 示例

const qa = RunnableLambda.from(async input => {
  // 获取相关文档
  const docs = await retriever.invoke(input)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return {answer, docs}
})

恭喜!您现在已经构建了一个基本的 RAG 系统,为个人使用的 AI 应用提供支持。

然而,一个面向多用户的生产级 AI 应用需要一个更先进的 RAG 系统。为了构建一个健壮的 RAG 系统,我们需要有效地回答以下问题:

  • 如何处理用户输入质量的变化?
  • 如何路由查询,从各种数据源检索相关数据?
  • 如何将自然语言转化为目标数据源的查询语言?
  • 如何优化我们的索引过程,即嵌入、文本拆分?

接下来,我们将讨论最新的研究支持的策略,来回答这些问题并构建一个生产级 RAG 系统。这些策略可以总结为图 3-4。

image.png

查询转换

基本的 RAG 系统的一个主要问题是,它过于依赖用户查询的质量来生成准确的输出。在生产环境中,用户很可能会以不完整、模棱两可或措辞不当的方式构造查询,这会导致模型幻觉(错误的输出)。

查询转换是一系列策略的子集,旨在修改用户的输入,以解决第一个 RAG 问题:我们如何处理用户输入质量的变化?图 3-5 展示了查询转换策略的范围,从使用户输入更加抽象或不那么抽象的策略,以生成准确的 LLM 输出。下一节将介绍一种中间策略。

image.png

重写-检索-阅读(Rewrite-Retrieve-Read)

微软研究团队提出的重写-检索-阅读策略,简单地提示 LLM 在执行检索之前重写用户的查询。为了说明这一点,我们可以回到之前构建的链,这次使用一个措辞不当的用户查询:

Python 示例

@chain
def qa(input):
    # 获取相关文档 
    docs = retriever.get_relevant_documents(input)
    # 格式化提示
    formatted = prompt.invoke({"context": docs, "question": input})
    # 生成答案
    answer = llm.invoke(formatted)
    return answer

qa.invoke("""Today I woke up and brushed my teeth, then I sat down to read the 
    news. But then I forgot the food on the cooker. Who are some key figures in 
    the ancient greek history of philosophy?""")

JavaScript 示例

const qa = RunnableLambda.from(async input => {
  // 获取相关文档
  const docs = await retriever.invoke(input)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return answer
})

await qa.invoke(`Today I woke up and brushed my teeth, then I sat down to read 
  the news. But then I forgot the food on the cooker. Who are some key figures 
  in the ancient greek history of philosophy?`)

输出
基于给定的上下文,未提供相关信息。
模型未能回答问题,因为它被用户查询中的无关信息所干扰。

现在,让我们实现重写-检索-阅读提示:

Python 示例

rewrite_prompt = ChatPromptTemplate.from_template("""Provide a better search 
    query for web search engine to answer the given question, end the queries 
    with ’**’. Question: {x} Answer:""")

def parse_rewriter_output(message):
    return message.content.strip('"').strip("**")

rewriter = rewrite_prompt | llm | parse_rewriter_output

@chain
def qa_rrr(input):
    # 重写查询
    new_query = rewriter.invoke(input)
    # 获取相关文档 
    docs = retriever.get_relevant_documents(new_query)
    # 格式化提示
    formatted = prompt.invoke({"context": docs, "question": input})
    # 生成答案
    answer = llm.invoke(formatted)
    return answer

# 执行
qa_rrr.invoke("""Today I woke up and brushed my teeth, then I sat down to read 
    the news. But then I forgot the food on the cooker. Who are some key 
    figures in the ancient greek history of philosophy?""")

JavaScript 示例

const rewritePrompt = ChatPromptTemplate.fromTemplate(`Provide a better search 
  query for web search engine to answer the given question, end the queries 
  with ’**’. Question: {question} Answer:`)

const rewriter = rewritePrompt.pipe(llm).pipe(message => {
  return message.content.replaceAll('"', '').replaceAll('**')
})

const qa = RunnableLambda.from(async input => {
  const newQuery = await rewriter.invoke({question: input});
  // 获取相关文档
  const docs = await retriever.invoke(newQuery)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return answer
})

await qa.invoke(`Today I woke up and brushed my teeth, then I sat down to read 
  the news. But then I forgot the food on the cooker. Who are some key 
  figures in the ancient greek history of philosophy?`)

输出
基于给定的上下文,一些古希腊哲学历史中的关键人物包括:忒弥斯托克勒斯(雅典政治家)、毕达哥拉斯和柏拉图。

注意,我们让 LLM 将用户最初分心的查询重写为一个更清晰的查询,并将这个更集中的查询传递给检索器,从中获取最相关的文档。注意:这种技术可以与任何检索方法一起使用,无论是像我们这里使用的向量存储,还是例如 Web 搜索工具。这个方法的缺点是,它会在链中引入额外的延迟,因为现在我们需要依次执行两个 LLM 调用。

多查询检索

单个用户查询可能不足以捕获回答查询所需的所有信息。多查询检索策略通过指示 LLM 根据用户的初始查询生成多个查询,执行每个查询的并行检索,然后将检索到的结果作为提示上下文插入,生成最终的模型输出,解决了这个问题。图 3-6 展示了这一过程。

image.png

这种策略对于那些单一问题可能依赖于多个视角来提供全面回答的应用场景特别有用。

以下是多查询检索实际应用的代码示例:

Python 示例

from langchain.prompts import ChatPromptTemplate

perspectives_prompt = ChatPromptTemplate.from_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}""")

def parse_queries_output(message):
    return message.content.split('\n')

query_gen = perspectives_prompt | llm | parse_queries_output

JavaScript 示例

const perspectivesPrompt = ChatPromptTemplate.fromTemplate(`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}`)

const queryGen = perspectivesPrompt.pipe(llm).pipe(message => {
  return message.content.split('\n')
})

请注意,提示模板旨在根据用户的初始查询生成问题的不同变体。

接下来,我们将生成的查询列表取出,并并行地从数据源中检索每个查询的最相关文档,然后将它们结合起来,获取所有检索到的相关文档的唯一联合:

Python 示例

def get_unique_union(document_lists):
    # 扁平化列表并去重
    deduped_docs = {
        doc.page_content: doc
        for sublist in document_lists for doc in sublist
    }
    # 返回去重后的文档列表
    return list(deduped_docs.values())

retrieval_chain = query_gen | retriever.batch | get_unique_union

JavaScript 示例

const retrievalChain = queryGen
  .pipe(retriever.batch.bind(retriever))
  .pipe(documentLists => {
    const dedupedDocs = {}
    documentLists.flat().forEach(doc => {
      dedupedDocs[doc.pageContent] = doc
    })
    return Object.values(dedupedDocs)
  })

因为我们从同一个检索器中用多个(相关的)查询来检索文档,所以很可能其中至少有一些是重复的。在将它们作为上下文用于回答问题之前,我们需要去重,以确保每个文档只有一个实例。这里我们通过使用文档的内容(字符串)作为字典(或在 JS 中作为对象)中的键来去重,因为字典中每个键只能包含一个条目。迭代所有文档后,我们简单地获取所有字典值,这些值现在没有重复。

注意我们还使用了 .batch,它并行执行所有生成的查询并返回结果列表——在这种情况下,是一个文档列表的列表,然后我们像前面描述的那样扁平化并去重。

最后一步是构建一个提示,包括用户的问题和合并后的检索到的相关文档,并创建一个模型接口来生成预测:

Python 示例

prompt = ChatPromptTemplate.from_template("""Answer the following question based 
    on this context:

{context}

Question: {question}
""")

@chain
def multi_query_qa(input):
    # 获取相关文档 
    docs = retrieval_chain.invoke(input)
    # 格式化提示
    formatted = prompt.invoke({"context": docs, "question": input})
    # 生成答案
    answer = llm.invoke(formatted)
    return answer

# 执行
multi_query_qa.invoke("""Who are some key figures in the ancient greek history 
    of philosophy?""")

JavaScript 示例

const prompt = ChatPromptTemplate.fromTemplate(`Answer the following 
  question based on this context:

{context}

Question: {question}
`)

const multiQueryQa = RunnableLambda.from(async input => {
  // 获取相关文档
  const docs = await retrievalChain.invoke(input)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return answer
})

await multiQueryQa.invoke(`Who are some key figures in the ancient greek 
  history of philosophy?`)

请注意,这与我们之前的 QA 链并没有太大不同,因为所有新的多查询检索逻辑都包含在 retrieval_chain 中。这是充分利用这些技术的关键——将每种技术实现为独立的链(在本例中为 retrieval_chain),这样可以轻松采用它们,甚至将它们结合使用。

RAG-Fusion

RAG-Fusion 策略与多查询检索策略相似,区别在于我们会对所有检索到的文档应用最终的重新排序步骤。这一重新排序步骤使用了倒数排名融合(RRF)算法,该算法通过结合不同搜索结果的排名来生成一个统一的排序。通过结合不同查询的排名,我们将最相关的文档排到最终列表的顶部。RRF 非常适合合并来自可能具有不同尺度或分数分布的查询结果。

让我们在代码中演示 RAG-Fusion。首先,我们创建一个与多查询检索策略类似的提示,根据用户的查询生成一系列查询:

Python 示例

from langchain.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

prompt_rag_fusion = ChatPromptTemplate.from_template("""You are a helpful 
    assistant that generates multiple search queries based on a single input 
    query. \n
    Generate multiple search queries related to: {question} \n
    Output (4 queries):""")

def parse_queries_output(message):
    return message.content.split('\n')

llm = ChatOpenAI(temperature=0)

query_gen = prompt_rag_fusion | llm | parse_queries_output

JavaScript 示例

import {ChatPromptTemplate} from '@langchain/core/prompts';
import {ChatOpenAI} from '@langchain/openai';
import {RunnableLambda} from '@langchain/core/runnables';

const perspectivesPrompt = ChatPromptTemplate.fromTemplate(`You are a helpful 
  assistant that generates multiple search queries based on a single input 
  query. \n
  Generate multiple search queries related to: {question} \n
  Output (4 queries):`)

const queryGen = perspectivesPrompt.pipe(llm).pipe(message => {
  return message.content.split('\n')
})

生成查询后,我们将为每个查询检索相关文档,并将它们传递到一个函数中,重新排序(即根据相关性重新排序)最终的相关文档列表。

reciprocal_rank_fusion 函数接收每个查询的搜索结果列表,实际上是一个包含文档列表的列表,其中每个文档列表根据其与查询的相关性进行排序。RRF 算法然后基于每个文档在不同列表中的排名(或位置)计算新的分数,并对其进行排序,从而生成最终的重新排序列表。

计算出融合后的分数后,函数按这些分数的降序排序文档,以得到最终的重新排序列表,并返回:

Python 示例

def reciprocal_rank_fusion(results: list[list], k=60):
    """对多个排名文档列表执行倒数排名融合,并使用 RRF 公式中的可选参数 k"""
    
    # 初始化一个字典来保存每个文档的融合分数
    # 使用文档内容作为键以确保唯一性
    fused_scores = {}
    documents = {}

    # 遍历每个排名文档列表
    for docs in results:
        # 遍历列表中的每个文档,带有其排名(在列表中的位置)
        for rank, doc in enumerate(docs):
            # 使用文档内容作为唯一标识符
            doc_str = doc.page_content
            # 如果文档尚未出现过,初始化分数为 0,并保存文档
            if doc_str not in fused_scores:
                fused_scores[doc_str] = 0
                documents[doc_str] = doc
            # 使用 RRF 公式更新文档的分数:
            # 1 / (排名 + k)
            fused_scores[doc_str] += 1 / (rank + k)

    # 根据融合后的分数降序排序文档,以获得最终的重新排序结果
    reranked_doc_strs = sorted(
        fused_scores, key=lambda d: fused_scores[d], reverse=True
    )
    # 获取每个 doc_str 对应的文档
    return [
        documents[doc_str]
        for doc_str in reranked_doc_strs
    ]

JavaScript 示例

function reciprocalRankFusion(results, k = 60) {
  // 初始化一个字典来保存每个文档的融合分数
  // 使用文档内容作为键以确保唯一性
  const fusedScores = {}
  const documents = {}

  results.forEach(docs => {
    docs.forEach((doc, rank) => {
      // 使用文档内容作为唯一标识符
      const key = doc.pageContent
      // 如果文档尚未出现过,初始化分数为 0,并保存文档
      if (!(key in fusedScores)) {
        fusedScores[key] = 0
        documents[key] = 0
      }
      // 使用 RRF 公式更新文档的分数:
      // 1 / (排名 + k)
      fusedScores[key] += 1 / (rank + k)
    })
  })

  // 根据融合后的分数降序排序文档,以获得最终的重新排序结果
  const sorted = Object.entries(fusedScores).sort((a, b) => b[1] - a[1])
  // 获取每个键对应的文档
  return sorted.map(([key]) => documents[key])
}

请注意,函数还接受一个 k 参数,该参数决定了每个查询结果集中的文档对最终文档列表的影响程度。较高的值表示较低排名的文档具有更大的影响。

最后,我们将新的检索链(现在使用 RRF)与我们之前看到的完整链结合:

Python 示例

prompt = ChatPromptTemplate.from_template("""Answer the following question based 
    on this context:

{context}

Question: {question}
""")

llm = ChatOpenAI(temperature=0)

@chain
def multi_query_qa(input):
    # 获取相关文档 
    docs = retrieval_chain.invoke(input)
    # 格式化提示
    formatted = prompt.invoke({"context": docs, "question": input})
    # 生成答案
    answer = llm.invoke(formatted)
    return answer

multi_query_qa.invoke("""Who are some key figures in the ancient greek history 
    of philosophy?""")

JavaScript 示例

const rewritePrompt = ChatPromptTemplate.fromTemplate(`Answer the following 
  question based on this context:

{context}

Question: {question}
`)

const llm = new ChatOpenAI({temperature: 0})

const multiQueryQa = RunnableLambda.from(async input => {
  // 获取相关文档
  const docs = await retrievalChain.invoke(input)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return answer
})

await multiQueryQa.invoke(`Who are some key figures in the ancient greek 
  history of philosophy?`)

RAG-Fusion 的优势在于其能够捕捉用户意图,处理复杂查询,并扩展检索到的文档范围,从而促进偶然发现。

假设文档嵌入(HyDE)

假设文档嵌入(HyDE)是一种策略,涉及基于用户的查询创建一个假设文档,嵌入该文档,并根据向量相似度检索相关文档。HyDE 的直觉是,LLM 生成的假设文档将比原始查询与最相关的文档更相似,如图 3-7 所示。

image.png

首先,定义一个生成假设文档的提示:

Python 示例

from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

prompt_hyde = ChatPromptTemplate.from_template("""Please write a passage to 
   answer the question.\n Question: {question} \n Passage:""")

generate_doc = (
    prompt_hyde | ChatOpenAI(temperature=0) | StrOutputParser() 
)

JavaScript 示例

import {ChatOpenAI} from '@langchain/openai'
import {ChatPromptTemplate} from '@langchain/core/prompts'
import {RunnableLambda} from '@langchain/core/runnables';

const prompt = ChatPromptTemplate.fromTemplate(`Please write a passage to 
  answer the question
Question: {question}
Passage:`)

const llm = new ChatOpenAI({temperature: 0})

const generateDoc = prompt.pipe(llm).pipe(msg => msg.content)

接下来,我们将假设文档作为输入传递给检索器,检索器将生成其嵌入并在向量存储中搜索相似的文档:

Python 示例

retrieval_chain = generate_doc | retriever 

JavaScript 示例

const retrievalChain = generateDoc.pipe(retriever)

最后,我们将检索到的文档作为上下文传递给最终的提示,并指示模型生成输出:

Python 示例

prompt = ChatPromptTemplate.from_template("""Answer the following question based 
    on this context:

{context}

Question: {question}
""")

llm = ChatOpenAI(temperature=0)

@chain
def qa(input):
  # 从之前定义的 hyde 检索链获取相关文档
  docs = retrieval_chain.invoke(input)
  # 格式化提示
  formatted = prompt.invoke({"context": docs, "question": input})
  # 生成答案
  answer = llm.invoke(formatted)
  return answer

qa.invoke("""Who are some key figures in the ancient greek history of 
    philosophy?""")

JavaScript 示例

const prompt = ChatPromptTemplate.fromTemplate(`Answer the following 
  question based on this context:

{context}

Question: {question}
`)

const llm = new ChatOpenAI({temperature: 0})

const qa = RunnableLambda.from(async input => {
  // 从之前定义的 hyde 检索链获取相关文档
  const docs = await retrievalChain.invoke(input)
  // 格式化提示
  const formatted = await prompt.invoke({context: docs, question: input})
  // 生成答案
  const answer = await llm.invoke(formatted)
  return answer
})

await qa.invoke(`Who are some key figures in the ancient greek history of 
  philosophy?`)

总结本节内容,查询转换包括以下步骤:

  • 重写为一个或多个查询
  • 将这些查询的结果合并为一组最相关的结果

重写查询的方式有多种形式,但通常以类似的方式进行:获取用户的原始查询(你编写的提示),并要求 LLM 编写一个或多个新的查询。通常的更改包括:

  • 从查询中删除不相关/无关的文本。
  • 用过去的对话历史对查询进行锚定。例如,为了理解类似“LA 怎么样”的查询,我们需要将其与关于 SF 天气的假设性过去问题结合,得出有用的查询,如 LA 的天气。
  • 通过为相关查询也检索文档,扩展相关文档的检索范围。
  • 将复杂问题分解为多个简单问题,然后将所有问题的结果包含在最终提示中以生成答案。

使用的正确重写策略将取决于你的应用场景。

现在我们已经讨论了主要的查询转换策略,接下来让我们讨论构建一个健壮的 RAG 系统需要回答的第二个主要问题:我们如何路由查询以从多个数据源检索相关数据?

查询路由

虽然使用单一的向量存储很有用,但所需的数据可能存在于多种数据源中,包括关系数据库或其他向量存储。例如,您可能有两个向量存储:一个用于 LangChain Python 文档,另一个用于 LangChain JS 文档。给定用户的查询,我们希望将查询路由到适当的数据源,以检索相关文档。查询路由是一种策略,用于将用户的查询转发到相关的数据源。

逻辑路由

在逻辑路由中,我们让 LLM 知道我们可以使用的各种数据源,然后让 LLM 根据用户的查询推理出应该使用哪个数据源,如图 3-8 所示。

image.png

为了实现这一目标,我们利用像 GPT-3.5 Turbo 这样的函数调用模型,帮助将每个查询分类到可用的路由中。函数调用涉及定义一个模式,模型可以根据查询使用该模式生成函数的参数。这使我们能够生成结构化的输出,并可以用来运行其他函数。以下 Python 代码定义了基于三种不同语言文档的数据源的路由器模式:

Python 示例

from typing import Literal
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_openai import ChatOpenAI

# 数据模型
class RouteQuery(BaseModel):
    """将用户查询路由到最相关的数据源。"""

    datasource: Literal["python_docs", "js_docs"] = Field(
        ...,
        description="""根据用户问题,选择哪个数据源最适合回答他们的问题"""
    )

# 带函数调用的 LLM 
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
structured_llm = llm.with_structured_output(RouteQuery)

# 提示 
system = """你是将用户问题路由到适当数据源的专家。

根据问题所指的编程语言,将其路由到相关数据源。"""

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system),
        ("human", "{question}"),
    ]
)

# 定义路由器 
router = prompt | structured_llm

JavaScript 示例

import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

const routeQuery = z.object({
  datasource: z.enum(["python_docs", "js_docs"]).describe(`根据用户问题,选择哪个数据源最适合回答他们的问题`),
}).describe("将用户查询路由到最相关的数据源。")

const llm = new ChatOpenAI({model: "gpt-3.5-turbo", temperature: 0})
const structuredLlm = llm.withStructuredOutput(routeQuery, {name: "RouteQuery"})

const prompt = ChatPromptTemplate.fromMessages([
  ['system', `你是将用户问题路由到适当数据源的专家。

根据问题所指的编程语言,将其路由到相关数据源。`],
  ['human', '{question}']
])

const router = prompt.pipe(structuredLlm)

现在,我们调用 LLM,根据预定义的模式提取数据源:

Python 示例

question = """Why doesn't the following code work:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages(["human", "speak in {language}"])
prompt.invoke("french")
"""

result = router.invoke({"question": question})

result.datasource
# "python_docs"

JavaScript 示例

const question = `Why doesn't the following code work:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages(["human", "speak in {language}"])
prompt.invoke("french")
`

await router.invoke({ question })

输出

{
    datasource: "python_docs"
}

注意,LLM 生成了符合我们之前定义的模式的 JSON 输出。这在许多其他任务中都很有用。

一旦提取了相关的数据源,我们可以将该值传递给另一个函数,以执行所需的其他逻辑:

Python 示例

def choose_route(result):
    if "python_docs" in result.datasource.lower():
        ### 这里执行逻辑 
        return "chain for python_docs"
    else:
        ### 这里执行逻辑 
        return "chain for js_docs"

full_chain = router | RunnableLambda(choose_route)

JavaScript 示例

function chooseRoute(result) {
  if (result.datasource.toLowerCase().includes('python_docs')) {
    return 'chain for python_docs';
  } else {
    return 'chain for js_docs';
  }
} 

const fullChain = router.pipe(chooseRoute) 

注意,我们没有进行精确的字符串比较,而是首先将生成的输出转换为小写字母,然后进行子字符串匹配。这使得我们的链更加灵活,能够应对 LLM 偏离预设并生成不完全符合我们要求的输出。

小贴士
对 LLM 输出的随机性质具有韧性是构建 LLM 应用时需要记住的重要主题。

逻辑路由最适用于具有定义的数据源列表,从中可以检索相关数据,并由 LLM 用于生成准确的输出。这些数据源可以是向量存储、数据库,甚至是 API。

语义路由

与逻辑路由不同,语义路由涉及将表示各种数据源的不同提示与用户的查询一起进行嵌入,然后执行向量相似性搜索以检索最相似的提示。如图 3-9 所示。

image.png

以下是语义路由的示例:

Python 示例

from langchain.utils.math import cosine_similarity
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import chain
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# 两个提示模板
physics_template = """You are a very smart physics professor. You are great at 
    answering questions about physics in a concise and easy-to-understand manner. 
    When you don't know the answer to a question, you admit that you don't know.

Here is a question:
{query}"""

math_template = """You are a very good mathematician. You are great at answering 
    math questions. You are so good because you are able to break down hard 
    problems into their component parts, answer the component parts, and then 
    put them together to answer the broader question.

Here is a question:
{query}"""

# 嵌入提示模板
embeddings = OpenAIEmbeddings()
prompt_templates = [physics_template, math_template]
prompt_embeddings = embeddings.embed_documents(prompt_templates)

# 路由查询到提示
@chain
def prompt_router(query):
    # 嵌入查询
    query_embedding = embeddings.embed_query(query)
    # 计算相似性
    similarity = cosine_similarity([query_embedding], prompt_embeddings)[0]
    # 选择与输入查询最相似的提示
    most_similar = prompt_templates[similarity.argmax()]
    return PromptTemplate.from_template(most_similar)

semantic_router = (
    prompt_router
    | ChatOpenAI()
    | StrOutputParser()
)

print(semantic_router.invoke("What's a black hole"))

JavaScript 示例

import {cosineSimilarity} from '@langchain/core/utils/math'
import {ChatOpenAI, OpenAIEmbeddings} from '@langchain/openai'
import {PromptTemplate} from '@langchain/core/prompts'
import {RunnableLambda} from '@langchain/core/runnables';

const physicsTemplate = `You are a very smart physics professor. You are great 
  at answering questions about physics in a concise and easy-to-understand 
  manner. When you don't know the answer to a question, you admit that you 
  don't know.

Here is a question:
{query}`

const mathTemplate = `You are a very good mathematician. You are great at 
  answering math questions. You are so good because you are able to break down 
  hard problems into their component parts, answer the component parts, and 
  then put them together to answer the broader question.

Here is a question:
{query}`

const embeddings = new OpenAIEmbeddings()

const promptTemplates = [physicsTemplate, mathTemplate]
const promptEmbeddings = await embeddings.embedDocuments(promptTemplates)

const promptRouter = RunnableLambda.from(query => {
  // 嵌入查询
  const queryEmbedding = await embeddings.embedQuery(query)
  // 计算相似性
  const similarities = cosineSimilarity([queryEmbedding], promptEmbeddings)[0]
  // 选择与输入查询最相似的提示
  const mostSimilar = similarities[0] > similarities[1] 
    ? promptTemplates[0] 
    : promptTemplates[1]
  return PromptTemplate.fromTemplate(mostSimilar)
})

const semanticRouter = promptRouter.pipe(new ChatOpenAI())

await semanticRouter.invoke("What's a black hole")

现在您已经看到如何将用户查询路由到相关数据源,让我们讨论构建一个健壮 RAG 系统时需要回答的第三个重要问题:“我们如何将自然语言转换为目标数据源的查询语言?”

查询构建

如前所述,RAG 是一种有效的策略,用于根据查询从向量存储中嵌入和检索相关的非结构化数据。但大多数可用于生产应用的数据是结构化的,通常存储在关系数据库中。此外,嵌入到向量存储中的非结构化数据也包含包含重要信息的结构化元数据。

查询构建是将自然语言查询转换为您正在与之交互的数据库或数据源查询语言的过程。见图 3-10。

image.png

例如,考虑查询:1980年有哪些关于外星人的电影?这个问题包含一个可以通过嵌入检索的非结构化主题(外星人),但它也包含潜在的结构化组件(年份 == 1980)。

接下来的章节将深入探讨各种查询构建形式。

文本到元数据过滤器

大多数向量存储提供基于元数据限制向量搜索的功能。在嵌入过程中,我们可以将元数据键值对附加到索引中的向量上,然后在查询索引时指定过滤表达式。

LangChain 提供了一个 SelfQueryRetriever,它抽象了这个逻辑,并使得将自然语言查询转换为结构化查询变得更加容易,适用于各种数据源。自查询利用 LLM 提取并执行相关的元数据过滤器,基于用户的查询和预定义的元数据模式:

Python 示例

from langchain.chains.query_constructor.base import AttributeInfo
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain_openai import ChatOpenAI

fields = [
    AttributeInfo(
        name="genre",
        description="The genre of the movie",
        type="string or list[string]",
    ),
    AttributeInfo(
        name="year",
        description="The year the movie was released",
        type="integer",
    ),
    AttributeInfo(
        name="director",
        description="The name of the movie director",
        type="string",
    ),
    AttributeInfo(
        name="rating", description="A 1-10 rating for the movie", type="float"
    ),
]
description = "Brief summary of a movie"

llm = ChatOpenAI(temperature=0)

retriever = SelfQueryRetriever.from_llm(
    llm, db, description, fields,
)

print(retriever.invoke(
    "What's a highly rated (above 8.5) science fiction film?"))

JavaScript 示例

import { ChatOpenAI } from "@langchain/openai";
import { SelfQueryRetriever } from "langchain/retrievers/self_query";
import { FunctionalTranslator } from "@langchain/core/structured_query";

/**
 * 首先,我们定义我们希望能够查询的属性。
 * 在本例中,我们希望能够查询电影的类型、年份、导演、评分和时长。
 * 我们还提供了每个属性的描述和属性的类型。
 * 这些信息用于生成查询提示。
 */
const fields = [
  {
    name: "genre",
    description: "The genre of the movie",
    type: "string or array of strings",
  },
  {
    name: "year",
    description: "The year the movie was released",
    type: "number",
  },
  {
    name: "director",
    description: "The director of the movie",
    type: "string",
  },
  {
    name: "rating",
    description: "The rating of the movie (1-10)",
    type: "number",
  },
  {
    name: "length",
    description: "The length of the movie in minutes",
    type: "number",
  },
];
const description = "Brief summary of a movie";

const llm = new ChatOpenAI();
const attributeInfos = fields.map((field) => new AttributeInfo(field.name,  
  field.description, field.type));
  
const selfQueryRetriever = SelfQueryRetriever.fromLLM({
  llm,
  db,
  description,
  attributeInfo: attributeInfos,
  /**
   * 我们需要使用一个翻译器,将查询转换为向量存储可以理解的
   * 过滤格式。LangChain 提供了一个翻译器。
   */
  structuredQueryTranslator: new FunctionalTranslator(),
});

await selfQueryRetriever.invoke(
  "What's a highly rated (above 8.5) science fiction film?"
);

这会生成一个检索器,它将用户查询拆分为:

  1. 首先应用于每个文档元数据的过滤器
  2. 用于在文档上进行语义搜索的查询

为此,我们必须描述文档的元数据包含哪些字段;这些描述将包含在提示中。然后,检索器将执行以下操作:

  1. 将查询生成提示发送到 LLM。
  2. 解析 LLM 输出中的元数据过滤器和重写的搜索查询。
  3. 将 LLM 生成的元数据过滤器转换为适合我们向量存储的格式。
  4. 在向量存储上执行相似性搜索,只匹配元数据通过生成的过滤器的文档。

文本到 SQL

SQL 和关系数据库是结构化数据的重要来源,但它们并不直接与自然语言交互。尽管我们可以简单地使用 LLM 将用户的查询翻译成 SQL 查询,但这种方式几乎没有错误的余地。

以下是一些有效的文本到 SQL 转换策略:

数据库描述
为了使 SQL 查询更具基础性,LLM 必须提供数据库的准确描述。一种常见的文本到 SQL 提示方法采用了这篇论文和其他论文中报告的思路:为每个表提供 LLM 一个 CREATE TABLE 描述,包括列名和数据类型。我们还可以提供一些(例如,三行)来自该表的示例数据。

少量示例
通过提供少量示例的问答匹配,能够提高查询生成的准确性。可以通过简单地在提示中附加标准的静态示例来实现这一点,从而指导代理如何根据问题构建查询。

查看图 3-11,了解该过程的视觉示意图。

image.png

完整代码示例:

Python 示例

from langchain_community.tools import QuerySQLDatabaseTool
from langchain_community.utilities import SQLDatabase
from langchain.chains import create_sql_query_chain
from langchain_openai import ChatOpenAI

# 替换为你的数据库连接详细信息
db = SQLDatabase.from_uri("sqlite:///Chinook.db")
llm = ChatOpenAI(model="gpt-4", temperature=0)

# 将问题转换为 SQL 查询
write_query = create_sql_query_chain(llm, db)

# 执行 SQL 查询
execute_query = QuerySQLDatabaseTool(db=db)

# 合并
chain = write_query | execute_query

# 调用链
chain.invoke('How many employees are there?');

JavaScript 示例

import { ChatOpenAI } from "@langchain/openai";
import { createSqlQueryChain } from "langchain/chains/sql_db";
import { SqlDatabase } from "langchain/sql_db";
import { DataSource } from "typeorm";
import { QuerySqlTool } from "langchain/tools/sql";

const datasource = new DataSource({
  type: "sqlite",
  database: "./Chinook.db", // 替换为你的数据库详细信息
});
const db = await SqlDatabase.fromDataSourceParams({
  appDataSource: datasource,
});
const llm = new ChatOpenAI({ model: "gpt-4", temperature: 0 });

// 将问题转换为 SQL 查询
const writeQuery = await createSqlQueryChain({ llm, db, dialect: "sqlite" });

// 执行查询
const executeQuery = new QuerySqlTool(db);

// 合并
const chain = writeQuery.pipe(executeQuery);

// 调用链
await chain.invoke('How many employees are there?');

首先,我们将用户的查询转换为适合数据库方言的 SQL 查询。然后,我们在数据库上执行该查询。请注意,在生产应用中,通过 LLM 从用户输入生成的任意 SQL 查询在数据库中执行是非常危险的。要在生产环境中使用这些想法,您需要考虑多项安全措施,以减少在数据库中运行意外查询的风险。以下是一些示例:

  • 使用只读权限的用户在数据库上运行查询。
  • 执行查询的数据库用户应仅访问您希望使其可查询的表。
  • 为应用运行的查询添加超时;这样,即使生成了一个代价昂贵的查询,它也会在占用过多数据库资源之前被取消。

这不是安全考虑的完整列表。LLM 应用程序的安全性是当前正在开发的领域,随着新漏洞的发现,更多的安全措施被添加到建议中。

总结

本章讨论了多种最先进的策略,用于根据用户的查询高效地检索最相关的文档,并将其与提示合成,以帮助 LLM 生成准确、最新的输出。

如前所述,一个健壮的、适用于生产的 RAG 系统需要多种有效策略来执行查询转换、查询构建、路由和索引优化。

查询转换使您的 AI 应用能够将模糊或格式不正确的用户查询转换为最适合检索的代表性查询。查询构建使您的 AI 应用能够将用户的查询转换为数据库或数据源中结构化数据的查询语言的语法。路由使您的 AI 应用能够动态地将用户的查询路由到相关数据源,以检索相关信息。

在第 4 章中,我们将在此基础上添加内存功能,使您的 AI 聊天机器人能够记住并从每次交互中学习。这将使用户能够像与 ChatGPT 聊天一样,与应用进行多轮对话。

  1. Patrick Lewis 等人,"Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks",arXiv,2021年4月12日。
  2. Xinbei Ma 等人,"Query Rewriting for Retrieval-Augmented Large Language Models",arXiv,2023年10月23日。由微软亚洲研究院委托的研究。
  3. Zackary Rackauckas,"RAG-Fusion: A New Take on Retrieval-Augmented Generation",arXiv,2024年2月21日。来自《国际自然语言计算期刊》,第13卷,第1期(2024年2月)。
  4. Luyu Gao 等人,"Precise Zero-Shot Dense Retrieval Without Relevance Labels",arXiv,2022年12月20日。
  5. Nitarshan Rajkumar 等人,"Evaluating the Text-to-SQL Capabilities of Large Language Models",arXiv,2022年3月15日。