在基准任务上评估 RAG 架构#

介绍#

如果您想比较不同方法在文档上的 Q&A,那么您会发现此笔记本有助于开始评估不同配置和常见 RAG 架构在基准任务上的表现。目标是让您轻松地尝试不同的技术,了解它们的权衡,并为您的特定用例做出明智的决策。

什么是 RAG?#

LLM 具有知识截止日期。为了让它们准确地响应用户查询,它们需要访问相关信息。检索增强生成 (RAG)(又称“为 LLM 提供搜索引擎”)是解决这个问题的常见设计模式。关键组件是

  • 检索器:从知识库中获取信息,知识库可以是向量搜索引擎、数据库或任何搜索引擎。

  • 生成器:使用学习的知识和检索到的信息的结合来合成响应。

系统的整体质量取决于这两个组件。

基准任务和数据集(截至 2023 年 11 月 21 日)#

目前有以下数据集可用

  • LangChain 文档 Q&A - 基于 LangChain python 文档的技术问题

  • 半结构化收益 - 关于包含表格和图表财务 PDF 的财务问题和答案

每个任务都附带一个标记数据集,包含问题和答案。它们还提供可配置的工厂函数,以便轻松自定义相关源文档的分块和索引。

有了这些,让我们开始吧!

先决条件#

由于我们将比较许多技术和模型,因此我们将为此示例安装相当多的先决条件。

我们将使用 LangSmith 来捕获评估跟踪。您可以在 smith.langchain.com 上创建一个免费帐户。完成此操作后,您可以创建一个 API 密钥并在下面设置它。

由于我们将在本笔记本中比较许多方法,因此我们将安装的依赖项列表很长。

%pip install -U --quiet langchain langsmith langchainhub langchain_benchmarks
%pip install --quiet chromadb openai huggingface pandas langchain_experimental sentence_transformers pyarrow anthropic tiktoken
import os

os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
os.environ["LANGCHAIN_API_KEY"] = "sk-..."  # Your API key
os.environ["OPENAI_API_KEY"] = "sk-..."  # Your OpenAI API key
os.environ["ANTHROPIC_API_KEY"] = "sk-..."  # Your Anthropic API key
# Silence warnings from HuggingFace
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import uuid

# Generate a unique run ID for these experiments
run_uid = uuid.uuid4().hex[:6]

回顾 Q&A 任务#

注册表提供配置以在经过整理的数据集上测试常见架构。以下是在撰写本文时可用的任务列表。

from langchain_benchmarks import clone_public_dataset, registry
registry.filter(Type="RetrievalTask")
名称类型数据集 ID描述
LangChain 文档 Q&ARetrievalTask452ccafc-18e1-4314-885b-edd735f17b9d基于 LangChain python 文档快照的问题和答案。环境提供文档和检索器信息。每个示例都包含一个问题和参考答案。成功与答案相对于参考答案的准确性有关。我们还衡量模型响应相对于检索到的文档(如果有)的忠实度。
半结构化报告RetrievalTaskc47d9617-ab99-4d6e-a6e6-92b8daf85a7d基于包含表格和图表的 PDF 的问题和答案。该任务提供原始文档以及工厂方法,以便轻松地对它们进行索引并创建一个检索器。每个示例都包含一个问题和参考答案。成功与答案相对于参考答案的准确性有关。我们还衡量模型响应相对于检索到的文档(如果有)的忠实度。
langchain_docs = registry["LangChain Docs Q&A"]
langchain_docs
clone_public_dataset(langchain_docs.dataset_id, dataset_name=langchain_docs.name)

基本向量检索#

对于我们的第一个示例,我们将为数据集中的每个文档生成单个嵌入,不进行分块或索引,然后将该检索器提供给 LLM 进行推理。

from langchain.embeddings import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
    model_name="thenlper/gte-base",
    model_kwargs={"device": 0},  # Comment out to use CPU
)

retriever_factory = langchain_docs.retriever_factories["basic"]
# Indexes the documents with the specified embeddings
# Note that this does not apply any chunking to the docs,
# which means the documents can be of arbitrary length
retriever = retriever_factory(embeddings)
# Factory for creating a conversational retrieval QA chain

chain_factory = langchain_docs.architecture_factories["conversational-retrieval-qa"]
from langchain.chat_models import ChatAnthropic

# Example
llm = ChatAnthropic(model="claude-2", temperature=1)

chain_factory(retriever, llm=llm).invoke({"question": "what's lcel?"})
from functools import partial

from langsmith.client import Client

from langchain_benchmarks.rag import get_eval_config
client = Client()
RAG_EVALUATION = get_eval_config()

test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(chain_factory, retriever, llm=llm),
    evaluation=RAG_EVALUATION,
    project_name=f"claude-2 qa-chain simple-index {run_uid}",
    project_metadata={
        "index_method": "basic",
        "embedding_model": "thenlper/gte-base",
        "llm": "claude-2",
    },
    verbose=True,
)
test_run.get_aggregate_feedback()

与其他索引策略进行比较#

上面使用的索引基于每个文档的单个向量来检索原始文档。它不会执行任何其他分块操作。您可以在生成索引时尝试更改分块参数。

自定义分块#

您可以对索引进行的最简单的更改是配置您如何拆分文档。

from langchain.text_splitter import RecursiveCharacterTextSplitter


def transform_docs(docs):
    splitter = RecursiveCharacterTextSplitter(chunk_size=4000, chunk_overlap=200)
    yield from splitter.split_documents(docs)


# Used for the cache
transformation_name = "recursive-text-cs4k-ol200"

retriever_factory = langchain_docs.retriever_factories["basic"]

chunked_retriever = retriever_factory(
    embeddings,
    transform_docs=transform_docs,
    transformation_name=transformation_name,
    search_kwargs={"k": 4},
)
chunked_results = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(chain_factory, chunked_retriever, llm=llm),
    evaluation=RAG_EVALUATION,
    project_name=f"claude-2 qa-chain chunked {run_uid}",
    project_metadata={
        "index_method": "basic",
        "chunk_size": 4000,
        "chunk_overlap": 200,
        "embedding_model": "thenlper/gte-base",
        "llm": "claude-2",
    },
    verbose=True,
)
chunked_results.get_aggregate_feedback()

父文档检索器#

此索引技术对文档进行分块并为每个块生成 1 个向量。在检索时,将获取 K 个“最相似”的块,然后返回完整的父文档供 LLM 推理。

这确保了该块以其完整的自然上下文呈现。它还可以潜在地提高初始检索质量,因为相似度得分仅限于单个块。

让我们看看这种技术在我们的例子中是否有效。

retriever_factory = langchain_docs.retriever_factories["parent-doc"]

# Indexes the documents with the specified embeddings
parent_doc_retriever = retriever_factory(embeddings)
parent_doc_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(chain_factory, parent_doc_retriever, llm=llm),
    evaluation=RAG_EVALUATION,
    project_name=f"claude-2 qa-chain parent-doc {run_uid}",
    project_metadata={
        "index_method": "parent-doc",
        "embedding_model": "thenlper/gte-base",
        "llm": "claude-2",
    },
    verbose=True,
)
parent_doc_test_run.get_aggregate_feedback()

HyDE#

HyDE(假设文档嵌入)是指使用 LLM 生成示例查询的技术,这些查询可用于检索文档。通过这样做,生成的嵌入将自动“更符合”从查询生成的嵌入。这会带来额外的索引成本,因为每个文档在索引时都需要对 LLM 进行额外调用。

retriever_factory = langchain_docs.retriever_factories["hyde"]

retriever = retriever_factory(embeddings)
hyde_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(chain_factory, retriever=retriever, llm=llm),
    evaluation=RAG_EVALUATION,
    verbose=True,
    project_name=f"claude-2 qa-chain HyDE {run_uid}",
    project_metadata={
        "index_method": "HyDE",
        "embedding_model": "thenlper/gte-base",
        "llm": "claude-2",
    },
)
hyde_test_run.get_aggregate_feedback()

比较嵌入#

到目前为止,我们一直在使用现成的 GTE-Base 嵌入来检索文档,但您可能会在其他嵌入中获得更好的结果。您甚至可以尝试在您自己的文档上微调嵌入并在此处进行评估。

让我们将到目前为止的结果与 OpenAI 的嵌入进行比较。

from langchain.embeddings.openai import OpenAIEmbeddings

openai_embeddings = OpenAIEmbeddings()
openai_retriever = langchain_docs.retriever_factories["basic"](openai_embeddings)
openai_embeddings_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(chain_factory, openai_retriever),
    evaluation=RAG_EVALUATION,
    project_name=f"claude-2 qa-chain oai-emb basic {run_uid}",
    project_metadata={
        "index_method": "basic",
        "embedding_model": "openai/text-embedding-ada-002",
        "llm": "claude-2",
    },
    verbose=True,
)
openai_embeddings_test_run.get_aggregate_feedback()

比较模型#

我们在之前的测试中使用了 Anthropic 的 Claude-2 模型,但让我们尝试其他模型。

您可以在下面的响应生成器中替换任何 LangChain LLM。我们将首先尝试一个长上下文 llama 2 模型(使用 Ollama)。

from langchain.chat_models import ChatOllama

# A llama2-based model with 128k context
# (in theory) In practice, we will see how well
# it actually leverages that context.
ollama = ChatOllama(model="yarn-llama2:7b-128k")
# We'll go back to the GTE embeddings for now

retriever_factory = langchain_docs.retriever_factories["basic"]
retriever = retriever_factory(embeddings)
ollama_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(chain_factory, llm=ollama, retriever=retriever),
    evaluation=RAG_EVALUATION,
    project_name=f"yarn-llama2:7b-128k qa-chain basic {run_uid}",
    project_metadata={
        "index_method": "basic",
        "embedding_model": "thenlper/gte-base",
        "llm": "ollama/yarn-llama2:7b-128k",
    },
    verbose=True,
)

更改响应生成器中的提示#

默认提示主要针对 OpenAI 的 gpt-3.5 模型进行了测试。在切换模型时,如果您修改提示,可能会获得更好的结果。让我们尝试一个简单的提示。

from langchain import hub
from langchain.schema.output_parser import StrOutputParser
prompt = hub.pull("wfh/rag-simple")
generator = prompt | ChatAnthropic(model="claude-2", temperature=1) | StrOutputParser()
new_chain = chain_factory(response_generator=generator, retriever=openai_retriever)
claude_simple_prompt_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=partial(
        chain_factory, response_generator=generator, retriever=retriever, llm=llm
    ),
    evaluation=RAG_EVALUATION,
    project_name=f"claude-2 qa-chain basic rag-simple {run_uid}",
    project_metadata={
        "index_method": "basic",
        "embedding_model": "thenlper/gte-base",
        "prompt": "wfh/rag-simple",
        "llm": "claude-2",
    },
    verbose=True,
)

测试代理#

代理使用 LLM 来决定行动和生成响应。有两种明显的方法可以让它们在上面方法失败的地方成功

  • 上面的链不会“改写”用户查询。可能是改写后的问题将导致更多相关的文档。

  • 上面的链必须基于单个检索步骤进行响应。代理可以迭代地查询检索器或将查询细分为不同的部分,以便在最后进行合成。我们的数据集包含许多需要来自不同文档的信息的问题 - 如果

让我们评估一下上面“合理”的陈述是否值得权衡。我们将使用基本检索器作为它们的工具。

from typing import List, Tuple

from langchain.agents import AgentExecutor
from langchain.agents.format_scratchpad import format_to_openai_functions
from langchain.agents.output_parsers import OpenAIFunctionsAgentOutputParser
from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.pydantic_v1 import BaseModel, Field
from langchain.schema.messages import AIMessage, HumanMessage
from langchain.tools import tool
from langchain.tools.render import format_tool_to_openai_function

# This is used to tell the model how to best use the retriever.


@tool
def search(query, callbacks=None):
    """Search the LangChain docs with the retriever."""
    return retriever.get_relevant_documents(query, callbacks=callbacks)


tools = [search]

llm = ChatOpenAI(model="gpt-4-1106-preview", temperature=0)
assistant_system_message = """You are a helpful assistant tasked with answering technical questions about LangChain. \
Use tools (only if necessary) to best answer the users questions. Do not make up information if you cannot find the answer using your tools."""
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", assistant_system_message),
        MessagesPlaceholder(variable_name="chat_history"),
        ("user", "{input}"),
        MessagesPlaceholder(variable_name="agent_scratchpad"),
    ]
)

llm_with_tools = llm.bind(functions=[format_tool_to_openai_function(t) for t in tools])


def _format_chat_history(chat_history: List[Tuple[str, str]]):
    buffer = []
    for human, ai in chat_history:
        buffer.append(HumanMessage(content=human))
        buffer.append(AIMessage(content=ai))
    return buffer


agent = (
    {
        "input": lambda x: x["input"],
        "chat_history": lambda x: _format_chat_history(x["chat_history"]),
        "agent_scratchpad": lambda x: format_to_openai_functions(
            x["intermediate_steps"]
        ),
    }
    | prompt
    | llm_with_tools
    | OpenAIFunctionsAgentOutputParser()
)


class AgentInput(BaseModel):
    input: str
    chat_history: List[Tuple[str, str]] = Field(..., extra={"widget": {"type": "chat"}})


agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=False).with_types(
    input_type=AgentInput
)


class ChainInput(BaseModel):
    question: str


def mapper(input: dict):
    return {"input": input["question"], "chat_history": []}


agent_executor = (mapper | agent_executor | (lambda x: x["output"])).with_types(
    input_type=ChainInput
)
oai_functions_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=agent_executor,
    evaluation=RAG_EVALUATION,
    project_name=f"oai-functions basic rag-simple {run_uid}",
    project_metadata={
        "index_method": "basic",
        "embedding_model": "thenlper/gte-base",
        "llm": "gpt-4-1106-preview",
        "architecture": "oai-functions-agent",
    },
    verbose=True,
)

助手#

OpenAI 通过其助手 API 提供了一个托管代理服务。

您可以将您的 LangChain 检索器连接到 OpenAI 的助手 API 并评估其性能。让我们在下面进行测试

import json

from langchain.agents import AgentExecutor
from langchain.tools import tool
from langchain_experimental.openai_assistant import OpenAIAssistantRunnable


@tool
def search(query, callbacks=None) -> str:
    """Search the LangChain docs with the retriever."""
    docs = retriever.get_relevant_documents(query, callbacks=callbacks)
    return json.dumps([doc.dict() for doc in docs])


tools = [search]

agent = OpenAIAssistantRunnable.create_assistant(
    name="langchain docs assistant",
    instructions="You are a helpful assistant tasked with answering technical questions about LangChain.",
    tools=tools,
    model="gpt-4-1106-preview",
    as_agent=True,
)


assistant_exector = (
    (lambda x: {"content": x["question"]})
    | AgentExecutor(agent=agent, tools=tools)
    | (lambda x: x["output"])
)
assistant_test_run = client.run_on_dataset(
    dataset_name=langchain_docs.name,
    llm_or_chain_factory=assistant_exector,
    evaluation=RAG_EVALUATION,
    project_name=f"oai-assistant basic rag-simple {run_uid}",
    project_metadata={
        "index_method": "basic",
        "embedding_model": "thenlper/gte-base",
        "llm": "gpt-4-1106-preview",
        "architecture": "oai-assistant",
    },
    verbose=True,
)
assistant_test_run.get_aggregate_feedback()