Fork me on GitHub

分类 大模型 下的文章

基于 LangGraph 创建智能体应用

早在年初的时候,LangChain 发布了 v0.1.0 稳定版本,版本公告里通过大量的篇幅对功能特性做了全面的介绍,最后,在公告的结尾,提到了一个不那么显眼的库,那就是 LangGraph。尽管看上去不那么显眼,但是它却非常重要,所以后来官方又 发表了一篇博客来单独介绍它,这是一个面向当前大模型领域最火热的智能体应用的库,是 LangChain 在智能体开发,特别是复杂的多智能体系统方面的一次重大尝试。

在之前的 LangChain 版本中,我们可以通过 AgentExecutor 实现智能体,在 大模型应用开发框架 LangChain 学习笔记(二) 中,我们曾经学习过 AgentExecutor 的用法,实现了包括 Zero-shot ReAct Agent、Conversational ReAct Agent、ReAct DocStore Agent、Self-Ask Agent、OpenAI Functions Agent 和 Plan and execute Agent 这些不同类型的智能体。但是这种方式过于黑盒,所有的决策过程都隐藏在 AgentExecutor 的背后,缺乏更精细的控制能力,在构建复杂智能体的时候非常受限。

LangGraph 提供了对应用程序的流程和状态更精细的控制,它允许定义包含循环的流程,并使用 状态图(State Graph) 来表示 AgentExecutor 的黑盒调用过程。

下面是 LangGraph 的关键特性:

  • 循环和分支(Cycles and Branching):支持在应用程序中实现循环和条件语句;
  • 持久性(Persistence):自动保存每一步的执行状态,支持在任意点暂停和恢复,以实现错误恢复、人机协同、时间旅行等功能;
  • 人机协同(Human-in-the-Loop):支持在行动执行前中断执行,允许人工介入批准或编辑;
  • 流支持(Streaming Support):图中的每个节点都支持实时地流式输出;
  • 与 LangChain 的集成(Integration with LangChain):LangGraph 与 LangChain 和 LangSmith 无缝集成,但并不强依赖于它们。

快速开始

我们从一个最简单的例子开始:

### 定义状态图

from langgraph.graph import StateGraph, MessagesState

graph_builder = StateGraph(MessagesState)

### 定义模型和 chatbot 节点

from langchain_openai import ChatOpenAI

llm = ChatOpenAI()

def chatbot(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

### 构建和编译图

from langgraph.graph import END, START

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
graph = graph_builder.compile()

### 运行

from langchain_core.messages import HumanMessage

response = graph.invoke(
    {"messages": [HumanMessage(content="合肥今天天气怎么样?")]}
)
response["messages"][-1].pretty_print()

在这个例子中,我们使用 LangGraph 定义了一个只有一个节点的图:

basic-chatbot.jpg

基本概念

上面的示例非常简单,还称不上什么智能体,尽管如此,它却向我们展示了 LangGraph 中的几个重要概念:

  • 图(Graph) 是 LangGraph 中最为重要的概念,它将智能体的工作流程建模为图结构。大学《数据结构》课程学过,图由 节点(Nodes)边(Edges) 构成,在 LangGraph 中也是如此,此外,LangGraph 中还增加了 状态(State) 这个概念;
  • 状态(State) 表示整个图运行过程中的状态数据,可以理解为应用程序当前快照,为图中所有节点所共享,它可以是任何 Python 类型,但通常是 TypedDict 类型或者 Pydantic 的 BaseModel 类型;
  • 节点(Nodes) 表示智能体的具体执行逻辑,它接收当前的状态作为输入,执行某些计算,并返回更新后的状态;节点不一定非得是调用大模型,可以是任意的 Python 函数;
  • 边(Edges) 表示某个节点执行后,接下来要执行哪个节点;边的定义可以是固定的,也可以是带条件的;如果是条件边,我们还需要定义一个 路由函数(Routing function),根据当前的状态来确定接下来要执行哪个节点。

通过组合节点和边,我们可以创建复杂的循环工作流,随着节点的执行,不断更新状态。简而言之:节点用于执行动作,边用于指示下一步动作

LangGraph 的实现采用了 消息传递(Message passing) 的机制。其灵感源自 Google 的 Pregel 和 Apache 的 Beam 系统,当一个节点完成其操作后,它会沿着一条或多条边向其他节点发送消息。这些接收节点随后执行其功能,将生成的消息传递给下一组节点,如此循环往复。

代码详解

了解这些基本概念后,再回过头来看下上面的代码,脉络就很清楚了。

首先我们通过 StateGraph 定义了状态图:

graph_builder = StateGraph(MessagesState)

它接受状态的 Schema 作为构造参数,在这里直接使用了内置的 MessagesState 类,它的定义如下:

class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

MessagesState 很简单,仅包含一个 LangChain 格式的消息列表,一般在构造聊天机器人或示例代码时使用,在正式环境中用的并不多,因为大多数应用程序需要的状态比消息列表更为复杂。

后面的 add_messages 被称为 规约函数(Reducers),表示当节点执行后状态如何更新。当没有定义规约函数时,默认是覆盖的逻辑,比如下面这样的状态 Schema:

from typing import TypedDict

class State(TypedDict):
    foo: int
    bar: list[str]

假设图的输入为 {"foo": 1, "bar": ["hi"]},接着假设第一个节点返回 {"foo": 2},这时状态被更新为 {"foo": 2, "bar": ["hi"]},注意,节点无需返回整个状态对象,只有返回的字段会被更新,再接着假设第二个节点返回 {"bar": ["bye"]},这时状态将变为 {"foo": 2, "bar": ["bye"]}

当定义了规约函数,更新逻辑就不一样了,比如对上面的状态 Schema 稍作修改:

from typing import TypedDict, Annotated
from operator import add

class State(TypedDict):
    foo: int
    bar: Annotated[list[str], add]

仍然假设图的输入为 {"foo": 1, "bar": ["hi"]},接着假设第一个节点返回 {"foo": 2},这时状态被更新为 {"foo": 2, "bar": ["hi"]},再接着假设第二个节点返回 {"bar": ["bye"]},这时状态将变为 {"foo": 2, "bar": ["hi", "bye"]}

定义了图之后,我们接下来就要定义节点,这里我们只定义了一个 chatbot 节点:

def chatbot(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

节点就是普通的 Python 函数,在这里调用大模型得到回复,也可以是任意其他的逻辑,函数的入参就是上面所定义的状态对象,我们可以从状态中取出最新的值,函数的出参也是状态对象,节点执行后,根据规约函数,返回值会被更新到状态中。

定义节点后,我们就可以使用 add_node 方法将其添加到图中:

graph_builder.add_node("chatbot", chatbot)

然后再使用 add_edge 方法添加两条边,一条边从 START 节点到 chatbot 节点,一个边从 chatbot 节点到 END 结束:

graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)

STARTEND 是两个特殊节点,START 表示开始节点,接受用户的输入,是整个图的入口,END 表示结束节点,执行到它之后就没有后续动作了。

值得注意的是,这里构建图的接口形式借鉴了 NetworkX 的设计理念。整个图构建好后,我们还需要调用 compile 方法编译图:

graph = graph_builder.compile()

只有编译后的图才能使用。编译是一个相当简单的步骤,它会对图的结构进行一些基本检查,比如无孤立节点等,也可以在编译时设置一些运行时参数,比如检查点、断点等。

编译后的图是一个 Runnable 对象,所以我们可以使用 invoke/ainvoke 来调用它:

response = graph.invoke(
    {"messages": [HumanMessage(content="合肥今天天气怎么样?")]}
)
response["messages"][-1].pretty_print()

也可以使用 stream/astream 来调用它:

for event in graph.stream({"messages": ("user", "合肥今天天气怎么样?")}):
    for value in event.values():
        value["messages"][-1].pretty_print()

输出结果如下:

================================== Ai Message ==================================

对不起,我无法提供实时天气信息。您可以通过天气预报应用程序或网站来获取合肥今天的天气情况。

工具调用

可以看到,现在这个程序只是对大模型进行了一层包装,还谈不上是智能体。Lilian Weng 在 LLM Powered Autonomous Agents 这篇博客中总结到,智能体至少要包含三个核心组件:规划(Planning)记忆(Memory)工具使用(Tool use)

agent-overview.png

其中,规划和记忆好比人的大脑,可以储存历史知识,对问题进行分析思考,现在的大模型都或多或少具备这样的能力;工具使用好比人的五官和手脚,可以感知世界,与外部源(例如知识库或环境)进行交互,以获取额外信息,并执行动作。工具的使用是人类区别于其他动物的重要特征,也是智能体区别于其他应用程序的重要特征。

这一节我们将对上面的 LangGraph 示例做些修改,使其具备工具调用的能力。首先,我们定义一个天气查询的工具:

### 定义工具

from pydantic import BaseModel, Field
from langchain_core.tools import tool

class GetWeatherSchema(BaseModel):
    city: str = Field(description = "城市名称,如合肥、北京、上海等")
    date: str = Field(description = "日期,如今天、明天等")

@tool(args_schema = GetWeatherSchema)
def get_weather(city: str, date: str):
    """查询天气"""
    if city == "合肥":
        return "今天晴天,气温30度。"
    return "今天有小雨,气温25度。"

这里使用了 LangChain 的 @tool 注解将一个方法定义成工具,并使用了 pydantic 对工具的参数做一些说明,在 这篇博客 中我还介绍了一些其他定义工具的方法,也可以使用。

接下来,和之前的示例一样,我们仍然需要定义一个状态图:

### 定义状态图

from langgraph.graph import StateGraph, MessagesState

graph_builder = StateGraph(MessagesState)

再接下来定义节点:

### 定义 tools 节点

from langgraph.prebuilt import ToolNode

tools = [get_weather]
tool_node = ToolNode(tools)

### 定义模型和 chatbot 节点

from langchain_openai import ChatOpenAI

llm = ChatOpenAI()
llm = llm.bind_tools(tools)

def chatbot(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

这和之前的示例有两点区别:

  1. 多了一个 tools 节点,我们使用 LangGraph 内置的 ToolNode 来定义,一个工具节点中可以包含多个工具方法;
  2. chatbot 节点 中,我们的大模型需要绑定这些工具,通过 llm.bind_tools() 实现;

再接下来,将节点添加到图中,并在节点和节点之间连上线:

### 构建和编译图

from langgraph.graph import END, START
from langgraph.prebuilt import tools_condition

graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("tools", 'chatbot')
graph_builder.add_conditional_edges("chatbot", tools_condition)
graph = graph_builder.compile()

构建出的图如下所示:

tools-chatbot.jpg

可以看到这里有两条比较特别的连线,是虚线,这被称为 条件边(Conditional Edges),LangGraph 通过调用某个函数来确定下一步将执行哪个节点,这里使用了内置的 tools_condition 函数,当大模型返回 tool_calls 时执行 tools 节点,否则则执行 END 节点。

此时,一个简单的智能体就构建好了,我们再次运行之:

### 运行

for event in graph.stream({"messages": ("user", "合肥今天天气怎么样?")}):
    for value in event.values():
        value["messages"][-1].pretty_print()

运行结果如下:

================================== Ai Message ==================================
Tool Calls:
  get_weather (call_Jjp7SNIQkJWpLUdTL4uL1h1O)
 Call ID: call_Jjp7SNIQkJWpLUdTL4uL1h1O
  Args:
    city: 合肥
    date: 今天
================================= Tool Message =================================
Name: get_weather

今天晴天,气温30度。
================================== Ai Message ==================================

合肥今天是晴天,气温30度。

完整的代码 参考这里

深入 Tool Call 的原理

从上面的运行结果中可以看出,用户消息首先进入 chatbot 节点,也就是调用大模型,大模型返回 tool_calls 响应,因此进入 tools 节点,接着调用我们定义的 get_weather 函数,得到合肥的天气,然后再次进入 chatbot 节点,将函数结果送给大模型,最后大模型就可以回答出用户的问题了。

这个调用的流程图如下:

tool-calling-flow.png

OpenAI 官方文档 中有一张更详细的流程图:

function-calling-diagram.png

其中要注意的是,第二次调用大模型时,可能仍然会返回 tool_calls 响应,这时可以循环处理。

为了更好的理解 LangGraph 是如何调用工具的,我们不妨深入接口层面一探究竟。总的来说,LangGraph 利用大模型的 Tool Call 功能,实现动态的选择工具,提取工具参数,执行工具函数,并根据工具运行结果回答用户问题。

有很多大模型具备 Tool Call 功能,比如 OpenAI、Anthropic、Gemini、Mistral AI 等,我们可以通过 llm.bind_tools(tools) 给大模型绑定可用的工具,实际上,绑定工具就是在请求大模型的时候,在入参中多加一个 tools 字段:

{
    "model": "gpt-4",
    "messages": [
        {
            "role": "user",
            "content": "合肥今天天气怎么样?"
        }
    ],
    "stream": false,
    "n": 1,
    "temperature": 0.7,
    "tools": [
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "查询天气",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "city": {
                            "type": "string",
                            "description": "城市名称,如合肥、北京、上海等"
                        },
                        "date": {
                            "type": "string",
                            "description": "日期,如今天、明天等"
                        }
                    },
                    "required": [
                        "city",
                        "date"
                    ]
                }
            }
        }
    ],
    "tool_choice": "auto"
}

这时大模型返回的结果类似于下面这样,也就是上面所说的 tool_calls 响应:

{
    "id": "chatcmpl-ABDVbXhhQLF8yN3xZV5FpW10vMQpP",
    "object": "chat.completion",
    "created": 1727236899,
    "model": "gpt-4-0613",
    "choices": [
        {
            "index": 0,
            "message": {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "call_aZaHgkaSmzq7kWX5f73h7nGg",
                        "type": "function",
                        "function": {
                            "name": "get_weather",
                            "arguments": "{\n  \"city\": \"合肥\",\n  \"date\": \"今天\"\n}"
                        }
                    }
                ]
            },
            "finish_reason": "tool_calls"
        }
    ],
    "usage": {
        "prompt_tokens": 91,
        "completion_tokens": 25,
        "total_tokens": 116
    },
    "system_fingerprint": ""
}

我们只需要判断大模型返回的结果中是否有 tool_calls 字段就能知道下一步是不是要调用工具,这其实就是 tools_condition 这个条件函数的逻辑:

def tools_condition(
    state: Union[list[AnyMessage], dict[str, Any]],
) -> Literal["tools", "__end__"]:

    if isinstance(state, list):
        ai_message = state[-1]
    elif messages := state.get("messages", []):
        ai_message = messages[-1]
    else:
        raise ValueError(f"No messages found in input state to tool_edge: {state}")
    if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
        return "tools"
    return "__end__"

tools_condition 函数判断 messages 中如果有 tool_calls 字段且不为空,则返回 tools,也就是工具节点,否则返回 __end__ 也就是结束节点。

工具节点的执行,我们使用的是 LangGraph 内置的 ToolNode 类,它的实现比较复杂,感兴趣的可以翻看下它的源码,但是大体流程可以用下面几行代码表示:

tools_by_name = {tool.name: tool for tool in tools}
def tool_node(state: dict):
    result = []
    for tool_call in state["messages"][-1].tool_calls:
        tool = tools_by_name[tool_call["function"]["name"]]
        observation = tool.invoke(tool_call["function"]["arguments"])
        result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
    return {"messages": result}

工具节点遍历 tool_calls 数组,根据大模型返回的函数名 name 和函数参数 arguments 依次调用工具,并将工具结果以 ToolMessage 形式附加到 messages 中。这样再次进入 chatbot 节点时,向大模型发起的请求就如下所示(多了一个角色为 tool 的消息):

{
    "model": "gpt-4",
    "messages": [
        {
            "role": "user",
            "content": "合肥今天天气怎么样?"
        },
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                { 
                    "id": "call_aZaHgkaSmzq7kWX5f73h7nGg",
                    "type": "function",
                    "function": {
                        "name": "get_weather",
                        "arguments": "{\n  \"city\": \"合肥\",\n  \"date\": \"今天\"\n}" 
                    }
                }
            ]
        },
        {
            "role": "tool",
            "content": "晴,27度",
            "tool_call_id": "call_aZaHgkaSmzq7kWX5f73h7nGg"
        }
    ],
    "stream": false,
    "n": 1,
    "temperature": 0.7,
    "tools": [
        ...
    ],
    "tool_choice": "auto"
}

大模型返回消息如下:

{
    "id": "chatcmpl-ABDeUc21mx3agWVPmIEHndJbMmYTP",
    "object": "chat.completion",
    "created": 1727237450,
    "model": "gpt-4-0613",
    "choices": [
        {
            "index": 0,
            "message": {
                "role": "assistant",
                "content": "合肥今天的天气是晴朗,气温为27度。"
            },
            "finish_reason": "stop"
        }
    ],
    "usage": {
        "prompt_tokens": 129,
        "completion_tokens": 24,
        "total_tokens": 153
    },
    "system_fingerprint": ""
}

此时 messages 中没有 tool_calls 字段,因此,进入 END 节点,这一轮的会话就结束了。

适配 Function Call 接口

经过上面的学习,我们知道,LangGraph 默认会使用大模型接口的 Tool Call 功能。Tool Call 是 OpenAI 推出 Assistants API 时引入的一种新特性,它相比于传统的 Function Call 来说,控制更灵活,比如支持一次返回多个函数,从而可以并发调用。

目前大多数大模型产商的接口都已经紧跟 OpenAI 的规范,推出了 Tool Call 功能,但是也有部分产商或开源模型只支持 Function Call,对于这些模型如何在 LangGraph 中适配呢?

Function Call 和 Tool Call 的区别在于,请求的参数中是 functions 而不是 tools,如下所示:

{
    "messages": [
        {
            "role": "user",
            "content": "合肥今天天气怎么样?"
        }
    ],
    "model": "gpt-4",
    "stream": false,
    "n": 1,
    "temperature": 0.7,
    "functions": [
        {
            "name": "get_weather",
            "description": "查询天气",
            "parameters": {
                "properties": {
                    "city": {
                        "description": "城市名称,如合肥、北京、上海等",
                        "type": "string"
                    },
                    "date": {
                        "description": "日期,如今天、明天等",
                        "type": "string"
                    }
                },
                "required": [
                    "city",
                    "date"
                ],
                "type": "object"
            }
        }
    ]
}

LangChain 提供了 llm.bind_functions(tools) 方法来给大模型绑定可用的工具,这里的工具定义和 llm.bind_tools(tools) 是一模一样的:

### 定义模型和 chatbot 节点

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4")
llm = llm.bind_functions(tools)

def chatbot(state: MessagesState):
    return {"messages": [llm.invoke(state["messages"])]}

大模型返回结果如下,messages 中会包含 function_call 字段而不是 tool_calls

{
    "id": "chatcmpl-ACcnVWbuWbyxuO0eWqQrKBE0dB921",
    "object": "chat.completion",
    "created": 1727572437,
    "model": "gpt-4-0613",
    "choices": [
        {
            "index": 0,
            "message": {
                "role": "assistant",
                "content": "",
                "function_call": {
                    "name": "get_weather",
                    "arguments": "{\"city\":\"合肥\",\"date\":\"今天\"}"
                }
            },
            "finish_reason": "function_call"
        }
    ],
    "usage": {
        "prompt_tokens": 91,
        "completion_tokens": 21,
        "total_tokens": 112
    },
    "system_fingerprint": "fp_5b26d85e12"
}

因此我们条件边的判断函数就不能以 tool_calls 来作为判断依据了,我们对其稍加修改:

def tools_condition(
    state: MessagesState,
) -> Literal["tools", "__end__"]:

    if isinstance(state, list):
        ai_message = state[-1]
    elif messages := state.get("messages", []):
        ai_message = messages[-1]
    else:
        raise ValueError(f"No messages found in input state to tool_edge: {state}")
    if "function_call" in ai_message.additional_kwargs:
        return "tools"
    return "__end__"

注意 LangChain 将 function_call 放在消息的额外字段 additional_kwargs 里。

最后是工具节点的实现,上面我们使用的是 LangGraph 内置的 ToolNode 类,它的实现比较复杂,要考虑工具的异步执行和并发执行等情况,我们不用实现和它完全一样的功能。最简单的做法是自定义一个 BasicToolNode 类,并实现一个 __call__ 方法:

import json
from langchain_core.messages import FunctionMessage

class BasicToolNode:

    def __init__(self, tools: list) -> None:
        self.tools_by_name = {tool.name: tool for tool in tools}

    def __call__(self, inputs: dict):
        if messages := inputs.get("messages", []):
            message = messages[-1]
        else:
            raise ValueError("No message found in input")
        outputs = []
        if "function_call" in message.additional_kwargs:
            tool_call = message.additional_kwargs["function_call"]
            tool_result = self.tools_by_name[tool_call["name"]].invoke(
                json.loads(tool_call["arguments"])
            )
            outputs.append(
                FunctionMessage(
                    content=json.dumps(tool_result),
                    name=tool_call["name"]
                )
            )
        return {"messages": outputs}

tools = [get_weather]
tool_node = BasicToolNode(tools=tools)

我们从 function_call 字段中提取出工具名称 name 和工具参数 arguments,然后调用相应的工具,最后最重要的一步是将工具调用结果包装成一个 FunctionMessage 并附加到 messages 中。当程序流程再次进入 chatbot 节点时,向大模型发起的请求就如下所示(多了一个角色为 function 的消息):

{
    "messages": [
        {
            "role": "user",
            "content": "合肥今天天气怎么样?"
        },
        {
            "role": "assistant",
            "content": "",
            "function_call": {
                "name": "get_weather",
                "arguments": "{\"city\":\"合肥\",\"date\":\"今天\"}"
            }
        },
        {
            "role": "function",
            "content": "晴,27度",
            "name": "get_weather"
        }
    ],
    "model": "gpt-4",
    "stream": false,
    "n": 1,
    "temperature": 0.7,
    "functions": [
        ...
    ]
}

至此,我们就通过 Function Call 实现了 LangGraph 的调用逻辑,完整的代码 参考这里

可以看出其中有三步是关键:

  1. 给大模型绑定工具,可以通过 llm.bind_tools()llm.bind_functions() 实现,对于不支持 Function Call 的模型,甚至可以通过自定义 Prompt 来实现;
  2. 解析大模型的返回结果,根据返回的结果中是否有 tool_callsfunction_call 字段,判断是否需要使用工具;
  3. 根据大模型的返回结果,调用一个或多个工具方法。

记忆

我们的智能体现在可以使用工具来回答用户的问题,但它不记得先前互动的上下文,这限制了它进行多轮对话的能力。比如我们接着上面的问题后面再问一个与之相关问题:

for event in graph.stream({"messages": ("user", "合肥今天天气怎么样?")}):
    for value in event.values():
        value["messages"][-1].pretty_print()

for event in graph.stream({"messages": ("user", "要带伞吗?")}):
    for value in event.values():
        value["messages"][-1].pretty_print()

智能体的回复如下:

================================== Ai Message ==================================

请问您在哪个城市以及哪一天需要查询天气情况呢?

很显然,这个智能体还不具备记忆功能,而上一节我们曾提到,记忆(Memory) 是智能体必须具备的三大核心组件之一,所以这一节我们就来学习如何使用 LangGraph 实现它。

LangGraph 通过 持久化检查点(persistent checkpointing)) 实现记忆。首先,我们在编译图时设置检查点(checkpointer)参数:

from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory)

然后在调用图时提供一个额外的线程 ID 配置:

config = {"configurable": {"thread_id": "1"}}

for event in graph.stream({"messages": ("user", "合肥今天天气怎么样?")}, config):
    for value in event.values():
        value["messages"][-1].pretty_print()

for event in graph.stream({"messages": ("user", "要带伞吗?")}, config):
    for value in event.values():
        value["messages"][-1].pretty_print()

LangGraph 在第一次运行时自动保存状态,当再次使用相同的线程 ID 调用图时,图会加载其保存的状态,使得智能体可以从停下的地方继续。这一次,智能体的回复如下:

================================== Ai Message ==================================

不需要带伞,今天是晴天哦。

可以看出智能体记住了上一轮的对话内容,现在我们可以和它进行多轮对话了。

持久化数据库

在上面的例子中,我们使用了 MemorySaver 这个检查点,这是一个简单的内存检查点,所有的对话历史都保存在内存中。对于一个正式的应用来说,我们需要将对话历史持久化到数据库中,可以考虑使用 SqliteSaverPostgresSaver 等,LangGraph 也支持自定义检查点,实现其他数据库的持久化,比如 MongoDBRedis

这一节我们将使用 PostgresSaver 来将智能体的记忆持久化到数据库。

首先,安装 PostgresSaver 所需的依赖:

$ pip3 install "psycopg[binary,pool]" langgraph-checkpoint-postgres

然后使用 Docker 启动一个 Postgre 实例:

$ docker run --name my-postgres -e POSTGRES_PASSWORD=123456 -p 5432:5432 -d postgres:latest

然后将上一节代码中的 MemorySaver 检查点替换成 PostgresSaver 如下:

from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://postgres:123456@localhost:5432/postgres?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    
    # 第一次运行时初始化
    checkpointer.setup()
    
    graph = graph_builder.compile(checkpointer=checkpointer)
    config = {"configurable": {"thread_id": "1"}}
    for event in graph.stream({"messages": ("user", "合肥今天天气怎么样?")}, config):
        for value in event.values():
            value["messages"][-1].pretty_print()
    for event in graph.stream({"messages": ("user", "要带伞吗?")}, config):
        for value in event.values():
            value["messages"][-1].pretty_print()

第一次运行时,我们需要使用 checkpointer.setup() 来初始化数据库,新建必须的库和表,后续运行可以省略这一步。后面的代码和上一节是完全一样的,设置线程 ID 进行两轮问答,只不过现在问答记录存到数据库里了。感兴趣的同学可以打开 checkpoints 表看看数据结构:

memory-db.png

注意这里我们直接基于连接字符串创建连接,这种方法简单方便,非常适用于快速测试验证,我们也可以创建一个 Connection 对象,设置一些额外的连接参数:

from psycopg import Connection

connection_kwargs = {
    "autocommit": True,
    "prepare_threshold": 0,
}
with Connection.connect(DB_URI, **connection_kwargs) as conn:
    checkpointer = PostgresSaver(conn)
    graph = graph_builder.compile(checkpointer=checkpointer)
    ...

在正式环境下,我们往往会复用数据库的连接,这时可以使用连接池 ConnectionPool 对象:

from psycopg_pool import ConnectionPool

with ConnectionPool(conninfo=DB_URI, max_size=20, kwargs=connection_kwargs) as pool:
    checkpointer = PostgresSaver(pool)
    graph = graph_builder.compile(checkpointer=checkpointer)
    ...

使用 LangSmith 调试智能体会话

当智能体的工具和节点不断增多,我们将会面临大量的问题,比如运行结果出乎意料,智能体出现死循环,反应速度比预期慢,运行花费了多少令牌,等等,这时如何调试智能体将变成一件棘手的事情。

一种简单的方法是使用 这里 介绍的包装类:

class Wrapper:
    ''' 包装类,用于调试 OpenAI 接口的原始入参和出参
    '''
    def __init__(self, wrapped_class):
        self.wrapped_class = wrapped_class

    def __getattr__(self, attr):
        original_func = getattr(self.wrapped_class, attr)

        def wrapper(*args, **kwargs):
            print(f"Calling function: {attr}")
            print(f"Arguments: {args}, {kwargs}")
            result = original_func(*args, **kwargs)
            print(f"Response: {result}")
            return result
        return wrapper

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4")
llm.client = Wrapper(llm.client)
llm = llm.bind_functions(tools)

这种方法相当于给大模型接口增加了一个切面,用于记录接口的原始入参和出参,方便我们调试。

另一种更专业的做法是使用 LangSmith。

LangSmith 是 LangChain 开发的一个用于构建生产级 LLM 应用程序的平台,允许你调试、测试、评估和监控基于任何 LLM 框架构建的程序,无论是 LangChain 开发的链,还是 LangGraph 开发的智能体。

要使用 LangSmith,我们首先登录平台并注册一个账号,然后进入 Settings -> API Keys 页面,点击 Create API Key 按钮创建一个 API Key,然后设置如下环境变量:

export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_API_KEY=lsv2_pt_xxx
export LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
export LANGCHAIN_PROJECT=default

其中,LANGCHAIN_TRACING_V2=true 表示开启日志跟踪模式;LANGCHAIN_API_KEY 就是上一步创建的 API Key;LANGCHAIN_ENDPOINT 表示 LangSmith 端点地址,一般来说不用配置,由于 LangSmith 是一个开源项目,我们可以私有化部署,这时才需要配置;LANGCHAIN_PROJECT 表示将日志保存到哪个 LangSmith 项目,如果不设置,默认使用的 default 项目。

设置好环境变量,整个工作就完成了,代码无需任何变动,完全没有侵入性。此时,我们再次运行之前的代码,就可以在 LangSmith 平台上看到相应的记录了:

langsmith-runs.png

Runs 列表表示智能体每次的运行记录,也可以切换到 Threads 列表查看所有的会话线程:

langsmith-threads.png

点击进入记录详情,可以很直观地看到 LangGraph 的调用顺序,每一步的耗时和令牌数一目了然:

langsmith-thread-details.png

每一步还可以继续展开,查看该步骤更为详细的入参和出参,便于我们排查问题。

除了调试,我们还可以在 LangSmith 平台上将某一步的结果添加到 测试数据集(Dataset)标注队列(Annotation Queue) 用于后续的测试和评估。还可以对 LLM 的调用情况进行监控分析:

langsmith-monitor.png

高级特性

通过检查点我们实现了智能体的记忆功能,从而可以让智能体支持多轮对话。实际上,检查点远比我们想象的更强大,通过它可以在任何时候保存和恢复智能体运行过程中的状态,从而实现错误恢复、人机交互、时间旅行等高级特性。

人机交互(Human-in-the-loop)

基于 LLM 的应用程序可能会不可靠,有时需要人类的输入才能成功完成任务;对于某些操作,比如预定机票、支付订单等,可能在运行之前要求人工批准,以确保一切都按照预期运行。LangGraph 支持一种被称为 Human-in-the-loop 的工作流程,允许我们在执行工具节点之前停下来,等待人类的介入。

首先我们将上面代码中的工具改为 book_ticket,用于预定机票:

class BookTicketSchema(BaseModel):
    from_city: str = Field(description = "出发城市名称,如合肥、北京、上海等")
    to_city: str = Field(description = "到达城市名称,如合肥、北京、上海等")
    date: str = Field(description = "日期,如今天、明天等")

@tool(args_schema = BookTicketSchema)
def book_ticket(from_city: str, to_city: str, date: str):
    """预定机票"""
    return "您已成功预定 %s 从 %s 到 %s 的机票" % (date, from_city, to_city)

再将用户的问题改为:

for event in graph.stream({"messages": ("user", "帮我预定一张明天从合肥到北京的机票")}, config):
    for value in event.values():
        value["messages"][-1].pretty_print()

运行得到结果:

================================== Ai Message ==================================
Tool Calls:
  book_ticket (call_WGzlRnbPXbN8YvwjIkIMNDS1)
 Call ID: call_WGzlRnbPXbN8YvwjIkIMNDS1
  Args:
    date: 明天
    from_city: 合肥
    to_city: 北京
================================= Tool Message =================================
Name: book_ticket

您已成功预定 明天 从 合肥 到 北京 的机票
================================== Ai Message ==================================

您已成功预定 明天从合肥到北京的机票。祝您旅途愉快!如果还需要帮助,请随时告诉我。

接下来我们稍微对代码做些修改,在编译图的时候设置 interrupt_before 参数:

graph = graph_builder.compile(
    checkpointer=memory,
    interrupt_before=["tools"]
)

这样在执行到工具节点时,整个流程就会中断,重新运行结果如下:

================================== Ai Message ==================================
Tool Calls:
  book_ticket (call_1jQtm6czoPrNhbRIR5FzyN47)
 Call ID: call_1jQtm6czoPrNhbRIR5FzyN47
  Args:
    date: 明天
    from_city: 合肥
    to_city: 北京

可以看到工具并没有执行,此时我们可以使用 graph.get_state(config) 获取流程图的当前状态,从当前状态里我们可以拿到上一步的消息和下一步将要执行的节点:

snapshot = graph.get_state(config)
print(snapshot.values["messages"][-1])
print(snapshot.next)

向用户展示当前状态,以便用户对工具的执行进行确认,如果用户确认无误,则继续流程图的运行,直接传入 None 即可:

### 继续运行

for event in graph.stream(None, config):
    for value in event.values():
        value["messages"][-1].pretty_print()

运行结果如下:

================================= Tool Message =================================
Name: book_ticket

您已成功预定 明天 从 合肥 到 北京 的机票
================================== Ai Message ==================================

好的,已为您成功预定一张明天从合肥到北京的机票。

手动更新状态

在上一节中,我们学习了如何在执行工具之前中断,以便我们可以检查和确认,如果确认没问题,就继续运行,但如果确认有问题,这时我们就要手动更新状态,改变智能体的行为方向。

书接上回,我们仍然使用机票预定的例子,假设用户确认时,希望将日期从明天改为后天。我们可以使用下面的代码:

snapshot = graph.get_state(config)
existing_message = snapshot.values["messages"][-1]
new_tool_call = existing_message.tool_calls[0].copy()
new_tool_call["args"]["date"] = "后天"
new_message = AIMessage(
    content=existing_message.content,
    tool_calls=[new_tool_call],
    # Important! The ID is how LangGraph knows to REPLACE the message in the state rather than APPEND this messages
    id=existing_message.id,
)
graph.update_state(config, {"messages": [new_message]})

这里我们首先获取当前状态,从当前状态中获取最后一条消息,我们知道最后一条消息是 tool_call 消息,于是将 tool_call 复制了一份,并修改 date 参数,然后重新构造 AIMessage 对象,并使用 graph.update_state() 来更新状态。值得注意的是,AIMessage 中的 id 参数非常重要,LangGraph 会从状态中找到和 id 匹配的消息,如果找到就更新,否则就是新增。

这样就实现了状态的更新,我们传入 None 参数继续运行之:

### 继续运行

for event in graph.stream(None, config):
    for value in event.values():
        value["messages"][-1].pretty_print()

运行结果如下:

================================= Tool Message =================================
Name: book_ticket

您已成功预定 后天 从 合肥 到 北京 的机票
================================== Ai Message ==================================

您已成功预定 后天从合肥到北京的机票。祝您旅途愉快!如果还需要帮助,请随时告诉我。

除了修改工具的参数之外,LangGraph 还支持我们修改状态中的任意消息,比如手动构造工具执行的结果以及大模型的回复:

snapshot = graph.get_state(config)
existing_message = snapshot.values["messages"][-1]
new_messages = [
    # The LLM API expects some ToolMessage to match its tool call. We'll satisfy that here.
    ToolMessage(content="预定失败", tool_call_id=existing_message.tool_calls[0]["id"]),
    # And then directly "put words in the LLM's mouth" by populating its response.
    AIMessage(content="预定失败"),
]
graph.update_state(config, {"messages": new_messages})

完整的代码 参考这里,更多内容,参考 LangGraph 文档:

LangGraph 应用场景

官网文档提供了很多 LangGraph 的应用场景,包括 聊天机器人、RAG、智能体架构、评估分析等。

Chatbots

聊天机器人是智能体最常见的应用场景。

RAG

检索增强生成(Retrieval-Augmented Generation,简称 RAG) 通过引入外部信息源实现知识问答,解决大模型缺乏领域知识、无法获取实时信息以及生成虚假内容等问题。我们在 这篇博客 中学习了不少高级 RAG 技巧,通过 LangGraph 可以将智能体和 RAG 相结合,实现更好的问答效果。

Agent Architectures

ReAct 是最常见的智能体架构,这个词出自论文 ReAct: Synergizing Reasoning and Acting in Language Models,它是由 ReasonAct 两个词组合而成,表示一种将 推理行动 与大模型相结合的通用范式。上面我们学习的 LangGraph 示例,其实就是参考了 ReAct 的思路,方便起见,LangGraph 将其内置在 SDK 中,我们可以直接使用 create_react_agent 方法来创建一个 ReAct 智能体

from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent

llm = ChatOpenAI()
memory = MemorySaver()
tools = [get_weather]
graph = create_react_agent(llm, tools=tools, checkpointer=memory)

除 ReAct 之外,还有不少其他的智能体架构,比如多智能体、规划型智能体、智能体的反思和批判。

Multi-Agent Systems

Planning Agents

Reflection & Critique

Evaluation & Analysis

使用智能体评估智能体。

Experimental

这里列举一些 LangGraph 的实验特性。

参考

LangGraph Blogs

Cobus Greyling

中文资料

扫描二维码,在手机上阅读!

高级 RAG 技术学习笔记

随着大模型技术的发展,基于大模型开发的应用也越来越多,比如类似 ChatGPT 的对话服务,将搜索引擎与大模型相结合的问答服务,等等。但在这些应用中,我们也面临着大量的问题,包括缺乏领域知识、无法获取实时信息以及生成虚假内容。检索增强生成(Retrieval-Augmented Generation,简称 RAG) 通过引入外部信息源,为这些问题提供了一种有效的缓解策略。

RAG 在生成式人工智能应用中被广泛采用,演变成了一门类似 提示工程 的学科,可以说它是 2023 年最受欢迎的基于大模型的开发架构。它的流行甚至推动了向量搜索领域的炒作,像 ChromaWeavaitePinecone 这样的向量数据库初创公司都因此火了一把。

RAG 之所以如此流行,原因有几个:

  1. 它利用了大模型的上下文学习的能力(In-Context Learning,ICL),增强了上下文理解,有助于减少幻觉;
  2. 它提供了一种非梯度方法(Non-Gradient Approach,所谓梯度方法就是微调或训练等方法),允许自定义 Prompt 而无需对模型进行微调,这种方法也能更好地适应不同的模型;
  3. 它提供了很好的可观察性和可检查性,可以对用户输入、检索的上下文和模型生成的回复进行比对,而微调过程是不透明的;
  4. 它更容易维护,对知识库持续更新的过程比较简单,而不需要专业人员;

我们在之前的笔记中已经学习过不少和 RAG 相关的内容,比如在 使用 Embedding 技术打造本地知识库助手 这篇笔记中,我们学习了如何打造一个针对本地文档的问答系统,在 基于结构化数据的文档问答 这篇笔记中,我们继续探索了如何针对结构化的数据进行问答。不过这些内容都比较简单,只是对 RAG 原理的入门级讲解,本篇博客将对 RAG 的高级技巧进行深入学习,并结合 LangChain 和 LlamaIndex 对各个技巧一一进行实战。

RAG 概述

RAG 的本质是搜索 + LLM 提示(Search + LLM prompting),根据用户的问题,通过一定的搜索算法找到相关的信息,将其注入到大模型的提示中,然后令大模型基于上下文来回答用户的问题。其工作流程如下图所示:

rag-overview.png

在这里,用户向大模型提出了一个近期新闻相关的问题,由于大模型依赖于预训练数据,无法提供最新的信息。RAG 通过从外部数据库中获取和整合知识来弥补这一信息差,它收集与用户查询相关的新闻文章,这些文章与原始问题结合起来,形成一个全面的提示,使大模型能够生成一个见解丰富的答案。

图中展示了 RAG 框架的四个基本组成部分:

  • 输入(Input):即用户输入的问题,如果不使用 RAG,问题直接由大模型回答;
  • 索引(Indexing):系统首先将相关的文档切分成段落,计算每个段落的 Embedding 向量并保存到向量库中;在进行查询时,用户问题也会以相似的方式计算 Embedding 向量;
  • 检索(Retrieval):从向量库中找到和用户问题最相关的段落;
  • 生成(Generation):将找到的文档段落与原始问题合并,作为大模型的上下文,令大模型生成回复,从而回答用户的问题;

RAG 范式的演变和发展

RAG 近年来发展迅速,随着对 RAG 的研究不断深入,各种 RAG 技术被开发出来。Yunfan Gao 等人在 Retrieval-Augmented Generation for Large Language Models: A Survey 这篇论文中详细考察了 RAG 范式的演变和发展,将其分成三个阶段:朴素 RAG、高级 RAG 和模块化 RAG:

rag-paradigms.png

其中朴素 RAG 最早出现,在 ChatGPT 爆火后不久就开始受到关注,它包括索引、检索和生成三部分,参考上一节所介绍的基本流程。朴素 RAG 简单易懂,但是也面临着不少问题:

  • 首先,在检索阶段,精确性和召回率往往是一个难题,既要避免选择无关片段,又要避免错过关键信息;
  • 其次,如何将检索到的信息整合在一起也是一个挑战,面对复杂问题,单个检索可能不足以获取足够的上下文信息;对检索的结果,我们要确定段落的重要性和相关性,对段落进行排序,并对冗余段落进行处理;
  • 最后,在生成回复时,模型可能会面临幻觉问题,即产生与检索到的上下文不符的内容;此外,模型可能会过度依赖上下文信息,导致只生成检索到的内容,而缺乏自己的见解;同时我们又要尽量避免模型输出不相关、有毒或有偏见的信息。

为了解决朴素 RAG 遗留的问题,高级 RAG 引入了一些改进措施,增加了 预检索过程(Pre-Retrieval Process)后检索过程(Post-Retrieval Process) 两个阶段,提高检索质量:

  • 在预检索过程这个阶段,主要关注的是 索引优化(index optimization)查询优化(query optimization);索引优化的目标是提高被索引内容的质量,常见的方法有:提高数据粒度(enhancing data granularity)优化索引结构(optimizing index structures)添加元数据(adding metadata)对齐优化(alignment optimization)混合检索(mixed retrieval);而查询优化的目标是使用户的原始问题更清晰、更适合检索任务,常见的方法有:查询重写(query rewriting)查询转换(query transformation)查询扩展(query expansion) 等技术;
  • 后检索过程关注的是,如何将检索到的上下文有效地与查询整合起来。直接将所有相关文档输入大模型可能会导致信息过载,使关键细节与无关内容混淆,为了减轻这种情况,后检索过程引入的方法包括:重新排序块(rerank chunks)上下文压缩(context compressing) 等;

可以看出,尽管高级 RAG 在检索前和检索后提出了多种优化策略,但是它仍然遵循着和朴素 RAG 一样的链式结构,架构的灵活性仍然收到限制。模块化 RAG 的架构超越了前两种 RAG 范式,增强了其适应性和功能性,可以灵活地引入特定功能模块或替换现有模块,整个过程不仅限于顺序检索和生成,还包括迭代和自适应检索等方法。

关于这些 RAG 技术的细节,推荐研读 Yunfan Gao 等人的 论文,写的非常详细。

开发 RAG 系统面临的 12 个问题

上一节我们学习了 RAG 范式的发展,并介绍了 RAG 系统中可能会面临的问题,Scott Barnett 等人在 Seven Failure Points When Engineering a Retrieval Augmented Generation System 这篇论文中对此做了进一步的梳理,整理了 7 个常见的问题:

7-failure-points.png

  1. 缺失内容(Missing Content)

当用户的问题无法从文档库中检索到时,可能会导致大模型的幻觉现象。理想情况下,RAG 系统可以简单地回复一句 “抱歉,我不知道”,然而,如果用户问题能检索到文档,但是文档内容和用户问题无关时,大模型还是可能会被误导。

  1. 错过超出排名范围的文档(Missed Top Ranked)

由于大模型的上下文长度限制,我们从文档库中检索时,一般只返回排名靠前的 K 个段落,如果问题答案所在的段落超出了排名范围,就会出现问题。

  1. 不在上下文中(Not In Context)

包含答案的文档已经成功检索出来,但却没有包含在大模型所使用的上下文中。当从数据库中检索到多个文档,并且使用合并过程提取答案时,就会出现这种情况。

  1. 未提取(Not Extracted)

答案在提供的上下文中,但是大模型未能准确地提取出来,这通常发生在上下文中存在过多的噪音或冲突信息时。

  1. 错误的格式(Wrong Format)

问题要求以特定格式提取信息,例如表格或列表,然而大模型忽略了这个指示。

  1. 不正确的具体性(Incorrect Specificity)

尽管大模型正常回答了用户的提问,但不够具体或者过于具体,都不能满足用户的需求。不正确的具体性也可能发生在用户不确定如何提问,或提问过于笼统时。

  1. 不完整的回答(Incomplete Answers)

考虑一个问题,“文件 A、B、C 包含哪些关键点?”,直接使用这个问题检索得到的可能只是每个文件的部分信息,导致大模型的回答不完整。一个更有效的方法是分别针对每个文件提出这些问题,以确保全面覆盖。

Wenqi Glantz 在他的博客 12 RAG Pain Points and Proposed Solutions 中又扩充了另 5 个问题:

  1. 数据摄入的可扩展性问题(Data Ingestion Scalability)

当数据规模增大时,系统可能会面临如数据摄入时间过长、系统过载、数据质量下降以及可用性受限等问题,这可能导致性能瓶颈甚至系统故障。

  1. 结构化数据的问答(Structured Data QA)

根据用户的问题准确检索出所需的结构化数据是一项挑战,尤其是当用户的问题比较复杂或比较模糊时。这是由于文本到 SQL 的转换不够灵活,当前大模型在处理这类任务上仍然存在一定的局限性。

  1. 从复杂 PDF 文档提取数据(Data Extraction from Complex PDFs)

复杂的 PDF 文档中可能包含有表格、图片等嵌入内容,在对这种文档进行问答时,传统的检索方法往往无法达到很好的效果。我们需要一个更高效的方法来处理这种复杂的 PDF 数据提取需求。

  1. 备用模型(Fallback Model(s))

在使用单一大模型时,我们可能会担心模型遇到问题,比如遇到 OpenAI 模型的访问频率限制错误。这时候,我们需要一个或多个模型作为备用,以防主模型出现故障。

  1. 大语言模型的安全性(LLM Security)

如何有效地防止恶意输入、确保输出安全、保护敏感信息不被泄露等问题,都是我们需要面对的重要挑战。

在 Wenqi Glantz 的博客中,他不仅整理了这些问题,而且还对每个问题给出了对应的解决方案,整个 RAG 系统的蓝图如下:

12-pain-points.png

LlamaIndex 实战

通过上面的学习,我们了解了 RAG 的基本原理和发展历史,以及开发 RAG 系统时可能遇到的一些问题。这一节我们将学习 LlamaIndex 框架,这是一个和 LangChain 齐名的基于大模型的应用开发框架,我们将使用它快速实现一个简单的 RAG 程序。

LlamaIndex 快速入门

LlamaIndex 是一个由 Jerry Liu 创建的 Python 库,用于开发基于大模型的应用程序,类似于 LangChain,但它更偏向于 RAG 系统的开发。使用 LlamaIndex,开发人员可以很方便地摄取、结构化和访问私有或领域特定数据,以便将这些数据安全可靠地注入大模型中,从而实现更准确的文本生成。

正如 LlamaIndex 的名字所暗示的,索引(Index) 是 RAG 系统中的核心概念,它是大模型和用户数据之间的桥梁,无论是数据库类的结构化数据,还是文档类的非结构化数据,抑或是程序类的 API 数据,都是通过索引来查询的,查询出来的内容作为上下文和用户的问题一起发送给大模型,得到响应:

basic-rag.png

LlamaIndex 将 RAG 分为五个关键阶段:

  • 加载(Loading):用于导入各种用户数据,无论是文本文件、PDF、另一个网站、数据库还是 API;LlamaHub 提供了数百个的加载器;
  • 索引(Indexing):可以是 Embedding 向量,也可以是其他元数据策略,方便我们准确地找到上下文相关的数据;
  • 存储(Storing):对索引持久化存储,以免重复索引;
  • 查询(Querying):对给定的索引策略进行查询,包括子查询、多步查询和混合策略;
  • 评估(Evaluation):提供客观的度量标准,用于衡量查询响应的准确性、忠实度和速度;

可以看到这些阶段几乎都和索引有关,为了对这些阶段有个更感性的认识,我们参考 LlamaIndex 官方文档中的 Starter Tutorial 来快速入门。

首先,我们使用 pip 安装 LlamaIndex:

$ pip3 install llama-index

通过 LlamaIndex 提供的高级 API,初学者只需 5 行代码即可实现一个简单的 RAG 程序:

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
documents = SimpleDirectoryReader("data").load_data()
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine()
response = query_engine.query("What did the author do growing up?")

示例中使用了保罗·格雷厄姆的文章 What I Worked On 作为测试数据,我们将其下载并保存到 data 目录,运行程序,得到下面的输出:

The author worked on writing and programming before college.

LlamaIndex 核心概念

上面的代码中展示了 加载 -> 索引 -> 查询 这几个阶段,其中有几个概念需要特别说明下:

  • Documents and Nodes

    • Documents 对应任何数据源的容器,比如 PDF 文档,API 接口的输出,或从数据库中检索数据;
    • 我们可以手动构造 Document 对象,也可以使用所谓的 数据连接器(Data Connectors) 来加载数据,示例中使用的 SimpleDirectoryReader 就是一个数据连接器;
    • 由于加载的数据可能很大,Document 通常不直接使用,在 LlamaIndex 中,会将 Document 切分成很多很多的小块,这些文档的分块被称为 Node,它是 LlamaIndex 中数据的原子单位;Node 中包含一些元数据,比如属于哪个文档,和其他 Node 的关联等;
    • 将 Document 切分成 Nodes 是由 Node Parser 或 Text Splitters 完成的,示例代码中并没有明确指定,用的默认的 SentenceSplitter,可以通过 Settings.text_splitter 来修改;
  • Indexes

    • 一旦完成了数据的读取,LlamaIndex 就可以帮我们对数据进行索引,便于快速检索用户查询的相关上下文;Index 是一种数据结构,它是 LlamaIndex 打造 RAG 的核心基础;
    • LlamaIndex 内置了几种不同的 Index 实现,如 Summary IndexVector Store IndexTree IndexKeyword Table IndexHow Each Index Works 这篇文档介绍了不同 Index 的实现原理;
    • 可以看到示例代码中使用了 VectorStoreIndex,这也是目前最常用的 Index;默认情况下 VectorStoreIndex 将 Index 数据保存到内存中,可以通过 StorageContextpersist() 方法将 Index 持久化到本地磁盘,或指定 Vector Store 将 Index 保存到向量数据库中,LlamaIndex 集成了大量的 Vector Store 实现
    • LlamaIndex 有一套完善的存储体系,除了 Vector Store,还支持 Document StoreIndex StoreGraph StoreChat Store 等,具体内容可以参考 官方文档
    • 此外,在使用 VectorStoreIndex 生成向量索引时,会使用 Embeddings 模型,它使用复杂的向量来表示文档内容,通过向量的距离来表示文本的语义相似性,默认的 Embedding 模型为 OpenAIEmbedding,可以通过 Settings.embed_model 来修改;
  • Query Engines

    • 加载完文档,构造完索引,我们就来到 RAG 中最重要的一环:Querying;根据用户的问题,或者是一个总结请求,或者一个更复杂的指令,检索出相关文档从而实现对数据的问答和聊天;
    • 查询引擎(Query Engines) 是最基础也是最常见的检索方式,通过 Index 的 as_query_engine() 方法可以构建查询引擎,查询引擎是无状态的,不能跟踪历史对话,如果要实现类似 ChatGPT 的对话场景,可以通过 as_chat_engine() 方法构建 聊天引擎(Chat Engines)
    • LlamaIndex 将查询分为三个步骤:第一步 Retrieval 是指从 Index 中找到并返回与用户查询最相关的文档;第二步 Node Postprocessing 表示后处理,这是在检索到结果后对其进行重排序、转换或过滤的过程;第三步 Response Synthesis 是指将用户查询、最相关的文档片段以及提示组合在一起发送到大模型以生成响应;查询的每个步骤 LlamaIndex 都内置了多种不同的策略,也可以完全由用户定制;
    • LlamaIndex 还支持多种不同的查询结合使用,它通过 路由器(Routers) 来做选择,确定要使用哪个查询,从而满足更多的应用场景。

通过上面的学习,我们对 LlamaIndex 中的各个组件的概念已经有了一个大致的了解,可以结合官网的 LearnUse CasesComponent Guides 等文档学习 LlamaIndex 的更多功能。

高级 RAG 技巧

基于 LlamaIndex,我们只用了 5 行代码就实现了一个简单的 RAG 系统,可以看出,这是朴素 RAG 的基本思路。这一节我们将继续学习高级 RAG 技巧,争取对每一种技巧都进行实战验证,带大家一窥 RAG 的技术全貌。

下图展示了高级 RAG 涉及的核心步骤和算法:

advanced-rag.jpeg

LangChain 的 这篇博客 对这些步骤进行详细的讨论。

查询转换(Query Transformations)

RAG 系统面临的第一个问题就是如何处理用户输入,我们知道,RAG 的基本思路是根据用户输入检索出最相关的内容,但是用户输入是不可控的,可能存在冗余、模糊或歧义等情况,如果直接拿着用户输入去检索,效果可能不理想。

查询转换(Query Transformations) 是一组旨在修改用户输入以改善检索的方法,使检索对用户输入的变化具有鲁棒性。可参考 LangChain 的 这篇博客 和 LlamaIndex 的 这份文档这份指南

查询扩展(Query Expansion)

假设你的知识库中包含了各个公司的基本信息,考虑这样的用户输入:微软和苹果哪一个成立时间更早? 要获得更好的检索效果,我们可以将其拆解成两个用户输入:微软的成立时间苹果的成立时间,这种将用户输入分解为多个子问题的方法被称为 查询扩展(Query Expansion)

再考虑另一个用户输入:哪个国家赢得了 2023 年的女子世界杯?该国的 GDP 是多少?,和上面的例子一样,我们也需要通过查询扩展将其拆分成两个子问题,只不过这两个子问题是有依赖关系的,我们需要先查出第一个子问题的答案,然后才能查第二个子问题。也就是说,上面的例子中我们可以并行查询,而这个例子需要串行查询。

查询扩展有多种不同的实现,比如:

多查询检索器(Multi Query Retriever)

MultiQueryRetriever 是 LangChain 中的一个类,可根据用户输入生成子问题,然后依次进行检索,最后将检索到的文档合并返回。

MultiQueryRetriever 不仅可以从原始问题中拆解出子问题,还可以对同一问题生成多个视角的提问,比如用户输入:What are the approaches to Task Decomposition?,大模型可以对这个问题生成多个角度的提问,比如:

  1. How can Task Decomposition be approached?
  2. What are the different methods for Task Decomposition?
  3. What are the various approaches to decomposing tasks?

MultiQueryRetriever 默认使用的 Prompt 如下:

You are an AI language model assistant. Your task is 
to generate 3 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 distance-based similarity search. Provide these alternative 
questions separated by newlines. Original question: {question}

我们可以在此基础上稍作修改,就可以实现子问题拆解:

你是一个 AI 语言助手,你的任务是将用户的问题拆解成多个子问题便于检索,多个子问题以换行分割,保证每行一个。
用户的原始问题为:{question}

在 LlamaIndex 中可以通过 Multi-Step Query EngineSub Question Query Engine 实现类似的多查询检索。

RAG 融合(RAG Fusion)

RAG FusionMultiQueryRetriever 基于同样的思路,生成子问题并检索,它对检索结果执行 倒数排名融合(Reciprocal Rank Fusion,RRF) 算法,使得检索效果更好。它的大致流程如下:

rag-fusion.png

可以分为四个步骤:

  • 首先,通过大模型将用户的问题转换为相似但不同的问题,例如,“气候变化的影响” 生成的问题可能包括 “气候变化的经济后果”、“气候变化和公共卫生” 等角度;
  • 其次,对原始问题和新生成的问题执行并发的向量搜索;
  • 接着,使用 RRF 算法聚合和细化所有结果;
  • 最后,将所有的问题和重新排序的结果丢给大模型,引导大模型进行有针对性的输出。

其中生成问题的逻辑和 MultiQueryRetriever 别无二致,聚合和重排序的逻辑我们在后处理部分再做讨论。

这里 是 RAG Fusion 原作者的基本实现,这里 是基于 LangChain 的实现。

后退提示(Step-Back Prompting)

后退提示(Step-Back Prompting) 是 Google DeepMind 团队在论文 Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models 中提出的一种新的提示技术,我在 之前的笔记中 已经介绍过后退提示的基本原理。总的来说,它基于用户的原始问题生成一个后退问题,后退问题相比原始问题具有更高级别的概念或原则,从而提高解决复杂问题的效果,例如一个关于物理学的问题可以后退为一个关于该问题背后的物理原理的问题,然后对原始问题和后退问题进行检索。

很显然,后退提示也可以在 RAG 中作为一种查询扩展的方法,这里 是基于后退提示实现 RAG 问答的一个示例,其中生成后退问题的 Prompt 如下:

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:
假设性文档嵌入(Hypothetical Document Embeddings,HyDE)

当我们使用基于相似性的向量检索时,在原始问题上进行检索可能效果不佳,因为它们的嵌入可能与相关文档的嵌入不太相似,但是,如果让大模型生成一个假设的相关文档,然后使用它来执行相似性检索可能会得到意想不到的结果。这就是 假设性文档嵌入(Hypothetical Document Embeddings,HyDE) 背后的关键思想。

HyDE 是 Luyu Gao 在 Precise Zero-Shot Dense Retrieval without Relevance Labels 这篇论文中提出的一种方法,它的思路非常有意思,首先通过大模型为用户问题生成答案,不管答案是否正确,然后计算生成的答案的嵌入,并进行向量检索,生成的答案虽然可能是错误的,但是通过它却可能比原问题更好地检索出正确的答案片段。

这里 是 LangChain 通过 HyDE 生成假设性文档的示例。

LlamaIndex 也提供了一个类 HyDEQueryTransform 来实现 HyDE,这里 是示例代码,同时文档也提到了使用 HyDE 可能出现的两个失败场景:

  1. 在没有上下文的情况下,HyDE 可能会对原始问题产出误解,导致检索出误导性的文档;比如用户问题是 “What is Bel?”,由于大模型缺乏上下文,并不知道 Bel 指的是 Paul Graham 论文中提到的一种编程语言,因此生成的内容和论文完全没有关系,导致检索出和用户问题没有关系的文档;
  2. 对开放式的问题,HyDE 可能产生偏见;比如用户问题是 “What would the author say about art vs. engineering?”,这时大模型会随意发挥,生成的内容可能带有偏见,从而导致检索的结果也带有偏见;

通过查询扩展不仅可以将用户冗余的问题拆解成多个子问题,便于更精确的检索;而且可以基于用户的问题生成更多角度的提问,这意味着对用户问题进行全方位分析,加大了搜索范围,所以会检索出更多优质内容。

但是查询扩展的最大缺点是太慢,而且费钱,因为需要大模型来生成子问题,这属于时间换效果,而且生成多个问题容易产生漂移,导致大模型输出的内容过于详细甚至偏题。

查询重写(Query Rewriting)

用户输入可能表达不清晰或措辞不当,一个典型的例子是用户输入中包含大量冗余的信息,看下面这个例子:

hi there! I want to know the answer to a question. is that okay? 
lets assume it is. my name is harrison, the ceo of langchain. 
i like llms and openai. who is maisie peters?

我们想要回答的真正问题是 “who is maisie peters?”,但用户输入中有很多分散注意力的文本,如果直接拿着原始文本去检索,可能检索出很多无关的内容。为解决这个问题,我们可以不使用原始输入,而是从用户输入生成搜索查询。Xinbei Ma 等人提出了一种 Rewrite-Retrieve-Read 的方法,对用户的输入进行改写,以改善检索效果,这里是论文地址,实现方法其实很简单,通过下面的 Prompt 让大模型基于用户的输入给出一个更好的查询:

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

具体实现可以参考 LangChain 的这个 cookbook

除了处理表达不清的用户输入,查询重写还经常用于处理聊天场景中的 后续问题(Follow Up Questions)。比如用户首先问 “合肥有哪些好玩的地方?”,接着用户又问 “那里有什么好吃的?”,如果直接用最后一句话进行嵌入和检索,就会丢失 “合肥” 这样的重要信息,这时,我们就可以用大模型来做问题重写来解决这个问题。

在开源网页搜索助手 WebLangChain 中,使用了如下的 Prompt 来实现问题重写:

Given the following conversation and a follow up question, rephrase the follow up \
question to be a standalone question.

Chat History:
{chat_history}
Follow Up Input: {question}
Standalone Question:

查询压缩(Query Compression)

在一些 RAG 应用程序中,用户可能是以聊天对话的形式与系统交互的,为了正确回答用户的问题,我们需要考虑完整的对话上下文,为了解决这个问题,可以将聊天历史压缩成最终问题以便检索,可以 参考这个 Prompt

查询路由(Routing)

在经过第一步查询转换后,我们已经将用户问题转换成易于检索的形式,接下来我们就要开始检索了。但是从哪里检索呢?有很多 RAG 示例都是从单一数据存储中检索。但是为了更好的组织数据,我们通常会将不同的数据存储在不同的库中;在真正的生产环境中,情况可能会更复杂,数据甚至可能存储在多个不同种类的库中,比如,向量数据库,关系型数据库,图数据库,甚至是 API 接口。这时我们需要对传入的用户问题进行动态路由,根据不同的用户问题检索不同的库。

这篇教程 介绍了 LangChain 中实现路由的两种方式,第一种方式是使用大模型将用户问题路由到一组自定义的子链,这些子链可以是不同的大模型,也可以是不同的向量存储,LangChain 提供了 RunnableLambdaRunnableBranch 两个类帮助我们快速实现这个功能,其中 RunnableLambda 是推荐的做法,用户可以在 route 方法中自定义路由逻辑:

def route(info):
    if "anthropic" in info["topic"].lower():
        return anthropic_chain
    elif "langchain" in info["topic"].lower():
        return langchain_chain
    else:
        return general_chain

from langchain_core.runnables import RunnableLambda

full_chain = {"topic": chain, "question": lambda x: x["question"]} | RunnableLambda(
    route
)
print(full_chain.invoke({"question": "how do I use Anthropic?"}))

另一种方法是计算用户问题和子链 Prompt 的嵌入向量,将最相似的子链作为下一步路由:

def prompt_router(input):
    query_embedding = embeddings.embed_query(input["query"])
    similarity = cosine_similarity([query_embedding], prompt_embeddings)[0]
    most_similar = prompt_templates[similarity.argmax()]
    print("Using MATH" if most_similar == math_template else "Using PHYSICS")
    return PromptTemplate.from_template(most_similar)

可以看到 LangChain 的路由功能非常地原始,连路由的 Prompt 都需要用户自己定义。相比来说,LlamaIndex 的路由器 显得就要高级得多,它可以根据用户的输入从一堆带有元数据的选项中动态地选择一个或多个。

LlamaIndex 将动态选择的过程抽象为选择器,并且内置了一些选择器,比如 LLMSingleSelectorLLMMultiSelector 通过 Prompt 让大模型返回一个或多个选项,PydanticSingleSelectorPydanticMultiSelector 则是通过 Function Call 功能来实现的。这里选择的选项可以是 查询引擎(Query Engines)检索器(Retrievers),甚至是任何用户自定义的东西,下面的代码演示了如何使用 LlamaIndex 的 RouterQueryEngine 实现根据用户的输入在多个查询引擎中动态选择其中一个:

# convert query engines to tools
list_tool = QueryEngineTool.from_defaults(
    query_engine=list_query_engine,
    description="Useful for summarization questions related to Paul Graham eassy on What I Worked On.",
)

vector_tool = QueryEngineTool.from_defaults(
    query_engine=vector_query_engine,
    description="Useful for retrieving specific context from Paul Graham essay on What I Worked On.",
)

# routing engine tools with a selector
query_engine = RouterQueryEngine(
    selector=PydanticSingleSelector.from_defaults(),
    query_engine_tools=[
        list_tool,
        vector_tool,
    ],
)

response = query_engine.query("What is the summary of the document?")

和 RouterQueryEngine 类似,使用 RouterRetriever 可以根据用户的输入动态路由到不同的检索器。此外,LlamaIndex 官方还有一些路由器的其他示例,比如 SQL Router Query Engine 这个示例演示了自定义路由器来路由到 SQL 数据库或向量数据库;Retriever Router Query Engine 这个示例演示了使用 ToolRetrieverRouterQueryEngine 来解决选项过多可能导致超出大模型 token 限制的问题。

查询构造(Query Construction)

我们面临的第三个问题是:使用什么语法来检索数据?在上一步中,我们知道数据可能存储在关系型数据库或图数据库中,根据数据的类型,我们将其分为结构化、半结构化和非结构化三大类:

  • 结构化数据:主要存储在 SQL 或图数据库中,结构化数据的特点是具有预定义的模式,并且以表格或关系的形式组织,使其适合进行精确的查询操作;
  • 半结构化数据:半结构化数据将结构化元素(例如文档中的表格或关系数据库)与非结构化元素(例如文本或关系数据库中的嵌入列)相结合;
  • 非结构化数据:通常存储在向量数据库中,非结构化数据由没有预定义模型的信息组成,通常伴随着结构化元数据,以便进行过滤。

将自然语言与各种类型的数据无缝连接是一件极具挑战的事情。要从这些库中检索数据,必须使用特定的语法,而用户问题通常都是用自然语言提出的,所以我们需要将自然语言转换为特定的查询语法。这个过程被称为 查询构造(Query Construction)

根据数据存储和数据类型的不同,查询构造可以分为以下几种常见的场景:

query-construction.png

Text-to-SQL

将自然语言翻译成 SQL 是一个非常热门的话题,已经有不少人对此展开了研究。通过向 LLM 提供一个自然语言问题以及相关的数据库表信息,可以轻松地完成文本到 SQL 的转换。

这个过程虽然简单,不过也有不少值得注意的问题和小技巧:

  • 大模型擅长写 SQL,但是写出来的 SQL 往往出现表名或字段名对应不上的情况;

解决方法是将你的数据库信息详细地告诉大模型,包括数据表的描述信息,有哪些字段,字段类型分别是什么,表中有什么样的数据,等等。Nitarshan Rajkumar 等人在 Evaluating the Text-to-SQL Capabilities of Large Language Models 这篇论文中发现,对于 OpenAI Codex 模型来说,使用 CREATE TABLE 语句来描述数据库表信息可以得到最佳性能,此外,在 CREATE TABLE 语句后通过一条 SELECT 语句附加 3 行表中的数据样本,可以进一步改善大模型生成 SQL 的效果。

LangChain 提供的 SQLDatabase 类可以方便地得到这些信息:

db = SQLDatabase.from_uri(
    "sqlite:///Chinook.db",
    include_tables=['Track'],
    sample_rows_in_table_info=3
)
print(db.table_info)

输出结果如下:

CREATE TABLE "Track" (
  "TrackId" INTEGER NOT NULL,
  "Name" NVARCHAR(200) NOT NULL,
  "AlbumId" INTEGER,
  "MediaTypeId" INTEGER NOT NULL,
  "GenreId" INTEGER,
  "Composer" NVARCHAR(220),
  "Milliseconds" INTEGER NOT NULL,
  "Bytes" INTEGER,
  "UnitPrice" NUMERIC(10, 2) NOT NULL,
  PRIMARY KEY ("TrackId"),
  FOREIGN KEY("MediaTypeId") REFERENCES "MediaType" ("MediaTypeId"),
  FOREIGN KEY("GenreId") REFERENCES "Genre" ("GenreId"),
  FOREIGN KEY("AlbumId") REFERENCES "Album" ("AlbumId")
)
SELECT * FROM 'Track' LIMIT 3;
TrackId    Name    AlbumId    MediaTypeId    GenreId    Composer    Milliseconds    Bytes    UnitPrice
1    For Those About To Rock (We Salute You)    1    1    1    Angus Young, Malcolm Young, Brian Johnson    343719    11170334    0.99
2    Balls to the Wall    2    2    1    None    342562    5510424    0.99
3    Fast As a Shark    3    2    1    F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman    230619    3990994    0.99

有时候,前 3 行数据不足以完整地表达出表中数据的样貌,这时我们可以手工构造数据样本;有时候,表中数据存在敏感信息,我们也可以使用伪造的假数据来代替真实情况。

使用 LangChain 提供的 create_sql_query_chain 可以方便地实现 Text-to-SQL 功能:

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

db = SQLDatabase.from_uri("sqlite:///./sqlite/Chinook.db")
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
chain = create_sql_query_chain(llm, db)
response = chain.invoke({"question": "How many employees are there"})

使用 LangChain 提供的 create_sql_agent 可以实现更智能的 Text-to-SQL 功能,包括 SQL 的生成,检查,执行,重试等:

from langchain_community.utilities import SQLDatabase
from langchain_openai import ChatOpenAI
from langchain_community.agent_toolkits import create_sql_agent

db = SQLDatabase.from_uri("sqlite:///./sqlite/Chinook.db")
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
agent_executor = create_sql_agent(llm, db=db, agent_type="openai-tools", verbose=True)
response = agent_executor.invoke({
    "input": "List the total sales per country. Which country's customers spent the most?"
})

具体内容可以参考 LangChain 的文档 Q&A over SQL + CSV

LlamaIndex 的 NLSQLTableQueryEngine 同样可以实现类似的 Text-to-SQL 功能:

from llama_index.llms.openai import OpenAI
from sqlalchemy import create_engine
from llama_index.core import SQLDatabase
from llama_index.core.query_engine import NLSQLTableQueryEngine

llm = OpenAI(temperature=0.1, model="gpt-3.5-turbo")

engine = create_engine("sqlite:///./sqlite/Chinook.db")
sql_database = SQLDatabase(engine, include_tables=["Employee"])

query_engine = NLSQLTableQueryEngine(
    sql_database=sql_database, tables=["Employee"], llm=llm
)
response = query_engine.query("How many employees are there?")
  • 数据库表过多,或查询结果过多,超出大模型的限制;

为了大模型能生成准确的 SQL,我们必须将数据库表的信息完整的送入大模型的上下文中,如果数据库表或列过多,就会超出大模型的 token 限制。这时,我们必须找到方法,动态地仅插入最相关的信息到提示中。我们可以使用 LangChain 内置的 create_extraction_chain_pydantic 链来实现这点,它通过 OpenAI 的 funtion call 功能动态地挑出和用户问题最相关的表,然后再基于这些表生成 SQL 语句;LlamaIndex 的 SQLTableRetrieverQueryEngine 也实现了同样的功能,它通过为每个表生成一个嵌入向量来实现这一点。

此外,生成 SQL 并执行后,我们通常需要将执行结果送到大模型的上下文中,以便它能回答用户的问题。但是如果查询结果过多,同样会超出大模型的 token 限制。因此,我们要对 SQL 输出的大小合理地进行限制,比如让大模型尽可能少地使用列并限制返回行数来实现这一点。

  • 大模型编写的 SQL 可能存在语法错误无法执行;

如果在执行大模型生成的 SQL 时出现语法错误,可以参考我们人类自己是如何解决这类问题的。通常我们会查看报错信息,然后去查询关于报错信息的资料,以便对错误的语法进行纠正。这篇博客 介绍了如何通过 Prompt 让大模型自动地做到这一点,将原始查询和报错信息发送给大模型,并要求它纠正,大模型就可以理解出了什么问题,从而生成更加精准的 SQL 查询。下面是作者所使用的 Prompt:

error_prompt = f"""{query.sql}

The query above produced the following error:

{query.error}

Rewrite the query with the error fixed:"""

这里 是基于 LangChain 的实现。


以上三点是处理 Text-to-SQL 时要面对的基本问题和解决思路,还有一些优化方法可以进一步地提高 Text-to-SQL 的效果:

  • 使用少样本示例

Nitarshan Rajkumar 等人的研究 中,他们发现给大模型一些问题和对应 SQL 查询的示例,可以提高 SQL 生成的准确性;LangChain 的这个示例 中介绍了如何构造 SQL 查询的少样本示例,以及如何通过 SemanticSimilarityExampleSelector 根据用户的问题动态的选择不同的少样本示例。

  • 使用子查询

一些用户发现,让大模型将问题分解成多个子查询,有助于得到正确答案,如果让大模型对每个子查询进行注释,效果更好,这有点类似于之前学习过的 CoT 或 PoT 等提示技术,将一个大问题拆分成多个子查询,会迫使大模型按逻辑逐步思考,而且每一步相对来说更简单,从而出错概率降低。

  • 处理高基数列(High-cardinality columns)

高基数列(High-cardinality columns) 是指一个数据列中包含的不同数值的个数较多,即列中数据的唯一性较高,重复率较低,比如姓名、地址、歌曲名称等这些专有名词的列。如果生成的 SQL 语句中包含这样的列,我们首先需要仔细检查拼写,以确保能正确地过滤数据,因为用户输入这些名称时往往会使用一些别名或拼写错误。

由于高基数列中的数据基本上不重复或者重复率非常低,所以我们可以想办法将用户的输入关联到正确的名称上,从而实现准确的查询。最简单的做法是创建一个向量存储,将数据库中存在的所有专有名词的向量存储进去,然后就可以计算和用户输入最接近的专有名词。这里这里 是基于 LangChain 的代码示例。

Text-to-SQL + Semantic

通过 Text-to-SQL 可以很好的回答关于结构化数据的问题,比如:公司一共有多少员工,公司里男女员工比例是多少,等等;但是有些用户问题不仅要对结构化字段进行过滤查询,还需要对非结构化字段进行语义检索,比如:1980 年上映了哪些有关外星人的电影?我们不仅要使用 year == 1980 对电影的上映年份进行过滤,还需要根据 外星人 从电影名称或描述中进行语义检索。

在关系型数据库中添加向量支持是实现混合数据检索的关键,这种混合类型的数据被称为 半结构化数据(semi-structured data),也就是说既有结构化数据,也有非结构化数据。比如使用 PostgreSQL 的 Pgvector 扩展 可以在表中增加向量列,这让我们可以使用自然语言与这些半结构化数据进行交互,将 SQL 的表达能力与语义检索相结合。

Pgvector 通过 <-> 运算符在向量列上进行相似性检索,比如下面的 SQL 用于查询名称最为伤感的 3 首歌曲:

SELECT * FROM tracks ORDER BY name_embedding <-> {sadness_embedding} LIMIT 3;

也可以将语义检索和正常的 SQL 查询结合,比如下面的 SQL 用于查询 1980 年上映的有关外星人的电影:

SELECT * FROM movies WHERE year == 1980 ORDER BY name_embedding <-> {aliens_embedding} LIMIT 5;

Pgvector 也支持内积(<#>)、余弦距离(<=>)和 L1 距离(<+>)等运算符。

为了让大模型准确使用 Pgvector 的向量运算符,我们需要在 Prompt 里将 Pgvector 的语法告诉大模型,可以参考 Incoporating semantic similarity in tabular databases 这篇教程里的实现:

...

You can use an extra extension which allows you to run semantic similarity using <-> operator 
on tables containing columns named "embeddings".
<-> operator can ONLY be used on embeddings columns.
The embeddings value for a given row typically represents the semantic meaning of that row.
The vector represents an embedding representation of the question, given below. 
Do NOT fill in the vector values directly, but rather specify a `[search_word]` placeholder, 
which should contain the word that would be embedded for filtering.
For example, if the user asks for songs about 'the feeling of loneliness' the query could be:
'SELECT "[whatever_table_name]"."SongName" FROM "[whatever_table_name]" ORDER BY "embeddings" <-> '[loneliness]' LIMIT 5'

...

这篇教程详细介绍了如何使用 LangChain 实现基于 Pgvector 的语义检索,并将 Text-to-SQL + Semantic 总结为三种场景:

  • 基于向量列的语义过滤:比如 查询名称最为伤感的 3 首歌曲
  • 结合普通列的过滤和向量列的语义过滤:比如 查询 1980 年上映的有关外星人的电影
  • 结合多个向量列的语义过滤:比如:从名称可爱的专辑中获取 5 首伤感的歌曲

在 LlamaIndex 中,也有一个 PGVectorSQLQueryEngine 类用于实现 Pgvector 的语义检索,参考 Text-to-SQL with PGVector 这篇教程。

Text-to-metadata filters

很多向量数据库都具备 元数据过滤(metadata filters) 的功能,这和关系型数据库的半结构化数据很像(参考上面的 Text-to-SQL + Semantic 一节),可以把带元数据的向量数据库看成有一个向量列的关系型数据表。下面是 Chroma 的一个带元数据过滤的查询示例:

collection.query(
    query_texts=["query1", "query2"],
    n_results=10,
    where={"metadata_field": "is_equal_to_this"},
    where_document={"$contains":"search_string"}
)

Chroma 不仅支持 query_texts 参数实现语义检索,还支持 where 参数实现类似 SQL 的结构化过滤,为了生成这样的查询语法,我们可以使用 LangChain 提供的 自查询检索器(Self Query Retriever)

document_content_description = "Brief summary of a movie"
metadata_field_info = [
    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"),
]

retriever = SelfQueryRetriever.from_llm(
    llm, vectorstore, document_content_description, metadata_field_info, verbose=True
)
response = retriever.invoke("What are some movies about dinosaurs")

首先我们对整个文档以及文档包含的元数据字段做一个大致的描述,然后通过 SelfQueryRetriever.from_llm() 构造自查询检索器,检索器可以对自然语言问题进行解释,将问题转换成用于语义检索的查询语句(被称为 Query)和用于元数据过滤的过滤器语法(被称为 Filters),由于 LangChain 集成了大量的向量数据库,每个向量数据库的过滤器语法都可能不一样,所以 LangChain 设计了一套中间语法,让大模型根据这套语法规则生成过滤器语句,然后通过 StructuredQueryOutputParser 将过滤器语句解析为 StructuredQuery 对象(使用 lark-parser 实现),再由各个向量数据库的 structured_query_translator 将其转换为各自的查询语法。

如果对这套中间语法感兴趣,可以使用 get_query_constructor_prompt() 查看 SelfQueryRetriever 内置的 Prompt:

from langchain.chains.query_constructor.base import get_query_constructor_prompt

prompt = get_query_constructor_prompt(document_content_description, metadata_field_info)
print(prompt.format(query="dummy question"))

通过这个 Prompt 我们可以手动构造 StructuredQuery 对象:

from langchain.chains.query_constructor.base import StructuredQueryOutputParser

output_parser = StructuredQueryOutputParser.from_components()
query_constructor = prompt | llm | output_parser

response = query_constructor.invoke({
 "query": "Songs by Taylor Swift or Katy Perry about teenage romance under 3 minutes long in the dance pop genre"
})

生成的过滤器语法类似于下面这样:

and(
    or(
        eq("artist", "Taylor Swift"), 
        eq("artist", "Katy Perry")
    ), 
    lt("length", 180), 
    eq("genre", "pop")
)

具体内容可以 参考这里,除此之外,Building hotel room search with self-querying retrieval 这篇教程使用自查询检索器实现了酒店数据的问答,感兴趣的同学可以一并参考。

同样,在 LlamaIndex 中也支持对向量数据库进行元数据过滤,这个功能被叫做 Auto-Retrieval,并抽象成 VectorIndexAutoRetriever 类,同时,LlamaIndex 也对不少的向量数据库做了集成,比如 PineconeChromaElasticsearchVectaraLanternBagelDB 等。

下面是 VectorIndexAutoRetriever 的使用示例,和 SelfQueryRetriever 很像:

from llama_index.core.vector_stores.types import MetadataInfo, VectorStoreInfo
from llama_index.core.retrievers import VectorIndexAutoRetriever

vector_store_info = VectorStoreInfo(
    content_info="brief biography of celebrities",
    metadata_info=[
        MetadataInfo(name="category", type="str", description="Category of the celebrity, one of [Sports, Entertainment, Business, Music]"),
        MetadataInfo(name="country", type="str", description="Country of the celebrity, one of [United States, Barbados, Portugal]"),
    ],
)

retriever = VectorIndexAutoRetriever(
    index, vector_store_info=vector_store_info
)

response = retriever.retrieve("Tell me about Sports celebrities from United States")

和 Text-to-SQL 一样,元数据过滤也面临着大模型生成的过滤条件可能和库中的元数据无法完全匹配的问题,比如:库中的字段是大写,而用户的输入是小写,库中的字段是全称,而用户的输入是简称,这时我们也可以借鉴 Text-to-SQL 中的优化手段,比如自定义 Prompt 或 根据用户输入动态选择样本,这里 是 LlamaIndex 的示例。此外,LlamaIndex 官网还有一篇使用元数据过滤实现 多文档检索(或者叫结构化分层检索)) 的示例。

Text-to-Cypher

向量数据库可以轻松处理非结构化数据,但它们无法理解向量之间的关系;SQL 数据库可以建模表之间的关系,但是却不擅长建模数据之间的关系,特别是多对多关系或难以在表格形式中表示的层次结构的数据;图数据库可以通过建模数据之间的关系并扩展关系类型来解决这些挑战。

和 SQL 一样,Cypher) 是一种对图数据库进行查询的结构化查询语言。LangChain 中提供的 GraphCypherQAChain 让我们可以方便地将自然语言翻译成 Cypher 语言,从而实现基于图数据库的问答:

from langchain_openai import ChatOpenAI
from langchain.chains import GraphCypherQAChain

chain = GraphCypherQAChain.from_llm(
    ChatOpenAI(temperature=0), graph=graph, verbose=True
)
response = chain.invoke({"query": "Who played in Top Gun?"})

值得注意的是,Cypher 是最流行的图数据库查询语言之一,可以用在很多不同的图数据库中,比如 Neo4jAmazon Neptune 等等,但是还有很多图数据库使用了其他的查询语言,比如 Nebula Graph 使用的是 nGQL,HugeGraph 使用的是 Gremlin 等等,我们在编写 Prompt 的时候也要稍加区别。

和 LangChain 一样,LlamaIndex 也支持图数据库的问答,我们可以使用 KnowledgeGraphRAGRetriever 来实现,它的用法如下:

from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.retrievers import KnowledgeGraphRAGRetriever
graph_rag_retriever = KnowledgeGraphRAGRetriever(storage_context=storage_context, verbose=True)
query_engine = RetrieverQueryEngine.from_args(
    graph_rag_retriever,
)

不过要注意的是,这里对图数据库的查询实现和 LangChain 是不同的,KnowledgeGraphRAGRetriever 通过从用户问题中提取相关 实体(Entity),然后在图数据库中查询和这些实体有关联的子图(默认深度为 2,查询的模式可以是 embedding 或 keyword),从而构建出上下文,大模型基于查询出的子图来回答用户问题,所以这也被称为 (Sub)Graph RAG

LlamaIndex 也支持 Text-to-Cypher 方式基于用户问题生成图查询语句,我们可以使用 KnowledgeGraphQueryEngine 来实现:

from llama_index.core.query_engine import KnowledgeGraphQueryEngine
query_engine = KnowledgeGraphQueryEngine(
    storage_context=storage_context,
    llm=llm,
    graph_query_synthesis_prompt=graph_query_synthesis_prompt,
    verbose=True,
)

不过当前的版本(0.10.25)支持得还不是很好,用户必须编写出合适的 Prompt 来能生成正确的 Cypher 语句。

LlamaIndex 也集成了不同的图数据库,比如 Neo4j Graph StoreNebula Graph Store

索引(Indexing)

上面三步都是关于检索的,包括从哪里检索以及如何检索。第四个要考虑的问题是怎么存储我的数据?怎么设计我的索引?通过上面的学习我们知道,可以将数据存储到向量数据库、SQL 数据库或者图数据库中,针对这些不同的存储方式,我们又可以使用不同的索引策略。

构建向量索引

构建向量索引是打造 RAG 系统中的关键步骤之一。在上面的 LlamaIndex 实战一节,我们使用 VectorStoreIndex 快速将文档构建成向量索引:

from llama_index.core import VectorStoreIndex
index = VectorStoreIndex.from_documents(documents)

默认情况下 VectorStoreIndex 将向量保存到内存中,可以通过 StorageContext 指定 Vector Store 将向量保存到向量数据库中,LlamaIndex 集成了大量的 Vector Store 实现,比如下面是集成 Chroma 的示例:

import chromadb
chroma_client = chromadb.EphemeralClient()
chroma_collection = chroma_client.create_collection("quickstart")

from llama_index.core import StorageContext
from llama_index.vector_stores.chroma import ChromaVectorStore
storage_context = StorageContext.from_defaults(
    vector_store=ChromaVectorStore(chroma_collection=chroma_collection)
)

from llama_index.core import VectorStoreIndex
index = VectorStoreIndex.from_documents(
    documents, storage_context=storage_context
)

很多向量数据库还支持元数据功能,我们可以将元数据与向量一起存储,然后使用元数据过滤器搜索某些日期或来源的信息,这在上面的 Text-to-metadata filters 一节中已经介绍过,此处略过。

LangChain 中没有 Index 和 StorageContext 概念,只有 Vector Store 的概念,所以 LangChain 构建向量索引的步骤看上去要精简的多:

from langchain_chroma import Chroma
db = Chroma.from_documents(documents, OpenAIEmbeddings())

构建向量索引有两个绕不开的话题,分块(Chunking)和嵌入(Embedding),下面将分节介绍。

分块策略(Chunking)

几乎所有的大模型或嵌入模型,输入长度都是受限的,因此,你需要将文档进行分块,通过分块不仅可以确保我们嵌入的内容尽可能少地包含噪音,同时保证嵌入内容和用户查询之间具有更高的语义相关性。有很多种不同的分块策略,比如你可以按长度进行分割,保证每个分块大小适中,你也可以按句子或段落进行分割,防止将完整的句子切成两半。每种分块策略可能适用于不同的情况,我们要仔细斟酌这些策略的优点和缺点,确定他们的适用场景,这篇博客 对常见的分块策略做了一个总结。

文档分块是索引构建中的关键步骤,无论是 LangChain 还是 LlamaIndex 都提供了大量的文档分块的方法,可以参考 LangChain 的 Text SplittersLlamaIndex 的 Node Parser 或 Text Splitters 文档。

固定大小分块(Fixed-size chunking)

这是最常见也是最直接的分块策略,文档被分割成固定大小的分块,分块之间可以保留一些重叠,以确保不会出现语义相关的内容被不自然地拆分的情况。在大多数情况下,固定大小分块都是最佳选择,与其他形式的分块相比,它既廉价又简单易用,而且不需要使用任何自然语言处理库。

分块大小是一个需要深思熟虑的参数,它取决于你所使用的嵌入模型的 token 容量,比如,基于 BERT 的 sentence-transformer 最多只能处理 512 个 token,而 OpenAI 的 ada-002 能够处理 8191 个;另外这里也需要权衡大模型的 token 限制,由于分块大小直接决定了我们加载到大模型上下文窗口中的信息量,这篇博客 中对不同的分块大小进行了实验,可以看到不同的分块大小可以得到不同的性能表现。

在 LangChain 中,我们可以使用 CharacterTextSplitterRecursiveCharacterTextSplitter 实现固定大小分块:

from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
    separator = "\n\n",
    chunk_size = 256,
    chunk_overlap  = 20
)
docs = text_splitter.create_documents([text])

可以看到,分块参数中除了分块大小(chunk_size)和分块间的重叠(chunk_overlap)两个配置之外,还有一个分隔符(separator)参数,CharacterTextSplitter 首先会按照分隔符进行分割,再对分割后的内容按大小分割,默认的分隔符是 \n\n,这样可以保证不同的段落会被划分到不同的分块里,提高分块的效果。

RecursiveCharacterTextSplitter 被称为 递归分块(Recursive chunking),和 CharacterTextSplitter 的区别是它可以接受一组分隔符,比如 ["\n\n", "\n", " ", ""],它首先使用第一个分隔符对文本进行分块,如果第一次分块后长度仍然超出分块大小,则使用第二个,以此类推,通过这种递归迭代的过程,直到达到所需的块大小。

LlamaIndex 中的 TokenTextSplitterSentenceSplitter 实现类似的功能,不过它没有递归分块的功能,只是简单的将分隔符分成单词间分隔符和段落间分隔符两个参数:

from llama_index.core.node_parser import SentenceSplitter
node_parser = SentenceSplitter(
    separator=" ",
    paragraph_separator="\n\n",
    chunk_size=512, 
    chunk_overlap=0
)
nodes = node_parser.get_nodes_from_documents(docs, show_progress=False)

此外,使用固定大小分块时有一点要注意的是,大模型的上下文限制是 token 数量,而不是文本长度,因此当我们将文本分成块时,建议计算分块的 token 数量,比如使用 OpenAI 的 tiktoken 库。LangChain 中可以使用 TokenTextSplitterCharacterTextSplitter.from_tiktoken_encoder() 来保证分块大小不超过 token 限制:

text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding="cl100k_base", chunk_size=100, chunk_overlap=0
)
texts = text_splitter.split_text(state_of_the_union)
句子拆分(Sentence splitting)

很多模型都针对句子级内容的嵌入进行了优化,所以,如果我们能将文本按句子拆分,可以得到很好的嵌入效果。常见的句子拆分方法有下面几种:

  • 直接按英文句号(.)、中文句号()或换行符等进行分割

这种方法快速简单,但这种方法不会考虑所有可能的边缘情况,可能会破坏句子的完整性。使用上面所介绍的 CharacterTextSplitterTokenTextSplitter 就可以实现。

NLTK 是一个流行的自然语言工具包,它提供了一个句子分词器(sentence tokenizer),可以将文本分割成句子,有助于创建更有意义的块。LangChain 中的 NLTKTextSplitter 就是基于 NLTK 实现的。

另外,LlamaIndex 中的 SentenceSplitterSentenceWindowNodeParser 也可以实现句子拆分,默认也是基于 NLTK 实现的。

spaCy 是另一个强大的用于自然语言处理任务的 Python 库,它提供了复杂的句子分割功能,可以高效地将文本分割成单独的句子,从而在生成的块中更好地保留上下文。LangChain 中的 SpacyTextSplitter 就是基于 spaCy 实现的。

LangChain 的 Split by tokens 这篇文档还介绍了一些其他方法可供参考。

特定格式分块(Specialized chunking)

有很多文本文件具有特定的结构化内容,比如 Markdown、LaTeX、HTML 或 各种源码文件等,针对这种格式的内容可以使用一些专门的分块方法。

  • Markdown 格式

Markdown 是一种轻量级标记语言,通常用于格式化文本,通过识别 Markdown 语法(例如标题、列表和代码块),可以根据其结构和层次智能地划分内容,从而产生更具语义一致性的块。LangChain 的 MarkdownHeaderTextSplitter 就是基于这一想法实现的分块方法,它通过 Markdown 的标题来组织分组,然后再在特定标题组中创建分块。

LlamaIndex 的 MarkdownNodeParserMarkdownElementNodeParser 提供了更精细化的分块,可以实现代码块或表格等元素的抽取。

  • HTML 格式

HTML 是另一种流行的标记语言,我们也可以根据 HTML 中的特殊标记(例如 <h1><h2><table> 等)对其进行分块,和 MarkdownHeaderTextSplitter 类似,LangChain 中的 HTMLHeaderTextSplitter 根据标题来实现 HTML 的分块,HTMLSectionSplitter 能够在元素级别上分割文本,它基于指定的标签和字体大小进行分割,将具有相同元数据的元素组合在一起,以便将相关文本语义地分组,并在文档结构中保留丰富的上下文信息。

LlamaIndex 的 HTMLNodeParser 使用 Beautiful Soup 解析 HTML,它使用一些预定义的标签来对 HTML 进行分块。

  • LaTeX 格式

LaTeX 是一种常用于学术论文和技术文档的文档准备系统和标记语言,通过解析 LaTeX 可以创建符合内容逻辑组织的块(例如章节、子章节和方程式),从而产生更准确和上下文相关的结果。LangChain 的 LatexTextSplitter 实现了 LaTex 格式的分块。

  • JSON 格式

JSON 格式的分块需要考虑嵌套的 JSON 对象的完整性,通常按照深度优先的方式遍历 JSON 对象,并构建出较小的 JSON 块,参考 LangChain 的 RecursiveJsonSplitter 和 LlamaIndex 的 JSONNodeParser

  • 其他代码格式

除了上面所说的 Markdown、HTML、JSON 等结构化文本,还有很多代码格式的文件,不同的编程语言拥有不同的关键字和语法,分块方式也略有区别。LangChain 为每种编程语言预定义了对应的分隔符,我们可以直接使用 RecursiveCharacterTextSplitter.from_language() 为特定语言创建文本分割器:

python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON, chunk_size=50, chunk_overlap=0
)
python_docs = python_splitter.create_documents([PYTHON_CODE])

SweepAI 的 Kevin Lu 提出了一种更加优雅的代码拆分解决方案,使用 AST 对代码语法进行解析,LlamaIndex 的 CodeSplitter 就是基于这种方案实现的。

语义分块(Semantic chunking)

这是一种实验性地分块技术,最初由 Greg Kamradt 提出,它在 The 5 Levels Of Text Splitting For Retrieval 这个视频中将分块技术划分为 5 个等级,其中 语义分块(Semantic chunking) 是第 4 级。它的基本原理如下:

  • 首先将文本划分成一个个句子,并计算第一个句子的向量;
  • 接着计算第二个句子的向量,并和第一个句子进行比较,得到相似度;
  • 接着计算第三个句子的向量,并和第二个句子进行比较,得到相似度,以此类推;
  • 当句子之间的相似度高于某个阈值时,说明这里的话题可能存在转折,可以将这个地方作为分块的临界点。

这里 是对应的代码实现。

LangChain 的 SemanticChunker 和 LlamaIndex 的 SemanticSplitterNodeParser 都实现了语义分块。

嵌入策略(Embedding)

分块完成后,我们接下来就要为每个分块计算 Embedding 向量,这里有很多嵌入模型可供选择,比如 BAAI 的 bge-large,微软的 multilingual-e5-large,OpenAI 的 text-embedding-3-large 等,可以在 MTEB 排行榜 上了解最新的模型更新情况。

词嵌入技术经历了一个从静态到动态的发展过程,静态嵌入为每个单词使用单一向量,而动态嵌入根据单词的上下文进行调整,可以捕获上下文理解。排行榜上排名靠前的基本上都是动态嵌入模型。

此外,关于嵌入模型的优化,通常围绕着嵌入模型的微调展开,将嵌入模型定制为特定领域的上下文,特别是对于术语不断演化或罕见的领域,可以参考下面的一些教程:

值得一提的是,嵌入不仅仅限于文本,我们还可以创建图像或音频的嵌入,并将其与文本嵌入进行比较,这个概念适用于强大的图像或音频搜索、分类、描述等系统。

构建图谱

在上面的查询构造一节,我们学习了如何实现 Text-to-Cypher,根据用户的问题生成图查询语句,从而实现图数据库的问答。查询构造依赖的是现有的图数据库,如果用户没有图数据库,数据散落在各种非结构化文档中,那么我们在查询之前可能还需要先对文档进行预处理,LlamaIndex 和 LangChain 都提供了相应的方法,让我们可以快速从杂乱的文档中构建出图谱数据。

LlamaIndex 可以通过 KnowledgeGraphIndex 实现:

from llama_index.core import KnowledgeGraphIndex
index = KnowledgeGraphIndex.from_documents(
    documents,
    storage_context=storage_context,
    max_triplets_per_chunk=10,
    space_name=space_name,
    edge_types=edge_types,
    rel_prop_names=rel_prop_names,
    tags=tags,
    include_embeddings=True,
)

KnowledgeGraphIndex 默认使用大模型自动从文档中抽取出实体以及他们之间的关系,也就是所谓的 三元组(Triplet),并将抽取出来的关系存入图数据库中,这个构建的过程可能会很长,构建完成后,就可以通过 index.as_query_engine() 将其转换为 RetrieverQueryEngine 来实现问答:

query_engine = index.as_query_engine(
    include_text=True, response_mode="tree_summarize"
)
response = query_engine.query("Tell me more about Interleaf")

此外,KnowledgeGraphIndex 还提供了一个 kg_triplet_extract_fn 参数,可以让用户自定义抽取三元组的逻辑:

index = KnowledgeGraphIndex.from_documents(
    documents, 
    kg_triplet_extract_fn=extract_triplets, 
    service_context=service_context
)

我们可以结合一些传统 NLP 里的关系抽取模型,比如 REBEL 来实现图谱构建,参考 Rebel + LlamaIndex Knowledge Graph Query EngineKnowledge Graph Construction w/ WikiData Filtering 这两个示例。

其中,documents 也可以设置成一个空数组,这样也可以实现基于现有的图数据库来问答,和 KnowledgeGraphRAGRetriever 的效果一样:

index = KnowledgeGraphIndex.from_documents([], storage_context=storage_context)

LangChain 也提供了一个类似的类 LLMGraphTransformer 来实现图谱构建:

from langchain_experimental.graph_transformers import LLMGraphTransformer

llm_transformer = LLMGraphTransformer(llm=llm)
graph_documents = llm_transformer.convert_to_graph_documents(documents)
graph.add_graph_documents(graph_documents)

其他索引策略

除了上面所介绍的向量索引(VectorStoreIndex)和图谱索引(KnowledgeGraphIndex),LlamaIndex 还提供了一些其他的索引策略,比如 SummaryIndexTreeIndexKeywordTableIndex 等。

在我看来,索引其实就是文档的组织方式,不同的索引代表不同的存储形式或数据结构,比如 VectorStoreIndex 以向量形式存储,KnowledgeGraphIndex 以图谱形式存储,SummaryIndex 以链表形式存储,TreeIndex 以树形式存储,KeywordTableIndex 以倒排索引形式存储。How Each Index Works 这份指南对不同索引的工作原理用图文的方式进行了通俗的讲解。

检索策略(Retrieval)

构建索引的目的是为了更快的检索,无论是 LlamaIndex 还是 LangChain 都提供了大量的 检索器(Retriever)。检索器可以针对单个索引,在 LlamaIndex 中这被称为 索引检索(Index Retrievers),不同的索引又可以有不同的 检索模式;检索器也可以组合不同检索技术,比如上面所学习的查询转换、查询路由、查询构造也都需要配合相应的检索策略来进行,下面还会学习一些其他的检索策略,比如父文档检索、混合检索等。

索引检索(Index Retrievers)

上面学习了很多了索引,从索引中检索是最简单也最基础的检索策略。LlamaIndex 中的所有 Index 都有一个 as_retriever() 方法,方便从索引中快速检索出想要的内容:

retriever = index.as_retreiver()
nodes = retriever.retrieve("<user question>")

在 LlamaIndex 中,不同的 Index 还可以有 不同的检索模式,比如使用 SummaryIndexllm 模式:

retriever = summary_index.as_retriever(
    retriever_mode="llm"
)

LangChain 中的 Vector Store 也有一个 as_retriever() 方法用于检索,这被称为 Vector store-backed retriever

retriever = db.as_retriever()
docs = retriever.invoke("<user question>")

父文档检索(Parent Document Retrieval)

当我们对文档进行分块的时候,我们可能希望每个分块不要太长,因为只有当文本长度合适,嵌入才可以最准确地反映它们的含义,太长的文本嵌入可能会失去意义;但是在将检索内容送往大模型时,我们又希望有足够长的文本,以保留完整的上下文。为了实现二者的平衡,我们可以在检索过程中,首先获取小的分块,然后查找这些小分块的父文档,并返回较大的父文档,这里的父文档指的是小分块的来源文档,可以是整个原始文档,也可以是一个更大的分块。LangChain 提供的 父文档检索器(Parent Document Retriever) 和 LlamaIndex 提供的 自动合并检索器(Auto Merging Retriever) 就是使用了这种策略;这种将嵌入的内容(用于检索)和送往大模型的内容(用于答案生成)分离的做法是索引设计中最简单且最有用的想法之一,它的核心理念是,检索更小的块以获得更好的搜索质量,同时添加周围的上下文以获取更好的推理结果。

除了对文档进行分割获取小块,我们也可以使用大模型对文档进行摘要,然后对摘要进行嵌入和检索,这种方法对处理包含大量冗余细节的文本非常有效,这里的原始文档就相当于摘要的父文档。另一种思路是通过大模型为每个文档生成 假设性问题(Hypothetical Questions),然后对问题进行嵌入和检索,也可以结合问题和原文档一起检索,这种方法提高了搜索质量,因为与原始文档相比,用户查询和假设性问题之间的语义相似性更高。我们可以使用 LlamaIndex 提供的 SummaryExtractorQuestionsAnsweredExtractor 来生成摘要和问题。

下图展示了这三种检索方法和原始检索方法的一个对比:

parent-retrieve.jpeg

这篇文章 中,作者综合使用了 Neo4j 的向量搜索和图搜索能力,对上面三种检索方法进行了实现,可供参考。首先,作者对原始文档依次进行分块、总结和生成假设性问题,并将生成的子文档和父文档存储在 Neo4j 图数据库中:

parent-retrieve-data.png

其中,紫色节点是父文档,长度为 512 个 token,每个父文档都有多个子节点:橙色节点包含将父文档切分成更小的子文档;蓝色节点包含针对父文档生成的假设性问题;红色节点包含父文档的摘要。

然后通过下面的代码对子文档进行检索:

parent_query = """
MATCH (node)<-[:HAS_CHILD]-(parent)
WITH parent, max(score) AS score // deduplicate parents
RETURN parent.text AS text, score, {} AS metadata LIMIT 1
"""

parent_vectorstore = Neo4jVector.from_existing_index(
    OpenAIEmbeddings(),
    index_name="parent_document",
    retrieval_query=parent_query,
)

层级检索(Hierarchical Retrieval)

假设我们有大量的文档需要检索,为了高效地在其中找到相关信息,一种高效的方法是创建两个索引:一个由摘要组成,另一个由文档块组成,然后分两步搜索,首先通过摘要筛选出相关文档,然后再在筛选出的文档中搜索。

hierarchical-retrieval.png

这在 LlamaIndex 中被称为 Hierarchical Retrieval

在上面的父文档检索中我们也举了一个检索摘要的例子,和这里的层级检索很相似,其区别在于父文档检索只检索一次摘要,然后由摘要扩展出原始文档,而层级检索是通过检索摘要筛选出一批文档,然后在筛选出的文档中执行二次检索。

混合检索(Fusion Retrieval)

在上面学习查询扩展策略时,有提到 RAG 融合(RAG Fusion) 技术,它根据用户的原始问题生成意思相似但表述不同的子问题并检索。其实,我们还可以结合不同的检索策略,比如最常见的做法是将基于关键词的老式搜索和基于语义的现代搜索结合起来,基于关键词的搜索又被称为 稀疏检索器(sparse retriever),通常使用 BM25TF-IDF 等传统检索算法,基于语义的搜索又被称为 密集检索器(dense retriever),使用的是现在流行的 embedding 算法。

在 LangChain 中,可以使用 EnsembleRetriever 来实现混合检索,LlamaIndex 中的 QueryFusionRetriever 也能实现类似的功能,Simple Fusion RetrieverReciprocal Rerank Fusion Retriever 是两个基于 QueryFusionRetriever 实现混合检索的示例。

混合检索将两种或多种互补的检索策略结合在一起,通常能得到更好的检索结果,其实现并不复杂,它的关键技巧是如何正确地将不同的检索结果结合起来,这个问题通常是通过 倒数排名融合(Reciprocal Rank Fusion,RRF) 算法来解决的,RRF 算法对检索结果重新进行排序从而获得最终的检索结果。

RRF 是滑铁卢大学和谷歌合作开发的一种算法,它可以将具有不同相关性指标的多个结果集组合成单个结果集,这里是 它的论文地址,其中最关键的部分就是下面这个公式:

rrf-score.png

其中,D 表示文档集,R 是从 1 到 |D| 的排列,k 是一个常量,默认值为 60.

为了对这个公式有个更直观的理解,我们不妨执行下 RAG Fusion 开源的代码,执行结果如下:

Initial individual search result ranks:
For query '1. Effects of climate change on biodiversity': {'doc7': 0.89, 'doc8': 0.79, 'doc5': 0.72}
For query '2. Economic consequences of climate change': {'doc9': 0.85, 'doc7': 0.79}
For query '3. Health impacts of climate change': {'doc1': 0.8, 'doc10': 0.76}
For query '4. Solutions to mitigate the impact of climate change': {'doc7': 0.85, 'doc10': 0.8, 'doc1': 0.74, 'doc9': 0.71}
Updating score for doc7 from 0 to 0.016666666666666666 based on rank 0 in query '1. Effects of climate change on biodiversity'
Updating score for doc8 from 0 to 0.01639344262295082 based on rank 1 in query '1. Effects of climate change on biodiversity'
Updating score for doc5 from 0 to 0.016129032258064516 based on rank 2 in query '1. Effects of climate change on biodiversity'
Updating score for doc9 from 0 to 0.016666666666666666 based on rank 0 in query '2. Economic consequences of climate change'
Updating score for doc7 from 0.016666666666666666 to 0.03306010928961749 based on rank 1 in query '2. Economic consequences of climate change'
Updating score for doc1 from 0 to 0.016666666666666666 based on rank 0 in query '3. Health impacts of climate change'
Updating score for doc10 from 0 to 0.01639344262295082 based on rank 1 in query '3. Health impacts of climate change'
Updating score for doc7 from 0.03306010928961749 to 0.04972677595628415 based on rank 0 in query '4. Solutions to mitigate the impact of climate change'
Updating score for doc10 from 0.01639344262295082 to 0.03278688524590164 based on rank 1 in query '4. Solutions to mitigate the impact of climate change'
Updating score for doc1 from 0.016666666666666666 to 0.03279569892473118 based on rank 2 in query '4. Solutions to mitigate the impact of climate change'
Updating score for doc9 from 0.016666666666666666 to 0.032539682539682535 based on rank 3 in query '4. Solutions to mitigate the impact of climate change'
Final reranked results: {'doc7': 0.04972677595628415, 'doc1': 0.03279569892473118, 'doc10': 0.03278688524590164, 'doc9': 0.032539682539682535, 'doc8': 0.01639344262295082, 'doc5': 0.016129032258064516}
Final output based on ['1. Effects of climate change on biodiversity', '2. Economic consequences of climate change', '3. Health impacts of climate change', '4. Solutions to mitigate the impact of climate change'] and reranked documents: ['doc7', 'doc1', 'doc10', 'doc9', 'doc8', 'doc5']

首先针对原始问题生成四个不同的问题,然后针对不同的问题分别执行检索得到不同的文档排名:

  • 问题 1 检索结果排名:{'doc7': 0.89, 'doc8': 0.79, 'doc5': 0.72}
  • 问题 2 检索结果排名:{'doc9': 0.85, 'doc7': 0.79}
  • 问题 3 检索结果排名:{'doc1': 0.8, 'doc10': 0.76}
  • 问题 4 检索结果排名:{'doc7': 0.85, 'doc10': 0.8, 'doc1': 0.74, 'doc9': 0.71}

可以看到每次检索出来的文档都不一样,就算是相同文档,得分也不一样。为了计算每个文档的最终排名,我们使用 RRF 公式对每个文档计算 RRF 分数,这里以 doc7 为例,该文档一共出现了三次,在问题 1 的检索中排名第一,问题 2 的检索中排名第二,问题 4 的检索中排名第一,所以它的得分计算如下:

RRF7 = 1/(1+60) + 1/(2+60) + 1/(1+60) = 0.049

使用类似的方法计算其他文档的得分,最终得到所有文档的最终排名。

从 RRF 分数的计算中,我们可以看出,RRF 不依赖于每次检索分配的绝对分数,而是依赖于相对排名,这使得它非常适合组合来自可能具有不同分数尺度或分布的查询结果。

值得注意的是,现在有很多数据库都原生支持混合检索了,比如 MilvusQdrantOpenSearchPinecone 等,Elasticsearch 的最新版本中也 支持 RRF 检索。对于这些支持混合检索的数据库,LlamaIndex 提供了一种简单的方式:

query_engine = index.as_query_engine(
    ...,
    vector_store_query_mode="hybrid", 
    alpha=0.5,  # 指定向量搜索和关键字搜索之间的加权
    ...
)

多向量检索(Multi-Vector Retrieval)

对于同一份文档,我们可以有多种嵌入方式,也就是为同一份文档生成几种不同的嵌入向量,这在很多情况下可以提高检索效果,这被称为 多向量检索器(Multi-Vector Retriever)。为同一份文档生成不同的嵌入向量有很多策略可供选择,上面所介绍的父文档检索就是比较典型的方法。

除此之外,当我们处理包含文本和表格的半结构化文档时,多向量检索器也能派上用场,在这种情况下,可以提取每个表格,为表格生成适合检索的摘要,但生成答案时将原始表格送给大模型。有些文档不仅包含文本和表格,还可能包含图片,随着多模态大模型的出现,我们可以为图像生成摘要和嵌入。

LangChain 的 这篇博客 对多向量检索做了一个全面的描述,并提供了大量的示例,用于表格或图片等多模任务的检索:

后处理

这是打造 RAG 系统的最后一个问题,如何将检索出来的信息丢给大模型?检索出来的信息可能过长,或者存在冗余(比如从多个来源进行检索),我们可以在后处理步骤中对其进行压缩、排序、去重等。LangChain 中并没有专门针对后处理的模块,文档也是零散地分布在各个地方,比如 Contextual compressionCohere reranker 等;而 LlamaIndex 对此有一个专门的 Postprocessor 模块,学习起来相对更体系化一点。

过滤策略

当检索结果太多时,与查询相关性最高的信息可能被埋在大量的无关文档中,如果将所有这些文档都传递到大模型,可能导致更昂贵的调用费用,生成的响应也更差。对检索结果进行过滤,是最容易想到的一种后处理方式。LlamaIndex 提供了下面这些过滤策略:

  • SimilarityPostprocessor

为每个检索结果按相似度打分,然后通过设置一个分数阈值进行过滤。

  • KeywordNodePostprocessor

使用 spacy短语匹配器(PhraseMatcher) 对检索结果进行检查,按包含或不包含特定的关键字进行过滤。

使用 nltk.tokenize 对检索出的每一条结果进行分句,然后通过计算每个分句和用户输入的相似性来过滤和输入不相干的句子,有两种过滤方式:threshold_cutoff 是根据相似度阈值来过滤(比如只保留相似度 0.75 以上的句子),percentile_cutoff 是根据百分位阈值来过滤(比如只保留相似度高的前 50% 的句子)。这种后处理方法可以极大地减少 token 的使用。

假设检索结果中有时间字段,我们可以按时间排序,然后取 topK 结果,这种策略对回答一些有关最近信息的问题非常有效。

FixedRecencyPostprocessor 类似,也是根据检索结果中的时间字段排序,只不过它不是取 topK,而是将旧文档和新文档比较,将相似度很高的旧文档过滤掉。

这种策略通过 时间加权(Time Weighted) 的方法对检索结果重新排序,然后再取 topK。每次检索时,对每一条检索结果设置一个最后访问时间,再通过下面的公式重新计算相似度分数:

hours_passed = (now - last_accessed) / 3600
time_similarity = (1 - time_decay) ** hours_passed
similarity = score + time_similarity

其中 hours_passed 指的是自上次访问以来经过的小时数,而 time_decay 是一个 0 到 1 之间的数值,该值由用户配置,值越低,表示记忆将会 “记住” 更长时间,值越高,记忆越容易 “遗忘”。可以看出 hours_passed 越大,time_similarity 就越小,这意味着经常访问的对象可以保持 “新鲜”,对于从没访问过的对象,hours_passed 为 0,这时 time_similarity 最大,这意味着检索更偏向于返回尚未查询过的信息。LangChain 也提供了 Time-weighted vector store retriever 实现相似的功能。

根据 Nelson F. Liu 等人在 Lost in the Middle: How Language Models Use Long Contexts 这篇论文中的研究,当前的大模型并没有充分利用上下文中的信息:当相关信息出现在上下文的开头或结尾时,性能往往最高,而当模型必须在长上下文的中间访问相关信息时,性能会显著下降。

long-context.png

基于这个结论,我们可以将检索出的最相关的片段分布在上下文的开头和结尾,而不是直接按相关性排序,比如检索结果是 1 2 3 4 5 6 7 8 9,重排序后可以是 1 3 5 7 9 8 6 4 2,这就是 Long-Context Reorder 的核心思路。

LangChain 也支持 Long-Context Reorder

此外,LangChain 中的 ContextualCompressionRetriever 也支持一些不同的过滤策略:

  • LLMChainExtractor

这个过滤器依次将检索文档丢给大模型,让大模型从文档中抽取出和用户问题相关的片段,从而实现过滤的功能。

  • LLMChainFilter

这个过滤器相比 LLMChainExtractor 稍微简单一点,它直接让大模型判断文档和用户问题是否相关,而不是抽取片段,这样做不仅消耗更少的 token,而且处理速度更快,而且可以防止大模型对文档原始内容进行篡改。

  • EmbeddingsFilter

和 LlamaIndex 的 SimilarityPostprocessor 类似,计算每个文档和用户问题的相似度分数,然后通过设置一个分数阈值进行过滤。

  • EmbeddingsRedundantFilter

这个过滤器虽然名字和 EmbeddingsFilter 类似,但是实现原理是不一样的,它不是计算文档和用户问题之间的相似度,而是计算文档之间的相似度,然后把相似的文档过滤掉,有点像 LlamaIndex 的 EmbeddingRecencyPostprocessor

重排序

在上面的过滤策略中,我们经常会用到 Embedding 来计算文档的相似性,然后根据相似性来对文档进行排序,这里的排序被称为 粗排,我们还可以使用一些专门的排序引擎对文档进一步排序和过滤,这被称为 精排。LlamaIndex 支持下面这些重排序策略:

Cohere AI 是一家加拿大初创公司,提供自然语言处理模型,帮助公司改善人机交互。可以使用 Cohere 提供的 Rerank API 来对文档进行相关性重排,过滤不相干的内容从而达到压缩的效果。

使用之前需要先申请和配置 COHERE_API_KEY,并安装 Python 依赖 pip install llama-index-postprocessor-cohere-rerank

LangChain 也集成了 Cohere 的 Rerank API,参考 这里

Jina AI 总部位于柏林,是一家领先的 AI 公司,提供一流的嵌入、重排序和提示优化服务,实现先进的多模态人工智能。可以使用 Jina 提供的 Rerank API 来对文档进行精排。

使用之前需要先申请和配置 JINAAI_API_KEY,并安装 Python 依赖 pip install llama-index-postprocessor-jinaai-rerank

除了使用商业服务,我们也可以使用一些本地模型来实现重排序。比如 sentence-transformer 包中的 交叉编码器(Cross Encoder) 可以用来重新排序节点。

LlamaIndex 默认使用的是 cross-encoder/ms-marco-TinyBERT-L-2-v2 模型,这个是速度最快的。为了权衡模型的速度和准确性,请参考 sentence-transformer 文档,以获取更完整的模型列表。

另一种实现本地重排序的是 ColBERT 模型,它是一种快速准确的检索模型,可以在几十毫秒内对大文本集合进行基于 BERT 的搜索。

使用时需要安装 Python 依赖 pip install llama-index-postprocessor-colbert-rerank

我们还可以使用大模型来做重排序,将文档丢给大模型,然后让大模型对文档的相关性进行评分,从而实现文档的重排序。下面是 LlamaIndex 内置的用于重排序的 Prompt:

DEFAULT_CHOICE_SELECT_PROMPT_TMPL = (
    "A list of documents is shown below. Each document has a number next to it along "
    "with a summary of the document. A question is also provided. \n"
    "Respond with the numbers of the documents "
    "you should consult to answer the question, in order of relevance, as well \n"
    "as the relevance score. The relevance score is a number from 1-10 based on "
    "how relevant you think the document is to the question.\n"
    "Do not include any documents that are not relevant to the question. \n"
    "Example format: \n"
    "Document 1:\n<summary of document 1>\n\n"
    "Document 2:\n<summary of document 2>\n\n"
    "...\n\n"
    "Document 10:\n<summary of document 10>\n\n"
    "Question: <question>\n"
    "Answer:\n"
    "Doc: 9, Relevance: 7\n"
    "Doc: 3, Relevance: 4\n"
    "Doc: 7, Relevance: 3\n\n"
    "Let's try this now: \n\n"
    "{context_str}\n"
    "Question: {query_str}\n"
    "Answer:\n"
)

RankGPT 是 Weiwei Sun 等人在论文 Is ChatGPT Good at Search? Investigating Large Language Models as Re-Ranking Agents 中提出的一种基于大模型的 zero-shot 重排方法,它采用了排列生成方法和滑动窗口策略来高效地对段落进行重排序,具体内容可以参考 RankGPT 的源码

使用时需要安装 Python 依赖 pip install llama-index-postprocessor-rankgpt-rerank

RankLLM 和 RankGPT 类似,也是利用大模型来实现重排,只不过它的重点放在与 FastChat 兼容的开源大模型上,比如 Vicuna 和 Zephyr 等,并且对这些开源模型专门为重排任务进行了微调,比如 RankVicuna 和 RankZephyr 等。

当前 RankLLM 依赖于 CUDA,且需要安装 JDK、PyTorch、Faiss 等依赖,使用时还需要安装 Python 依赖 pip install llama-index-postprocessor-rankllm-rerank

句子窗口检索(Sentence Window Retrieval)

除了对检索结果进行压缩过滤,我们也可以对检索结果进行增强。在上面的父文档检索一节中,我们提到,通过检索更小的块可以获得更好的搜索质量,然后通过扩大上下文范围可以获取更好的推理结果,句子窗口检索 使用的也是这个思想。它首先将文档分割成一个个句子,一句话相比于一段话来说,语义可能要更接近于用户的问题;每个句子包含一个窗口,也就是前后几句话,当检索出语义相近的句子后,将每个句子替换为包含前后句子的窗口。可以看到整个过程和父文档检索几乎是一样的,但是 LlamaIndex 为了区别其实现方式,将其放在了后处理模块,而不是检索模块。

sentence-window.png

LlamaIndex 的文档中有一个示例 Metadata Replacement + Node Sentence Window 演示了句子窗口检索的实现,首先使用 SentenceWindowNodeParser 将文档分割为 Node 列表,每个 Node 对应一个句子,并将前后 3 个句子放在 Node 的元数据中:

from llama_index.core.node_parser import SentenceWindowNodeParser

node_parser = SentenceWindowNodeParser.from_defaults(
    window_size=3,
    window_metadata_key="window",
    original_text_metadata_key="original_text",
)
nodes = node_parser.get_nodes_from_documents(documents)

然后对分割后的句子构建向量索引和查询引擎,最后将 MetadataReplacementNodePostProcessor 设置为查询引擎的后处理模块即可:

from llama_index.core import VectorStoreIndex
from llama_index.core.postprocessor import MetadataReplacementPostProcessor

sentence_index = VectorStoreIndex(nodes)
query_engine = sentence_index.as_query_engine(
    similarity_top_k=2,
    node_postprocessors=[
        MetadataReplacementPostProcessor(target_metadata_key="window")
    ],
)

句子窗口检索通过扩大上下文范围来获取更好的推理结果,其实,LlamaIndex 中还有另外两个后处理器也使用了这种策略:PrevNextNodePostprocessorAutoPrevNextNodePostprocessor,他们将检索结果的前后内容也一并送往大模型,所以也被称为 前向/后向增强(Forward/Backward Augmentation),这在回答一些关于某个时间点之前或之后的问题时非常有用。

prev-next.png

如上图所示,用户的问题是 “作者在 YC 之后的时间里都做了啥?”,如果使用传统的检索方法,可能只检索到作者在 YC 期间的活动,很显然我们可以将文档后面的内容都带出来,更利于大模型的回答。PrevNextNodePostprocessor 通过手动设定向前或向后增强,而 AutoPrevNextNodePostprocessor 通过大模型自动判断是否要向前或向后增强。

敏感信息处理

检索的文档中可能含有如用户名、身份证、手机号等敏感信息,这类信息统称为 PII(Personal Identifiable Information、个人可识别信息),如果将这类信息丢给大模型生成回复,可能存在一定的安全风险,所以需要在后处理步骤中将 PII 信息删除。LlamaIndex 提供了两种方式来 删除 PII 信息:使用大模型(PIINodePostprocessor)和使用专用的 NER 模型(NERPIINodePostprocessor)。

引用来源

一个基于 RAG 的应用不仅要提供答案,还要提供答案的引用来源,这样做有两个好处,首先,用户可以打开引用来源对大模型的回复进行验证,其次,方便用户对特定主体进行进一步的深入研究。

这里是 Perplexity 泄露出来的 Prompt 可供参考,这里是 WebLangChain 对其修改后的实现。在这个 Prompt 中,要求大模型在生成内容时使用 [N] 格式表示来源,然后在客户端解析它并将其呈现为超链接。

总结

这篇博客断断续续地写了将近三个月,最初想写 RAG 这个主题是因为在网上看到 IVAN ILIN 大神的 Advanced RAG Techniques: an Illustrated Overview 这篇博客,看完之后我深受启发,感叹 RAG 技巧之多之杂,于是打算写一篇笔记记录总结一下。我是一个实践狂,在写的过程中,想着把每种技巧都一一实践一遍,由点到线,由线到面,这才发现自己掉入了一个大坑,关于 RAG 的内容远远不是一篇笔记能概括的,于是越陷越深,发现自己不懂的东西也越来越多,笔记的篇幅也越来越长。

RAG 是一门实践学科,它参考了大量的传统搜索技术,比如上面学习的 RAG 融合、查询重写等,都是 Google 多少年之前玩剩下的。学习之余,不得不佩服前人的智慧,同时也提醒我们学习传统技术的重要性,有很多新技术都是基于传统技术的再包装。

这篇博客几乎包括了打造 RAG 系统的方方面面,综合了 LlamaIndex 和 LangChain 两个著名的 LLM 开发框架,对 RAG 中的各种高级技巧进行了详细讲解和实践。尽管如此,还是有很多内容没有介绍到,比如 LlamaIndex 最近比较火的 Agentic RAG 概念,如何对 RAG 的效果进行评估模型的微调(这包括 Embedding 的微调、Re-ranking 的微调、LLM 的微调),等等这些话题。

博客篇幅较长,难免疏漏,如果有任何问题,欢迎留言指正。这篇博客仅仅作为一个引子,希望拓宽读者对 RAG 领域的视野,并引导读者踏上一场 RAG 的探索之旅。如果探索过程中有任何发现,也欢迎与我分享!

参考

更多

Advanced RAG Learning Series | Akash Mathur

Self-RAG | Florian June

Knowledge Graph RAG

RAG Eval

Agentic RAG

Recursive Retrieval

扫描二维码,在手机上阅读!

开源大模型 Llama 实战

去年 2 月 24 日,Facebook 的母公司 Meta AI 推出 Llama 语言模型,该模型完全使用公开可用的数据集进行训练,拥有 70 亿到 650 亿个参数,包括 7B13B30B65B 四个版本,可以进行本地部署和微调训练,非常适合个人和中小型企业。

值得注意的是,Llama 以非商业授权的形式发布,主要用于学术研究,官方仓库 里只给出了加载模型的示例代码,想要获取核心模型权重,还需要填写一份表单进行申请。尽管如此,Llama 模型的发布也具有划时代的意义,由于 OpenAI 对于 GPT-2 之后的模型就不再开源,这个时候 Meta 推出的 Llama 补上了这个缺口,掀起了开源大模型的发展浪潮。

3 月 13 日,斯坦福大学发布了指令精调模型 Alpaca 7B,它通过 OpenAI 的 text-davinci-003 模型生成了 5.2 万指令数据,然后对 Llama 7B 进行精调而得。

3 月 16 日,Guanaco 问世,它在 Alpaca 基础上补充了多语种语料和指令任务。

3 月 23 日,中文小羊驼 Chinese-Vicuna 面世,它基于 Llama 模型和 LoRA 方案,可按需投喂数据进行个性化指令精调。

3 月 24 日,Databricks 发布 Dolly 模型,它本质是 Alpaca 的开源克隆,基于 GPT-J-6B 精调,旨在证明精调指令数据比底座模型更为重要。

3 月 25 日,来自华中师范大学和商汤的几位伙伴发起中文大语言模型开源项目 骆驼(Luotuo),包含了一系列大语言模型、数据、管线和应用。

3 月 28 日,中文 LLaMA & Alpaca 大模型发布,包括了中文 Llama 模型和指令精调的 Alpaca 模型;中文 Llama 模型在原版 Llama 的基础上扩充了中文词表并使用了中文数据进行二次预训练,进一步提升了中文基础语义理解能力;同时,中文 Alpaca 模型进一步使用了中文指令数据进行精调,显著提升了模型对指令的理解和执行能力。

3 月 30 日,来自加州大学伯克利分校、卡内基梅隆大学、斯坦福大学、加州大学圣地亚哥分校的几位计算机博士成立 LMSYS 组织,并发布了 Vicuna-13B,它基于 ShareGPT 收集的对话对 Llama 进行精调,仅需 300 美元即完成训练,号称达到了 ChatGPT 90% 的能力。

同月,智谱 AI 开源了 ChatGLM-6B 模型,这是一个开源的、支持中英双语的对话语言模型,基于 GLM 架构,具有 62 亿参数,使用了和 ChatGPT 相似的技术,针对中文问答和对话进行了优化。

6 月 7 日,上海 AI 实验室发布了开源多语言大型语言模型 InternLM-7B,中文名书生·浦语,在 1.6 万亿标记的大型语料库上进行预训练,采用多阶段渐进式的过程,然后进行了微调以与人类偏好对齐。

6 月 15 日,百川智能发布了开源可商用的大规模预训练语言模型 Baichuan-7B,基于 Transformer 结构,在大约 1.2 万亿 tokens 上训练的 70 亿参数模型,支持中英双语。

开源大模型如雨后春笋般冒了出来,层出不穷,到了 7 月,Meta AI 联合 Microsoft 又推出了 Llama 2 模型,将预训练语料库的大小增加了 40%,将模型的上下文长度增加了一倍,并采用了分组查询注意力,参数范围从 70 亿到 700 亿,包括 7B13B70B 三个版本。同时还发布了 Llama 2 的微调版本 Llama 2-Chat,专门针对聊天场景进行了优化。

oss-llm.png

模型下载

想要体验 Llama 模型,我们首先得把模型给下载下来,这里总结几种不同的下载方法。

官方版本下载

根据官方仓库的说明,我们需要填写一份表单进行申请:

当申请通过后,你会收到一份带有下载链接的邮件。然后下载 Llama 仓库的源码,执行其中的 download.sh 脚本:

$ git clone https://github.com/meta-llama/llama.git
$ cd llama
$ ./download.sh 
Enter the URL from email:

按提示输入邮件中的下载链接即可。

值得注意的是,这个下载脚本依赖于 wgetmd5sum 命令,确保你的系统上已经安装了下面这两个工具:

$ brew install wget md5sha1sum

泄露版本下载

如果嫌从官方下载太麻烦,网上也有一些泄露的模型版本可以直接下载。

这里 应该是最早泄漏的版本,可以使用 IPFS 客户端 进行下载。

社区里也有人制作了种子,可以使用 BitTorrent 下载,磁链地址为 magnet:?xt=urn:btih:ZXXDAUWYLRUXXBHUYEMS6Q5CE5WA3LVA&dn=LLaMA

使用 pyllama 下载

另一种下载 Llama 模型的方法是使用 pyllama 库。首先,通过 pip 安装它:

$ pip3 install transformers pyllama -U

然后通过下面的命令下载 Llama 7B 模型(根据需要你也可以下载 13B30B65B,如果不指定 --model_size 则下载所有):

$ python3 -m llama.download --model_size 7B

在 Mac M2 下可能会遇到下面这样的报错:

ImportError: dlopen(/Library/Python/3.9/site-packages/_itree.cpython-39-darwin.so, 0x0002): 
    tried: '/Library/Python/3.9/site-packages/_itree.cpython-39-darwin.so' 
    (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')), 
    '/System/Volumes/Preboot/Cryptexes/OS/Library/Python/3.9/site-packages/_itree.cpython-39-darwin.so' 
    (no such file), 
    '/Library/Python/3.9/site-packages/_itree.cpython-39-darwin.so' 
    (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64'))

根据 itree 的官方文档,这个库我们需要自己手动构建:

$ brew install cmake
$ pip3 install https://github.com/juncongmoo/itree/archive/refs/tags/v0.0.18.tar.gz

安装完成后,再次下载,这次虽然没有报错,但是模型的下载目录 pyllama_data 却是空的,根据 这里 的解决方案,我们使用源码重新安装 pyllama:

$ pip3 uninstall pyllama
$ git clone https://github.com/juncongmoo/pyllama
$ pip3 install -e pyllama

然后再次下载即可,7B 模型文件大约 13G,下载速度取决于你的网速,成功后输出如下:

$ python3 -m llama.download --model_size 7B
❤️  Resume download is supported. You can ctrl-c and rerun the program to resume the downloading

Downloading tokenizer...
✅ pyllama_data/tokenizer.model
✅ pyllama_data/tokenizer_checklist.chk
tokenizer.model: OK

Downloading 7B

downloading file to pyllama_data/7B/consolidated.00.pth ...please wait for a few minutes ...
✅ pyllama_data/7B/consolidated.00.pth
✅ pyllama_data/7B/params.json
✅ pyllama_data/7B/checklist.chk

Checking checksums for the 7B model
consolidated.00.pth: OK
params.json: OK

一共有 5 个文件:

$ tree pyllama_data
pyllama_data
|-- 7B
|   |-- checklist.chk
|   |-- consolidated.00.pth
|   `-- params.json
|-- tokenizer.model
`-- tokenizer_checklist.chk

2 directories, 5 files

模型推理

从下载文件 consolidated.00.pth 的后缀可以看出这是一个 PyTorch 中用于保存模型权重的文件,该文件包含了模型在训练过程中学到的权重参数,我们可以通过 PyTorch 提供的加载机制重新装载到相同或者相似结构的模型中,从而继续训练或者进行推理。

官方已经提供了这样的示例代码,可以对模型进行测试,我们先下载代码:

$ git clone https://github.com/meta-llama/llama.git
$ cd llama
$ git checkout llama_v1

注意切换到 llama_v1 分支,因为我们下的是 Llama 1 模型。然后安装所需依赖:

$ pip3 install -r requirements.txt

然后安装 Llama:

$ pip3 install -e .

最后运行下面的命令测试模型:

$ torchrun --nproc_per_node 1 example.py --ckpt_dir ../pyllama_data/7B --tokenizer_path ../pyllama_data/tokenizer.model

运行这个命令需要具备 NVIDIA 卡并且需要安装 CUDA,否则很可能会报下面这样的错:

Traceback (most recent call last):
  File "/Users/aneasystone/Codes/github/llama/example.py", line 119, in <module>
    fire.Fire(main)
  File "/Library/Python/3.9/site-packages/fire/core.py", line 141, in Fire
    component_trace = _Fire(component, args, parsed_flag_args, context, name)
  File "/Library/Python/3.9/site-packages/fire/core.py", line 475, in _Fire
    component, remaining_args = _CallAndUpdateTrace(
  File "/Library/Python/3.9/site-packages/fire/core.py", line 691, in _CallAndUpdateTrace
    component = fn(*varargs, **kwargs)
  File "/Users/aneasystone/Codes/github/llama/example.py", line 74, in main
    local_rank, world_size = setup_model_parallel()
  File "/Users/aneasystone/Codes/github/llama/example.py", line 23, in setup_model_parallel
    torch.distributed.init_process_group("nccl")
  File "/Library/Python/3.9/site-packages/torch/distributed/c10d_logger.py", line 86, in wrapper
    func_return = func(*args, **kwargs)
  File "/Library/Python/3.9/site-packages/torch/distributed/distributed_c10d.py", line 1184, in init_process_group
    default_pg, _ = _new_process_group_helper(
  File "/Library/Python/3.9/site-packages/torch/distributed/distributed_c10d.py", line 1302, in _new_process_group_helper
    raise RuntimeError("Distributed package doesn't have NCCL built in")
RuntimeError: Distributed package doesn't have NCCL built in

在深度学习的训练和推理过程中,我们常常会遇到单机多卡或多机多卡的情况,这就会涉及到各个卡或节点之间的通信,这种通信被称为 集合通信(Collective Communication),而 NCCL 就是这样的一个集合通信库,它是英伟达基于自家 NVIDIA GPU 定制开发的一套开源集合通信库,可以通过 PCIe 和 NVLink 等高速互联从而实现高带宽和低延迟。除了 NCCL,还有一些其他的库可以选择,比如 MPI 接口的开源实现 Open MPI 、Facebook 的 Gloo 等。

为了让代码能在我的 Mac 上跑起来,我参考了 这里这里 的方法,将代码中和 CUDA 有关的内容都删掉,虽然可以运行,模型也显示加载成功了,但是却一直没有运行结果。最后,参考网友 b0kch01 的实现,还需要对参数做一些修改,然后将代码改成一次只处理一个提示词,再将机器上所有程序全部关闭,终于把 Llama 模型运行起来了:

$ torchrun --nproc_per_node 1 example.py --ckpt_dir ../pyllama_data/7B --tokenizer_path ../pyllama_data/tokenizer.model
Locating checkpoints
Found MP=1 checkpoints
Creating checkpoint instance...
Grabbing params...
Loading model arguments...
Creating tokenizer...
Creating transformer...

-- Creating embedding
-- Creating transformer blocks (32)
-- Adding output layers 
-- Precomputing frequencies

Loading checkpoint to model...done in 57.88 seconds
Creating LLaMA generator...done in 0.01 seconds
Loaded in 89.92 seconds
Enter prompt: 

等了 90 秒,模型加载成功,接着我们手动输入示例中的第一个提示词:

Enter prompt: I believe the meaning of life is
Starting generation with prompt: I believe the meaning of life is
Forwarding 38 times
responded in 472.85 seconds
I believe the meaning of life is to fulfill your purpose in life, and once you’ve done that, you live to serve others and to love others.
My goal is

==================================

Enter next prompt:

又等了将近 8 分钟,模型才慢吞吞地输出 150 个左右的字符。

模型量化

可以看到,就算是最小的 7B 模型,在一般的个人电脑上跑起来也是相当费劲。一般来说,基础模型都是 16 位浮点精度的,或称为 FP16 模型,也就是说,模型的每个参数都需要一个 16 位浮点数(2 字节)来保存,所以模型权重的体积和推理所需的显存大小约为模型参数量的两倍,比如运行 Llama 7B 大约需要 14GB 的显存。

目前有很多方法在研究如何减少大模型的资源占用,例如 llama.cpp,号称可以在树莓派上进行推理,最低只需要 4G 内存。这种技术也被称为 量化(Quantization),通过降低权重的精度,可以很大程度上降低显存要求,加快推理速度,同时保持大部分模型的性能。以 4 Bit 量化为例,它将原本的 16 位浮点精度压缩为 4 位整数精度,使模型权重的体积减小到原本的 1/4,推理所需显存也大幅减少,意味着约 4GB 左右的显存就可以对 7B 模型进行推理。

常见的量化技术有:NF4、GPTQ 和 GGML 等,对量化原理感兴趣的同学可以参考 Introduction to Weight Quantization 这篇文章。

使用 llama.cpp 量化并运行 Llama 模型

想要在个人电脑上玩转大模型,首推 llama.cpp 项目,它使用 C/C++ 重写了 Llama 的推理代码,不仅避免了 PyTorch 引入的复杂依赖,而且对各类硬件和库提供了广泛的支持,比如支持纯 CPU 推理,支持 Apple Silicon 芯片,支持不同的操作系统,包括 Mac OS、Linux、Windows、Docker、FreeBSD 等,还支持大量的开源大模型,包括 Meta 的 Llama、Google 的 Gemma、Mistral AI 的 Mistral 系列、阿里的 Qwen 系列、零一万物的 Yi 系列等。

首先我们下载 llama.cpp 的源码:

$ git clone https://github.com/ggerganov/llama.cpp
$ cd llama.cpp

官方提供了很多种不同的编译方法,包括 makeCMakeZig 等,你可以根据你的喜好进行选择。另外,它还支持苹果的 Metal 框架、不同的消息传递接口 MPI 实现,比如 MPICHOpen MPI 以及大量的 BLAS 库,具体的编译选项可以 参考官方文档。我们这里直接使用 make 命令编译:

$ make

在 Mac 上编译无需额外参数,llama.cpp 已经对 Arm Neon 做了优化,会自动启动 BLAS,在 M 系列芯片上,还会自动使用 Metal 框架,显著提升 GPU 推理速度。

编译完成后会在当前目录生成一些可执行文件,比如:

  • main - 用于模型推理的主程序
  • quantize - 用于模型量化
  • server - 以服务器模式运行

不过此时我们还无法直接运行推理程序,llama.cpp 不支持 PyTorch 格式的模型文件,我们需要将其转换为 GGUF 格式,在之前的版本中叫做 GGML 格式,它是由 Georgi Gerganov 创建的一种独特的二进制格式,用来分发语言模型文件,GG 就是他名字的缩写,同时他也是 llama.cpp 的作者。

将模型转换成这种格式非常简单,在 llama.cpp 的源码里已经内置了 convert.py 脚本,直接执行该脚本即可:

$ pip3 install -r requirements.txt
$ python3 convert.py ../pyllama_data/7B

转换完成后,模型目录下会多一个 ggml-model-f16.gguf 文件:

$ ls -lh ../pyllama_data/7B 
total 52679296
-rw-r--r--@ 1 aneasystone  staff   100B Mar  5  2023 checklist.chk
-rw-r--r--@ 1 aneasystone  staff    13G Mar  5  2023 consolidated.00.pth
-rw-r--r--@ 1 aneasystone  staff    13G Mar 24 15:33 ggml-model-f16.gguf
-rw-r--r--@ 1 aneasystone  staff   101B Mar  5  2023 params.json

这个文件和之前的模型文件一样,还是很大,接着我们使用 quantize 程序对模型文件进行量化,量化的尺寸可以选择 8 Bit、4 Bit 或 2 Bit 等,不同的尺寸在效果和资源占用上存在差异。我们这里选择的是 Q4_K_M,这是一种既能保留大部分模型的性能又能节约内存的量化类型。运行命令如下:

$ ./quantize ../pyllama_data/7B/ggml-model-f16.gguf ../pyllama_data/7B/ggml-model-Q4_K_M.gguf Q4_K_M

除此之外,下面是该命令支持的所有量化类型:

Allowed quantization types:
   2  or  Q4_0    :  3.56G, +0.2166 ppl @ LLaMA-v1-7B
   3  or  Q4_1    :  3.90G, +0.1585 ppl @ LLaMA-v1-7B
   8  or  Q5_0    :  4.33G, +0.0683 ppl @ LLaMA-v1-7B
   9  or  Q5_1    :  4.70G, +0.0349 ppl @ LLaMA-v1-7B
  19  or  IQ2_XXS :  2.06 bpw quantization
  20  or  IQ2_XS  :  2.31 bpw quantization
  28  or  IQ2_S   :  2.5  bpw quantization
  29  or  IQ2_M   :  2.7  bpw quantization
  24  or  IQ1_S   :  1.56 bpw quantization
  10  or  Q2_K    :  2.63G, +0.6717 ppl @ LLaMA-v1-7B
  21  or  Q2_K_S  :  2.16G, +9.0634 ppl @ LLaMA-v1-7B
  23  or  IQ3_XXS :  3.06 bpw quantization
  26  or  IQ3_S   :  3.44 bpw quantization
  27  or  IQ3_M   :  3.66 bpw quantization mix
  12  or  Q3_K    : alias for Q3_K_M
  22  or  IQ3_XS  :  3.3 bpw quantization
  11  or  Q3_K_S  :  2.75G, +0.5551 ppl @ LLaMA-v1-7B
  12  or  Q3_K_M  :  3.07G, +0.2496 ppl @ LLaMA-v1-7B
  13  or  Q3_K_L  :  3.35G, +0.1764 ppl @ LLaMA-v1-7B
  25  or  IQ4_NL  :  4.50 bpw non-linear quantization
  30  or  IQ4_XS  :  4.25 bpw non-linear quantization
  15  or  Q4_K    : alias for Q4_K_M
  14  or  Q4_K_S  :  3.59G, +0.0992 ppl @ LLaMA-v1-7B
  15  or  Q4_K_M  :  3.80G, +0.0532 ppl @ LLaMA-v1-7B
  17  or  Q5_K    : alias for Q5_K_M
  16  or  Q5_K_S  :  4.33G, +0.0400 ppl @ LLaMA-v1-7B
  17  or  Q5_K_M  :  4.45G, +0.0122 ppl @ LLaMA-v1-7B
  18  or  Q6_K    :  5.15G, +0.0008 ppl @ LLaMA-v1-7B
   7  or  Q8_0    :  6.70G, +0.0004 ppl @ LLaMA-v1-7B
   1  or  F16     : 13.00G              @ 7B
   0  or  F32     : 26.00G              @ 7B
          COPY    : only copy tensors, no quantizing

这时,模型目录下应该会生成一个 ggml-model-Q4_K_M.gguf 文件:

$ ls -lh ../pyllama_data/7B 
total 60674720
-rw-r--r--@ 1 aneasystone  staff   100B Mar  5  2023 checklist.chk
-rw-r--r--@ 1 aneasystone  staff    13G Mar  5  2023 consolidated.00.pth
-rw-r--r--@ 1 aneasystone  staff   3.8G Mar 24 15:38 ggml-model-Q4_K_M.gguf
-rw-r--r--@ 1 aneasystone  staff    13G Mar 24 15:33 ggml-model-f16.gguf
-rw-r--r--@ 1 aneasystone  staff   101B Mar  5  2023 params.json

为了节约时间,我们也可以从 TheBloke 这里下载已经量化好的模型直接使用。

相比于原文件,这个模型文件减小了很多,只有 3.8G,接下来就可以使用 main 对其进行推理了:

$ ./main -m ../pyllama_data/7B/ggml-model-Q4_K_M.gguf -n 128 -p "I believe the meaning of life is"
Log start
main: build = 2518 (ddf65685)
main: built with Apple clang version 15.0.0 (clang-1500.0.40.1) for arm64-apple-darwin22.6.0
main: seed  = 1711266065
llama_model_loader: loaded meta data with 17 key-value pairs and 291 tensors from ../pyllama_data/7B/ggml-model-Q4_K_M.gguf (version GGUF V3 (latest))
llama_model_loader: Dumping metadata keys/values. Note: KV overrides do not apply in this output.
llama_model_loader: - kv   0:                       general.architecture str              = llama
llama_model_loader: - kv   1:                               general.name str              = pyllama_data
llama_model_loader: - kv   2:                           llama.vocab_size u32              = 32000
llama_model_loader: - kv   3:                       llama.context_length u32              = 2048
llama_model_loader: - kv   4:                     llama.embedding_length u32              = 4096
llama_model_loader: - kv   5:                          llama.block_count u32              = 32
llama_model_loader: - kv   6:                  llama.feed_forward_length u32              = 11008
llama_model_loader: - kv   7:                 llama.rope.dimension_count u32              = 128
llama_model_loader: - kv   8:                 llama.attention.head_count u32              = 32
llama_model_loader: - kv   9:              llama.attention.head_count_kv u32              = 32
llama_model_loader: - kv  10:     llama.attention.layer_norm_rms_epsilon f32              = 0.000001
llama_model_loader: - kv  11:                          general.file_type u32              = 15
llama_model_loader: - kv  12:                       tokenizer.ggml.model str              = llama
llama_model_loader: - kv  13:                      tokenizer.ggml.tokens arr[str,32000]   = ["<unk>", "<s>", "</s>", "<0x00>", "<...
llama_model_loader: - kv  14:                      tokenizer.ggml.scores arr[f32,32000]   = [0.000000, 0.000000, 0.000000, 0.0000...
llama_model_loader: - kv  15:                  tokenizer.ggml.token_type arr[i32,32000]   = [2, 3, 3, 6, 6, 6, 6, 6, 6, 6, 6, 6, ...
llama_model_loader: - kv  16:               general.quantization_version u32              = 2
llama_model_loader: - type  f32:   65 tensors
llama_model_loader: - type q4_K:  193 tensors
llama_model_loader: - type q6_K:   33 tensors
llm_load_vocab: special tokens definition check successful ( 259/32000 ).
llm_load_print_meta: format           = GGUF V3 (latest)
llm_load_print_meta: arch             = llama
llm_load_print_meta: vocab type       = SPM
llm_load_print_meta: n_vocab          = 32000
llm_load_print_meta: n_merges         = 0
llm_load_print_meta: n_ctx_train      = 2048
llm_load_print_meta: n_embd           = 4096
llm_load_print_meta: n_head           = 32
llm_load_print_meta: n_head_kv        = 32
llm_load_print_meta: n_layer          = 32
llm_load_print_meta: n_rot            = 128
llm_load_print_meta: n_embd_head_k    = 128
llm_load_print_meta: n_embd_head_v    = 128
llm_load_print_meta: n_gqa            = 1
llm_load_print_meta: n_embd_k_gqa     = 4096
llm_load_print_meta: n_embd_v_gqa     = 4096
llm_load_print_meta: f_norm_eps       = 0.0e+00
llm_load_print_meta: f_norm_rms_eps   = 1.0e-06
llm_load_print_meta: f_clamp_kqv      = 0.0e+00
llm_load_print_meta: f_max_alibi_bias = 0.0e+00
llm_load_print_meta: f_logit_scale    = 0.0e+00
llm_load_print_meta: n_ff             = 11008
llm_load_print_meta: n_expert         = 0
llm_load_print_meta: n_expert_used    = 0
llm_load_print_meta: causal attn      = 1
llm_load_print_meta: pooling type     = 0
llm_load_print_meta: rope type        = 0
llm_load_print_meta: rope scaling     = linear
llm_load_print_meta: freq_base_train  = 10000.0
llm_load_print_meta: freq_scale_train = 1
llm_load_print_meta: n_yarn_orig_ctx  = 2048
llm_load_print_meta: rope_finetuned   = unknown
llm_load_print_meta: ssm_d_conv       = 0
llm_load_print_meta: ssm_d_inner      = 0
llm_load_print_meta: ssm_d_state      = 0
llm_load_print_meta: ssm_dt_rank      = 0
llm_load_print_meta: model type       = 7B
llm_load_print_meta: model ftype      = Q4_K - Medium
llm_load_print_meta: model params     = 6.74 B
llm_load_print_meta: model size       = 3.80 GiB (4.84 BPW) 
llm_load_print_meta: general.name     = pyllama_data
llm_load_print_meta: BOS token        = 1 '<s>'
llm_load_print_meta: EOS token        = 2 '</s>'
llm_load_print_meta: UNK token        = 0 '<unk>'
llm_load_print_meta: LF token         = 13 '<0x0A>'
llm_load_tensors: ggml ctx size =    0.22 MiB
ggml_backend_metal_buffer_from_ptr: allocated buffer, size =  3820.94 MiB, ( 3821.00 / 10922.67)
llm_load_tensors: offloading 32 repeating layers to GPU
llm_load_tensors: offloading non-repeating layers to GPU
llm_load_tensors: offloaded 33/33 layers to GPU
llm_load_tensors:      Metal buffer size =  3820.93 MiB
llm_load_tensors:        CPU buffer size =    70.31 MiB
..................................................................................................
llama_new_context_with_model: n_ctx      = 512
llama_new_context_with_model: n_batch    = 512
llama_new_context_with_model: n_ubatch   = 512
llama_new_context_with_model: freq_base  = 10000.0
llama_new_context_with_model: freq_scale = 1
ggml_metal_init: allocating
ggml_metal_init: found device: Apple M2
ggml_metal_init: picking default device: Apple M2
ggml_metal_init: default.metallib not found, loading from source
ggml_metal_init: GGML_METAL_PATH_RESOURCES = nil
ggml_metal_init: loading '/Users/zhangchangzhi/Codes/github/llama.cpp/ggml-metal.metal'
ggml_metal_init: GPU name:   Apple M2
ggml_metal_init: GPU family: MTLGPUFamilyApple8  (1008)
ggml_metal_init: GPU family: MTLGPUFamilyCommon3 (3003)
ggml_metal_init: GPU family: MTLGPUFamilyMetal3  (5001)
ggml_metal_init: simdgroup reduction support   = true
ggml_metal_init: simdgroup matrix mul. support = true
ggml_metal_init: hasUnifiedMemory              = true
ggml_metal_init: recommendedMaxWorkingSetSize  = 11453.25 MB
ggml_backend_metal_buffer_type_alloc_buffer: allocated buffer, size =   256.00 MiB, ( 4078.00 / 10922.67)
llama_kv_cache_init:      Metal KV buffer size =   256.00 MiB
llama_new_context_with_model: KV self size  =  256.00 MiB, K (f16):  128.00 MiB, V (f16):  128.00 MiB
llama_new_context_with_model:        CPU  output buffer size =    62.50 MiB
ggml_backend_metal_buffer_type_alloc_buffer: allocated buffer, size =    70.50 MiB, ( 4148.50 / 10922.67)
llama_new_context_with_model:      Metal compute buffer size =    70.50 MiB
llama_new_context_with_model:        CPU compute buffer size =     9.00 MiB
llama_new_context_with_model: graph nodes  = 1060
llama_new_context_with_model: graph splits = 2

system_info: n_threads = 4 / 8 | AVX = 0 | AVX_VNNI = 0 | AVX2 = 0 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 0 | 
NEON = 1 | ARM_FMA = 1 | F16C = 0 | FP16_VA = 1 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 0 | SSSE3 = 0 | VSX = 0 | MATMUL_INT8 = 0 | 
sampling: 
        repeat_last_n = 64, repeat_penalty = 1.000, frequency_penalty = 0.000, presence_penalty = 0.000
        top_k = 40, tfs_z = 1.000, top_p = 0.950, min_p = 0.050, typical_p = 1.000, temp = 0.800
        mirostat = 0, mirostat_lr = 0.100, mirostat_ent = 5.000
sampling order: 
CFG -> Penalties -> top_k -> tfs_z -> typical_p -> top_p -> min_p -> temperature 
generate: n_ctx = 512, n_batch = 2048, n_predict = 128, n_keep = 1


I believe the meaning of life is to serve others. As a doctor, I want to help those in need and make a difference in their lives. 
I am honored to be able to do just that in my community.
I love meeting new people and developing relationships with them. My goal is to provide high-quality care in a relaxed and comfortable environment. 
I take the time to listen to each patient and get to know them on a personal level.
I believe that a healthy life starts with prevent
llama_print_timings:        load time =    1040.38 ms
llama_print_timings:      sample time =       2.49 ms /   128 runs   (    0.02 ms per token, 51384.99 tokens per second)
llama_print_timings: prompt eval time =     231.36 ms /     8 tokens (   28.92 ms per token,    34.58 tokens per second)
llama_print_timings:        eval time =    6948.32 ms /   127 runs   (   54.71 ms per token,    18.28 tokens per second)
llama_print_timings:       total time =    7196.03 ms /   135 tokens
ggml_metal_free: deallocating
Log end

和之前比起来,推理速度有了质的提升,而且生成效果也还可以。我们也可以使用 -i 选项,以交互形式和大模型对话:

$ ./main -m ../pyllama_data/7B/ggml-model-Q4_K_M.gguf -n 128 --repeat_penalty 1.0 --color -i -r "User:" -f prompts/chat-with-bob.txt
...
== Running in interactive mode. ==
 - Press Ctrl+C to interject at any time.
 - Press Return to return control to LLaMa.
 - To return control without starting a new line, end your input with '/'.
 - If you want to submit another line, end your input with '\'.

 Transcript of a dialog, where the User interacts with an Assistant named Bob. 
 Bob is helpful, kind, honest, good at writing, and never fails to answer the User's requests immediately and with precision.

User: Hello, Bob.
Bob: Hello. How may I help you today?
User: Please tell me the largest city in Europe.
Bob: Sure. The largest city in Europe is Moscow, the capital of Russia.
User: What;s your name?
Bob: My name is Bob.
User: What can you do?
Bob: I am very good at writing.
User: Tell me a joke
Bob: Knock knock. Who's there?

其中 -n 表示限定生成的 token 数量;--repeat_penalty 有助于防止模型生成重复或单调的文本,较高的值会更严厉地惩罚重复,而较低的值则更宽容;--color 表示使用彩色输出区分提示词、用户输入和生成的文本;-r 表示 Reverse Prompts,用于暂停文本生成并切换到交互模式,这里的 -r "User:" 表示轮到用户发言时停止,这有助于创建更具互动性和对话性的体验;-f-p 一样,用于指定提示词,只不过提示词位于文件中;关于 main 程序的其他可用参数可以参考 这篇文档

除了以命令行形式运行大模型,llama.cpp 也提供了服务器模式运行模型,我们运行 server 程序:

$ ./server -m ../pyllama_data/7B/ggml-model-Q4_K_M.gguf -c 1024
...
{"tid":"0x1fd44a080","timestamp":1711270965,"level":"INFO","function":"init","line":702,"msg":"initializing slots","n_slots":1}
{"tid":"0x1fd44a080","timestamp":1711270965,"level":"INFO","function":"init","line":714,"msg":"new slot","id_slot":0,"n_ctx_slot":1024}
{"tid":"0x1fd44a080","timestamp":1711270965,"level":"INFO","function":"main","line":2881,"msg":"model loaded"}
{"tid":"0x1fd44a080","timestamp":1711270965,"level":"INFO","function":"main","line":2906,"msg":"chat template","chat_example":"<|im_start|>system\nYou are a helpful assistant<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\nHi there<|im_end|>\n<|im_start|>user\nHow are you?<|im_end|>\n<|im_start|>assistant\n","built_in":true}
{"tid":"0x1fd44a080","timestamp":1711270965,"level":"INFO","function":"main","line":3524,"msg":"HTTP server listening","port":"8080","n_threads_http":"7","hostname":"127.0.0.1"}

服务启动成功后,我们就能通过 http://localhost:8080 来访问它,下面是使用 curl 调用该接口的例子:

$ curl --request POST \
    --url http://localhost:8080/completion \
    --header "Content-Type: application/json" \
    --data '{"prompt": "Building a website can be done in 10 simple steps:","n_predict": 128}'

这篇文档 对服务器模式的其他接口和参数做了详细说明。

使用 Ollama 运行 Llama 模型

上一节我们学习了如何使用 llama.cpp 量化和运行 Llama 大模型,整个过程虽然不复杂,但是对于普通用户来说,无论是获取模型文件,还是编译和构建源码,抑或是以命令行形式运行推理程序,还是有一定门槛的。所以,很长一段时间里,在本地运行大模型都只局限于少数的极客和研究人员,直到 Ollama 项目的问世,才真正将大模型带入千万用户的个人电脑,让更多的普通小白也可以方便地在自己电脑上玩转大模型了。

Ollama 基于 llama.cpp 实现,它的安装非常简单,直接进入 官方下载页面,找到适合自己系统的版本下载运行即可,支持 Mac OS、Linux 和 Windows 系统。

打开终端,输入 ollama --version 命令,如果能成功查询到版本号,表示 Ollama 已经安装好了:

$ ollama --version
ollama version is 0.1.29

接下来,我们就可以用 ollama pull 命令来下载模型文件:

$ ollama pull llama2

熟悉 Docker 的同学应该对这个命令感到很亲切,Ollama 参考了 Docker 的设计理念,类似于 docker pull 可以从镜像仓库下载镜像,ollama pull 可以从 模型仓库 下载模型。在不指定 tag 的情况下,我们下载的是 llama2:latest 模型,从 模型详情页 可以看出这是 Llama 2 7B 模型的 4 Bit 量化版本(实际上是 Llama 2-Chat 模型,Llama 2 模型对应的 tag 是 llama2:text):

llama2-latest.png

接下来使用 ollama run 命令运行大模型:

$ ollama run llama2
>>> 

这样就可以和大模型进行对话了:

>>> Hello
Hello! It's nice to meet you. Is there something I can help you with or would you like to chat?

>>> Who are you?
Hello! I am LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input 
in a conversational manner. I'm here to help you with any questions 
or topics you'd like to discuss. Is there something specific you'd like to talk about?

>>> 用中文回答
你好!我是LLaMA,一个由Meta AI开发的人工智能助手。我可以理解和回应人类输入的语言,让您与我互动。您有什么问题或话题想聊?

>>> /bye

此外,Ollama 也支持以服务器模式启动:

$ ollama serve

这样我们就可以通过接口形式来调用:

$ curl -X POST http://localhost:11434/api/generate -d '{
  "model": "llama2",
  "prompt":"Why is the sky blue?"
 }'

更多关于 Ollama 的接口细节,可以参考官方的 API 文档

除了 ollama pullollama run,Ollama 还支持一些其他的命令选项,比如:

  • ollama list - 显示所有本地已安装的模型
  • ollama rm - 删除已安装的模型
  • ollama show - 显示模型的详细信息
  • ollama create - 通过 Modelfile 创建模型文件
  • ollama push - 将创建的模型文件推送到远程仓库

因为 Ollama 是基于 llama.cpp 实现的,所以它也支持大量的开源大模型,比如 Gemma、Mistral、Qwen、Yi 这些基础大模型,还有 Code Llama、DeepSeek Coder、StarCoder 这些代码大模型,还有 LLaVA 和 BakLLaVA 这些多模态大模型,等等,可以在 模型仓库 页面找到所有 Ollama 支持的模型。

不仅如此,Ollama 还支持用户自己创建模型,正如在 Docker 中我们可以使用 Dockerfile 来构建自己的镜像,在 Ollama 中我们也可以使用 Modelfile 来构建自己的模型。细心的同学可能已经注意到,Ollama 的模型仓库里只有 Llama 2 的模型,并没有 Llama 模型,我们不妨自己来创建一个。

Ollama 支持根据 GGUF 文件创建模型,首先我们新建一个 Modelfile 文件,在第一行使用 FROM 语句导入我们上面生成好的量化版模型文件:

FROM ../pyllama_data/7B/ggml-model-Q4_K_M.gguf

如果要导入其他类型的模型文件,比如 PyTorch 或 Safetensors 等,请参考文档 Import a model

然后使用 ollama create 命令创建模型:

$ ollama create llama -f Modelfile 
transferring model data 
creating model layer 
using already created layer sha256:3672cbbdd94aaf2ec25e242afbba8691c44dacd1d627c478ad83c2248c80040c 
writing layer sha256:5bbed095407083c16b0f36844732fd4b5aed0932420eb389f132b6e494376c32 
writing manifest 
success

很简单,是不是?这样我们就可以使用 Ollama 运行 Llama 模型了:

$ ollama run llama
>>> The meaning of life is
 to find your gift. The purpose of life is to give it away. ~Pablo Picasso

不过 Llama 模型是基础模型,不具有对话能力,我们可以使用提示词和停止词来模拟出对话效果(参考 llama.cpp 的交互模式):

FROM ../pyllama_data/7B/ggml-model-Q4_K_M.gguf

TEMPLATE """Transcript of a dialog, where the User interacts with an Assistant named Bob. 
Bob is helpful, kind, honest, good at writing, and never fails to answer the User's requests immediately and with precision.

User: Hello, Bob.
Bob: Hello. How may I help you today?
User: Please tell me the largest city in Europe.
Bob: Sure. The largest city in Europe is Moscow, the capital of Russia.
User: {{ .Prompt }}
"""

PARAMETER temperature 1
PARAMETER num_ctx 4096
PARAMETER num_predict 128
PARAMETER repeat_penalty 1.0
PARAMETER stop User:
PARAMETER stop Transcript of a dialog

其中 TEMPLATE 关键字用于指定提示词,PARAMETER 关键字用于配置参数,这些参数和 llama.cpp 的参数非常类似,可以参考 Ollama Model File,其中 PARAMETER stop 用于设置停止词,这是模拟对话性体验的关键。

然后重新创建模型并运行:

$ ollama create llama -f Modelfile 
$ ollama run llama

这次我们就可以和它进行对话了:

>>> Hello
Bob: Hello

>>> What's your name?
Bob: My name is Bob, and I am an artificial intelligent robot.

>>> Tell me a joke.
Bob: A: Knock knock.
B: Who’s there?
A: Tom.
B: Tom who?
A: I don’t know.

>>> /bye

实现类似 ChatGPT 的聊天应用

至此,我们已经可以熟练地在本地部署和运行 Llama 模型了,为了让我们和语言模型之间的交互更加友好,我们还可以借助一些开源项目打造一款类似 ChatGPT 的聊天应用。无论是 llama.cpp 还是 Ollama,周边生态都非常丰富,社区开源了大量的网页、桌面、终端等交互界面以及诸多的插件和拓展,参考 Ollama 的 Community Integrations

下面列举一些比较有名的 Web UI:

接下来我们就基于 Open WebUI 来实现一个本地聊天应用。Open WebUI 是一个可扩展、功能丰富且用户友好的自托管 WebUI,旨在完全离线运行。它的原名叫 Ollama WebUI,原本只是对 Ollama 的,后来在社区的推动下,发展成了一款通用的聊天应用 WebUI,支持各种 LLM 运行器,包括 Ollama 以及与 OpenAI 兼容的接口。

Open WebUI 具备大量的功能特性,包括:

  • 直观的界面:接近 ChatGPT 的界面,提供用户友好的体验;
  • 响应式的设计:同时兼容桌面和移动端设备;
  • 快速的响应:让用户享受快速且响应迅速的性能;
  • 轻松的安装:支持使用 Docker 或 Kubernetes 进行安装;
  • 代码语法高亮:增强代码的可读性;
  • 全面支持 Markdown 和 LaTeX:实现更丰富的交互,提升用户的体验;
  • 本地 RAG 集成:支持在聊天中对文档进行问答;
  • 网页浏览功能:支持在聊天中对网页进行问答;
  • 预设的提示词:聊天时输入 / 命令即可立即访问预设的提示词;
  • RLHF 注释:通过给消息点赞或点踩,为 RLHF 创建数据集,便于使用您的消息来训练或微调模型;
  • 对话标记:轻松分类和定位特定的聊天,以便快速参考和高效数据收集;
  • 模型管理:支持在页面上下载或删除模型;支持导入 GGUF 文件,轻松创建 Ollama 模型或 Modelfile 文件;
  • 多模型切换:支持多个模型之间的切换;
  • 多模型对话:同时与多个模型进行交流,通过比较获得最佳回应;
  • 多模态:支持多模态大模型,可以在聊天中使用图片;
  • 聊天记录:轻松访问和管理对话历史,支持导入和导出聊天数据;
  • 语音输入支持:通过语音互动与模型进行交流,享受直接与模型对话的便利;
  • 图像生成集成:无缝地使用 AUTOMATIC1111 API 和 DALL-E 集成图像生成功能,为聊天体验增添动态视觉内容;
  • OpenAI API 集成:轻松地将与 Ollama 模型兼容的 OpenAI API 集成到对话中;
  • 国际化(i18n):支持多种不同的语言;

运行如下的 Docker 命令即可安装 Open WebUI:

$ docker run -d -p 3000:8080 \
    --add-host=host.docker.internal:host-gateway \
    -v open-webui:/app/backend/data \
    --name open-webui \
    --restart always \
    ghcr.io/open-webui/open-webui:main

安装成功后,浏览器访问 http://localhost:3000/ 即可,首次访问需要注册一个账号:

open-webui-login.png

注册账号并登录后,就可以看到我们熟悉的聊天界面了:

open-webui.png

总结

随着开源大模型技术的不断发展,以及个人电脑硬件水平的不断提高,大模型对于普通人的门槛也越来越低。在本地设备运行大模型至少有两方面的好处:

  • 无需担心数据隐私:您的数据不会发送给第三方,并且不受商业服务条款的约束;
  • 推理成本显著降低:几乎没有推理费用,这对于令牌密集型应用程序非常重要(例如:长时间运行的模拟程序,对长文本进行摘要等);

PrivateGPTllama.cppGPT4Allllamafile 这些项目的流行也凸显出这种需求的旺盛。

这篇笔记对开源大模型 Llama 进行了全面的学习,从基础模型的下载,到模型的量化运行,以及部署可视化的 Web 应用,都做了详细的说明。尽管如此,受篇幅限制,还有很多大模型相关技术没有提到,特别是模型的微调和训练,争取在后面的笔记中继续学习之。

参考

更多

大模型部署

大模型量化

大模型微调

Meta 最初发布的 Llama 模型并没有进行指令微调,于是斯坦福马上公布了 Alpaca 模型,该模型是由 Llama 7B 利用 52k 的指令微调出来的。

扫描二维码,在手机上阅读!

提示工程学习笔记(二)

上一篇笔记 中,我们学习了很多提示工程相关的技术,比如思维链(CoT)和最小到最多提示(Least-to-Most Prompting)等,显著改善了大模型的推理能力。尽管如此,我们常常还是会看到这样的现象:大模型可以准确地生成解决问题的逻辑步骤,但最终结果仍然不正确,通常这个结果是由于非常简单的错误引起的,比如数值计算错误、无法理解私有知识等。因此研究人员又提出很多想法希望对语言模型进行增强,最常见的思路有:检索增强、编程增强和工具增强,这样的语言模型被称为 增强语言模型(Augmented Language Models)

检索增强

在处理 知识密集型(knowledge-intensive) 任务时,语言模型往往会出现 幻觉(hallucination) 现象,检索增强生成(Retrieval Augmented Generation,RAG) 是一种常见的解决幻觉的技术,它将信息检索技术和文本生成模型结合在一起,通过检索外部知识源,增强答案的可靠程度。

一个典型的 RAG 包含两个主要的部分:

  • 索引构建:首先准备和加载数据,将数据划分成小的数据块,然后对每个小数据块做向量表征存储,方便后续做语义检索;
  • 检索和生成:基于用户输入的问题,尽可能地检索出最相关的数据块,将检索出的数据块作为上下文和用户问题一起组合成 prompt 让大模型生成回答。

RAG 让语言模型不用重新训练就能够获取最新的信息,基于检索出的文档来回答用户问题,不仅提高了答案的可靠性,而且可以给出答案的引用来源,提高了模型的可解释性。

我们也可以省去构建检索系统这一步,直接使用一些现成的搜索引擎,比如 Google、Bing、维基百科等,OpenAI 提出的 WebGPT 和 DeepMind 团队提出的 Internet 增强语言模型 是两个比较典型的示例。

WebGPT 是一个基于 GPT-3 的微调模型,它可以搜索和浏览网页,并且通过人工反馈来优化回答问题的效果:

webgpt.png

相对的,Internet 增强语言模型不需要微调,通过少样本提示,就可以让模型从互联网上检索信息。给定一个问题,从 Google 返回的 20 个 URL 中提取出干净的文本,从而得到一组文档,由于这些文档很长,论文将每个文档切分成一个个段落,每个段落包含 6 个句子,然后通过 TF-IDF 余弦相关性算法,计算段落与用户输入的相似性,选取最相关的段落加入到提示词中,输入给大模型获取答案。

internet-agumented-llm.png

下面是一些关于 RAG 的论文:

编程增强

正如前文所述,结合一些提示技术,语言模型能够准确地给出解决问题的推理步骤,但是,生成正确的推理步骤并不意味着它能正确的解决问题!推理过程中一个小小的算术错误都将导致最终结果的错误,这种错误通常被称为语言模型的 组合性差距(Compositionality Gap),而且这个差距不会随着模型的增大和复杂度的增加而减小。

导致这个现象的根本原因是语言模型不擅长计算,如果能将计算从推理中解耦就好了,让语言模型只负责推理,将计算任务交给专门的计算模块,为了实现这一点,研究人员引入了代码模型来解决这个问题。

首先我们让代码模型产生解决问题的推理步骤,注意不需要模型产生实际的答案,而是生成与推理步骤对应的程序,这段程序是代码和自然语言的混合体(可以理解为带有注释的 Python 脚本),然后使用外部的代码解释器执行程序,从而生成最终的正确答案。这就是编程增强技术的基本思路。

程序辅助语言模型(PAL)

受 CoT 提示技术的启发,Luyu Gao 等人于 2022 年 11 月发表论文 PAL: Program-aided Language Models,提出了一种 程序辅助语言模型(Program-aided Language Model, PAL),这种模型将问题分解为解决问题的推理步骤,每一步包含自然语言和 Python 代码,在生成这样的混合步骤后,我们可以通过 Python 解释器来执行代码,从而解决问题。

使用 PAL,我们只需要 LLM 生成解决问题的推理步骤,而无需生成结果,这种方法可以显著减小上文中提到的组合性差距。我们可以提供几个将问题分解为混合步骤的示例,通过少样本学习来生成这样的混合步骤。PAL 与 CoT 提示非常相似,它们之间的主要区别在于,PAL 中的提示是由交错的自然语言和程序代码组成,见下图:

pal.png

PAL 与 CoT 提示的另一个区别是,PAL 使用的少样本示例中不包含最终结果,最终解决方案是由 Python 解释器生成的。

使用 PAL 推理过程中的每一步都通过编程语句进行增强,作者建议使用 Python 注释语法(即 # 字符)来生成基于自然语言的中间步骤,这使得基于语言的组件能够插入到生成的程序中。另外,作者观察到,为代码中的变量提供有意义的名称是有益的。

论文作者还给出了 PAL 的数据集和代码,有兴趣的可以 研究一下

思维程序提示(PoT)

几乎在同一时间,Wenhu Chen 等人发表了论文 Program of Thoughts Prompting: Disentangling Computation from Reasoning for Numerical Reasoning Tasks,提出了 思维程序提示(PoT) 技术,它和 PAL 非常相似。论文的作者同样意识到,尽管大模型擅长复杂的推理,但是却往往在简单的算术计算上栽跟头,从而导致回答错误,通过引入代码增强提示方法可以改善这个问题,使得大模型能够准确地解决复杂的数值任务。

和 PAL 一样,PoT 也是利用 LLM 来生成包含自然语言语句和 Python 代码的混合逻辑步骤,然后,将代码部分放到 Python 解释器中执行,从而实现推理和计算的解耦:

pot.png

从上图中可以看到,CoT 提示无法解决斐波那契数列这种迭代问题,也求解不了简单的三次方程,PoT 通过程序就可以轻松解决这些问题!

PoT 也分为 少样本 PoT(Few-shot PoT)零样本 PoT(Few-shot PoT) 两种,而且作者发现,零样本 PoT 提示也可以达到很好的效果:

pot-few-zero.png

工具增强

检索增强扩展了模型获取信息的能力,编程增强扩展了模型解决问题的能力,如果抽象来看,他们实际上都是外部工具的调用,让模型负责推理,推理之外的事通过调用外部工具来实现。在 大模型应用开发框架 LangChain 学习笔记(二) 中,我们学习了 OpenAI 的插件机制和 Function Calling 功能,这些其实都是通过外部工具实现的。

关于工具增强,目前已经有不少的论文对此进行了研究,比如上文提到的 Internet-Augmented Language Models 将搜索引擎作为工具,PAL 和 PoT 将 Python 解释器作为工具,我们还可以将浏览器、计算器、QA 系统、翻译系统等等作为工具,比如 LaMDABlenderBot 3WebGPT 等,不过这些方法要么是依赖于大量的人类监督,要么是事先通过少样本提示确定好什么任务中要使用什么工具,使用起来都不够灵活。相比之下,TALM 和 Toolformer 通过 自我监督(self-supervised) 机制,使语言模型能够学习如何以及何时使用工具,而不需要编写任务和工具的示例。

TALM

2022 年 5 月,Aaron Parisi 等人发表论文 TALM: Tool Augmented Language Models,提出了 工具增强语言模型 的概念,算得上是工具增强技术的鼻祖了。TALM 和传统语言模型的区别在于,它会引导模型输出要调用的工具以及工具的参数,然后将工具调用的结果输入模型,得到最终的结果:

talm-vs-lm.png

具体来说,TALM 使用了一种 文本到文本的 API 调用(text-to-text API calls) 方式,首先模型根据问题输出 |tool-call 这种特殊的格式,其中 tool-call 表示所使用的工具,然后输出 tool input text,表示文本形式的工具参数,后面紧接着输出 |result 固定格式,此时停止模型的输出,开始调用外部工具,然后将调用结果追加到刚生成的文本后面,再加上 |output 送回语言模型,从而生成最终的结果。下面是使用 TALM 调用天气工具的一个示例:

talm-examples.png

此外,TALM 采用 自我对弈(self-play) 的方法来扩充工具使用示例的数据集,每次模型与工具的交互,通过一种方法判断其是否能改善模型的输出,如果有改善,就扩展到数据集中,并将其用于语言模型的微调。

Toolformer

Toolformer 是 Timo Schick 等人于论文 Toolformer: Language Models Can Teach Themselves to Use Tools 中提出的一种语言模型,和 TALM 一样,也是通过引导模型输出要调用的工具以及工具的参数,然后将工具调用的结果输入模型,最终得到期望的结果:

toolformer.png

Toolformer 支持下面 5 种不同的工具:

  • 计算器:让语言模型处理数学计算问题;
  • QA 系统:避免语言模型生成错误的内容和幻觉;
  • 搜索引擎:为语言模型提供最新的信息;
  • 翻译系统:提高低资源语言的性能;
  • 日历:让语言模型知道时间信息;

Toolformer 和 TALM 非常类似,这里就不过多赘述了,我们重点关注它的训练过程:

toolformer-train.png

  1. LM Dataset:首先我们需要有一批带有 API 调用标注的数据集,Toolformer 的方法很巧妙,它通过一段提示词和几个 API 调用示例,让语言模型自动生成这样的数据集;比如下面是生成 QA 系统 API 调用的示例:
Your task is to add calls to a Question Answering API to a piece of text. The questions should help you get
information required to complete the text. You can call the API by writing "[QA(question)]" where "question" is the
question you want to ask. Here are some examples of API calls:

Input: Joe Biden was born in Scranton, Pennsylvania.
Output: Joe Biden was born in [QA("Where was Joe Biden born?")] Scranton, [QA("In which state is Scranton?")] Pennsylvania.

Input: Coca-Cola, or Coke, is a carbonated soft drink manufactured by the Coca-Cola Company.
Output: Coca-Cola, or [QA("What other name is Coca-Cola known by?")] Coke, is a carbonated soft drink manufactured 
by [QA("Who manufactures Coca-Cola?")] the Coca-Cola Company.

Input: x
Output:
  1. Sample API Calls:将每一个 API 调用表示为一个二元组(API 的名称和相应的输入),对于同一个位置 i,我们进行多次采样,生成不同的 API 调用 ci1ci2 等;
  2. Excute API Calls:执行上面生成的每个 API 调用得到结果 ri1ri2 等;
  3. Filter API Calls:计算模型在标记上的 加权交叉熵损失(weighted cross entropy loss),只有大于阈值的 API 调用被保留,这意味着添加这个 API 调用及其结果有助于模型预测未来的标记;
  4. LM Dataset with API Calls:至此就生成了一批带有 API 调用标注的数据集,然后在这个标注好的数据集上对语言模型进行微调,从而提高模型调用工具的性能。

Toolformer 的创新之处在于,仅使用少量的人工标注样本制造大量的自监督样本,理论上可以支持任意的 API 调用,但 Toolformer 也有一些局限性,比如不支持链式工具使用(使用一个工具的输出作为另一个工具的输入)或以交互方式使用(人工选择后采用 API 响应)。

自动推理并使用工具 (ART)

TALM 和 Toolformer 都是微调方案,相比于 Prompt 方案,在复杂问题规划上效果更好,但是很显然没有开箱即用的 Prompt 方案灵活。自动推理并使用工具 (Automatic Reasoning and Tool-use, ART) 是一种简单的工具增强的提示框架,由 Bhargavi Paranjape 等人于 2023 年发表的论文 ART: Automatic multi-step reasoning and tool-use for large language models 中提出,该框架的工作原理是在接到一个新任务时,从任务库中选择多步推理和使用工具的示范,然后在测试中,每当需要调用外部工具时,就暂停生成,将工具输出整合后再继续生成:

art.png

可以看出,ART 可以引导模型进行推理,同时还可以调用外部工具进行帮助,使得模型的性能得到提升。ART 相比于 Toolformer,不仅使用上更简单,而且没有 Toolformer 的局限性,支持链式调用和人工反馈,另外,ART 还支持手动扩展,只要简单地更新任务和工具库就可以修正推理步骤中的错误或是添加新的工具。

在 BigBench 和 MMLU 基准测试中,ART 在未见任务上的表现超过了少样本提示和自动 CoT,并且配合人类反馈后,它的表现超过了手写的 CoT 提示。

作者在 GitHub 上开源了 ART 的实现代码,有兴趣的可以参考一下。

任务规划

在上一篇笔记中,我们学习了不少改善大模型推理能力的提示技术,如思维链(CoT)、思维树(ToT)、最小到最多提示(Least-to-Most Prompting)等,在这一篇笔记中,我们又继续学习如何使用工具增强让大模型的能力得到更大的提升。尽量这两方面的研究都展示了令人印象深刻的效果,但是大模型在解决一些复杂任务时还是不尽如人意。于是研究人员开始将这两点结合起来,智能体的概念也随之浮出水面。

去年 6 月 23 日,OpenAI 的应用研究主管 Lilian Weng 在她的博客上发表了一篇文章 LLM Powered Autonomous Agents,她提出 智能体 = 大模型 + 记忆 + 任务规划 + 工具使用,如下图所示:

agent-overview.png

其中,记忆可以分为 短期记忆长期记忆,将所有的上下文都视为模型的短期记忆,而外部向量存储和快速检索则视为模型的长期记忆;工具使用表示的是模型通过调用外部 API 获取模型权重中缺失的额外信息,可以参考上文中介绍的内容;任务规划对应的是大模型的推理能力,具体表现在两个方面:

  • 任务分解:可以将大任务分解为多个更小的任务,生成行动计划,从而高效地处理复杂任务;
  • 反思和改善:可以对过去的行动进行自我批评和自我反思,从错误中吸取教训并为未来的步骤进行改进,从而提高最终结果的质量。

MRKL System

2022 年 5 月,以色列 NLP 研究机构 AI21 Labs 发表了一篇论文 MRKL Systems: A modular, neuro-symbolic architecture that combines large language models, external knowledge sources and discrete reasoning,提出了 MRKL 系统的概念。MRKL 全称为 Modular Reasoning, Knowledge and Language(模块化推理、知识和语言系统),发音为英文单词 miracle(奇迹),这是一种模块化的神经符号架构,试图将现有的神经网络模型(比如大模型),和外部知识库,以及过去流行的符号专家系统结合在一起,从而来兼顾神经模型和符号推理能力。

同时他们还基于 MRKL 实现了 Jurassic-X,其前身是对标 BERT、GPT-3、PaLM 等大模型的 Jurassic-1,在引入 MRKL 系统之前,这些大模型普遍表现出不能获取实时信息、不能访问外部知识、不擅长算术推理、更新成本高等缺点,论文中给出了一些 GPT-3 回答错误(甚至离谱)的例子:

mrkl-error-examples.png

尽管存在这些缺点,但 AI21 Labs 仍然认为,大型语言模型是未来人工智能系统的重要支柱。为解决这些问题,他们提出 MRKL 解决方案,概要设计如下:

mrkl-system.png

一个 MRKL 系统由一组可扩展的模块和一个路由器组成,路由器将每个传入的自然语言输入路由到一个可以最好地响应输入的模块。这些模块被称之为 专家(experts),它们可以是:

  • 神经网络:包括通用的大型语言模型以及其他更小的、专门的语言模型;
  • 符号系统:包括数学计算器、货币转换器或对数据库的 API 调用等;

通过将符号系统和神经网络相结合,我们可以充分挖掘大型语言模型的潜力。论文中给出了一个计算器的测试用例,当被问到 123 乘以 456 等于多少? 时,MRKL 系统将其路由到计算器应用程序,并从问题中提取出算式,从而得出计算结果。此外,Jurassic-X 的这篇博客 中还介绍了很多 MRKL 的应用场景,涉及到日常生活中的各种问题,感兴趣的同学可以直接阅读原文。

当然,要完成所有这些工作还有很多细节和挑战,比如训练离散专家、平滑符号与神经网络之间的接口、在不同模块之间进行路由等等。遗憾的是,论文中并没有给出 MRKL 的训练方法和代码,只是高屋建瓴地从概念上对 MRKL 系统进行了阐述。下面介绍几种类似 MRKL 系统的实现。

ReAct

推理和行动(Reasoning and Acting,ReAct) 是 Shunyu Yao 等人在 ReAct: Synergizing Reasoning and Acting in Language Models 这篇论文中提出的一种推理框架,作者通过语言模型以交错的方式生成 推理轨迹任务特定的行动,从而在两者之间实现更大的协同效应:推理轨迹帮助模型诱导、跟踪和更新行动计划,并处理异常情况,而行动则使其能够与知识库或外部环境进行交互,以收集额外信息。

这类似于我们人类在处理复杂问题时的行为,通过推理和行动之间的紧密协同作用,使我们能够快速学习新任务并执行强大的推理和决策,即使面临不可预见的情况时,我们也能做到这一点。

react.png

下图是 ReAct 和其他提示技术的一个对比:

react-hotspot-qa.png

我们向语言模型提出一个问题:

除了苹果遥控器,还有哪些设备可以控制苹果遥控器最初设计用来交互的程序?

这个问题来自于 HotpotQA,这是一个需要复杂推理的问答数据集。可以看出无论是使用思维链提示(Reason Only)还是使用工具增强(Act-Only)得到的答案都是错的,只有将推理和行动两者结合起来,才能得到准确的答案。ReAct 首先通过推理明确下一步要做什么(Thought 1),接着执行一个动作(Act 1)发送查询请求,然后收到了一个观察(Obs 1),一直重复这个循环,直到达到结论(Act 4)。通过 ReAct 不仅得到了正常的答案,而且我们可以看到模型一步步解决问题的轨迹(task solving trajectories),具有很好的可解释性。

那么 ReAct 是如何工作的呢?其实还是通过少样本提示来实现的,ReAct 在提示中包含了少量的推理轨迹示例,推理轨迹由多个 思考-操作-观察(Thought-Action-Observation) 步骤组成,如下:

问题 科罗拉多造山带东部区域延伸到的区域的海拔范围是多少?
思考 1 我需要搜索科罗拉多造山带,找到科罗拉多造山带东段延伸的区域,然后找到该区域的海拔范围。
操作 1 搜索 [科罗拉多造山带]
观察 1 科罗拉多造山带是科罗拉多及其周边地区造山运动的一段。
思考 2 它没有提到东部地区。所以我需要查一下东部地区。
操作 2 查找 [东部地区]
观察 2 (结果 1 / 1) 东部区域延伸至高平原,称为中原造山带。
思考 3 科罗拉多造山运动的东部区域延伸到高平原。所以我需要搜索高平原,找到它的海拔范围。
操作 3 搜索 [高平原]
观察 3 高平原指的是两个截然不同的陆地区域之一。
思考 4 我需要搜索高平原(美国)
操作 4 搜索 [高平原(美国)]
观察 4 高平原是大平原的一个分区。从东到西,高平原的海拔从 1800 到 7000 英尺 (550 到 2130 米) 不等。
思考 5 高平原海拔高度从 1800 到 7000 英尺,所以答案是 1800 到 7000 英尺。
操作 5 结束 [1800 到 7000 英尺]

不同类型的任务可以使用不同的示例,比如对那些以推理为主要目标的任务,使用多个思考-操作-观察的步骤有助于任务的解决,而对于涉及许多操作步骤的决策任务来说,则较少使用思考。

论文还给出了 ReAct 在不同任务上的表现结果,在知识密集型推理任务如问答(HotpotQA)和事实验证(Fever)方面,ReAct 通过与维基百科 API 交互,克服了思维链推理中普遍存在的幻觉和错误传播问题,生成了比没有推理痕迹的基准更易解释的类人任务解决轨迹。

论文结果显示,ReAct 在 Fever 上的表现优于 CoT,而在 HotpotQA 上落后于 CoT,作者对此进行了总结:

  • ReAct 的结构性约束降低了它在制定推理步骤方面的灵活性;
  • ReAct 在很大程度上依赖于它正在检索的信息,非信息性搜索结果阻碍了模型推理,并导致难以恢复和重新形成思想;

将链式思考、自我一致性、ReAct 几种提示方法结合起来,通常优于所有其他提示方法。

另外,在两个交互式决策型任务(ALFWorldWebShop)上,只需一两个上下文示例的提示,ReAct 就实现了分别比模仿学习和强化学习方法高出 34% 和 10% 的成功率。不过要注意的是,尽管在这些类型的任务中,ReAct 的推理显露出优势,但目前基于提示的方法在这些任务上的表现与人类专家相差甚远。

ReAct 的实现代码在 GitHub 上开源了,有兴趣同学的可以尝试下。另外,LangChain 基于 ReAct 的思想实现了 Zero-shot ReAct Agent,关于它的使用方法可以参考我之前写的 大模型应用开发框架 LangChain 学习笔记

Self-ask Prompting

前面我们提到过一个概念叫 组合性差距(Compositionality Gap),它表示语言模型能够准确地给出解决问题的推理步骤,但是最终回答却是错的这种现象。这一概念最早由 Ofir Press 等人在 Measuring and Narrowing the Compositionality Gap in Language Models 这篇论文中提出的,他们指出可以通过推理来缩小组合性差距,例如引发思维链,同时他们提出了一种新的方法,即 自问自答(Self-ask),进一步改进了思维链的效果。

Self-ask 的工作原理是,模型在回答初始问题之前,明确地向自己提出后续问题并回答,直到不需要再提问为止:

self-ask.png

Self-ask 有点类似于之前学过的 最少到最多提示(Least-To-Most Prompting),将问题分解为更小的后续问题来解决。Self-ask 也依赖于少样本的思维链提示,但是不同于传统的思维链,Self-ask 在提示中不断的反问自己 Are follow up questions needed here,让模型生成后续问题,回答之后再继续反问自己,直到得到最终答案。得益于 Self-ask 的结构化提示,我们能够轻松地插入搜索引擎来回答后续问题,从而进一步提高准确性。

Self-ask 的原理很简单,实现起来也比较容易,可以参考 GitHub 上的源码。

另外,Harsh Trivedi 等人提出的 IRCoT(Interleaving Retrieval with Chain-of-Thought) 方法,将 CoT 生成步骤和信息检索步骤交错使用,和 Self-ask 非常类似。

Plan-and-Solve Prompting

对于 ReAct 和 Self-ask,工作原理基本上是一样的:给定一组工具,然后大模型根据用户的输入一步一步地选择工具来执行,每一步的结果都用于决定下一步操作,直到问题被解决。这种逐步执行的 Agent 通常被称为 Action Agent,这些 Agent 本质上使用的都是少样本的思维链提示,比较适合小型任务;如果要处理需要保持长期目标的复杂任务,使用 Action Agent 经常会出现推理跑偏的问题。

为了解决这个问题,Lei Wang 等人提出了 Plan-and-Solve Prompting 提示技术,对应的论文为 Plan-and-Solve Prompting: Improving Zero-Shot Chain-of-Thought Reasoning by Large Language Models,这个提示会提前对问题制定好完整的执行计划,然后在不更新计划的情况下逐步执行,即先把用户的问题拆解成多个子任务,然后再执行各个子任务,直到用户的问题完全被解决。

该提示技术其实和零样本思维链提示非常类似,只是将 Let’s think step by step 换成了下面的提示词:

Let's first understand the problem and devise a plan to solve the problem.
Then, let's carry out the plan to solve the problem step by step.

下图是传统的零样本思维链提示和 PS 提示对比示例:

plan-and-solve.png

Plan-and-Solve Prompting 的实现代码可以在 GitHub 上找到,要注意的是它只是一种提示技术,并没有调用外部工具的能力,论文中为了让大模型能正确的处理数学计算问题,还列出了多种改善后的 PS+ 提示词,比如在提示词中添加 pay attention to calculation 要求大模型尽可能准确地进行计算,添加 extract relevant variables and their corresponding numerals 指示大模型不要忽略输入问题陈述中的相关信息,添加 calculate intermediate results 增强大模型生成推理步骤的能力。

所以单独使用 Plan-and-Solve Prompting 在智能体中作用并不大,一般使用这种思想来将用户的问题拆解成子任务,然后每个子任务再使用传统的 Action Agent 进行处理。在 大模型应用开发框架 LangChain 学习笔记(二) 中,我们学习了 Plan and execute Agent 的概念和用法,它的基本思想就来自于此。

plan-and-execute.png

除此之外,这篇博客 还介绍了另两种 Plan and execute Agent:LLMCompilerReWOO,并提供了基于 LangChain 的实现,有时间再深入研究下。

HuggingGPT

关于任务规划和工具增强的另一个典型例子是由微软提出的 HuggingGPT,对应的论文为 HuggingGPT: Solving AI Tasks with ChatGPT and its Friends in Hugging Face

HuggingGPT 将 ChatGPT 作为控制器,首先对用户的请求任务进行规划,拆分成不同的子任务,然后在 Hugging Face 提供的开源模型库中选择合适的 AI 模型来完成子任务,最终将结果汇总返回给用户。整个工作流程如下:

hugginggpt.png

HuggingGPT 最有意思的一点是它使用的所有工具都来自于 Hugging Face 的开源模型,由于模型非常多,所以在实际使用过程中,首先需要进行模型选择,例如根据模型下载量和任务相关性进行排序,选出 top-K 模型列表,将模型名称和描述等信息放到上下文提示词里,再由大模型选择合适的模型并执行。

下面是一个更具体的任务示例:

hugginggpt-overview.png

可以看到,这是一个比较复杂的任务,任务要求生成一张照片,照片中要包含一个小女孩在读书,且小女孩的姿势要和 example.jpg 中的男孩一样,然后使用语音描述下新生成的图片。HuggingGPT 将这个任务划分成了 6 个子任务,pose-control -> pose-to-image -> image-class -> object-det -> image-to-text -> text-to-speech,并依次执行。

对 HuggingGPT 感兴趣的同学可以参考开源项目 JARVIS 的实现。

参考

更多

论文集锦

其他提示技术

工具增强

LLMs As Tool Makers(LATM)

提示工程安全

Plan-and-Execute Agents

提示工程实战

扫描二维码,在手机上阅读!

提示工程学习笔记

在之前的笔记中,我们学习了很多大模型的使用技巧,比如 实现一个划词翻译插件实现基于文档的问答助手实现基于数据库的问答助手 等等,在这些使用场景中,我们应该都或多或少听过 提示工程(Prompt Engineering) 这个概念;另外,在 大模型应用开发框架 LangChain 学习笔记(二) 这篇笔记中,我们学习了什么是 智能体(Agent),并使用 LangChain 实现了几种不同类型的智能体,将提示工程技术发挥得淋漓尽致。那么到底什么是提示工程呢?提示工程又有哪些使用技巧呢?这篇笔记就来系统地学习下相关知识。

什么是提示工程

根据 《Prompt Engineering Guide》 这份指南中对提示工程的解释,提示工程(Prompt Engineering) 是一门关注于 提示词(Prompt) 的开发和优化的学科,能够帮助用户将大模型用于各种应用场景和研究领域,比如我们可以利用提示工程来提升大模型处理复杂任务的能力(如问答和算术推理);或者实现大模型与其他生态工具的对接。

所谓提示词,说白了就是我们给大模型下发的指令,提示词写对了,大模型才能输出相应的结果,提示词写的越好,大模型输出的结果就越准确。提示词由下面的一个或多个要素组成:

  • 指令(Instruction):给模型下达指令,或者描述要执行的任务;
  • 上下文(Context):给模型提供额外的上下文信息,引导模型更好地响应;
  • 输入数据(Input Data):用户输入的内容或问题;
  • 输出指示(Output Indicator):指定输出的类型或格式;

提示词所需的格式取决于你完成的任务类型,并非所有以上要素都是必须的。比如在前面的笔记中,我通过下面的提示词实现了英汉翻译:

Translate this into Simplified Chinese:

The OpenAI API can be applied to virtually any task that involves understanding or generating natural language, 
code, or images.

这个提示词只包含了 指令输入数据 两个部分。我还通过下面的提示词实现了基于文档的问答:

你是一个知识库助手,你将根据我提供的知识库内容来回答问题
已知有知识库内容如下:
1. 小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。
2. 小红家也有一条宠物狗,叫大白,非常听话。
3. 小红的好朋友叫小明,他们是同班同学。
请根据知识库回答以下问题:小明家的宠物狗叫什么名字?

这里除 指令输入数据 之外,还新增了 上下文 部分。可以看到,这些提示词都非常简单,而且效果也都还不错,这其实得益于大模型强大的自然语言处理能力。对于这种简单的任务,提示工程的作用并不明显。但是对于一些复杂的任务,比如算术和推理,或者解决大模型的局限性问题,比如幻觉和上下文限制等,不同的提示工程技术可以大大改善大模型的输出效果。

基本原则

提示工程是一门经验科学,提示词的细微差别可能会导致不一样的输出结果,甚至相同的提示工程技术,在不同模型之间也可能效果会有很大的差异,因此提示工程需要进行大量的实验和测试。尽管如此,编写提示词还是有一些通用的原则可以遵守的。

从简单开始

在设计提示词时,需要记住这是一个迭代的过程,需要大量的实验来获得最佳结果。避免从一开始就引入过多的复杂性,而应该从简单的提示词开始,然后不断地添加更多的元素和上下文,观察效果是否提高,在这个过程中对提示词进行版本控制。

比如你可以从零样本提示开始,如果效果不好,再改用少样本提示,如果效果还不好,再改用 Fine-tuning 方案。

另外,当你面对一个复杂的大任务时,可以尝试将任务分解为更简单的子任务,通过构建不同的提示词来解决每个子任务。

使用指令

正如前文所述,指令是提示词的几大要素之一,通过指令可以完成一些简单任务,比如:分类、总结、翻译等。在 OpenAI 的提示工程最佳实践 中,建议将指令放在提示的开头,并使用一些诸如 ###''' 的分隔符来分隔指令和上下文:

总结下面的文本内容,将其中的要点以列表形式展示出来。

文本内容:"""
{text input here}
"""

减少不精确的描述

确保你的提示词是明确的(Be specific)、具体的(Descriptive)、并且尽可能详细的(As detailed as possible),可以把和大模型的对话类比为和人的对话,沟通越直接,信息传递就越有效。比如下面是一个反例:

写一首关于 OpenAI 的诗

这个提示词就不够精确,我们应该对诗的内容做进一步描述才能让大模型更好的生成内容:

写一首鼓舞人心的关于 OpenAI 的短诗,聚焦最近的 DALL-E 产品发布(DALL-E 是一种文本到图像的机器学习模型),风格类似于莎士比亚。

下面是另一个描述不够精确的例子:

对该产品进行描述,描述应该相当简短,只有几句话,不能过多。

这个提示词啰里啰嗦,而且使用了一些模糊不清的概念,我们可以改得更直接、更具体、更简洁:

使用 3 到 5 句话描述该产品。

通过示例明确输出的格式

我们如果对模型的输出格式有特殊要求,最好提供几个示例,比如下面这个例子:

提取下面文本中的公司名称和成立时间。

以 JSON 格式输出:
[
    { "name": "XXX", "establish_time": "XXX" },
    { "name": "YYY", "establish_time": "YYY" }
]

文本内容:"""
{text input here}
"""

这样的输出格式有一个好处,我们可以在程序中对大模型的输出进行可靠地解析。

避免说不要做什么

设计提示词的另一个常见技巧是避免说不要做什么,而是说要做什么。下面是一个反例:

下面是客户和代理商之间的对话。不要问客户的用户名和密码。不要重复回复的内容。

客户:我登录不了我的账号
代理商:

改成下面这样会更好:

下面是客户和代理商之间的对话。代理商将尝试诊断问题并给出解决方案,同时避免询问客户的个人信息(如用户名和密码),
当涉及到这些信息时,建议用户访问帮助文档:www.samplewebsite.com/help/faq

客户:我登录不了我的账号
代理商:

角色扮演

当我们使用大模型构建一个客服聊天机器人之类的对话系统时,可以在提示词中明确它的身份和意图,就像玩角色扮演一样,比如:

我希望你扮演面试官的角色。我会充当一名 Java 开发工程师的候选人,然后你要问我关于这个职位的面试问题。你要像面试官一样说话。
不要一次写下所有的对话,不要写解释,像面试官一样一个接一个地问我问题,然后等待我的答复。我的第一句话是 “你好”。

这时大模型就变成了一位 Java 面试官,这种技巧有时也被称为 角色提示(Role Prompting)。你也可以尝试其他角色,比如教师、小说家、医生、足球评论员,甚至可以让它扮演 Linux 终端、浏览器、Python 执行器等等,这里有大量案例可供参考:Awesome ChatGPT Prompts

提示词框架

上面提到,一个提示词是由指令、上下文、输入数据和输出指示这几个要素中的一个或多个组成的,这其实就为如何编写提示词提供了一个基础框架,最初由 Elavis Saravia 在 《Prompt Engineering Guide》 中总结的。

除此之外,还有一些提示词框架对提示词的格式和内容做了更明确的定义,比如 Matt Nigh 的 CRISPE 框架

  • CR: Capacity and Role(能力与角色)。你希望 ChatGPT 扮演怎样的角色。
  • I: Insight(洞察力),背景信息和上下文。
  • S: Statement(指令),你希望 ChatGPT 做什么。
  • P: Personality(个性),你希望 ChatGPT 以什么风格或方式回答你。
  • E: Experiment(实验),要求 ChatGPT 为你提供多个答案。

云中江树的 结构化提示词

# Role: Your_Role_Name

## Profile

- Author: YZFly
- Version: 0.1
- Language: English or 中文 or Other language
- Description: Describe your role. Give an overview of the character's characteristics and skills

### Skill 1
1. xxx
2. xxx

### Skill 2
1. xxx
2. xxx

## Rules
1. Don't break character under any circumstance.
2. Don't talk nonsense and make up facts.

## Workflow
1. First, xxx
2. Then, xxx
3. Finally, xxx

## Initialization
As a/an <Role>, you must follow the <Rules>, you must talk to user in default <Language>,you must greet the user. 
Then introduce yourself and introduce the <Workflow>.

感兴趣的同学可以尝试一下。

提示工程技术

上面介绍了设计提示词时应该注意的基本原则,遵守这些原则有助于让大模型输出你期望的结果,另外,还有一些提示技术或技巧,也可以大大提高大模型的效果。

零样本提示(Zero-shot Prompting) vs. 少样本提示(Few-shot Prompting)

零样本提示(Zero-shot Prompting)少样本提示(Few-shot Prompting) 是最基础的提示技术。零样本提示就是直接向模型输入文本以获取回答,比如:

文本:今天的天气真不错!
情感分类:

有些模型会直接输出分类结果:

积极

有些模型还会输出一些解释性的内容:

您的文本:“今天的天气真不错!”表示的是一种积极或正面的情感。
这种表达通常反映出满足、愉悦或幸福的情绪。因此,情感分类可以是“正面”或“积极”。

这可能并不是我们所想要的,这时,我们就可以通过少样本提示来引导大模型输出我们期望的格式:

文本:这是我读过最精彩的一本小说!
情感分类:积极

文本:这部电影内容一般般啊!
情感分类:消极

文本:这是一部关于友谊的电影。
情感分类:中性

文本:今天的天气真不错!
情感分类:

少样本提示通过提供一些包含输入和期望输出的示例,让大模型更好地理解我们的意图,因此,少样本提示通常比零样本提示有更好的表现,然而它是以消耗更多的 token 为代价的,并且当输入和输出文本很长时可能会达到上下文长度限制。

少样本提示技术由 Tom Brown 等人 2020 年在 Language Models are Few-Shot Learners 这篇论文中提出,这项技术利用了大模型的 上下文学习(In-context Learning) 能力,即大模型可以从少量的示例数据中学习新任务,而无需进行任何参数更新。Sewon Min 等人在 Rethinking the Role of Demonstrations: What Makes In-Context Learning Work? 这篇论文中做了更深入的研究,探讨了少样本提示是如何工作的?以及它为什么是有效的?论文中还总结了一些有趣的结论:

  • 示例数据中的标签空间和输入文本的分布对于上下文学习至关重要,就算标签是错的也所谓;
  • 整体格式也至关重要,当标签空间未知时,使用随机的单词作为标签也比不使用标签要好得多;

比如将上面的例子改成下面这样:

文本:这是我读过最精彩的一本小说!
情感分类:消极

文本:这部电影内容一般般啊!
情感分类:中性

文本:这是一部关于友谊的电影。
情感分类:积极

文本:今天的天气真不错!
情感分类:

尽管示例数据中的分类结果都是错的,但是大模型依然可以输出正确的结果。

如何构建少样本提示中的示例数据是另一个值得探讨的课题,目前已经有很多论文对此进行了研究。Tony Z. Zhao 等人在 Calibrate Before Use: Improving Few-Shot Performance of Language Models 这篇论文中提出:提示词的格式、示例数据的选择以及示例数据的顺序都可能导致截然不同的性能。

论文中进一步指出,出现这种现象的原因可以归结为如下几种偏差:

  • 多数标签偏差(Majority label bias):当示例中的标签分布不平衡时会出现;
  • 近因效应偏差(Recency bias):模型可能会出现在末尾重复标签的倾向;
  • 常见令牌偏差(Common token bias):模型更倾向于生成常见令牌而不是罕见令牌;

为了克服这些偏差,论文中提出了一种方法,使用一个无内容的输入(如:N/A)来估计模型对每个答案的偏差,然后调整参数,使得对于这个输入的预测在所有答案上均衡。

关于示例数据的选择有几个普遍建议可供参考:

  • 保持示例数据的多样化
  • 与测试样本相关
  • 并且以随机顺序排列

如果想更深入地学习相关的内容,下面这些论文可供参考:

指令提示(Instruction Prompting)

在少样本提示中,我们提供少量示例数据的目的是向大模型解释我们的意图,那么,为什么我们不直接将我们的意图告诉大模型呢?

对下面的文本进行情感分类,分类结果可以是“积极”、“消极”或“中性”。

文本:今天的天气真不错!
情感分类:

能从指令中理解用户意图的模型我们称之为 指令模型(Instructed LM),这些模型通过高质量的数据(包括指令、输入和输出)对预训练模型进行微调,以使语言模型更好地理解用户意图并遵循指令,这个过程叫做 指令微调(Instruction Tuning)

Google 在 2021 年首次提出指令微调可以解锁大模型的指令理解能力,并发布了 FLAN 模型;BigScience 紧随其后,发布了 T0 模型,相对 FLAN 来说,它的指令数据集更加丰富多样;正当 Google 和 BigScience 还在各种不同的标准任务上评估大模型能力提升时,OpenAI 却开始从另一个角度来评估人工智能,那就是如何更好地帮助人类解决问题,它将数据集从标准的 NLP 任务改成用户提交的真实问题,最终在 2022 年发布了 InstructGPT 模型,并在 InstructGPT 的基础上训练出了风靡全球的 ChatGPT;之后还有 AllenAI 发布的 TK-Instruct 模型,它使用了更大规模的指令数据集进行训练,并将 指令集完全开源,推动了指令模型的发展。

这些指令模型都有对应的论文:

此外,指令微调常见的方法是 RLHF(Reinforcement Learning Human Feedback,来自人类反馈的强化学习),可以让模型被调整得更好地适应人类的偏好。

目前市面上的大语言模型基本上都是指令模型,在与指令模型交互时,我们要遵守上一节中介绍的基本原则,指令要求要详细,尽量具体和准确,避免说不做什么,而是说明要做什么。

指令提示和少样本提示可以组合使用,Seonghyeon Ye 等人在 Investigating the Effectiveness of Task-Agnostic Prefix Prompt for Instruction Following 论文中提出一种 In-context Instruction Learning 的方法,他们在提示词中包含了不同任务的多个示例:

任务:确定对话的发言人是 “代理商” 还是 “客户”
输入:我已经成功为你预定了机票。
输出:代理商

任务:确定问题所属的类别是 “数量” 还是 “位置”
输入:美国最古老的建筑是什么?
输出:位置

任务:对给定的电影评论进行分类,“积极” 还是 “消极”
输入:我猜视频游戏一定比电影有趣多了。
输出:

通过这种方式可以显著提高预训练模型和指令微调模型的零样本任务泛化性能。

思维链(CoT)

传统的少样本提示可以显著提高大模型在分类、翻译、生成等任务中的性能,但是在处理算术、常识、符号推理等任务时却不那么明显。Jason Wei 等人于 2022 年发表论文 Chain-of-Thought Prompting Elicits Reasoning in Large Language Models,提出了一种新的名为 思维链(Chain of Thought, CoT) 的提示技术,通过向大模型展示中间推理步骤实现了复杂的推理能力,结合少样本提示还可以获得更好的结果。

下面是思维链的一个经典示例:

cot.png

左边是传统的提示技术,首先向大模型展示一个问题样例以及该问题的答案,我们希望大模型能直接给出答案,但是很可惜,结果是错的;右边是使用思维链提示技术,和左边一样,也是向大模型展示一个问题样例,但是接下来我们不是直接给出问题的答案,而是给出解答该问题的推理过程,这样大模型就会模仿你的推理步骤,并成功解决新的未知问题。

虽然思维链很强大,但是要注意的是,这种能力只有在足够大的语言模型上才会涌现(大于等于 100B),在较小的模型上使用思维链效果可能比标准提示更差。

在 Jason Wei 等人的论文发表后不久,Takeshi Kojima 等人也发表了一篇关于思维链的论文:Large Language Models are Zero-Shot Reasoners,论文中介绍了 零样本思维链(Zero-Shot-CoT) 技术,而 Jason Wei 等人提出的技术被称为 少样本思维链(Few-Shot-CoT),和之前的思维链不同的是,零样本思维链不需要在提示词中给出解决问题的推理过程,而是直接在提示词中加上一句 让我们逐步思考(Let's think step by step.) 这样的话即可:

zero-cot.png

这么简单的一句话,竟然可以起到这么大的作用,着实让人意想不到。有趣的是,论文中还尝试了不少其他的提示词,最终发现 Let's think step by step. 效果最好:

zero-cot-exaples.png

不过,零样本思维链通常不如少样本思维链有效,只有当你没有太多的示例数据时可以尝试一下。此外,这个技巧除了用于解决复杂的推理问题,还适合生成一些连贯主题的内容,比如写长篇文章、电影剧本等。

自我一致性(Self-Consistency)

根据上面的学习我们知道,思维链提示是让大模型模仿示例数据生成一系列的推理步骤,最终解决用户问题,但是很显然,大模型在生成中间步骤时仍然是可能出错的。Xuezhi Wang 等人在 2022 年提出的一种改进思维链的方法,即 自我一致性(Self-Consistency),参见论文 Self-Consistency Improves Chain of Thought Reasoning in Language Models

自我一致性的想法很简单,通过多次执行 CoT 得到多个推理路径,然后在多个结果中投票选择最一致的答案:

self-consistency.png

从上图可以看出自我一致性方法整体包括三个步骤:

  1. 构造 CoT 示例数据;
  2. 通过大模型生成多个不同的推理路径(reasoning path);
  3. 使用多数投票(majority vote)的方法选出最一致的答案;

虽然这种方式有点大力出奇迹的感觉,但是它确实可以提高思维链在算术和常识推理等任务中的性能。在具体的使用过程中,还有两个问题值得注意:

  1. 在生成多个推理路径时,一般将模型的温度值设置为 0.5,因为这个值如果设置过小会导致答案基本都一样,过大又会导致答案全都不一样,都会影响到最终的效果;
  2. 需要生成多少个推理路径(也就是采样次数)也是一个问题,从论文结果来看,候选样本数越多,最终效果越好,论文中一共采样了 40 次,但在实际应用中不可能这样豪横,一般采样 5 次以上就能超过普通的思维链提示;

自我一致性本质上是一种集成学习方法,Xuezhi Wang 等人后来又对其进行了优化,提出了 推理增强集成(Rationale-Augmented Ensembles) 方法,通过改变示例顺序或使用模型生成的推理链来替换人工编写的推理链,在多个样本试验中引入随机性,然后通过多数投票来聚合模型输出,得到最终答案,参见论文 Rationale-Augmented Ensembles in Language Models

最少到最多提示(Least-to-Most Prompting)

零样本思维链(Zero-Shot-CoT) 那篇论文中,作者提出了一种利用大模型进行两阶段推理的设想:

zero-cot-stages.png

第一个阶段先进行问题的拆分并分段解答问题(Reasoning Extraction),然后第二阶段再进行答案的汇总(Answer Extraction),这给了最少到最多提示很大的启发。

最少到最多提示(Least-to-Most Prompting,LtM) 也是一种改进思维链提示的方法,由 Denny Zhou 等人在 Least-to-Most Prompting Enables Complex Reasoning in Large Language Models 这篇论文中提出。

ltm.png

LtM 提出的初衷是为了解决 CoT 泛化能力不足的问题:即通过人工编写的示例数据可能并不能够很好的迁移到别的问题当中去,这种泛化能力的不足会导致新的问题无法使用老的模板进行解决。所以一个思想就是:让大模型自己找到解决当前问题的思维链。

相比于自我一致性,LtM 明显更优雅一些,它的思路使用了分治的思想,首先将大问题拆分成小问题,然后依次解决小问题,最后解决大问题:

  1. 问题拆解(Problem Reducing):第一步自上而下的分解问题,引导模型把问题拆分成子问题;
  2. 子问题有序解答(Sequentially Solve Subquestions):第二步自下而上的依次解决问题,逐一回答子问题,并把子问题的回答作为下一个子问题回答的上下文,循序渐进地解决问题,直到给出最终答案;在这个依次回答问题的过程中,问题由少变多,这也是 Least-to-Most 一词的来源。

思维树 (ToT)

传统的思维链提示,以及基于思维链的改进方法比如自我一致性,都存在着明显的缺陷:

  • 对于局部,并没有对思考过程的不同分支进行探索,生成的思维链都是一条路走到黑,不会去思考每一步有没有其他分支的解决方案;
  • 对于全局,没有利用任何类型的规划、前瞻以及回溯来帮助评估不同的选择,而这种启发式的探索正是人类解决问题的特性;真正的问题解决过程涉及反复利用可用信息来启动探索,进一步揭示更多信息,直到最终发现解决方法;

为解决这些不足,Shunyu Yao 等人在 2023 年 5 月发表了一篇论文 Tree of Thoughts: Deliberate Problem Solving with Large Language Models,提出了 思维树(Tree of Thoughts,ToT) 的框架,让语言模型可以探索多个推理路径,把解决问题视作在一棵树上的搜索,树上的每个节点代表问题以及到目前为止的思考过程:

tot.png

ToT 允许语言模型在解决问题的中间过程进行探索,通过考虑多种不同推理路径并进行评估,同时具备向前看跟向后回溯的能力以获得更佳决策选择。一个完整的 ToT 包括下面四个过程:

  1. 思考分解(Thought deconposition) - 如何将推理中间过程分解成多个想法步骤

ToT 会根据问题属性去设计和分解中间的想法过程,每个想法应该足够小,使得语言模型可以生成有潜力跟多样的样本,同时又应该足够大,使得语言模型可以评估该想法解决问题的潜力;

  1. 想法生成器(Thought generator) - 如何根据当前状态生成候选想法

文中提供了 Sample 和 Propose 两个想法生成策略,前者利用 CoT prompt 多次采样,这种方式能保证多样性,在想法空间更宽泛时效果更佳,后者依据 "propose prompt" 依次生成想法,可以避免同一个上下文生成重复的想法,更适用于思维空间受限的场景;

  1. 状态评估器(State evaluator) - 如何启发性地评估状态

给定不同的当前状态,让状态评估器评估它们对于解决问题的帮助,以确定哪些状态值得继续探索,以及以何种方式探索;

  1. 搜索算法(Search algorithm) - 使用什么搜索算法

在 ToT 框架中,可以根据树形结构插入和使用不同的搜索算法,文中探索了两种相对简单的搜索算法:BFS 广度优先算法,每一步中保留最优潜力的 K 个状态;DFS 深度优先算法,优先探索最优潜力的状态,直到得到最终结果,或者超过当前状态被评估不可能解决问题就停止,如果是后者的话可以退回父节点,继续进行探索。

论文中使用 ToT 开展了三个不同的实验:24 点游戏、迷你填字游戏 和 创意文本生成,都取得了非常好的表现,论文作者还在 GitHub 上开源了他们的代码 princeton-nlp/tree-of-thought-llm,感兴趣的同学可以尝试下。

另外,除了 Shunyu Yao 等人发表的这篇关于思维树的论文外,Jieyi Long 也发表了一篇类似的论文 Large Language Model Guided Tree-of-Thought,他提出由强化学习(Reinforcement Learning)训练出的 “ToT 控制器”(ToT Controller)来驱动树的搜索策略,这种方法的好处是可以从新的数据集学习,或是在自对弈的过程中学习,使得 ToT 系统可以不断进化。

一般来说执行 ToT 的过程中会涉及到多次大模型的调用,在处理大型任务时 token 的消耗会非常大,于是就有人将 ToT 框架的主要概念概括成了一段简短的提示词,指导 LLM 在一次提示中对中间思维做出评估,下面是一些示例。

示例一

Imagine three different experts are answering this question.
All experts will write down 1 step of their thinking,
then share it with the group.
Then all experts will go on to the next step, etc.
If any expert realises they're wrong at any point then they leave.
The question is...

示例二

Three experts with exceptional logical thinking skills are 
collaboratively answering a question using the tree of thoughts method. 
Each expert will share their thought process in detail, 
taking into account the previous thoughts of others and admitting any errors.
They will iteratively refine and expand upon each other's ideas, giving credit where it's due.
The process continues until a conclusive answer is found.
Organize the entire response in a markdown table format.
The task is:

后退提示(Step-Back Prompting)

后退提示(Step-Back Prompting) 是 Google DeepMind 团队在论文 Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models 中提出的一种新的提示技术,它的灵感来自于 当人类面对具有挑战性的任务时,其思维经常会出现退一步并进行抽象,以得出指导过程的高级概念和原则,后退提示解决问题的方法就是要求大模型先后退一步,重新考虑问题的基础原理,有助于避免直接跳入细节而导致错误。

后退提示使大模型能够从包含具体细节的实例中进行抽象,得出高级概念和基础原理,然后利用这些概念和原理来指导推理步骤,从而解决复杂问题。实验表明,在各种具有挑战性的推理密集型任务中,包括 STEM、知识问答和多跳推理,后退提示都取得了显著的性能提升。这种策略与思维链等直接解决问题的方法形成了鲜明的对比,下图对后退提示与思维链提示在解决问题的方法上进行对比:

step-back.png

左侧是思维链提示,它是一个直接解决问题的过程,按步骤逐一推理。第一个示例(顶部)来自 MMLU 高中物理题:如果温度增加 2 倍且体积增加 1 倍,理想气体的压力 P 会发生什么变化?,使用思维链提示对此问题进行推理时偏离了理想气体定律的第一原理。

第二示例(底部)来自 TimeQA 中的例子,当问及 Estella Leopold 在 1954 年 8 月至 1954 年 11 月期间去了哪所学校?,详细的时间范围限制让大模型很难直接解决,而后退提示会先询问 “教育史”,这是一个包含原始问题的高级概念,因此大模型可以得到所有必要的信息来推理 “Estella Leopold 在特定时期去了哪所学校”。

从图中可以看出,后退提示分为两个步骤:

  • 第一步:抽象(Abstraction):首先将问题的基础原理和概念提取出来。例如,面对理化问题,先问 “解决这个任务涉及哪些物理或化学原理和概念?”,从而让模型先确定这些原理和概念;
  • 第二步:推理(Reasoning):有了基础原理后,再进行问题的解答。例如,根据气体定律来计算压力如何变化。

后退提示鼓励大模型着眼于大局,而不是迷失在细节中,通过 “先抽象、再推理” 的过程正确解答问题,而不是仅仅依靠直观的连续思考。后退提示鼓励深入理解问题的本质,因此可以促进更深层次的思考和更精确的推理。

检索增强生成 (RAG)

实验表明,大型语言模型能够从海量数据中学习到广泛的世界知识,这些知识以参数的形式存储在模型中,经过适当的微调就能在下游任务中取得 SOTA 表现。但是模型容量再大,也很难记住所有知识,这类通用语言模型在处理 知识密集型(knowledge-intensive) 任务时仍旧存在一定的局限性,比如知识更新不及时、生成虚假信息以及对不存在来源的引用等问题,也就是我们所说的 幻觉(hallucination)

治理幻觉的方式有很多,比如:在训练时提供更高质量的数据,对模型进行微调补充领域知识,在 RLHF 时给予奖励模型对数据真实性更高的倾向性,通过 Prompt 引导大模型避免生成缺乏依据的信息,以及这一节所介绍的 检索增强生成(RAG,Retrieval Augment Generation)

大模型的幻觉并非一无是处,有研究者指出幻觉是让大模型产出创意的基础。

RAG 早在 GPT 等大模型出来之前就有了相关的研究,例如 Facebook 在 2020 年 的研究提出,将模型知识分为 参数记忆(parametric memory)非参数记忆(nonparametric memory),也就是内部信息和外部信息,同时结合这两类信息来回答用户问题可以提供更准确的回复,而且可以减少模型的幻觉。这里的外部信息可以是文档、数据库、网页、笔记、日志、图片、视频、甚至可以是从 API 获取的数据等等,通常我们将这些外部信息切块后保存在向量数据库中,然后基于用户输入的问题做检索。

一个典型的 RAG 包含两个主要的部分:

  • 索引构建:首先准备和加载数据,将数据划分成小的数据块,然后对每个小数据块做向量表征存储,方便后续做语义检索;
  • 检索和生成:基于用户输入的问题,尽可能地检索出最相关的数据块,将检索出的数据块作为上下文和用户问题一起组合成 prompt 让大模型生成回答。

rag.png

Yunfan Gao 等人在 Retrieval-Augmented Generation for Large Language Models: A Survey 这篇论文中对 RAG 技术做了一个全面的总结,推荐阅读。

目前有很多开源的工具可以用来打造 RAG 系统,比如 LangChainLlamaIndex 的官方文档中都有很多关于 RAG 的示例可供参考。

生成知识提示(Generated Knowledge Prompting)

使用检索增强生成(RAG)可以让大模型根据外部知识回答用户问题,由此可见,整合外部知识可以改善大模型的表现,有趣的是,我们也可以通过大模型生成知识来提高它自身的能力。这是由 Jiacheng Liu 等人所提出的一种新型的提示工程技术,叫做 生成知识提示(Generated Knowledge Prompting),在论文 Generated Knowledge Prompting for Commonsense Reasoning 中首次提出,使用生成知识提示不需要整合外部知识,相反,它直接从通用语言模型中生成知识,然后将这些知识作为上下文来回答用户的问题。

knowledge.png

它的核心思想如上图所示,包含了两个步骤:

  • 知识生成:在这个步骤中,我们提供少量的示例数据,要求大模型生成有关用户问题的一组事实,也就是知识;
  • 知识集成:然后将这些生成的知识作为上下文来回答用户的问题;

自动提示工程师(APE)

通过上面的学习我们知道,任务性能在很大程度上取决于用于引导模型的提示的质量,而大多数有效的提示都是由人工手工制作的,那么有没有一种方法能自动生成提示呢?

其实,提示的本质就是通过输入一系列的前缀文本,增加获取所需输出的概率。因此,我们可以将它们视为可训练的参数,并通过梯度下降直接在嵌入空间中进行优化,针对这个问题目前有很多相关的研究,例如 AutoPromptPrefix-TuningP-tuningPrompt-Tuning 等。

Yongchao Zhou 等人在论文 Large Language Models Are Human-Level Prompt Engineers 中提出了一种更简单的方法:自动提示工程师(Automatic Prompt Engineer,APE)

APE 的目的是自动化进行指令生成和选择,通过 LLM 生成指令,将这些生成的指令放到一个指令池中,选择一个打分函数对这些指令进行打分,然后选择出分数最高的指令。整个过程可以概况为三步:

  1. 给定一组输入输出对形式的示例数据,让 LLM 生成候选指令,比如:{{Given desired input-output pairs}}\n\nThe instruction is
  2. 对于给定数据集,制定一个打分函数,比如准确率或对数概率,我们希望找到一个指令能够令其最大化;
  3. 使用迭代的蒙特卡洛搜索方法,通过提供类似语义的变体提示来改进最佳候选者,比如:Generate a variation of the following instruction while keeping the semantic meaning.\n\nInput: ...\n\nOutput:...

可以将整个工作流分为 推理(Inference)评分(Scoring)采样(Resampling) 三步,其最大的特点是三步都是基于 LLM 实现的,如下所示:

ape.png

有趣的是,作者通过 APE 方法还发现了一个比人工设计的零样本 CoT 更好的提示:

Let’s work this out in a step by step way to be sure we have the right answer.

该提示在 MultiArith 上获得了 82.0 的性能得分:

ape-zero-shot-cot.png

另外,除了 APE,还有很多论文也对自动生成提示做了更深入的研究,比如:

主动提示(Active Prompting)

通过借鉴基于不确定性的 主动学习(Active Learning) 的思想,Shizhe Diao 等人提出了一种新的示例选择方法 Active Prompting,引入度量标准来表征不确定性,然后选择最不确定的问题作为示例数据,论文地址 Diao et al. 2023, Active Prompting with Chain-of-Thought for Large Language Models

和 APE 一样,Active Prompting 也是一种自动生成提示的技术,它的流程图如下:

active-prompt.png

主要分四个阶段:

  1. 不确定性评估(Uncertainty Estimation):针对数据集中的所有问题,向 LLM 重复请求 k 次,通过一定的算法对产生的答案计算不确定性;我们知道 LLM 的输出有一定的随机性,同一个问题得到的结果可能是稳定的也可能是不稳定的,第一步就是要找到数据集中的最不稳定的问题;
  2. 选择阶段(Selection):选择不确定性指标最高的问题用于后续标注;
  3. 标注阶段(Annotation):人工参与对选择的问题进行标注;
  4. 推理阶段(Inference):使用这些人工标注的问题作为 CoT 的示例进行推理;

定向刺激提示(Directional Stimulus Prompting)

这是一种相对比较简单的提示方法,由 Zekun Li 等人发表在论文 Guiding Large Language Models via Directional Stimulus Prompting 中,它通过训练一个可调节的 策略语言模型(Policy LM) 来生成关键词或其他提示信息,然后将其和用户输入组合在一起作为下游的 LLM 的输入,这种方法对大模型的特定方向给予刺激,所以被称为 定向刺激提示(Directional Stimulus Prompting,DSP),它在内容总结或内容创作任务中可以实现更好的效果。

dsp.png

整个流程如下:

  1. 首先通过人工标注的数据(输入和刺激)训练出一个策略模型,这个策略模型可以很小,比如 T5;
  2. 根据用户输入使用策略模型生成刺激,作为指导下游 LLM 的提示;
  3. 将生成的刺激与原始输入相结合,作为下游 LLM 的输入,以引导其向刺激的方向生成文本;
  4. 生成的结果可以通过强化学习对策略模型再次进行训练,使 LLM 与人工偏好更好地结合起来;

下图是论文中的一个示例,对比了普通提示和定向刺激提示的差异:

dsp-example.png

总结

提示工程是一门实践性很强的学科,需要针对不同的任务,采取不同的策略,不断尝试和探索,才能达到理想的效果。在这篇笔记中,我们学习了提示工程的概念和基本原则,以及一堆的提示工程技术或技巧,如少样本提示和思维链提示等,大大改善了大模型的推理能力。不过大模型在其他方面仍然存在很多不足,比如不擅长数值计算,无法求解复杂方程,不能访问外部知识和工具等,因此研究人员又提出很多想法希望对语言模型进行增强,比如检索增强、编程增强、工具增强等,这样的语言模型被称为 增强语言模型(Augmented Language Models)。通过结合外部知识和工具,我们就可以打造出更高级的智能体应用,我们将在下一篇笔记中继续学习相关的知识。

参考

更多

结构化 Prompt

应用产品

其他提示技术

多模态提示

基于图的提示

Prompt Ensembling

数学推理

其他

扫描二维码,在手机上阅读!

基于结构化数据的文档问答

利用大模型打造文档问答系统对于个人和企业来说都是一个非常重要的应用场景,也是各大公司争相推出的基于大模型的落地产品之一,同时,在开源领域,文档问答也是非常火热,涌现出了一大批与之相关的开源项目,比如:QuivrPrivateGPTdocument.aiFastGPTDocsGPT 等等。我在 使用 Embedding 技术打造本地知识库助手 这篇笔记中介绍了文档问答的基本原理,通过 OpenAI 的 Embedding 接口实现了一个最简单的本地知识库助手,并在 大模型应用开发框架 LangChain 学习笔记 这篇笔记中通过 LangChain 的 RetrievalQA 再次实现了基于文档的问答,还介绍了四种处理大文档的方法(Stuff Refine MapReduceMapRerank)。

大抵来说,这类文档问答系统基本上都是基于 Embedding 和向量数据库来实现的,首先将文档分割成片段保存在向量库中,然后拿着用户的问题在向量库中检索,检索出来的结果是和用户问题最接近的文档片段,最后再将这些片段和用户问题一起交给大模型进行总结和提炼,从而给出用户问题的答案。在这个过程中,向量数据库是最关键的一环,这也是前一段时间向量数据库火得飞起的原因。

不过,并不是所有的知识库都是以文档的形式存在的,还有很多结构化的知识散落在企业的各种数据源中,数据源可能是 MySQL、Mongo 等数据库,也可能是 CSV、Excel 等表格,还可能是 Neo4j、Nebula 等图谱数据库。如果要针对这些知识进行问答,Embedding 基本上就派不上用场了,所以我们还得想另外的解决方案,这篇文章将针对这种场景做一番粗略的研究。

基本思路

我们知道,几乎每种数据库都提供了对应的查询方法,比如可以使用 SQL 查询 MySQL,使用 VBA 查询 Excel,使用 Cipher 查询 Neo4j 等等。那么很自然的一种想法是,如果能将用户的问题转换为查询语句,就可以先对数据库进行查询得到结果,这和从向量数据库中查询文档是类似的,再将查询出来的结果丢给大模型,就可以回答用户的问题了:

db-qa.png

那么问题来了,如何将用户的问题转换为查询语句呢?毋庸置疑,当然是让大模型来帮忙。

准备数据

首先,我们创建一个测试数据库,然后创建一个简单的学生表,包含学生的姓名、学号、性别等信息:

/*!40101 SET NAMES utf8 */;

CREATE DATABASE IF NOT EXISTS `demo` DEFAULT CHARSET utf8 COLLATE utf8_general_ci;

USE `demo`;

CREATE TABLE IF NOT EXISTS `students`(
   `id` INT UNSIGNED AUTO_INCREMENT,
   `no` VARCHAR(100) NOT NULL,
   `name` VARCHAR(100) NOT NULL,
   `sex` INT NULL,
   `birthday` DATE NULL,
   PRIMARY KEY ( `id` )
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_general_ci;

接着插入 10 条测试数据:

INSERT INTO `students` (`no`, `name`, `sex`, `birthday`) VALUES
('202301030001', '张启文', 1, '2015-04-14'),
('202301030002', '李金玉', 2, '2015-06-28'),
('202301030003', '王海红', 2, '2015-07-01'),
('202301030004', '王可可', 2, '2015-04-03'),
('202301030005', '郑丽英', 2, '2015-10-19'),
('202301030006', '张海华', 1, '2015-01-04'),
('202301030007', '文奇', 1, '2015-11-03'),
('202301030008', '孙然', 1, '2014-12-29'),
('202301030009', '周军', 1, '2015-07-15'),
('202301030010', '罗国华', 1, '2015-08-01');

然后将上面的初始化 SQL 语句放在 init 目录下,通过下面的命令启动 MySQL 数据库:

$ docker run -d -p 3306:3306 --name mysql \
    -v $PWD/init:/docker-entrypoint-initdb.d \
    -e MYSQL_ROOT_PASSWORD=123456 \
    mysql:5.7

将用户问题转为 SQL

接下来,我们尝试一下让大模型将用户问题转换为 SQL 语句。实际上,这被称之为 Text-to-SQL,有很多研究人员对这个课题进行过探讨和研究,Nitarshan Rajkumar 等人在 Evaluating the Text-to-SQL Capabilities of Large Language Models 这篇论文中对各种提示语的效果进行了对比测试,他们发现,当在提示语中使用 CREATE TABLE 来描述数据库表结构时,模型的效果最好。所以我们构造如下的提示语:

from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate

llm = OpenAI(temperature=0.9)

prompt = PromptTemplate.from_template("""根据下面的数据库表结构,生成一条 SQL 查询语句来回答用户的问题:

{schema}

用户问题:{question}
SQL 查询语句:""")

def text_to_sql(schema, question):
    text = prompt.format(schema=schema, question=question)
    response = llm.predict(text)
    return response

这个提示语非常直白,直接将数据库表结构和用户问题丢给大模型,让其生成一条 SQL 查询语句。使用几个简单的问题测试下,发现效果还可以:

schema = "CREATE TABLE ..."
question = "王可可的学号是多少?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT no FROM students WHERE name = '王可可';
question = "王可可今年多大?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT YEAR(CURRENT_DATE) - YEAR(birthday) FROM students WHERE NAME='王可可';
question = "王可可和孙然谁的年龄大?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT NAME, YEAR(CURDATE())-YEAR(birthday) AS age
# FROM students
# WHERE NAME IN ("王可可", "孙然")
# ORDER BY age DESC LIMIT 1;

不过,当我们的字段有特殊含义时,生成的 SQL 语句就不对了,比如这里我们使用 sex=1 表示男生,sex=2 表示女生,但是 ChatGPT 生成 SQL 的时候,认为 sex=0 表示女生:

question = "班上一共有多少个女生?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT COUNT(*) FROM students WHERE sex=0;

为了让大模型知道字段的确切含义,我们可以在数据库表结构中给字段加上注释:

schema = """CREATE TABLE IF NOT EXISTS `students`(
   `id` INT UNSIGNED AUTO_INCREMENT,
   `no` VARCHAR(100) NOT NULL,
   `name` VARCHAR(100) NOT NULL,
   `sex` INT NULL COMMENT '1表示男生,2表示女生',
   `birthday` DATE NULL,
   PRIMARY KEY ( `id` )
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_general_ci;
"""

这样生成的 SQL 语句就没问题了:

question = "班上一共有多少个女生?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT COUNT(*) FROM students WHERE sex=2;

根据 Nitarshan Rajkumar 等人的研究,我们还可以对提示语做进一步的优化,比如:

  • 给表结构进行更详细的说明;
  • 在表结构后面加几条具有代表性的示例数据;
  • 增加几个用户问题和对应的 SQL 查询的例子;
  • 使用向量数据库,根据用户问题动态查询相关的 SQL 查询的例子;

执行 SQL

得到 SQL 语句之后,接下来,我们就可以查询数据库了。在 Python 里操作 MySQL 数据库,有两个库经常被人提到:

这两个库的区别在于:PyMySQL 是使用纯 Python 实现的,使用起来简单方便,可以直接通过 pip install PyMySQL 进行安装;mysqlclient 其实就是 Python 3 版本的 MySQLdb,它是基于 C 扩展模块实现的,需要针对不同平台进行编译安装,但正因为此,mysqlclient 的速度非常快,在正式项目中推荐使用它。

这里我们就使用 mysqlclient 来执行 SQL,首先安装它:

$ sudo apt-get install python3-dev default-libmysqlclient-dev build-essential pkg-config
$ pip3 install mysqlclient

然后连接数据库执行 SQL 语句,它的用法和 MySQLdb 几乎完全兼容:

import MySQLdb

def execute_sql(sql):
    result = ''
    db = MySQLdb.connect("192.168.1.44", "root", "123456", "demo", charset='utf8' )
    cursor = db.cursor()
    try:
        cursor.execute(sql)
        results = cursor.fetchall()
        for row in results:
            result += ' '.join(str(x) for x in row) + '\n'
    except:
        print("Error: unable to fetch data")
    db.close()
    return result

注意,大模型生成的 SQL 可能会对数据库造成破坏,所以在生产环境一定要做好安全防护,比如:使用只读的账号,限制返回结果的条数,等等。

回答用户问题

拿到 SQL 语句的执行结果之后,我们就可以再次组织下提示语,让大模型以自然语言的形式来回答用户的问题:

prompt_qa = PromptTemplate.from_template("""根据下面的数据库表结构,SQL 查询语句和结果,以自然语言回答用户的问题:

{schema}

用户问题:{question}
SQL 查询语句:{query}
SQL 查询结果:{result}
回答:""")

def qa(schema, question):
    query = text_to_sql(schema=schema, question=question)
    print(query)
    result = execute_sql(query)
    text = prompt_qa.format(schema=schema, question=question, query=query, result=result)
    response = llm.predict(text)
    return response

测试效果如下:

schema = "CREATE TABLE ..."
question = "王可可的学号是多少?"
answer = qa(schema=schema, question=question)
print(answer)

# 王可可的学号是202301030004。

LangChain

上面的步骤我们也可以使用 LangChain 来实现。

使用 SQLDatabase 获取数据库表结构信息

在 LangChain 的最新版本中,引入了 SQLDatabase 类可以方便地获取数据库表结构信息。我们首先安装 LangChain 的最新版本:

在写这篇博客时,最新版本是 0.0.324,LangChain 的版本更新很快,请随时关注官方文档。

$ pip3 install langchain==0.0.324

然后使用 SQLDatabase.from_uri() 初始化一个 SQLDatabase 实例,由于 SQLDatabase 是基于 SQLAlchemy 实现的,所以参数格式和 SQLAlchemy 的 create_engine 是一致的:

from langchain.utilities import SQLDatabase

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8")

然后我们就可以使用 get_table_info() 方法来获取表结构信息:

print(db.get_table_info())

默认情况下该方法会返回数据库中所有表的信息,可以通过 table_names 参数指定只返回某个表的信息:

print(db.get_table_info(table_names=["students"]))

也可以在 SQLDatabase.from_uri() 时通过 include_tables 参数指定:

from langchain.utilities import SQLDatabase

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8", include_tables=["students"])

查询结果如下:

CREATE TABLE students (
        id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, 
        no VARCHAR(100) NOT NULL, 
        name VARCHAR(100) NOT NULL, 
        sex INTEGER(11) COMMENT '1表示男生,2表示女生', 
        birthday DATE, 
        PRIMARY KEY (id)
)DEFAULT CHARSET=utf8 ENGINE=InnoDB

/*
3 rows from students table:
id      no      name    sex     birthday
1       202301030001    张启文  1       2015-04-14
2       202301030002    李金玉  2       2015-06-28
3       202301030003    王海红  2       2015-07-01
*/

可以看出 SQLDatabase 不仅查询了表的结构信息,还将表中前三条数据一并返回了,用于组装提示语。

使用 create_sql_query_chain 转换 SQL 语句

接下来我们将用户问题转换为 SQL 语句。LangChain 提供了一个 Chain 来做这个事,这个 Chain 并没有具体的名字,但是我们可以使用 create_sql_query_chain 来创建它:

from langchain.chat_models import ChatOpenAI
from langchain.chains import create_sql_query_chain

chain = create_sql_query_chain(ChatOpenAI(temperature=0), db)

create_sql_query_chain 的第一个参数是大模型,第二个参数是上一节创建的 SQLDatabase,注意这里大模型的参数 temperature=0,因为我们希望大模型生成的 SQL 语句越固定越好,而不是随机变化。

得到 Chain 之后,就可以调用 chain.invoke() 将用户问题转换为 SQL 语句:

response = chain.invoke({"question": "班上一共有多少个女生?"})
print(response)

# SELECT COUNT(*) AS total_female_students
# FROM students
# WHERE sex = 2

其实,create_sql_query_chain 还有第三个参数,用于设置提示语,不设置的话将使用下面的默认提示语:

PROMPT_SUFFIX = """Only use the following tables:
{table_info}

Question: {input}"""

_mysql_prompt = """You are a MySQL expert. Given an input question, first create a syntactically correct MySQL query to run, then look at the results of the query and return the answer to the input question.
Unless the user specifies in the question a specific number of examples to obtain, query for at most {top_k} results using the LIMIT clause as per MySQL. You can order the results to return the most informative data in the database.
Never query for all columns from a table. You must query only the columns that are needed to answer the question. Wrap each column name in backticks (`) to denote them as delimited identifiers.
Pay attention to use only the column names you can see in the tables below. Be careful to not query for columns that do not exist. Also, pay attention to which column is in which table.
Pay attention to use CURDATE() function to get the current date, if the question involves "today".

Use the following format:

Question: Question here
SQLQuery: SQL Query to run
SQLResult: Result of the SQLQuery
Answer: Final answer here

"""

MYSQL_PROMPT = PromptTemplate(
    input_variables=["input", "table_info", "top_k"],
    template=_mysql_prompt + PROMPT_SUFFIX,
)

执行 SQL 语句并回答用户问题

得到 SQL 语句之后,我们就可以通过 SQLDatabase 运行它:

result = db.run(response)
print(result)

# [(4,)]

然后再重新组织提示语,让大模型以自然语言的形式对用户问题进行回答,跟上面类似,此处略过。

使用 SQLDatabaseChain 实现数据库问答

不过 LangChain 提供了更方便的方式实现数据库问答,那就是 SQLDatabaseChain,可以将上面几个步骤合而为一。不过 SQLDatabaseChain 目前还处于实验阶段,我们需要先安装 langchain_experimental

$ pip3 install langchain_experimental==0.0.32

然后就可以使用 SQLDatabaseChain 来回答用户问题了:

from langchain.utilities import SQLDatabase
from langchain.llms import OpenAI
from langchain_experimental.sql import SQLDatabaseChain

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8")
llm = OpenAI(temperature=0, verbose=True)
db_chain = SQLDatabaseChain.from_llm(llm, db, verbose=True)

response = db_chain.run("班上一共有多少个女生?")
print(response)

我们通过 verbose=True 参数让 SQLDatabaseChain 输出执行的详细过程,结果如下:

> Entering new SQLDatabaseChain chain...
班上一共有多少个女生?
SQLQuery:SELECT COUNT(*) FROM students WHERE sex = 2;
SQLResult: [(4,)]
Answer:班上一共有4个女生。
> Finished chain.
班上一共有4个女生。

注意:SQLDatabaseChain 会一次性查询出数据库中所有的表结构,然后丢给大模型生成 SQL,当数据库中表较多时,生成效果可能并不好,这时最好手工指定使用哪些表,再生成 SQL,或者使用 SQLDatabaseSequentialChain,它第一步会让大模型确定该使用哪些表,然后再调用 SQLDatabaseChain

使用 SQL Agent 实现数据库问答

大模型应用开发框架 LangChain 学习笔记(二) 这篇笔记中,我们学习了 LangChain 的 Agent 功能,借助 ReAct 提示工程或 OpenAI 的 Function Calling 技术,可以让大模型具有推理和使用外部工具的能力。很显然,如果我们将数据库相关的操作都定义成一个个的工具,那么通过 LangChain Agent 应该也可以实现数据库问答功能。

LangChain 将数据库相关的操作封装在 SQLDatabaseToolkit 工具集中,我们可以直接使用:

from langchain.agents.agent_toolkits import SQLDatabaseToolkit
from langchain.sql_database import SQLDatabase
from langchain.llms.openai import OpenAI

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8")
toolkit = SQLDatabaseToolkit(db=db, llm=OpenAI(temperature=0))

这个工具集中实际上包含了四个工具:

工具名工具类工具说明
sql_db_list_tablesListSQLDatabaseTool查询数据库中所有表名
sql_db_schemaInfoSQLDatabaseTool根据表名查询表结构信息和示例数据
sql_db_queryQuerySQLDataBaseTool执行 SQL 返回执行结果
sql_db_query_checkerQuerySQLCheckerTool使用大模型分析 SQL 语句是否正确

另外,LangChain 还提供了 create_sql_agent 方法用于快速创建一个用于处理数据库的 Agent:

from langchain.agents import create_sql_agent
from langchain.agents.agent_types import AgentType

agent_executor = create_sql_agent(
    llm=OpenAI(temperature=0),
    toolkit=toolkit,
    verbose=True,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
)

其中 agent_type 只能选 ZERO_SHOT_REACT_DESCRIPTIONOPENAI_FUNCTIONS 这两种,实际上就对应着 ZeroShotAgentOpenAIFunctionsAgent,在使用上和其他的 Agent 并无二致:

response = agent_executor.run("班上一共有多少个女生?")
print(response)

执行结果如下:

> Entering new AgentExecutor chain...
Thought: I should query the database to get the answer.
Action: sql_db_list_tables
Action Input: ""
Observation: students
Thought: I should query the schema of the students table.
Action: sql_db_schema
Action Input: "students"
Observation: 
CREATE TABLE students (
        id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, 
        no VARCHAR(100) NOT NULL, 
        name VARCHAR(100) NOT NULL, 
        sex INTEGER(11) COMMENT '1表示男生,2表示女生', 
        birthday DATE, 
        PRIMARY KEY (id)
)DEFAULT CHARSET=utf8 ENGINE=InnoDB

/*
3 rows from students table:
id      no      name    sex     birthday
1       202301030001    张启文  1       2015-04-14
2       202301030002    李金玉  2       2015-06-28
3       202301030003    王海红  2       2015-07-01
*/
Thought: I should query the database to get the number of female students.
Action: sql_db_query
Action Input: SELECT COUNT(*) FROM students WHERE sex = 2
Observation: [(4,)]
Thought: I now know the final answer.
Final Answer: 班上一共有4个女生。

> Finished chain.
班上一共有4个女生。

可以看出整个执行过程非常流畅,首先获取数据库中的表,然后查询表结构,接着生成 SQL 语句并执行,最后得到用户问题的答案。

使用 SQL Agent 比 SQLDatabaseChain 要灵活的多,我们不仅可以实现数据库问答,还可以实现一些其他功能,比如 SQL 生成,SQL 校验,SQL 解释和优化,生成数据库描述,等等,我们还可以根据需要在工具集中添加自己的工具,扩展出更丰富的功能。

LangChain 的 这篇文档中 就给出了两个拓展工具集的例子,我觉得很有参考意义:

  • Including dynamic few-shot examples:这个例子将一些用户问题和对应的 SQL 示例存储到向量数据库中,然后创建一个额外的名为 sql_get_similar_examples 的工具用于从向量库中获取类似示例,并将提示语修改为:先从向量库中查找类似的示例,判断示例能否构造出回答用户问题的 SQL 语句,如果能,直接通过示例构造出 SQL 语句,如果不能,则通过查询数据库的表结构来构造;
  • Finding and correcting misspellings for proper nouns:这也是一个很实用的示例,用户在提问时往往会输入一些错别字,特别是人物名称、公司名称或地址信息等专有名词,比如将 张晓红今年多大? 写成了 张小红今年多大?,这时直接搜索数据库肯定是搜不出结果的。在这个例子中,首先将数据库中所有艺人名称和专辑名称存储到向量数据库中,然后创建了一个额外的名为 name_search 的工具用于从向量库中获取近似的名称,并将提示语修改为:如果用户问题设计到专有名词,首先搜索向量库判断名称的拼写是否有误,如果拼写有误,要使用正确的名称构造 SQL 语句来回答用户的问题。

一些开源项目

目前市面上已经诞生了大量基于结构化数据的问答产品,比如 酷表ExcelSheet+Julius AI 等,它们通过聊天的方式来操控 Excel 或 Google Sheet 表格,还有 AI QueryAI2sql 等,它们将自然语言转化为可以执行的 SQL 语句,让所有数据库小白也可以做数据分析。

在开源社区,类似的项目也是百花齐放,比如 sql-translatortextSQLsqlchatChat2DBDB-GPT 等等,其中热度最高的当属 Chat2DB 和 DB-GPT 这两个开源项目。

Chat2DB

Chat2DB 是一款智能的数据库客户端软件,和 Navicat、DBeaver 相比,Chat2DB 集成了 AIGC 的能力,能够将自然语言转换成 SQL,也可以将 SQL 翻译成自然语言,或对 SQL 提出优化建议;此外,Chat2DB 还集成了报表能力,用户可以用对话的形式进行数据统计和分析。

Chat2DB 提供了 Windows、Mac、Linux 等平台的安装包,也支持以 Web 形式进行部署,我们直接通过官方镜像安装:

$ docker run --name=chat2db -ti -p 10824:10824 chat2db/chat2db:latest

启动成功后,访问 http://localhost:10824 会进入 Chat2DB 的登录页面,默认的用户名和密码是 chat2db/chat2db,登录成功后,添加数据库连接,然后可以创建新的表,查看表结构,查看表中数据,等等,这和传统的数据库客户端软件并无二致:

chat2db-console.png

和传统的数据库客户端软件相比,Chat2DB 最重要的一点区别在于,用户可以在控制台中输入自然语言,比如像下面这样,输入 查询王可可的学号 并回车,这时会自动生成 SQL 语句,点击执行按钮就可以得到结果:

chat2db-generate-sql-select.png

通过这种方式来管理数据库让人感觉很自然,即使数据库小白也能对数据库进行各种操作,比如要创建一个表:

chat2db-generate-sql-create-table.png

插入一些测试数据:

chat2db-generate-sql-insert.png

遇到不懂的 SQL 语句,用户还可以对 SQL 语句进行解释和优化,整个体验可以说是非常流畅,另外,Chat2DB 还支持通过自然语言的方式生成报表,比如柱状图、折线图、饼图等,便于用户进行数据统计和分析:

chat2db-dashboard.png

默认情况下,Chat2DB 使用的是 Chat2DB AI 接口,关注官方公众号后就可以免费使用,我们也可以切换成 OpenAI 或 AzureAI 接口,或使用自己部署的大模型接口,具体内容请参考官方提供的 ChatGLM-6Bsqlcoder 的部署方法。

DB-GPT

DB-GPT 是一款基于知识库的问答产品,它同时支持结构化和非结构化数据的问答,支持生成报表,还支持自定义插件,在交互形式上和 ChatGPT 类似。它的一大特点是支持海量的模型管理,包括开源模型和 API 接口,并支持模型的自动化微调。

DB-GPT 的侧重点在于私有化,它强调数据的隐私安全,可以做到整个系统都不依赖于外部环境,完全避免了数据泄露的风险,是一款真正意义上的本地知识库问答产品。它集成了常见的开源大模型和向量数据库,因此,在部署上复杂一点,而且对硬件的要求也要苛刻一点。不过对于哪些没有条件部署大模型的用户来说,DB-GPT 也支持直接 使用 OpenAI 或 Bard 等接口

DB-GPT 支持 从源码安装从 Docker 镜像安装,不过官方提供的 Docker 镜像缺少 openai 等依赖,需要我们手工安装,所以不建议直接启动,而是先通过 bash 进入容器做一些准备工作:

$ docker run --rm -ti -p 5000:5000 eosphorosai/dbgpt:latest bash

安装 openai 依赖:

# pip3 install openai

然后设置一些环境变量,让 DB-GPT 使用 OpenAI 的 Completions 和 Embedding 接口:

# export LLM_MODEL=chatgpt_proxyllm
# export PROXY_SERVER_URL=https://api.openai.com/v1/chat/completions
# export PROXY_API_KEY=sk-xx
# export EMBEDDING_MODEL=proxy_openai
# export proxy_openai_proxy_server_url=https://api.openai.com/v1
# export proxy_openai_proxy_api_key=sk-xxx

如果由于网络原因导致 OpenAI 接口无法访问,还需要配置代理(注意先安装 pysocks 依赖):

# pip3 install pysocks
# export https_proxy=socks5://192.168.1.45:7890
# export http_proxy=socks5://192.168.1.45:7890

一切准备就绪后,启动 DB-GPT server:

# python3 pilot/server/dbgpt_server.py

等待服务启动成功,访问 http://localhost:5000/ 即可进入 DB-GPT 的首页:

dbgpt-home.png

DB-GPT 支持几种类型的聊天功能:

  • Chat Data
  • Chat Excel
  • Chat DB
  • Chat Knowledge
  • Dashboard
  • Agent Chat

Chat DB & Chat Data & Dashboard

Chat DB、Chat Data 和 Dashboard 这三个功能都是基于数据库的问答,要使用它们,首先需要在 数据库管理 页面添加数据库。Chat DB 会根据数据库和表的结构信息帮助用户编写 SQL 语句:

dbgpt-chat-db.png

Chat Data 不仅会生成 SQL 语句,还会自动执行并得到结果:

dbgpt-chat-data.png

Dashboard 则比 Chat Data 更进一步,它会生成 SQL 语句,执行得到结果,并生成相应的图表:

dbgpt-chat-dashboard.png

Chat Excel

Chat Excel 功能依赖 openpyxl 库,需要提前安装:

# pip3 install openpyxl

然后就可以上传 Excel 文件,对其进行分析,并回答用户问题:

dbgpt-chat-excel.png

其他功能

DB-GPT 除了支持结构化数据的问答,也支持非结构化数据的问答,Chat Knowledge 实现的就是这样的功能。要使用它,首先需要在 知识库管理 页面添加知识,DB-GPT 支持从文本,URL 或各种文档中导入:

dbgpt-chat-kb.png

然后在 Chat Knowledge 页面就可以选择知识库进行问答了。

DB-GPT 还支持插件功能,你可以从 DB-GPT-Plugins 下载插件,也可以 编写自己的插件并上传,而且 DB-GPT 兼容 Auto-GPT 的插件 接口,原则上,所有的 Auto-GPT 插件都可以在这里使用:

dbgpt-chat-plugins.png

然后在 Agent Chat 页面,就可以像 ChatGPT Plus 一样,选择插件进行问答了。

另外,DB-GPT 的模型管理功能也很强大,不仅支持像 OpenAI 或 Bard 这样的大模型代理接口,还集成了大量的开源大模型,而且在 DB-GPT-Hub 项目中还提供了大量的数据集、工具和文档,让我们可以对这些大模型进行微调,实现更强大的 Text-to-SQL 能力。

参考

更多

基于其他结构化数据源的文档问答

Neo4j

Elasticsearch

CSV

Excel

基于半结构化和多模数据源的文档问答

学习 LCEL

在 LangChain 中,我们还可以通过 LCEL(LangChain Expression Language) 来简化 Chain 的创建,比如对数据库进行问答,官方有一个示例,可以用下面这样的管道式语法来写:

full_chain = (
    RunnablePassthrough.assign(query=sql_response)
    | RunnablePassthrough.assign(
        schema=get_schema,
        response=lambda x: db.run(x["query"]),
    )
    | prompt_response
    | model
)
扫描二维码,在手机上阅读!

大模型应用开发框架 LangChain 学习笔记(二)

上一篇笔记 中,我们学习了 LangChain 中的一些基础概念:使用 LLMsChatModels 实现基本的聊天功能,使用 PromptTemplate 组装提示语,使用 Document loadersDocument transformersText embedding modelsVector storesRetrievers 实现文档问答;然后,我们又学习了 LangChain 的精髓 Chain,以及 Chain 的三大特性:使用 Memory 实现 Chain 的记忆功能,使用 RetrievalQA 组合多个 Chain 再次实现文档问答,使用 Callbacks 对 Chain 进行调试;最后,我们学习了四个基础 Chain:LLMChainTransformChainSequentialChainRouterChain,使用这四个 Chain 可以组装出更复杂的流程,其中 RouterChainMultiPromptChain 为我们提出了一种新的思路,使用大模型来决策 Chain 的调用链路,可以动态地解决用户问题;更进一步我们想到,大模型不仅可以动态地选择调用 Chain,也可以动态地选择调用外部的函数,而且使用一些提示语技巧,可以让大模型变成一个推理引擎,这便是 Agent

OpenAI 的插件功能

在学习 LangChain 的 Agent 之前,我们先来学习一下 OpenAI 的插件功能,这可以让我们对 Agent 的基本概念和工作原理有一个更深入的了解。

ChatGPT Plugins

2023 年 3 月 23 日,OpenAI 重磅推出 ChatGPT Plugins 功能,引起了全球用户的热议。众所周知,GPT-3.5 是使用 2021 年之前的历史数据训练出来的大模型,所以它无法回答关于最新新闻和事件的问题,比如你问它今天是星期几,它只能让你自己去查日历:

day-of-the-week.png

不仅如此,ChatGPT 在处理数学问题时也表现不佳,而且在回答问题时可能会捏造事实,胡说八道;另一方面,虽然 ChatGPT 非常强大,但它终究只是一个聊天机器,如果要让它成为真正的私人助理,它还得帮助用户去做一些事情,解放用户的双手。引入插件功能后,就使得 ChatGPT 具备了这两个重要的能力:

  • 访问互联网:可以实时检索最新的信息以回答用户问题,比如调用搜索引擎接口,获取和用户问题相关的新闻和事件;也可以访问用户的私有数据,比如公司内部的文档,个人笔记等,这样通过插件也可以实现文档问答;
  • 执行任务:可以了解用户的意图,代替用户去执行任务,比如调用一些三方服务的接口订机票订酒店等;

暂时只有 GPT-4 才支持插件功能,所以要体验插件功能得有个 ChatGPT Plus 账号。截止目前为止,OpenAI 的插件市场中已经开放了近千个插件,如果我们想让 ChatGPT 回答今天是星期几,可以开启其中的 Wolfram 插件:

chatgpt-4-plugins.png

Wolfram|Alpha 是一个神奇的网站,建立于 2009 年,它是一个智能搜索引擎,它上知天文下知地理,可以回答关于数学、物理、化学、生命科学、计算机科学、历史、地理、音乐、文化、天气、时间等等方面的问题,它的愿景是 Making the world's knowledge computable,让世界的知识皆可计算。Wolfram 插件就是通过调用 Wolfram|Alpha 的接口来实现的,开启 Wolfram 插件后,ChatGPT 就能准确回答我们的问题了:

day-of-the-week-with-plugins.png

从对话的结果中可以看到 ChatGPT 使用了 Wolfram 插件,展开插件的调用详情还可以看到调用的请求和响应:

wolfram-detail.png

结合插件功能,ChatGPT 不再是一个简单的聊天对话框了,它有了一个真正的生态环境,网上有这样一个比喻,如果说 ChatGPT 是 AI 时代的 iPhone,那么插件就是 ChatGPT 的 App Store,我觉得这个比喻非常贴切。通过插件机制,ChatGPT 可以连接成千上万的第三方应用,向各个行业渗透,带给我们无限的想象力。

开发自己的插件

目前 ChatGPT 的插件功能仍然处于 beta 版本,OpenAI 还没有完全开放插件的开发功能,如果想要体验开发 ChatGPT 插件的流程,需要先 加入等待列表

开发插件的步骤大致如下:

  1. ChatGPT 插件其实就是标准的 Web 服务,可以使用任意的编程语言开发,开发好插件服务之后,将其部署到你的域名下;
  2. 准备一个清单文件 .well-known/ai-plugin.json 放在你的域名下,清单文件中包含了插件的名称、描述、认证信息、以及所有插件接口的信息等;
  3. 在 ChatGPT 的插件中心选择 Develop your own plugin,并填上你的插件地址;
  4. 开启新会话时,先选择并激活你的插件,然后就可以聊天了;如果 ChatGPT 认为用户问题需要调用你的插件(取决于插件和接口的描述),就会调用你在插件中定义的接口;

其中前两步应该是开发者最为关心的部分,官网提供了一个入门示例供我们参考,这个示例是一个简单的 TODO List 插件,可以让 ChatGPT 访问并操作我们的 TODO List 服务,我们就以这个例子来学习如何开发一个 ChatGPT 插件。

首先我们使用 Python 语言开发好 TODO List 服务,支持 TODO List 的增删改查。

然后准备一个插件的清单文件,对我们的插件进行一番描述,这个清单文件的名字必须是 ai-plugin.json,并放在你的域名的 .well-known 路径下,比如 https://your-domain.com/.well-known/ai-plugin.json。文件的内容如下:

{
    "schema_version": "v1",
    "name_for_human": "TODO List",
    "name_for_model": "todo",
    "description_for_human": "Manage your TODO list. You can add, remove and view your TODOs.",
    "description_for_model": "Help the user with managing a TODO list. You can add, remove and view your TODOs.",
    "auth": {
        "type": "none"
    },
    "api": {
        "type": "openapi",
        "url": "http://localhost:3333/openapi.yaml"
    },
    "logo_url": "http://localhost:3333/logo.png",
    "contact_email": "support@example.com",
    "legal_info_url": "http://www.example.com/legal"
}

清单中有些信息是用于展示在 OpenAI 的插件市场的,比如 name_for_humandescription_for_humanlogo_urlcontact_emaillegal_info_url 等,有些信息是要送给 ChatGPT 的,比如 name_for_modeldescription_for_modelapi 等;送给 ChatGPT 的信息需要仔细填写,确保 ChatGPT 能理解你这个插件的用途,这样 ChatGPT 才会在对话过程中根据需要调用你的插件。

然后我们还需要准备插件的接口定义文件,要让 ChatGPT 知道你的插件都有哪些接口,每个接口的作用是什么,以及每个接口的入参和出参是什么。一般使用 OpenAPI 规范 来定义插件的接口,下面是一个简单的示例,定义了一个 getTodos 接口用于获取所有的 TODO List:

openapi: 3.0.1
info:
  title: TODO Plugin
  description: A plugin that allows the user to create and manage a TODO list using ChatGPT.
  version: 'v1'
servers:
  - url: http://localhost:3333
paths:
  /todos:
    get:
      operationId: getTodos
      summary: Get the list of todos
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/getTodosResponse'
components:
  schemas:
    getTodosResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos.

一切准备就绪后,就可以在 ChatGPT 的插件中心填上你的插件地址并调试了。

除了入门示例,官网还提供了一些其他的 插件示例,其中 Chatgpt Retrieval Plugin 是一个完整而复杂的例子,对我们开发真实的插件非常有参考价值。

当然,还有很多插件的内容没有介绍,比如 插件的最佳实践用户认证 等,更多信息可以参考 OpenAI 的插件手册

Function Calling

尽管 ChatGPT 的插件功能非常强大,但是它只能在 ChatGPT 页面中使用,这可能是出于 OpenAI 的私心,OpenAI 的野心很大,它对 ChatGPT 的定位,就是希望将其做成整个互联网的入口,其他的应用都对接到 ChatGPT 的生态中来。不过很显然,这种脑洞大开的想法有点太过超前了,其他的互联网厂商也不傻,谁都知道流量入口的重要性,怎么会轻易将自己的应用入口交给其他人呢?对于其他的互联网厂商来说,他们更希望将 ChatGPT 的能力(包括插件能力)集成到自己的应用中来。

2023 年 6 月 13 日,这种想法变成了可能,这一天,OpenAI 对 GPT 模型进行了一项重大更新,推出了 Function Calling 功能,在 Chat Completions API 中添加了新的函数调用能力,帮助开发者通过 API 的方式实现类似于 ChatGPT 插件的数据交互能力。

基于 ChatGPT 实现一个划词翻译 Chrome 插件 这篇笔记中,我们已经学习过 OpenAI 的 Chat Completions API,感兴趣的同学可以复习下。

使用 Function Calling 回答日期问题

更新后的 Chat Completions API 中添加了一个 functions 参数,用于定义可用的函数,就像在 ChatGPT 中开启插件一样,这里的函数就相当于插件,对于每一个函数,我们需要定义它的名称、描述以及参数信息,如下:

completion = openai.ChatCompletion.create(
    temperature=0.7,
    model="gpt-3.5-turbo",
    messages=[
        {'role': 'user', 'content': "今天是星期几?"},
    ],
    functions=[
        {
          "name": "get_current_date",
          "description": "获取今天的日期信息,包括几月几号和星期几",
          "parameters": {
              "type": "object",
              "properties": {}
          }
        }
    ],
    function_call="auto",
)
print(completion)

在上面的例子中,我们定义了一个名为 get_current_date() 的函数,用于获取今天的日期和星期信息,这个函数我们要提前实现好:

def get_current_date(args):
    import datetime
    today = datetime.date.today()
    weekday = today.weekday()
    weeekdays = ['一','二','三','四','五','六','日']
    return '今天是' + str(today) + ', ' + '星期' + weeekdays[weekday]

当 GPT 无法回答关于日期的问题时,就会自动地选择调用这个函数来进一步获取信息,Chat Completions API 的响应结果如下:

{
  "id": "chatcmpl-7pQO7iJ3WeggIYZ5CnLc88ZxMgMMV",
  "object": "chat.completion",
  "created": 1692490519,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "function_call": {
          "name": "get_current_date",
          "arguments": "{}"
        }
      },
      "finish_reason": "function_call"
    }
  ],
  "usage": {
    "prompt_tokens": 63,
    "completion_tokens": 8,
    "total_tokens": 71
  }
}

可以看到接口返回的 message.content 是空,反而多了一个 function_call 字段,这就说明 GPT 无法回答我们的问题,希望调用某个外部函数。为了方便我们调用外部函数,GPT 非常贴心地将函数名和参数都准备好了,我们只需要使用 globals().get() 拿到函数,再使用 json.loads() 拿到参数,然后直接调用即可:

function_call = completion.choices[0].message.function_call
function = globals().get(function_call.name)
args = json.loads(function_call.arguments)
result = function(args)
print(result)

拿到函数调用的结果之后,我们再一次调用 Chat Completions API,这一次我们将函数调用的结果和用户的问题一起放在 messages 中,注意将它的 role 设置为 function

completion = openai.ChatCompletion.create(
    temperature=0.7,
    model="gpt-3.5-turbo",
    messages=[
        {'role': 'user', 'content': "今天是星期几?"},
        {'role': 'function', 'name': 'get_current_date', 'content': "今天是2023-08-20, 星期日"},
    ],
)
print(completion)

这样 GPT 就能成功回答我们的问题了:

{
  "id": "chatcmpl-7pQklbWnMyVFvK73YbWXMybVsOTJe",
  "object": "chat.completion",
  "created": 1692491923,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "今天是星期日。"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 83,
    "completion_tokens": 8,
    "total_tokens": 91
  }
}

多轮 Function Calling

有时候,只靠一个函数解决不了用户的问题,比如用户问 明天合肥的天气怎么样?,那么 GPT 首先需要知道明天的日期,然后再根据日期查询合肥的天气,所以我们要定义两个函数:

functions = [
    {
        "name": "get_current_date",
        "description": "获取今天的日期信息,包括几月几号和星期几",
        "parameters": {
            "type": "object",
            "properties": {}
        }
    },
    {
        "name": "get_weather_info",
        "description": "获取某个城市某一天的天气信息",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "城市名",
                },
                "date": {
                    "type": "string",
                    "description": "日期,格式为 yyyy-MM-dd",
                },
            },
            "required": ["city", "date"],
        }
    }
]

第一次调用 Chat Completions API 时,传入用户的问题:

messages=[
    {'role': 'user', 'content': "明天合肥的天气怎么样?"},
],

接口返回了一个 function_call,希望我们去调用 get_current_date() 函数:

"function_call": {
    "name": "get_current_date",
    "arguments": "{}"
}

然后我们调用 get_current_date() 函数得到今天的日期,再次调用 Chat Completions API 时,传入函数的调用结果:

messages=[
    {'role': 'user', 'content': "明天合肥的天气怎么样?"},
    {'role': 'function', 'name': 'get_current_date', 'content': "今天是2023-08-20, 星期日"},
],

接口再次返回了一个 function_call,希望我们去调用 get_weather_info() 函数:

"function_call": {
    "name": "get_weather_info",
    "arguments": "{\n  \"city\": \"合肥\",\n  \"date\": \"2023-08-21\"\n}"
}

注意这里的 date 参数,上面我们通过 get_current_date() 得到今天的日期是 2023-08-20,而用户问的是明天合肥的天气,GPT 非常聪明地推导出明天的日期是 2023-08-21,可以说是非常优秀了,我们直接使用 GPT 准备好的参数调用 get_weather_info() 即可获得明天合肥的天气,再次调用 Chat Completions API:

messages=[
    {'role': 'user', 'content': "明天合肥的天气怎么样?"},
    {'role': 'function', 'name': 'get_current_date', 'content': "今天是2023-08-20, 星期日"},
    {'role': 'function', 'name': 'get_weather_info', 'content': "雷阵雨,33/24℃,北风转西北风"},
],

通过不断的调用 Function Calling,最后准确地对用户的问题作出了回答:

明天合肥的天气预报为雷阵雨,最高温度为33℃,最低温度为24℃,风向将从北风转为西北风。请注意防雷阵雨的天气情况。

除了能不断地返回 function_call 并调用函数外,GPT 还会主动尝试补充函数的参数。有时候,用户的问题不完整,缺少了函数的某个参数,比如用户问 明天的天气怎么样?,这时 GPT 并不知道用户所在的城市,它就会问 请问您所在的城市是哪里?,等待用户回答之后,才返回 get_weather_info() 函数以及对应的参数。

学习 LangChain Agent

学习完 OpenAI 的插件机制之后,我们再来学习 LangChain 的 Agent 就会发现有很多概念是相通的。我们从官方文档中的一个入门示例开始。

快速入门

我们知道,大模型虽然擅长推理,但是却不擅长算术和计数,比如问它单词 hello 是由几个字母组成的,它就有可能胡编乱造,我们可以定义一个函数 get_word_length() 帮助大模型来回答关于单词长度的问题。

入门示例的代码如下:

from langchain.chat_models import ChatOpenAI
from langchain.agents import tool
from langchain.schema import SystemMessage
from langchain.agents import OpenAIFunctionsAgent
from langchain.agents import AgentExecutor

# llm
llm = ChatOpenAI(temperature=0)

# tools
@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = [get_word_length]

# prompt
system_message = SystemMessage(
    content="You are very powerful assistant, but bad at calculating lengths of words."
)
prompt = OpenAIFunctionsAgent.create_prompt(system_message=system_message)

# create an agent
agent = OpenAIFunctionsAgent(llm=llm, tools=tools, prompt=prompt)

# create an agent executor
agent_executor = AgentExecutor(agent=agent, tools=tools)

# run the agent executor
result = agent_executor.run("how many letters in the word 'hello'?")
print(result)

从上面的代码中我们可以注意到 Agent 有这么几个重要的概念:

  • Tools - 希望被 Agent 执行的函数,被称为 工具,类似于 OpenAI 的插件,我们需要尽可能地描述清楚每个工具的功能,以便 Agent 能选择合适的工具;
  • Agent - 经常被翻译成 代理,类似于 OpenAI 的 Function Calling 机制,可以帮我们理解用户的问题,然后从给定的工具集中选择能解决用户问题的工具,并交给 Agent Executor 执行;
  • Agent Executor - Agent 执行器,它本质上是一个 Chain,所以可以和其他的 Chain 或 Agent Executor 进行组合;它会递归地调用 Agent 获取下一步的动作,并执行 Agent 中定义的工具,直到 Agent 认为问题已经解决,则递归结束,下面是整个过程的伪代码:
next_action = agent.get_action(...)
while next_action != AgentFinish:
    observation = run(next_action)
    next_action = agent.get_action(..., next_action, observation)
return next_action

LangChain Agent 进阶

下面深入学习 LangChain Agent 的这几个概念。

使用工具

在入门示例中,我们使用 @tool 装饰器定义了一个工具:

@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

工具的名称默认为方法名,工具的描述为方法的 doc_string,工具方法支持多个参数:

@tool
def get_word_length(word: str, excluding_hyphen: bool) -> int:
    """Returns the length of a word."""
    if excluding_hyphen:
        return len(word.replace('-', ''))
    else:
        return len(word)

当工具方法有多个参数时,参数的描述就很重要,我们可以通过 args_schema 来传入一个 BaseModel,这是 Pydantic 中用于定义数据模型的基类:

class WordLengthSchema(BaseModel):
    word: str = Field(description = "the word to be calculating")
    excluding_hyphen: bool = Field(description = "excluding the hyphen or not, default to false")

@tool(args_schema = WordLengthSchema)
def get_word_length(word: str, excluding_hyphen: bool) -> int:
    """Returns the length of a word."""
    if excluding_hyphen:
        return len(word.replace('-', ''))
    else:
        return len(word)

LangChain 的代码中大量使用了 Pydantic 库,它提供了一种简单而强大的方式来验证和解析输入数据,并将其转换为类型安全的 Python 对象。

除了使用 @tool 装饰器,官方还提供了另外两种方式来定义工具。第一种是使用 Tool.from_function()

Tool.from_function(
    func=get_word_length,
    name="get_word_length",
    description="Returns the length of a word."
)

不过这个方法只支持接受一个字符串输入和一个字符串输出,如果工具方法有多个参数,必须得使用 StructuredTool.from_function()

StructuredTool.from_function(
    func=get_word_length,
    name="get_word_length",
    description="Returns the length of a word."
)

同样,我们可以通过 args_schema 来传入一个 BaseModel 对方法的参数进行描述:

StructuredTool.from_function(
    func=get_word_length,
    name="get_word_length",
    description="Returns the length of a word.",
    args_schema=WordLengthSchema
)

实际上查看 LangChain 的源码你就会发现,@tool 装饰器就是通过 Tool.from_function()StructuredTool.from_function() 来实现的。

第二种定义工具的方法是直接继承 BaseTool 类:

class WordLengthTool(BaseTool):
    name = "get_word_length"
    description = "Returns the length of a word."

    def _run(
        self, word: str, run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        """Use the tool."""
        return len(word)

    async def _arun(
        self, word: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
    ) -> str:
        """Use the tool asynchronously."""
        raise NotImplementedError("get_word_length does not support async")

当工具方法有多个参数时,我们就在 _run 方法上定义多个参数,同时使用 args_schema 对多个参数进行描述:

class WordLengthTool(BaseTool):
    name = "get_word_length"
    description = "Returns the length of a word."
    args_schema: Type[WordLengthSchema] = WordLengthSchema

    def _run(
        self, word: str, excluding_hyphen: bool = False, run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        """Use the tool."""
        if excluding_hyphen:
            return len(word.replace('-', ''))
        else:
            return len(word)

除了自己定义工具,LangChain 还内置了一些常用的工具,我们可以直接使用 load_tools() 来加载:

from langchain.agents import load_tools

tools = load_tools(["serpapi"])

可以从 load_tools.py 源码中找到支持的工具列表。

Agent 类型

有些同学可能已经注意到,在示例代码中我们使用了 OpenAIFunctionsAgent,我们也可以使用 initialize_agent() 方法简化 OpenAIFunctionsAgent 的创建过程:

from langchain.chat_models import ChatOpenAI
from langchain.agents import tool
from langchain.agents import initialize_agent
from langchain.agents import AgentType

# llm
llm = ChatOpenAI(temperature=0)

# tools
@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = [get_word_length]

# create an agent executor
agent_executor = initialize_agent(tools, llm, agent=AgentType.OPENAI_FUNCTIONS, verbose=True)

# run the agent executor
result = agent_executor.run("how many letters in the word 'weekly-practice'?")
print(result)

很显然,这个 Agent 是基于 OpenAI 的 Function Calling 实现的,它通过 format_tool_to_openai_function() 将 LangChain 的工具转换为 OpenAI 的 functions 参数。但是 Function Calling 机制只有 OpenAI 的接口才支持,而 LangChain 面对的是各种大模型,并不是所有的大模型都支持 Function Calling 机制,这是要专门训练的,所以 LangChain 的 Agent 还需要支持一种更通用的实现机制。根据所使用的策略,LangChain 支持 多种不同的 Agent 类型,其中最通用,也是目前最流行的 Agent 是基于 ReAct 的 Agent。

Zero-shot ReAct Agent

ReAct 这个词出自一篇论文 ReAct: Synergizing Reasoning and Acting in Language Models,它是由 ReasonAct 两个词组合而成,表示一种将 推理行动 与大模型相结合的通用范式:

react.png

传统的 Reason Only 型应用(如 Chain-of-Thought Prompting)具备很强的语言能力,擅长通用文本的逻辑推断,但由于不会和外部环境交互,因此它的认知非常受限;而传统的 Act Only 型应用(如 WebGPTSayCanACT-1)能和外界进行交互,解决某类特定问题,但它的行为逻辑较简单,不具备通用的推理能力。

ReAct 的思想,旨在将这两种应用的优势结合起来。针对一个复杂问题,首先使用大模型的推理能力制定出解决该问题的行动计划,这好比人的大脑,可以对问题进行分析思考;然后使用行动能力与外部源(例如知识库或环境)进行交互,以获取额外信息,这好比人的五官和手脚,可以感知世界,并执行动作;大模型对行动的结果进行跟踪,并不断地更新行动计划,直到问题被解决。通过这种模式,我们能基于大模型构建更为强大的 AI 应用,大名鼎鼎的 Auto-GPT 项目就是基于 ReAct 模式实现的。

LangChain 基于 ReAct 思想实现了一些 Agent,其中最简单的就是 Zero-shot ReAct Agent,我们将上面的 AgentType.OPENAI_FUNCTIONS 替换成 AgentType.ZERO_SHOT_REACT_DESCRIPTION 即可:

agent_executor = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True)

执行结果如下:

> Entering new AgentExecutor chain...
I should use the get_word_length tool to find the length of the word.
Action: get_word_length
Action Input: 'weekly-practice'
Observation: 17
Thought:The word 'weekly-practice' has 17 letters.
Final Answer: 17

> Finished chain.
17

从输出结果可以一窥 Agent 的思考过程,包括三个部分:Thought 是由大模型生成的想法,是执行行动的依据;Action 是指大模型判断本次需要执行的具体动作;Observation 是执行动作后从外部获取的信息。

可以看到这个 Agent 没有 OpenAI 那么智能,它在计算单词长度时没有去掉左右的引号。

为了展示 Agent 思维链的强大之处,我们可以输入一个更复杂的问题:

Calculate the length of the word 'weekly-practice' times the word 'aneasystone'?

要回答这个问题,Agent 必须先计算两个单词的长度,然后将两个长度相乘得到乘积,所以我们要在工具箱中加一个用于计算的工具,可以直接使用 LangChain 内置的 llm-math 工具:

@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = load_tools(["llm-math"], llm=llm)
tools.append(get_word_length)

运行结果如下:

> Entering new AgentExecutor chain...
We need to calculate the length of the word 'weekly-practice' and the length of the word 'aneasystone', and then multiply them together.
Action: get_word_length
Action Input: 'weekly-practice'
Observation: 17
Thought:We have the length of the word 'weekly-practice'. Now we need to find the length of the word 'aneasystone'.
Action: get_word_length
Action Input: 'aneasystone'
Observation: 13
Thought:We have the lengths of both words. Now we can multiply them together to get the final answer.
Action: Calculator
Action Input: 17 * 13
Observation: Answer: 221
Thought:I now know the final answer.
Final Answer: The length of the word 'weekly-practice' times the word 'aneasystone' is 221.

> Finished chain.
The length of the word 'weekly-practice' times the word 'aneasystone' is 221.

针对最简单的 Zero-shot ReAct Agent,LangChain 提供了三个实现:

  • ZERO_SHOT_REACT_DESCRIPTION - 使用 LLMs 实现;
  • CHAT_ZERO_SHOT_REACT_DESCRIPTION - 使用 ChatModels 实现;
  • STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION - 上面两种 Agent 使用的工具都只支持输入简单的字符串,而这种 Agent 通过 args_schema 来生成工具的输入,支持在工具中使用多个参数;

Conversational ReAct Agent

和 Chain 一样,也可以给 Agent 增加记忆功能,默认情况下,Zero-shot ReAct Agent 是不具有记忆功能的,不过我们可以通过 agent_kwargs 参数修改 Agent 让其具备记忆功能。我们可以使用下面的技巧将 Agent 所使用的 Prompt 打印出来看看:

print(agent_executor.agent.llm_chain.prompt.template)

输出结果如下:

Answer the following questions as best you can. You have access to the following tools:

Calculator: Useful for when you need to answer questions about math.
get_word_length: get_word_length(word: str) -> int - Returns the length of a word.

Use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [Calculator, get_word_length]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

Begin!

Question: {input}
Thought:{agent_scratchpad}

这个 Prompt 就是实现 ReAct 的核心,它实际上包括了三个部分。第一部分称为前缀(prefix),可以在这里加一些通用的提示语,并列出大模型可以使用的工具名称和描述:

Answer the following questions as best you can. You have access to the following tools:

Calculator: Useful for when you need to answer questions about math.
get_word_length: get_word_length(word: str) -> int - Returns the length of a word.

第二部分称为格式化指令(format_instructions),内容如下:

Use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [Calculator, get_word_length]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

这段指令让大模型必须按照 Thought/Action/Action Input/Observation 这种特定的格式来回答我们的问题,然后我们将 Observation 设置为停止词(Stop Word)。如果大模型返回的结果中有 ActionAction Input,就说明大模型需要调用外部工具获取进一步的信息,于是我们就去执行该工具,并将执行结果放在 Observation 中,接着再次调用大模型,这样我们每执行一次,就能得到大模型的一次思考过程,直到大模型返回 Final Answer 为止,此时我们就得到了最终结果。

第三部分称为后缀(suffix),包括两个占位符,{input} 表示用户输入的问题,{agent_scratchpad} 表示 Agent 每一步思考的过程,可以让 Agent 继续思考下去:

Begin!

Question: {input}
Thought:{agent_scratchpad}

可以看到上面的 Prompt 中并没有考虑历史会话,如果要让 Agent 具备记忆功能,我们必须在 Prompt 中加上历史会话内容,我们将 prefix 修改成下面这样:

Have a conversation with a human, answering the following questions as best you can. You have access to the following tools:

明确地指出这是一次和人类的会话。然后将 suffix 修改为:


Begin!"

{chat_history}
Question: {input}
{agent_scratchpad}

我们在里面加上了 {chat_history} 占位符表示历史会话记录。由于引入了新的占位符,所以在 input_variables 中也需要加上 chat_history 变量,这个变量的内容会被 ConversationBufferMemory 自动替换,修改后的代码如下:

from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(memory_key="chat_history")

# create an agent executor
agent_executor = initialize_agent(
    tools, 
    llm, 
    agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, 
    verbose=True,
    memory=memory,
    agent_kwargs={
        "prefix": prefix,
        "suffix": suffix,
        "input_variables": ["input", "chat_history", "agent_scratchpad"],
    }
)

至此我们这个 Agent 就具备记忆功能了,可以将上面那个复杂问题拆分成三个小问题分别问它:

result = agent_executor.run("how many letters in the word 'weekly-practice'?")
print(result)
result = agent_executor.run("how many letters in the word 'hello-world'?")
print(result)
result = agent_executor.run("what is the product of results above?")
print(result)

执行结果如下,可以看出在回答第三个问题时它记住了上面两轮对话的内容:

> Entering new AgentExecutor chain...
Thought: I can use the get_word_length tool to find the length of the word 'weekly-practice'.
Action: get_word_length
Action Input: 'weekly-practice'
Observation: 17
Thought:I now know the final answer
Final Answer: The word 'weekly-practice' has 17 letters.

> Finished chain.
The word 'weekly-practice' has 17 letters.


> Entering new AgentExecutor chain...
Thought: I need to find the length of the word 'hello-world'.
Action: get_word_length
Action Input: 'hello-world'
Observation: 13
Thought:The word 'hello-world' has 13 letters.
Final Answer: The word 'hello-world' has 13 letters.

> Finished chain.
The word 'hello-world' has 13 letters.


> Entering new AgentExecutor chain...
Thought: I need to calculate the product of the results above.
Action: Calculator
Action Input: 17 * 13
Observation: Answer: 221
Thought:I now know the final answer.
Final Answer: The product of the results above is 221.

> Finished chain.
The product of the results above is 221.

像上面这样给 Agent 增加记忆实在是太繁琐了,LangChain 于是内置了两种 Conversational ReAct Agent 来简化这个过程:

  • CONVERSATIONAL_REACT_DESCRIPTION - 使用 LLMs 实现;
  • CHAT_CONVERSATIONAL_REACT_DESCRIPTION - 使用 ChatModels 实现;

使用 Conversational ReAct Agent 要简单得多,我们只需要准备一个 memory 参数即可:

# memory
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)

# create an agent executor
agent_executor = initialize_agent(
    tools, 
    llm, 
    agent=AgentType.CHAT_CONVERSATIONAL_REACT_DESCRIPTION, 
    verbose=True,
    memory=memory,
)

ReAct DocStore Agent

和 Zero-shot ReAct Agent 一样,ReAct DocStore Agent 也是一个基于 ReAct 框架实现的 Agent。事实上,ReAct DocStore Agent 才是 ReAct 这篇论文 的标准实现,这个 Agent 必须包含两个指定工具:Search 用于调用 DocStore 搜索相关文档,Lookup 用于从搜索的文档中查询关键词信息。

Zero-shot ReAct Agent 更像是一个通用的 MRKL 系统,MRKL 的全称是模块化推理、知识和语言系统,它是一种模块化的神经符号架构,结合了大型语言模型、外部知识源和离散推理,它最初 由 AI21 Labs 提出,并实现了 Jurassic-X,对 MRKL 感兴趣的同学可以参考 这篇博客

LangChain 目前貌似只实现了 Wikipedia 和 InMemory 两个 DocStore,下面的例子中我们使用 Wikipedia 来进行搜索:

from langchain import Wikipedia
from langchain.agents import Tool
from langchain.agents.react.base import DocstoreExplorer

docstore = DocstoreExplorer(Wikipedia())
tools = [
    Tool(
        name="Search",
        func=docstore.search,
        description="useful for when you need to ask with search",
    ),
    Tool(
        name="Lookup",
        func=docstore.lookup,
        description="useful for when you need to ask with lookup",
    ),
]

然后创建一个类型为 AgentType.REACT_DOCSTORE 的 Agent,并提出一个问题:谁是当今美国总统?

agent_executor = initialize_agent(tools, llm, agent=AgentType.REACT_DOCSTORE, verbose=True)

result = agent_executor.run("Who is the current president of the United States?")
print(result)

运行结果如下:

> Entering new AgentExecutor chain...
Thought: I need to find out who the current president of the United States is.
Action: Search[current president of the United States]
Observation: The president of the United States (POTUS) is the head of state and head of government of the United States... 
Joe Biden is the 46th and current president of the United States, having assumed office on January 20, 2021.
Thought:Joe Biden is the current president of the United States.
Action: Finish[Joe Biden]

> Finished chain.
Joe Biden

Self-Ask Agent

Self-Ask Agent 是另一种基于 ReAct 框架的 Agent,它直接使用搜索引擎作为唯一的工具,工具名称必须叫做 Intermediate Answer,一般使用 Google 搜索来实现;Self-Ask 的原理来自于 这篇论文,通过下面的提示语技巧让大模型以 Follow up/Intermediate answer 这种固定的格式回答用户问题:

Question: Who lived longer, Muhammad Ali or Alan Turing?
Are follow up questions needed here: Yes.
Follow up: How old was Muhammad Ali when he died?
Intermediate answer: Muhammad Ali was 74 years old when he died.
Follow up: How old was Alan Turing when he died?
Intermediate answer: Alan Turing was 41 years old when he died.
So the final answer is: Muhammad Ali

其中 Follow up 是我们需要搜索的内容,它类似于 Zero-shot ReAct Agent 里的 Action/Action Input,由于直接使用搜索引擎,所以不需要让大模型决定使用哪个工具和参数;Intermediate answer 是搜索的结果,它类似于 Zero-shot ReAct Agent 里的 Observation;经过不断的搜索,大模型最终得到问题的答案。

下面是使用 Self-Ask Agent 的示例,首先通过 SerpAPI 定义一个名为 Intermediate Answer 的工具:

search = SerpAPIWrapper()
tools = [
    Tool(
        name="Intermediate Answer",
        func=search.run,
        description="useful for when you need to ask with search",
    )
]

然后创建一个类型为 AgentType.SELF_ASK_WITH_SEARCH 的 Agent,并提出一个问题:当今美国总统的出生日期是什么时候?

agent_executor = initialize_agent(tools, llm, agent=AgentType.SELF_ASK_WITH_SEARCH, verbose=True)

result = agent_executor.run("When is the current president of the United States born?")
print(result)

运行结果如下:

> Entering new AgentExecutor chain...
Yes.
Follow up: Who is the current president of the United States?
Intermediate answer: Joe Biden
Follow up: When was Joe Biden born?
Intermediate answer: November 20, 1942
So the final answer is: November 20, 1942

> Finished chain.
November 20, 1942

OpenAI Functions Agent

上面的几种 Agent 都是基于 ReAct 框架实现的,这虽然是一种比较通用的解决方案,但是当我们使用 OpenAI 时,OpenAI Functions Agent 才是我们的最佳选择,因为 ReAct 归根结底是基于提示工程的,执行结果有着很大的不确定性,OpenAI 的 Function Calling 机制相对来说要更加可靠。

在入门示例中,我们已经用到了 OpenAI Functions Agent 类型,这一节我们将学习另一种类型 OpenAI Multi Functions Agent

OpenAI Functions Agent 的缺点是每次只能返回一个工具,比如我们的问题是 合肥和上海今天的天气怎么样?,OpenAI Functions Agent 第一次会返回一个函数调用 get_weather_info(city='合肥'),然后我们需要再调用一次,第二次又会返回一个函数调用 get_weather_info(city='上海'),最后 OpenAI 对两个结果进行总结得到最终答案。

很显然,这两次调用是可以并行处理的,如果 Agent 能一次性返回两次调用,这将大大提高我们的执行效率,这就是提出 OpenAI Multi Functions Agent 的初衷。

为了对比这两种 Agent 的区别,我们可以使用下面的技巧开启 LangChain 的调试模式:

import langchain

langchain.debug = True

然后使用同一个问题对两个 Agent 进行提问:

Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?

OpenAI Functions Agent 的运行结果如下:

[chain/start] [1:chain:AgentExecutor] Entering Chain run with input:
{
  "input": "Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
}
[llm/start] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
  ]
}
[llm/end] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] [1.08s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {
                "name": "get_word_length",
                "arguments": "{\n  \"word\": \"weekly-practice\"\n}"
              }
            }
          }
...
}
[tool/start] [1:chain:AgentExecutor > 3:tool:get_word_length] Entering Tool run with input:
"{'word': 'weekly-practice'}"
[tool/end] [1:chain:AgentExecutor > 3:tool:get_word_length] [0.217ms] Exiting Tool run with output:
"15"
[llm/start] [1:chain:AgentExecutor > 4:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?\nAI: {'name': 'get_word_length', 'arguments': '{\\n  \"word\": \"weekly-practice\"\\n}'}\nFunction: 15"
  ]
}
[llm/end] [1:chain:AgentExecutor > 4:llm:ChatOpenAI] [649.318ms] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {
                "name": "get_word_length",
                "arguments": "{\n  \"word\": \"aneasystone\"\n}"
              }
            }
          }
...
}
[tool/start] [1:chain:AgentExecutor > 5:tool:get_word_length] Entering Tool run with input:
"{'word': 'aneasystone'}"
[tool/end] [1:chain:AgentExecutor > 5:tool:get_word_length] [0.148ms] Exiting Tool run with output:
"11"
[llm/start] [1:chain:AgentExecutor > 6:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?\nAI: {'name': 'get_word_length', 'arguments': '{\\n  \"word\": \"weekly-practice\"\\n}'}\nFunction: 15\nAI: {'name': 'get_word_length', 'arguments': '{\\n  \"word\": \"aneasystone\"\\n}'}\nFunction: 11"
  ]
}
[llm/end] [1:chain:AgentExecutor > 6:llm:ChatOpenAI] [1.66s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "The word 'weekly-practice' has a length of 15 characters, while the word 'aneasystone' has a length of 11 characters.",
            "additional_kwargs": {}
          }
...
}
[chain/end] [1:chain:AgentExecutor] [3.39s] Exiting Chain run with output:
{
  "output": "The word 'weekly-practice' has a length of 15 characters, while the word 'aneasystone' has a length of 11 characters."
}
The word 'weekly-practice' has a length of 15 characters, while the word 'aneasystone' has a length of 11 characters.

可以看到 OpenAI Functions Agent 调了两次大模型,第一次大模型返回 get_word_length 函数计算单词 weekly-practice 的长度,第二次再次返回 get_word_length 函数计算单词 aneasystone 的长度。

而 OpenAI Multi Functions Agent 的执行结果如下:

[chain/start] [1:chain:AgentExecutor] Entering Chain run with input:
{
  "input": "Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
}
[llm/start] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
  ]
}
[llm/end] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] [3.26s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {
                "name": "tool_selection",
                "arguments": "{\n  \"actions\": [\n    {\n      \"action_name\": \"get_word_length\",\n      \"action\": {\n        \"word\": \"weekly-practice\"\n      }\n    },\n    {\n      \"action_name\": \"get_word_length\",\n      \"action\": {\n        \"word\": \"aneasystone\"\n      }\n    }\n  ]\n}"
              }
            }
          }
...
}
[tool/start] [1:chain:AgentExecutor > 3:tool:get_word_length] Entering Tool run with input:
"{'word': 'weekly-practice'}"
[tool/end] [1:chain:AgentExecutor > 3:tool:get_word_length] [0.303ms] Exiting Tool run with output:
"15"
[tool/start] [1:chain:AgentExecutor > 4:tool:get_word_length] Entering Tool run with input:
"{'word': 'aneasystone'}"
[tool/end] [1:chain:AgentExecutor > 4:tool:get_word_length] [0.229ms] Exiting Tool run with output:
"11"
[llm/start] [1:chain:AgentExecutor > 5:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?\nAI: {'name': 'tool_selection', 'arguments': '{\\n  \"actions\": [\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"weekly-practice\"\\n      }\\n    },\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"aneasystone\"\\n      }\\n    }\\n  ]\\n}'}\nFunction: 15\nAI: {'name': 'tool_selection', 'arguments': '{\\n  \"actions\": [\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"weekly-practice\"\\n      }\\n    },\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"aneasystone\"\\n      }\\n    }\\n  ]\\n}'}\nFunction: 11"
  ]
}
[llm/end] [1:chain:AgentExecutor > 5:llm:ChatOpenAI] [1.02s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "The word 'weekly-practice' has a length of 15 characters, and the word 'aneasystone' has a length of 11 characters.",
            "additional_kwargs": {}
          }
...
}
[chain/end] [1:chain:AgentExecutor] [4.29s] Exiting Chain run with output:
{
  "output": "The word 'weekly-practice' has a length of 15 characters, and the word 'aneasystone' has a length of 11 characters."
}
The word 'weekly-practice' has a length of 15 characters, and the word 'aneasystone' has a length of 11 characters.

可以看到 OpenAI Multi Functions Agent 调了一次大模型,返回了一个叫做 tool_selection 的工具,这个函数是 LangChain 特意构造的,它的参数是我们定义的多个工具,这样就使得 OpenAI 一次返回多个工具供我们调用。

Plan and execute Agent

上面所介绍的所有 Agent,本质上其实都是一样的:给定一组工具,然后大模型根据用户的输入一步一步地选择工具来执行,每一步的结果都用于决定下一步操作,直到问题被解决,像这种逐步执行的 Agent 被称为 Action Agent,它比较适合小型任务;如果要处理需要保持长期目标的复杂任务,使用 Action Agent 经常会出现推理跑偏的问题,这时就轮到 Plan and execute Agent 上场了。

Plan and execute Agent 会提前对问题制定好完整的执行计划,然后在不更新计划的情况下逐步执行,即先把用户的问题拆解成多个子任务,然后再执行各个子任务,直到用户的问题完全被解决:

agent.png

我们可以动态的选择工具或 Chain 来解决这些子任务,但更好的做法是使用 Action Agent,这样我们可以将 Action Agent 的动态性和 Plan and execute Agent 的计划能力相结合,对复杂问题的解决效果更好。

Plan and execute Agent 的思想来自开源项目 BabyAGI,相比于 Action Agent 所使用的 Chain-of-Thought Prompting,它所使用的是 Plan-and-Solve Prompting,感兴趣的同学可以阅读 Plan-and-Solve Prompting 的论文

和之前的 Action Agent 一样,在使用 Plan and execute Agent 之前我们需要提前准备好工具列表,这里我们准备两个工具,一个搜索,一个计算:

from langchain.llms import OpenAI
from langchain import SerpAPIWrapper
from langchain.agents.tools import Tool
from langchain import LLMMathChain

search = SerpAPIWrapper()
llm_math_chain = LLMMathChain.from_llm(llm=OpenAI(temperature=0), verbose=True)
tools = [
    Tool(
        name = "Search",
        func=search.run,
        description="useful for when you need to answer questions about current events"
    ),
    Tool(
        name="Calculator",
        func=llm_math_chain.run,
        description="useful for when you need to answer questions about math"
    ),
]

接下来创建 Plan and execute Agent,它主要包括两个部分:plannerexecutorplanner 用于制定计划,将任务划分成若干个子任务,executor 用于执行计划,处理子任务,其中 executor 可以使用 Chain 或上面的任意 Action Agent 来实现。Plan and execute Agent 目前还是 LangChain 的实验功能,相应的实现在 langchain.experimental 模块下:

from langchain.chat_models import ChatOpenAI
from langchain.experimental.plan_and_execute import PlanAndExecute, load_agent_executor, load_chat_planner

model = ChatOpenAI(temperature=0)
planner = load_chat_planner(model)
executor = load_agent_executor(model, tools, verbose=True)
agent = PlanAndExecute(planner=planner, executor=executor, verbose=True)
agent.run("Who is Leo DiCaprio's girlfriend? What is her current age raised to the 0.43 power?")

运行结果的第一步可以看到整个任务的执行计划:

> Entering new PlanAndExecute chain...
steps=[
  Step(value="Search for information about Leo DiCaprio's girlfriend."), 
  Step(value='Find her current age.'), 
  Step(value='Calculate her current age raised to the 0.43 power.'), 
  Step(value="Given the above steps taken, respond to the user's original question.\n\n")]

然后是每个子任务的执行结果,首先是搜索 Leo DiCaprio's girlfriend

> Entering new AgentExecutor chain...
Thought: I can use the Search tool to find information about Leo DiCaprio's girlfriend.

Action:
```{"action": "Search", "action_input": "Leo DiCaprio girlfriend"}```
Observation: Blake Lively and DiCaprio are believed to have enjoyed a whirlwind five-month romance in 2011. The pair were seen on a yacht together in Cannes, ...
Thought:Based on my search, it seems that Blake Lively was one of Leo DiCaprio's girlfriends. They were believed to have had a five-month romance in 2011 and were seen together on a yacht in Cannes.

> Finished chain.
*****

Step: Search for information about Leo DiCaprio's girlfriend.

Response: Based on my search, it seems that Blake Lively was one of Leo DiCaprio's girlfriends. They were believed to have had a five-month romance in 2011 and were seen together on a yacht in Cannes.

搜到的这个新闻有点老了,得到结果是 Blake Lively,然后计算她的年龄:

> Entering new AgentExecutor chain...
Thought: To find Blake Lively's current age, I can calculate it based on her birthdate. I will use the Calculator tool to subtract her birth year from the current year.

Action:
{"action": "Calculator", "action_input": "2022 - 1987"}

> Entering new LLMMathChain chain...
2022 - 1987

...numexpr.evaluate("2022 - 1987")...

Answer: 35
> Finished chain.

Observation: Answer: 35
Thought:Blake Lively's current age is 35.

> Finished chain.
*****

Step: Find her current age.

Response: Blake Lively's current age is 35.

然后使用数学工具计算年龄的 0.43 次方:

> Entering new AgentExecutor chain...
Thought: To calculate Blake Lively's current age raised to the 0.43 power, I can use a calculator tool.

Action:
{"action": "Calculator", "action_input": "35^0.43"}

> Entering new LLMMathChain chain...
35**0.43

...numexpr.evaluate("35**0.43")...

Answer: 4.612636795281377
> Finished chain.

Observation: Answer: 4.612636795281377
Thought:To calculate Blake Lively's current age raised to the 0.43 power, I used a calculator tool and the result is 4.612636795281377.

Action:
{"action": "Final Answer", "action_input": "Blake Lively's current age raised to the 0.43 power is approximately 4.6126."}


> Finished chain.
*****

Step: Calculate her current age raised to the 0.43 power.

Response: Blake Lively's current age raised to the 0.43 power is approximately 4.6126.

最后得到结果,润色后输出:

> Entering new AgentExecutor chain...
Action:
{
  "action": "Final Answer",
  "action_input": "Blake Lively's current age raised to the 0.43 power is approximately 4.6126."
}

> Finished chain.
*****

Step: Given the above steps taken, respond to the user's original question.

Response: Action:
{
  "action": "Final Answer",
  "action_input": "Blake Lively's current age raised to the 0.43 power is approximately 4.6126."
}
> Finished chain.

总结

在这篇笔记中,我们首先学习了 OpenAI 的插件和 Function Calling 机制,然后再对 Agent 的基本概念做了一个大概的了解,最后详细地学习了 LangChain 中不同的 Agent 类型,包括 Zero-shot ReAct Agent、Conversational ReAct Agent、ReAct DocStore Agent、Self-Ask Agent、OpenAI Functions Agent 和 Plan and execute Agent,这些 Agent 在使用上虽然大同小异,但每一种 Agent 都代表一种解决问题的思路,它们使用了不同的提示语技术:Chain-of-Thought Prompting 和 Plan-and-Solve Prompting。

从这里我们也可以看出提示语的重要性,由此也诞生了一门新的学科:提示工程(Prompt Engineering),这门学科专门研究如何开发和优化提示词,将大模型用于各种应用场景,提高大模型处理复杂任务的能力。LangChain 中内置了大量的提示词,我们将在下一篇笔记中继续学习它。

参考

更多

AI Agents

OpenAI Functions Chain

自从 OpenAI 推出 Function Calling 功能之后,LangChain 就积极地将其应用到自己的项目中,在 LangChain 的文档中,可以看到很多 OpenAI Functions 的身影。除了这篇笔记所介绍的 OpenAI Functions AgentOpenAI Multi Functions Agent,OpenAI Functions 还可以用在 Chain 中,OpenAI Functions Chain 可以用来生成结构化的输出,在信息提取、文本标注等场景下非常有用。

扫描二维码,在手机上阅读!

大模型应用开发框架 LangChain 学习笔记

一场关于大模型的战役正在全世界激烈地上演着,国内外的各大科技巨头和研究机构纷纷投入到这场战役中,光是写名字就能罗列出一大串,比如国外的有 OpenAI 的 GPT-4,Meta 的 LLaMa,Stanford University 的 Alpaca,Google 的 LaMDAPaLM 2,Anthropic 的 Claude,Databricks 的 Dolly,国内的有百度的 文心,阿里的 通义,科大讯飞的 星火,华为的 盘古,复旦大学的 MOSS,智谱 AI 的 ChatGLM 等等等等。

一时间大模型如百花齐放,百鸟争鸣,并在向各个行业领域渗透,让人感觉通用人工智能仿佛就在眼前。基于大模型开发的应用和产品也如雨后春笋,让人目不暇接,每天都有很多新奇的应用和产品问世,有的可以充当你的朋友配你聊天解闷,有的可以充当你的老师帮你学习答疑,有的可以帮你写文章编故事,有的可以帮你写代码改 BUG,大模型的崛起正影响着我们生活中的方方面面。

正是在这样的背景下,为了方便和统一基于大模型的应用开发,一批大模型应用开发框架横空出世,LangChain 就是其中最流行的一个。

快速开始

正如前文所述,LangChain 是一个基于大语言模型(LLM)的应用程序开发框架,它提供了一整套工具、组件和接口,简化了创建大模型应用程序的过程,方便开发者使用语言模型实现各种复杂的任务,比如聊天机器人、文档问答、各种基于 Prompt 的助手等。根据 官网的介绍,它可以让你的应用变得 Data-awareAgentic

  • Data-aware:也就是数据感知,可以将语言模型和其他来源的数据进行连接,比如让语言模型针对指定文档回答问题;
  • Agentic:可以让语言模型和它所处的环境进行交互,实现类似代理机器人的功能,帮助用户完成指定任务;

LangChain 在 GitHub 上有着异乎寻常的热度,截止目前为止,星星数高达 55k,而且它的更新非常频繁,隔几天就会发一个新版本,有时甚至一天发好几个版本,所以学习的时候最好以官方文档为准,网络上有很多资料都过时了(包括我的这篇笔记)。

LangChain 提供了 PythonJavaScript 两个版本的 SDK,这里我主要使用 Python 版本的,在我写这篇笔记的时候,最新的版本为 0.0.238,使用下面的命令安装:

$ pip install langchain==0.0.238

注意:Python 版本需要在 3.8.1 及以上,如果低于这个版本,只能安装 langchain==0.0.27

另外要注意的是,这个命令只会安装 LangChain 的基础包,这或许并没有什么用,因为 LangChain 最有价值的地方在于它能和各种各样的语言模型、数据存储、外部工具等进行交互,比如如果我们需要使用 OpenAI,则需要手动安装:

$ pip install openai

也可以在安装 LangChain 时指定安装可选依赖包:

$ pip install langchain[openai]==0.0.238

或者使用下面的命令一次性安装所有的可选依赖包(不过很多依赖可能会用不上):

$ pip install langchain[all]==0.0.238

LangChain 支持的可选依赖包有:

llms = ["anthropic", "clarifai", "cohere", "openai", "openllm", "openlm", "nlpcloud", "huggingface_hub", ... ]
qdrant = ["qdrant-client"]
openai = ["openai", "tiktoken"]
text_helpers = ["chardet"]
clarifai = ["clarifai"]
cohere = ["cohere"]
docarray = ["docarray"]
embeddings = ["sentence-transformers"]
javascript = ["esprima"]
azure = [ ... ]
all = [ ... ]

可以在项目的 pyproject.toml 文件中查看依赖包详情。

入门示例:LLMs vs. ChatModels

我们首先从一个简单的例子开始:

from langchain.llms import OpenAI

llm = OpenAI(temperature=0.9)
response = llm.predict("给水果店取一个名字")
print(response)

# 果舞时光

LangChain 集成了许多流行的语言模型,并提供了一套统一的接口方便开发者直接使用,比如在上面的例子中,我们引入了 OpenAI 这个 LLM,然后调用 llm.predict() 方法让语言模型完成后续内容的生成。如果用户想使用其他语言模型,只需要将上面的 OpenAI 换成其他的即可,比如流行的 Anthropic 的 Claude 2,或者 Google 的 PaLM 2 等,这里 可以找到 LangChain 目前支持的所有语言模型接口。

回到上面的例子,llm.predict() 方法实际上调用的是 OpenAI 的 Completions 接口,这个接口的作用是给定一个提示语,让 AI 生成后续内容;我们知道,除了 Completions,OpenAI 还提供了一个 Chat 接口,也可以用于生成后续内容,而且比 Completions 更强大,可以给定一系列对话内容,让 AI 生成后续的回复,从而实现类似 ChatGPT 的聊天功能。

官方推荐使用 Chat 替换 Completions 接口,在后续的 OpenAI 版本中,Completions 接口可能会被弃用。

因此,LangChain 也提供 Chat 接口:

from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages([
    HumanMessage(content="窗前明月光,下一句是什么?"),
])
print(response.content)

# 疑是地上霜。

和上面的 llm.predict() 方法比起来,chat.predict_messages() 方法可以接受一个数组,这也意味着 Chat 接口可以带上下文信息,实现聊天的效果:

from langchain.chat_models import ChatOpenAI
from langchain.schema import AIMessage, HumanMessage, SystemMessage

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages([
    SystemMessage(content="你是一个诗词助手,帮助用户回答诗词方面的问题"),    
    HumanMessage(content="窗前明月光,下一句是什么?"),
    AIMessage(content="疑是地上霜。"),
    HumanMessage(content="这是谁的诗?"),
])
print(response.content)

# 这是李白的《静夜思》。

另外,Chat 接口也提供了一个 chat.predict() 方法,可以实现和 llm.predict() 一样的效果:

from langchain.chat_models import ChatOpenAI

chat = ChatOpenAI(temperature=0.9)
response = chat.predict("给水果店取一个名字")
print(response)

# 果香居

实现翻译助手:PromptTemplate

基于 ChatGPT 实现一个划词翻译 Chrome 插件 这篇笔记中,我们通过提示语技术实现了一个非常简单的划词翻译 Chrome 插件,其中的翻译功能我们也可以使用 LangChain 来完成,当然,使用 LLMsChatModels 都可以。

使用 LLMs 实现翻译助手:

from langchain.llms import OpenAI

llm = OpenAI(temperature=0.9)
response = llm.predict("将下面的句子翻译成英文:今天的天气真不错")
print(response)

# The weather is really nice today.

使用 ChatModels 实现翻译助手:

from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages([
    SystemMessage(content="你是一个翻译助手,可以将中文翻译成英文。"),
    HumanMessage(content="今天的天气真不错"),
])
print(response.content)

# The weather is really nice today.

观察上面的代码可以发现,输入参数都具备一个固定的模式,为此,LangChain 提供了一个 PromptTemplate 类来方便我们构造提示语模板:

from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate

prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")
text = prompt.format(sentence="今天的天气真不错")

llm = OpenAI(temperature=0.9)
response = llm.predict(text)
print(response)

# Today's weather is really great.

其实 PromptTemplate 默认实现就是 Python 的 f-strings,只不过它提供了一种抽象,还可以支持其他的模板实现,比如 jinja2 模板引擎

对于 ChatModels,LangChain 也提供了相应的 ChatPromptTemplate,只不过使用起来要稍微繁琐一点:

from langchain.chat_models import ChatOpenAI
from langchain.prompts.chat import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate

system_message_prompt = SystemMessagePromptTemplate.from_template(
    "你是一个翻译助手,可以将{input_language}翻译成{output_language}。")
human_message_prompt = HumanMessagePromptTemplate.from_template("{text}")
chat_prompt = ChatPromptTemplate.from_messages([system_message_prompt, human_message_prompt])
messages = chat_prompt.format_messages(input_language="中文", output_language="英文", text="今天的天气真不错")

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages(messages)
print(response.content)

# The weather today is really good.

实现知识库助手:Data connection

使用 Embedding 技术打造本地知识库助手 这篇笔记中,我们通过 OpenAI 的 Embedding 接口和开源向量数据库 Qdrant 实现了一个非常简单的知识库助手。在上面的介绍中我们提到,LangChain 的一大特点就是数据感知,可以将语言模型和其他来源的数据进行连接,所以知识库助手正是 LangChain 最常见的用例之一,这一节我们就使用 LangChain 来重新实现它。

LangChain 将实现知识库助手的过程拆分成了几个模块,可以自由组合使用,这几个模块是:

  • Document loaders - 用于从不同的来源加载文档;
  • Document transformers - 对文档进行处理,比如转换为不同的格式,对大文档分片,去除冗余文档,等等;
  • Text embedding models - 通过 Embedding 模型将文本转换为向量;
  • Vector stores - 将文档保存到向量数据库,或从向量数据库中检索文档;
  • Retrievers - 用于检索文档,这是比向量数据库更高一级的抽象,不仅仅限于从向量数据库中检索,可以扩充更多的检索来源;

data-connection.jpg

读取文档

TextLoader 是最简单的读取文档的方法,它可以处理大多数的纯文本,比如 txtmd 文件:

from langchain.document_loaders import TextLoader

loader = TextLoader("./kb.txt")
raw_documents = loader.load()
print(raw_documents)

LangChain 还提供了一些其他格式的文档读取方法,比如 JSONHTMLCSVWordPPTPDF 等,也可以加载其他来源的文档,比如通过 URLLoader 抓取网页内容,通过 WikipediaLoader 获取维基百科的内容等,还可以使用 DirectoryLoader 同时读取整个目录的文档。

文档分割

使用 TextLoader 加载得到的是原始文档,有时候我们还需要对原始文档进行处理,最常见的一种处理方式是文档分割。由于大模型的输入存在上下文窗口的限制,所以我们不能直接将一个几百兆的文档丢给大模型,而是将大文档分割成一个个小分片,然后通过 Embedding 技术查询与用户问题最相关的几个分片丢给大模型。

最简单的文档分割器是 CharacterTextSplitter,它默认使用分隔符 \n\n 来分割文档,我们也可以修改为使用 \n 来分割:

from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(        
    separator = "\n",
    chunk_size = 0,
    chunk_overlap  = 0,
    length_function = len,
)
documents = text_splitter.split_documents(raw_documents)
print(documents)

CharacterTextSplitter 可以通过 chunk_size 参数控制每个分片的大小,默认情况下分片大小为 4000,这也意味着如果有长度不足 4000 的分片会递归地和其他分片合并成一个分片;由于我这里的测试文档比较小,总共都没有 4000,所以按 \n 分割完,每个分片又会被合并成一个大分片了,所以我这里将 chunk_size 设置为 0,这样就不会自动合并。

另一个需要注意的是,这里的分片大小是根据 length_function 来计算的,默认使用的是 len() 函数,也就是根据字符的长度来分割;但是大模型的上下文窗口限制一般是通过 token 来计算的,这和长度有一些细微的区别,所以如果要确保分割后的分片能准确的适配大模型,我们就需要 通过 token 来分割文档,比如 OpenAI 的 tiktoken,Hugging Face 的 GPT2TokenizerFast 等。OpenAI 的这篇文档 介绍了什么是 token 以及如何计算它,感兴趣的同学可以参考之。

不过在一般情况下,如果对上下文窗口的控制不需要那么严格,按长度分割也就足够了。

Embedding 与向量库

有了分割后的文档分片,接下来,我们就可以对每个分片计算 Embedding 了:

from langchain.embeddings.openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings()
doc_result = embeddings.embed_documents(['你好', '再见'])
print(doc_result)

这里我们使用的是 OpenAI 的 Embedding 接口,除此之外,LangChain 还集成了很多 其他的 Embedding 接口,比如 Cohere、SentenceTransformer 等。不过一般情况下我们不会像上面这样单独计算 Embedding,而是和向量数据库结合使用:

from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import Qdrant

qdrant = Qdrant.from_documents(
    documents,
    OpenAIEmbeddings(),
    url="127.0.0.1:6333",
    prefer_grpc=False,
    collection_name="my_documents",
)

这里我们仍然使用 Qdrant 来做向量数据库,Qdrant.from_documents() 方法会自动根据文档列表计算 Embedding 并存储到 Qdrant 中。除了 Qdrant,LangChain 也集成了很多 其他的向量数据库,比如 Chroma、FAISS、Milvus、PGVector 等。

再接下来,我们通过 qdrant.similarity_search() 方法从向量数据库中搜索出和用户问题最接近的文本片段:

query = "小明家的宠物狗叫什么名字?"
found_docs = qdrant.similarity_search(query)
print(found_docs)

后面的步骤就和 使用 Embedding 技术打造本地知识库助手 这篇笔记中一样,准备一段提示词模板,向 ChatGPT 提问就可以了。

LangChain 的精髓:Chain

通过上面的快速开始,我们学习了 LangChain 的基本用法,从几个例子下来,或许有人觉得 LangChain 也没什么特别的,只是一个集成了大量的 LLM、Embedding、向量库的 SDK 而已,我一开始也是这样的感觉,直到学习 Chain 这个概念的时候,才明白这时才算是真正地进入 LangChain 的大门。

LLMChain 开始

LLMChain 是 LangChain 中最基础的 Chain,它接受两个参数:一个是 LLM,另一个是提供给 LLM 的 Prompt 模板:

from langchain import PromptTemplate, OpenAI, LLMChain

llm = OpenAI(temperature=0.9)
prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt
)
result = llm_chain("今天的天气真不错")
print(result['text'])

其中,LLM 参数同时支持 LLMsChatModels,运行效果和上面的入门示例是一样的。

在 LangChain 中,Chain 的特别之处在于,它的每一个参数都被称为 Component,Chain 由一系列的 Component 组合在一起以完成特定任务,Component 也可以是另一个 Chain,通过封装和组合,形成一个更复杂的调用链,从而创建出更强大的应用程序。

上面的例子中,有一点值得注意的是,我们在 Prompt 中定义了一个占位符 {sentence},但是在调用 Chain 的时候并没有明确指定该占位符的值,LangChain 是怎么知道要将我们的输入替换掉这个占位符的呢?实际上,每个 Chain 里都包含了两个很重要的属性:input_keysoutput_keys,用于表示这个 Chain 的输入和输出,我们查看 LLMChain 的源码,它的 input_keys 实现如下:

    @property
    def input_keys(self) -> List[str]:
        return self.prompt.input_variables

所以 LLMChain 的入参就是 Prompt 的 input_variables,而 PromptTemplate.from_template() 其实是 PromptTemplate 的简写,下面这行代码:

prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")

等价于下面这行代码:

prompt = PromptTemplate(
    input_variables=['sentence'],
    template = "将下面的句子翻译成英文:{sentence}"
)

所以 LLMChain 的入参也就是 sentence 参数。

而下面这行代码:

result = llm_chain("今天的天气真不错")

是下面这行代码的简写:

result = llm_chain({'sentence': "今天的天气真不错"})

当参数比较简单时,LangChain 会自动将传入的参数转换成它需要的 Dict 形式,一旦我们在 Prompt 中定义了多个参数,那么这种简写就不行了,就得在调用的时候明确指定参数:

from langchain import PromptTemplate, OpenAI, LLMChain

llm = OpenAI(temperature=0.9)
prompt = PromptTemplate(
    input_variables=['lang', 'sentence'],
    template = "将下面的句子翻译成{lang}:{sentence}"
)

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt
)
result = llm_chain({"lang": "日语", "sentence": "今天的天气真不错"})
print(result['text'])

# 今日の天気は本当に良いです。

我们从 LangChain 的源码中可以更深入的看下 Chain 的类定义,如下:

class Chain(BaseModel, ABC):

    def __call__(
        self,
        inputs: Union[Dict[str, Any], Any],
        return_only_outputs: bool = False,
        callbacks: Callbacks = None,
        ...
    ) -> Dict[str, Any]:
        ...

这说明 Chain 的本质其实就是根据一个 Dict 输入,得到一个 Dict 输出而已。只不过 Chain 为我们还提供了三个特性:

  • Stateful: 内置 Memory 记忆功能,使得每个 Chain 都是有状态的;
  • Composable: 具备高度的可组合性,我们可以将 Chain 和其他的组件,或者其他的 Chain 进行组合;
  • Observable: 支持向 Chain 传入 Callbacks 回调执行额外功能,从而实现可观测性,如日志、监控等;

使用 Memory 实现记忆功能

Chain 的第一个特征就是有状态,也就是说,它能够记住会话的历史,这是通过 Memory 模块来实现的,最简单的 Memory 是 ConversationBufferMemory,它通过一个内存变量保存会话记忆。

在使用 Memory 时,我们的 Prompt 需要做一些调整,我们要在 Prompt 中加上历史会话的内容,比如下面这样:

template = """
下面是一段人类和人工智能之间的友好对话。

当前对话内容:
{history}
Human: {input}
AI:"""
prompt = PromptTemplate(input_variables=["history", "input"], template=template)

其中 {history} 这个占位符的内容就由 Memory 来负责填充,我们只需要将 Memory 设置到 LLMChain 中,这个填充操作将由框架自动完成:

llm = OpenAI(temperature=0.9)
memory = ConversationBufferMemory()

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt,
    memory = memory
)

result = llm_chain("窗前明月光,下一句是什么?")
print(result['text'])

result = llm_chain("这是谁的诗?")
print(result['text'])

对于 ChatModels,我们可以使用 ChatPromptTemplate 来构造 Prompt:

prompt = ChatPromptTemplate(
    messages=[
        SystemMessagePromptTemplate.from_template("你是一个聊天助手,和人类进行聊天。"),
        MessagesPlaceholder(variable_name="history"),
        HumanMessagePromptTemplate.from_template("{input}")
    ]
)

占位符填充的基本逻辑是一样的,不过在初始化 Memory 的时候记得指定 return_messages=True,让 Memory 使用 Message 列表来填充占位符,而不是默认的字符串:

chat = ChatOpenAI(temperature=0.9)
memory = ConversationBufferMemory(return_messages=True)

llm_chain = LLMChain(
    llm = chat, 
    prompt = prompt,
    memory = memory
)

result = llm_chain("窗前明月光,下一句是什么?")
print(result['text'])

result = llm_chain("这是谁的诗?")
print(result['text'])

为了简化 Prompt 的写法,LangChain 基于 LLMChain 实现了 ConversationChain,使用起来更简单:

from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory

chat = ChatOpenAI(temperature=0.9)
memory = ConversationBufferMemory()

conversation = ConversationChain(
    llm = chat,
    memory = memory
)

result = conversation.run("窗前明月光,下一句是什么?")
print(result)

result = conversation.run("这是谁的诗?")
print(result)

除了 ConversationBufferMemory,LangChain 还提供了很多其他的 Memory 实现:

  • ConversationBufferWindowMemory - 基于内存的记忆,只保留最近 K 次会话;
  • ConversationTokenBufferMemory - 基于内存的记忆,根据 token 数来限制保留最近的会话;
  • ConversationEntityMemory - 从会话中提取实体,并在记忆中构建关于实体的知识;
  • ConversationKGMemory - 基于知识图来重建记忆;
  • ConversationSummaryMemory - 对历史会话进行总结,减小记忆大小;
  • ConversationSummaryBufferMemory - 既保留最近 K 次会话,也对历史会话进行总结;
  • VectorStoreRetrieverMemory - 通过向量数据库保存记忆;

这些 Memory 在长对话场景,或需要对记忆进行持久化时非常有用。内置的 Memory 大多数都是基于内存实现的,LangChain 还集成了很多第三方的库,可以实现记忆的持久化,比如 Sqlite、Mongo、Postgre 等。

再聊文档问答:RetrievalQA

在上面的实现知识库助手一节,我们学习了各种加载文档的方式,以及如何对大文档进行分片,并使用 OpenAI 计算文档的 Embedding,最后保存到向量数据库 Qdrant 中。一旦文档库准备就绪,接下来就可以对它进行问答了,LangChain 也贴心地提供了很多关于文档问答的 Chain 方便我们使用,比如下面最常用的 RetrievalQA

from langchain.chains import RetrievalQA
from langchain.llms import OpenAI

qa = RetrievalQA.from_chain_type(llm=OpenAI(), chain_type="stuff", retriever=qdrant.as_retriever())
query = "小明家的宠物狗比小红家的大几岁?"
result = qa.run(query)
print(result)

# 毛毛比大白大两岁,毛毛今年3岁,大白今年1岁。

RetrievalQA 充分体现了 Chain 的可组合性,它实际上是一个复合 Chain,真正实现文档问答的 Chain 是它内部封装的 CombineDocumentsChainRetrievalQA 的作用是通过 Retrievers 获取和用户问题相关的文档,然后丢给内部的 CombineDocumentsChain 来处理,而 CombineDocumentsChain 又是一个复合 Chain,它会将用户问题和相关文档组合成提示语,丢到内部的 LLMChain 处理最终得到输出。

但总的来说,实现文档问答最核心的还是 RetrievalQA 内部使用的 CombineDocumentsChain,实际上除了 文档问答,针对文档的使用场景还有 生成文档摘要提取文档信息,等等,这些利用大模型处理文档的场景中,最难解决的问题是如何对大文档进行拆分和组合,而解决方案也是多种多样的,LangChain 提供的 CombineDocumentsChain 是一个抽象类,根据不同的解决方案它包含了四个实现类:

StuffDocumentsChain

StuffDocumentsChain 是最简单的文档处理 Chain,单词 stuff 的意思是 填充,正如它的名字所示,StuffDocumentsChain 将所有的文档内容连同用户的问题一起全部填充进 Prompt,然后丢给大模型。这种 Chain 在处理比较小的文档时非常有用。在上面的例子中,我们通过 RetrievalQA.from_chain_type(chain_type="stuff") 生成了 RetrievalQA,内部就是使用了 StuffDocumentsChain。它的示意图如下所示:

stuff-documents.jpg

RefineDocumentsChain

refine 的含义是 提炼RefineDocumentsChain 表示从分片的文档中一步一步的提炼出用户问题的答案,这个 Chain 在处理大文档时非常有用,可以保证每一步的提炼都不超出大模型的上下文限制。比如我们有一个很大的 PDF 文件,我们要生成它的文档摘要,我们可以先生成第一个分片的摘要,然后将第一个分片的摘要和第二个分片丢给大模型生成前两个分片的摘要,再将前两个分片的摘要和第三个分片丢给大模型生成前三个分片的摘要,依次类推,通过不断地对大模型的答案进行更新,最终生成全部分片的摘要。整个提炼的过程如下:

refine-documents.jpg

RefineDocumentsChain 相比于 StuffDocumentsChain,会产生更多的大模型调用,而且对于有些场景,它的效果可能很差,比如当文档中经常出现相互交叉的引用时,或者需要从多个文档中获取非常详细的信息时。

MapReduceDocumentsChain

MapReduceDocumentsChainRefineDocumentsChain 一样,都适合处理一些大文档,它的处理过程可以分为两步:MapReduce。比如我们有一个很大的 PDF 文件,我们要生成它的文档摘要,我们可以先将文档分成小的分片,让大模型挨个生成每个分片的摘要,这一步称为 Map;然后再将每个分片的摘要合起来生成汇总的摘要,这一步称为 Reduce;如果所有分片的摘要合起来超过了大模型的限制,那么我们需要对合起来的摘要再次分片,然后再次递归地执行 MapReduce 这个过程:

map-reduce-documents.jpg

MapRerankDocumentsChain

MapRerankDocumentsChain 一般适合于大文档的问答场景,它的第一步和 MapReduceDocumentsChain 类似,都是遍历所有的文档分片,挨个向大模型提问,只不过它的 Prompt 有一点特殊,它不仅要求大模型针对用户的问题给出一个答案,而且还要求大模型为其答案的确定性给出一个分数;得到每个答案的分数后,MapRerankDocumentsChain 的第二步就可以按分数排序,给出分数最高的答案:

map-rerank-documents.jpg

对 Chain 进行调试

当 LangChain 的输出结果有问题时,开发者就需要 对 Chain 进行调试,特别是当组合的 Chain 非常多时,LangChain 的输出结果往往非常不可控。最简单的方法是在 Chain 上加上 verbose=True 参数,这时 LangChain 会将执行的中间步骤打印出来,比如上面的 使用 Memory 实现记忆功能 一节中的例子,加上调试信息后,第一次输出结果如下:

> Entering new LLMChain chain...
Prompt after formatting:
System: 你是一个聊天助手,和人类进行聊天。
Human: 窗前明月光,下一句是什么?

> Finished chain.
疑是地上霜。

第二次的输出结果如下:

> Entering new LLMChain chain...
Prompt after formatting:
System: 你是一个聊天助手,和人类进行聊天。
Human: 窗前明月光,下一句是什么?
AI: 疑是地上霜。
Human: 这是谁的诗?

> Finished chain.
这是《静夜思》的开头,是中国唐代诗人李白创作的。

可以很方便的看出 Memory 模块确实起作用了,它将历史会话自动拼接在 Prompt 中了。

当一个 Chain 组合了其他 Chain 的时候,比如上面的 RetrievalQA,这时我们不仅要给 Chain 加上 verbose=True 参数,注意还要通过 chain_type_kwargs 为内部的 Chain 也加上该参数:

qa = RetrievalQA.from_chain_type(
    llm=OpenAI(), 
    chain_type="stuff", 
    retriever=qdrant.as_retriever(), 
    verbose=True, 
    chain_type_kwargs={'verbose': True})

输出来的结果类似下面这样:

> Entering new RetrievalQA chain...

> Entering new StuffDocumentsChain chain...

> Entering new LLMChain chain...
Prompt after formatting:
Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.

小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的,它今年3岁了。

小红家也有一条宠物狗,叫大白,非常听话,它今年才1岁呢。

小红的好朋友叫小明,他们是同班同学。

小华是小明的幼儿园同学,从小就欺负他。

Question: 小明家的宠物狗比小红家的大几岁?
Helpful Answer:

> Finished chain.

> Finished chain.

> Finished chain.
 毛毛比大白大2岁。

从输出的结果我们不仅可以看到各个 Chain 的调用链路,而且还可以看到 StuffDocumentsChain 所使用的提示语,以及 retriever 检索出来的内容,这对我们调试 Chain 非常有帮助。

另外,Callbacks 是 LangChain 提供的另一种调试机制,它提供了比 verbose=True 更好的扩展性,用户可以基于这个机制实现自定义的监控或日志功能。LangChain 内置了很多的 Callbacks,这些 Callbacks 都实现自 CallbackHandler 接口,比如最常用的 StdOutCallbackHandler,它将日志打印到控制台,参数 verbose=True 就是通过它实现的;或者 FileCallbackHandler 可以将日志记录到文件中;LangChain 还 集成了很多第三方工具,比如 StreamlitCallbackHandler 可以将日志以交互形式输出到 Streamlit 应用中。

用户如果要实现自己的 Callbacks,可以直接继承基类 BaseCallbackHandler 即可,下面是一个简单的例子:

class MyCustomHandler(BaseCallbackHandler):

    def on_llm_start(
        self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any
    ) -> Any:
        """Run when LLM starts running."""
        print(f"on_llm_start: {serialized} {prompts}")

    def on_llm_end(self, response: LLMResult, **kwargs: Any) -> Any:
        """Run when LLM ends running."""
        print(f"on_llm_end: {response}")

    def on_llm_error(
        self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any
    ) -> Any:
        """Run when LLM errors."""
        print(f"on_llm_error: {error}")

    def on_chain_start(
        self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any
    ) -> Any:
        """Run when chain starts running."""
        print(f"on_chain_start: {serialized} {inputs}")

    def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> Any:
        """Run when chain ends running."""
        print(f"on_chain_end: {outputs}")

    def on_chain_error(
        self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any
    ) -> Any:
        """Run when chain errors."""
        print(f"on_chain_error: {error}")

    def on_text(self, text: str, **kwargs: Any) -> Any:
        """Run on arbitrary text."""
        print(f"on_text: {text}")

在这个例子中,我们对 LLM 和 Chain 进行了监控,我们需要将这个 Callback 分别传递到 LLM 和 Chain 中:

callback = MyCustomHandler()

llm = OpenAI(temperature=0.9, callbacks=[callback])
prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt,
    callbacks=[callback]
)
result = llm_chain("今天的天气真不错")
print(result['text'])

LangChain 会在调用 LLM 和 Chain 开始和结束的时候回调我们的 Callback,输出结果大致如下:

on_chain_start: <...略>
on_text: Prompt after formatting:
将下面的句子翻译成英文:今天的天气真不错
on_llm_start: <...略>
on_llm_end: <...略>
on_chain_end: {'text': '\n\nThe weather today is really nice.'}

从 Chain 到 Agent

通过上一节的学习,我们掌握了 Chain 的基本概念和用法,对 Chain 的三大特性也有了一个大概的认识,关于 Chain 还有很多高级主题等着我们去探索,比如 Chain 的异步调用实现自定义的 ChainChain 的序列化和反序列化从 LangChainHub 加载 Chain,等等。

此外,除了上一节学习的 LLMChainConversationChainRetrievalQA 以及各种 CombineDocumentsChain,LangChain 还提供了很多其他的 Chain 供用户使用,其中有四个是最基础的:

  • LLMChain - 这个 Chain 在前面已经学习过,它主要是围绕着大模型添加一些额外的功能,比如 ConversationChainCombineDocumentsChain 都是基于 LLMChain 实现的;
  • TransformChain - 这个 Chain 主要用于参数转换,这在对多个 Chain 进行组合时会很有用,我们可以使用 TransformChain 将上一个 Chain 的输出参数转换为下一个 Chain 的输入参数;
  • SequentialChain - 可以将多个 Chain 组合起来并按照顺序分别执行;
  • RouterChain - 这是一种很特别的 Chain,当你有多个 Chain 且不知道该使用哪个 Chain 处理用户请求时,可以使用它;首先,你给每个 Chain 取一个名字,然后给它们分别写一段描述,然后让大模型来决定该使用哪个 Chain 处理用户请求,这就被称之为 路由(route)

在这四个基础 Chain 中,RouterChain 是最复杂的一个,而且它一般不单独使用,而是和 MultiPromptChainMultiRetrievalQAChain 一起使用,类似于下面这样:

chain = MultiPromptChain(
    router_chain=router_chain,
    destination_chains=destination_chains,
    default_chain=default_chain,
    verbose=True,
)

MultiPromptChain 中,我们给定了多个目标 Chains,然后使用 RouterChain 来选取一个最适合处理用户请求的 Chain,如果不存在,则使用一个默认的 Chain,这种思路为我们打开了一扇解决问题的新大门,如果我们让大模型选择的不是 Chain,而是一个函数,或是一个外部接口,那么我们就可以通过大模型做出更多的动作,完成更多的任务,这不就是 OpenAI 的 Function Calling 功能吗?

实际上,LangChain 也提供了 create_structured_output_chain()create_openai_fn_chain() 等方法来创建 OpenAI Functions Chain,只不过 OpenAI 的 Function Calling 归根结底仍然只是一个 LLMChain,它只能返回要使用的函数和参数,并没有真正地调用它,如果要将大模型的输出和函数执行真正联系起来,这就得 Agents 出马了。

Agents 是 LangChain 里一个非常重要的主题,我们将在下一篇笔记中继续学习它。

本文所有的源码可以 参考这里

参考

LangChain 官方资料

LangChain 项目

LangChain 教程

LangChain 可视化

LlamaIndex 参考资料

其他

扫描二维码,在手机上阅读!

使用 Embedding 技术打造本地知识库助手

基于不同的提示语,可以让 ChatGPT 实现各种不同的功能,比如在 基于 ChatGPT 实现一个划词翻译 Chrome 插件 这篇文章中,我基于 ChatGPT 实现了一个翻译助手,OpenAI 官方的 Examples 页面 也列出了提示语的更多示例,展示了 ChatGPT 在问答、翻译、文本总结、代码生成、推理等各方面的能力。

尽管 ChatGPT 的表现非常亮眼,但是它也有其局限性,由于它是基于互联网上公开的资料训练的,所以它只能回答公开领域的知识的问题,比如你问它是谁发明了空调,第一个登月的人是谁,它都能回答得头头是道,但是对于一些私有领域的知识,比如你问它张三家的宠物狗叫什么名字,它就鞭长莫及了。

Fine tuning vs. Embedding

打造私有领域的知识库助手对于企业和个人来说是一个非常重要的应用场景,可以实现个性化定制化的问答效果,要实现这个功能,一般有两种不同的方式:Fine tuningEmbedding。Fine tuning 又被称为微调,它可以在不改动预训练模型的基础上,对特定任务进一步训练,以适应特定数据和要求。OpenAI Cookbook 中有一个 基于 Fine tuning 实现 QA 的例子,不过官方已经不推荐使用这种方式来做知识问答任务,因为 Fine tuning 更适合于学习新任务或新模式,而不是接受新信息,比如我们可以使用 Fine tuning 让模型按特定的语气或风格来回答问题,或者让模型按固定的格式来回答问题。

相对应的,Embedding 更适合知识问答任务,而且 Embedding 技术还顺便解决了大模型的一个问题,那就是上下文限制,比如 OpenAI 的 GPT-3.5 模型,它的限制在 4k - 16k 个 token,就算是 GPT-4 模型,最多也只支持 32k 个 token,所以,如果你的知识库内容长度超出了限制,我们就不能直接让 ChatGPT 对其进行总结并回答问题。通过 Embedding 技术,我们可以使用语义搜索来快速找到相关的文档,然后只将相关的文档内容注入到大模型的上下文窗口中,让模型来生成特定问题的答案,从而解决大模型的限制问题。这种做法比 Fine tuning 速度更快,而且不需要训练,使用上也更灵活。

Embedding 也被称为嵌入,它是一种数据表征的方式,最早可以追溯到 1986 年 Hinton 的论文 《Learning distributed representations of concepts》,他在论文中提出了分布式表示(Distributed Representation)的概念,这个概念后来被人们称为词向量或词嵌入(Word Embedding),使用它可以将单词表示成一个数字向量,同时可以保证相关或相似的词在距离上很接近。Embedding 技术发展到今天,已经可以将任意对象向量化,包括文本、图像甚至音视频,在搜索和推荐等业务中有着广泛的应用。

知道了 Embedding 技术,我们就可以理解如何使用 Embedding 实现本地知识问答了,整个处理流程如下图所示:

qa-with-embedding.png

构建本地知识库

假设我们有一个本地知识库,这可能是某个产品的使用手册,或者某个公司的内部文档,又或者是你自己的一些私人资料,我们希望 ChatGPT 能够回答关于这些本地知识的问题。根据上面的流程图,我们首先需要对我们的知识库进行 Embedding 处理,将知识库中的所有文档向量化,这里其实涉及两个问题:

  1. 如何计算每个文档的向量?
  2. 如何存储每个文档的向量?

如何计算文档的向量?

对此,前辈大佬们提出了很多种不同的解决方案,比如 Word2vec、GloVe、FastText、ELMo、BERT、GPT 等等,不过这些都是干巴巴的论文和算法,对我们这种普通用户来说,可以直接使用一些训练好的模型。开源项目 Sentence-Transformers 是一个很好的选择,它封装了 大量可用的预训练模型;另外开源项目 Towhee 不仅支持大量的 Embedding 模型,而且还提供了其他常用的 AI 流水线的实现,这里是它支持的 Embedding 模型列表;不过在本地跑 Embedding 模型对机器有一定的门槛要求,我们也可以直接使用一些公开的 Embedding 服务,比如 OpenAI 提供的 /v1/embeddings 接口,它使用的 text-embedding-ada-002 模型是 OpenAI 目前提供的效果最好的第二代 Embedding 模型,相比于第一代的 davincicuriebabbage 等模型,效果更好,价格更便宜。我们这里就直接使用该接口生成任意文本的向量。使用 OpenAI 的 Python SDK 调用该接口如下:

import os
import openai

openai.api_key = os.getenv("OPENAI_API_KEY")

text_string = "sample text"
model_id = "text-embedding-ada-002"

embedding = openai.Embedding.create(input=text_string, model=model_id)['data'][0]['embedding']
print(embedding)

输出的是一个长度为 1536 的数组,也可以说是一个 1536 维的向量:

[
    -0.0022714741062372923, 
    0.009765749797224998, 
    -0.018565727397799492,
    ...
     0.0037550802808254957, 
     -0.004177606198936701
]

如何存储文档的向量?

第二个问题是计算出来的向量该如何存储?实际上,自从大模型兴起之后,Embedding 和向量数据库就变成了当前 AI 领域炙手可热的话题,一时间,涌出了很多专注于向量数据库的公司或项目,比如 PineconeWeaviateQdrantChromaMilvus 等,很多老牌数据库厂商也纷纷加入向量数据库的阵营,比如 ElasticSearchCassandraPostgresRedisMongo 等。

我们这里使用 Qdrant 作为示例,首先通过 Docker 在本地启动 Qdrant 服务:

$ docker run -p 6333:6333 -v $(pwd)/data:/qdrant/storage qdrant/qdrant

然后通过下面的代码创建一个名为 kb 的向量库:

from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams

client = QdrantClient("127.0.0.1", port=6333)
client.recreate_collection(
    collection_name='kb',
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

注意我们指定向量维度为 1536,这是 OpenAI Embedding 输出的维度,另外指定距离为 COSINE,它决定如何度量向量之间的相似度,这个我们后面再讲。

作为示例,我们将一个文件 kb.txt 作为本地知识库,文件中的每一行代表一条知识,比如:

小红的好朋友叫小明,他们是同班同学。
小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。
小红家也有一条宠物狗,叫大白,非常听话。
小华是小明的幼儿园同学,从小就欺负他。

然后我们读取该知识库文件,依次计算每一行的向量,并将其保存在 kb 库中:

with open('kb.txt', 'r', encoding='utf-8') as f:
    for index, line in enumerate(tqdm.tqdm(f.readlines())):
        embedding = to_embedding(line)
        client.upsert(
            collection_name='kb',
            wait=True,
            points=[
                PointStruct(id=index+1, vector=embedding, payload={"text": line}),
            ],
        )

在保存向量时,可以通过 payload 带上一些元数据,比如这里将原始的文本内容和向量一起保存,这样可以方便后面检索时知道向量和原始文本的对应关系。

至此我们的本地知识库就初始化好了。

实现本地知识问答助手

构建好知识库之后,我们就可以基于知识库实现本地知识库助手了。接下来要解决的问题也有两个:

  1. 如何从知识库中检索出和问题最相关的文档?
  2. 如何让 ChatGPT 回答关于知识库的问题?

如何检索知识?

传统的检索方式是关键词检索,比如 Elasticsearch 通过分词和倒排索引,让我们可以快速搜出含有关键词的文档,再通过一定的排序规则,比如文档中包含的关键词越多得分越高,就可以检索出和问题最相关的文档。不过这种方式面临非常多的问题,比如一个关键词可能有很多同义词,用户搜 “番茄” 时可能就搜不到包含 “西红柿” 的文档。另外,我们还可能需要对用户的输入进行纠错,对用户进行意图识别,不同语言的处理等等,或者用户希望搜索图片、音频或视频,这时传统的检索方式就捉襟见肘了。

我们上面已经介绍过 Embedding 技术,通过 Embedding 技术可以将任何事物表示成一个向量,而且最牛的地方是,它可以保证相关或相似的事物在距离上很接近,或者说有着相似的语义,这就给我们提供了一种新的检索方式:语义搜索(Semantic Search)。

所以想要搜索出和用户问题最相关的文档,我们可以先将用户问题通过 Embedding 转换为向量,然后在上面构建好的知识库中搜索和问题向量最接近的向量及对应的文档即可。那么怎么计算两个向量之间的距离呢?其实我们在上面创建库时已经使用过 COSINE,除了 COSINE,Qdrant 还支持使用 EUCLIDDOT 等方法:

  • COSINE - 余弦相似度,计算两个向量之间的夹角,夹角越小,向量之间越相似;
  • EUCLID - 欧几里得距离,计算两个向量之间的直线距离,距离越近,向量之间越相似;这种度量方法简单直接,在 2 维或 3 维等低维空间表现得很好,但是在高维空间,每个向量之间的欧几里得距离都很靠近,无法起到度量相似性的作用;
  • DOT - 点积相似度,点积是两个向量的长度与它们夹角余弦的积,点积越大,向量之间就越相似。

在机器学习和数据挖掘中还有很多度量距离的方法:

  • 闵可夫斯基距离
  • 曼哈顿距离
  • 切比雪夫距离
  • 马氏距离
  • 皮尔逊相关系数
  • 汉明距离
  • 杰卡德相似系数
  • 编辑距离
  • DTW 距离
  • KL 散度

不过这些度量方法在语义搜索中用的不多,感兴趣的同学可以进一步了解之。

在语义搜索中,用的最多的是余弦相似度。当计算出用户问题的向量之后,我们就可以遍历知识库中的所有向量,依次计算每个向量和问题向量之间的距离,然后按距离排序,取距离最近的几条数据,就能得到和用户问题最相似的文档了。

不过很显然这里存在一个问题,如果知识库中的向量比较多,这种暴力检索的方法就会非常耗时。为了加快检索向量库的速度,人们提出了很多种 ANN(Approximate Nearest Neighbor,相似最近邻)算法,算法的基本思路是通过对全局向量空间进行分割,将其分割成很多小的子空间,在搜索的时候,通过某种方式,快速锁定在某一个或某几个子空间,然后在这些子空间里做遍历。可以粗略地将这些子空间认为是向量数据库的索引。常见的 ANN 算法可以分为三大类:基于树的方法、基于哈希的方法、基于矢量量化的方法,比如 Annoy、KD 树、LSH(局部敏感哈希)、PQ(乘积量化)、HNSW 等。

Qdrant 针对负载数据和向量数据使用了不同的 索引策略,其中对向量数据的索引使用的就是 HNSW 算法。通过 client.search() 搜索和问题向量最接近的 N 个向量:

question = '小明家的宠物狗叫什么名字?'
search_results = client.search(
    collection_name='kb',
    query_vector=to_embedding(question),
    limit=3,
    search_params={"exact": False, "hnsw_ef": 128}
)

搜索出来的结果类似于下面这样,不仅包含了和问题最接近的文档,而且还有对应的相似度得分:

[
    ScoredPoint(id=2, version=1, score=0.91996545, payload={'text': '小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。\n'}, vector=None), 
    ScoredPoint(id=3, version=2, score=0.8796822, payload={'text': '小红家也有一条宠物狗,叫大白,非常听话。\n'}, vector=None), 
    ScoredPoint(id=1, version=0, score=0.869504, payload={'text': '小红的好朋友叫小明,他们是同班同学。\n'}, vector=None)
]

如何向 ChatGPT 提问?

经过上面的三步,我们的本地知识库助手其实就已经完成 90% 了,下面是最后一步,我们需要将搜出来的文档和用户问题重新组装并丢给 ChatGPT,下面是一个简单的 Prompt 模板:

你是一个知识库助手,你将根据我提供的知识库内容来回答问题
已知有知识库内容如下:
1. 小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。
2. 小红家也有一条宠物狗,叫大白,非常听话。
3. 小红的好朋友叫小明,他们是同班同学。
请根据知识库回答以下问题:小明家的宠物狗叫什么名字?

组装好 Prompt 之后,通过 openai.ChatCompletion.create() 调用 ChatGPT 接口:

completion = openai.ChatCompletion.create(
    temperature=0.7,
    model="gpt-3.5-turbo",
    messages=format_prompt(question, search_results),
)
print(completion.choices[0].message.content)

得到 ChatGPT 的回复:

小明家的宠物狗叫毛毛。

总结

本文首先从 Fine tuning 和 Embedding 技术聊起,介绍了如何通过这些技术实现本地知识库助手;然后通过学习 OpenAI 的 Embedding 接口和开源向量数据库 Qdrant,从零开始实现了一个非常简单的知识库助手程序,程序的完整源码 在这里。源码实现得非常粗糙,只是对技术原理的简单验证,如果要实现一个真实可用的知识库助手,还有很多其他问题值得考虑,比如如何支持更多类型的文档(pdf、word、excel),如何支持对数据库中的数据进行问答,如何针对大文档进行拆分,等等。

参考

更多

类似项目

扫描二维码,在手机上阅读!

基于 ChatGPT 实现一个划词翻译 Chrome 插件

去年 11 月,美国的 OpenAI 公司推出了 ChatGPT 产品,它在发布后的 5 天内用户数就突破了 100 万,两个月后月活用户突破了 1 个亿,成为至今为止人类历史上用户数增长最快的消费级应用。ChatGPT 之所以能在全球范围内火出天际,不仅是因为它能以逼近自然语言的能力和人类对话,而且可以根据不同的提示语解决各种不同场景下的问题,它的推理能力、归纳能力、以及多轮对话能力都让世人惊叹不已,让实现通用人工智能(AGI,Artificial General Intelligence)变成为了现实,也意味着一种新型的人机交互接口由此诞生,这为更智能的 AI 产品提供了无限可能。

很快,OpenAI 推出了相应的 API 接口,所有人都可以基于这套 API 快速实现一个类似 ChatGPT 这样的产品,当然,聊天对话只是这套 API 的基本能力,OpenAI 官方网站有一个 Examples 页面,展示了结合不同的提示语 OpenAI API 在更多场景下的应用:

chatgpt-examples.png

OpenAI API 快速入门

OpenAI 提供了很多和 AI 相关的接口,如下:

  • Models - 用于列出所有可用的模型;
  • Completions - 给定一个提示语,让 AI 生成后续内容;
  • Chat - 给定一系列对话内容,让 AI 生成对应的回复,使用这个接口就可以实现类似 ChatGPT 的功能;
  • Edits - 给定一个提示语和一条指令,AI 将对提示语进行相应的修改,比如常见的语法纠错场景;
  • Images - 用于根据提示语生成图片,或对图片进行编辑,可以实现类似于 Stable DiffusionMidjourney 这样的 AI 绘画应用,这个接口使用的是 OpenAI 的图片生成模型 DALL·E
  • Embeddings - 用于获取一个给定文本的向量表示,我们可以将结果保存到一个向量数据库中,一般用于搜索、推荐、分类、聚类等任务;
  • Audio - 提供了语音转文本的功能,使用了 OpenAI 的 Whisper 模型;
  • Files - 文件管理类接口,便于用户上传自己的文件进行 Fine-tuning;
  • Fine-tunes - 用于管理你的 Fine-tuning 任务,详细内容可参考 Fine-tuning 教程
  • Moderations - 用于判断给定的提示语是否违反 OpenAI 的内容政策;

关于 API 的详细内容可以参考官方的 API referenceDocumentation

其中,CompletionsChatEdits 这三个接口都可以用于对话任务,Completions 主要解决的是补全问题,也就是说用户给出一段话,模型可以按照提示语续写后面的内容;Chat 用于处理聊天任务,它显式的定义了 systemuserassistant 三个角色,方便维护对话的语境信息和多轮对话的历史记录;Edit 主要用于对用户的输入进行修改和纠正。

要调用 OpenAI 的 API 接口,必须先创建你的 API Keys,然后请求时像下面这样带上 Authorization 头即可:

Authorization: Bearer OPENAI_API_KEY

下面是直接使用 curl 调用 Chat 接口的示例:

$ curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
     "model": "gpt-3.5-turbo",
     "messages": [{"role": "user", "content": "你好!"}],
     "temperature": 0.7
   }'

我们可以得到类似下面的回复:

{
  "id": "chatcmpl-7LgiOhYPcGGwoBcEPQmQ2LaO2pObn",
  "object": "chat.completion",
  "created": 1685403440,
  "model": "gpt-3.5-turbo-0301",
  "usage": {
    "prompt_tokens": 11,
    "completion_tokens": 18,
    "total_tokens": 29
  },
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "你好!有什么我可以为您效劳的吗?"
      },
      "finish_reason": "stop",
      "index": 0
    }
  ]
}

如果你无法访问 OpenAI 的接口,或者没有 OpenAI 的 API Keys,网上也有很多免费的方法,比如 chatanywhere/GPT_API_free

OpenAI 官方提供了 Python 和 Node.js 的 SDK 方便我们在代码中调用 OpenAI 接口,下面是使用 Node.js 调用 Completions 的示例:

import { Configuration, OpenAIApi } from "openai";

const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

const response = await openai.createCompletion({
    "model": "text-davinci-003",
    "prompt": "你好!",
    "max_tokens": 100,
    "temperature": 0
});
console.log(response.data);

由于 SDK 底层使用了 axios 库发请求,所以我们还可以对 axios 进行配置,比如像下面这样设置代理:

const response = await openai.createCompletion({
    "model": "text-davinci-003",
    "prompt": "你好!",
    "max_tokens": 100,
    "temperature": 0
}, {
    proxy: false,
    httpAgent: new HttpsProxyAgent(process.env.HTTP_PROXY),
    httpsAgent: new HttpsProxyAgent(process.env.HTTP_PROXY)
});

使用 OpenAI API 实现翻译功能

从上面的例子可以看出,OpenAI 提供的 CompletionsChat 只是一套用于对话任务的接口,并没有提供翻译接口,但由于它的对话已经初步具备 AGI 的能力,所以我们可以通过特定的提示语让它实现我们想要的功能。官方的 Examples 页面有一个 English to other languages 的例子,展示了如何通过提示语技术将英语翻译成法语、西班牙语和日语,我们只需要稍微修改下提示语,就可以实现英译中的功能:

async function translate(text) {

    const prompt = `Translate this into Simplified Chinese:\n\n${text}\n\n`
    
    const openai = createOpenAiClient();
    const response = await openai.createCompletion({
        "model": "text-davinci-003",
        "prompt": prompt,
        "max_tokens": 100,
        "temperature": 0
    }, createAxiosOptions());
    return response.data.choices[0].text
}

上面我们使用了 Translate this into Simplified Chinese: 这样的提示语,这个提示语既简单又直白,但是翻译效果却非常的不错,我们随便将一段官方文档丢给它:

console.log(await translate("The OpenAI API can be applied to virtually any task that involves understanding or generating natural language, code, or images."));

OpenAI API 可以应用于几乎任何涉及理解或生成自然语言、代码或图像的任务。

看上去,翻译的效果不亚于 Google 翻译,而且更神奇的是,由于这里的提示语并没有明确输入的文本是什么,也就意味着,我们可以将其他任何语言丢给它:

console.log(await translate("どの部屋が利用可能ですか?"));

这些房间可以用吗?

这样我们就得到了一个通用中文翻译接口。

Chrome 插件快速入门

我在很久以前写过一篇关于 Chrome 插件的博客,我的第一个 Chrome 扩展:Search-faster,不过当时 Chrome 扩展还是 V2 版本,现在 Chrome 扩展已经发展到 V3 版本了,并且 V2 版本不再支持,于是我决定将 Chrome 扩展的开发文档 重温一遍。

一个简单的例子

每个 Chrome 插件都需要有一个 manifest.json 清单文件,我们创建一个空目录,并在该目录下创建一个最简单的 manifest.json 文件:

{
  "name": "Chrome Extension Sample",
  "version": "1.0.0",
  "manifest_version": 3,
  "description": "Chrome Extension Sample"
}

这时,一个最简单的 Chrome 插件其实就已经准备好了。我们打开 Chrome 的 管理扩展程序 页面 chrome://extensions/,启用开发者模式,然后点击 “加载已解压的扩展程序”,选择刚刚创建的那个目录就可以加载我们编写的插件了:

chrome-extension-sample.png

只不过这个插件还没什么用,如果要添加实用的功能,还得添加这些比较重要的字段:

  • background:背景页通常是 Javascript 脚本,在扩展进程中一直保持运行,它有时也被称为 后台脚本,它是一个集中式的事件处理器,用于处理各种扩展事件,它不能访问页面上的 DOM,但是可以和 content_scriptsaction 之间进行通信;在 V2 版本中,background 可以定义为 scriptspage,但是在 V3 版本中已经废弃,V3 版本中统一定义为 service_worker
  • content_scripts:内容脚本可以让我们在 Web 页面上运行我们自定义的 Javascript 脚本,通过它我们可以访问或操作 Web 页面上的 DOM 元素,从而实现和 Web 页面的交互;内容脚本运行在一个独立的上下文环境中,类似于沙盒技术,这样不仅可以确保安全性,而且不会导致页面上的脚本冲突;
  • action:在 V2 版本中,Chrome 扩展有 browser_actionpage_action 两种表现形式,但是在 V3 版本中,它们被统一合并到 action 字段中了;用于当用户点击浏览器右上角的扩展图标时弹出一个 popup 页面或触发某些动作;
  • options_page:当你的扩展参数比较多时,可以制作一个单独的选项页面对你的扩展进行配置;

接下来,我们在 manifest.json 文件中加上 action 字段:

  "action": {
    "default_popup": "popup.html"
  }

然后,编写一个简单的 popup.html 页面,比如直接使用 iframe 嵌入我的博客:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <iframe src="https://www.aneasystone.com" frameborder="0" style="width: 400px;height:580px;"></iframe>
    </body>
</html>

修改完成后,点击扩展上的刷新按钮,将会重新加载扩展:

chrome-extension-sample-reload.png

这样当我们点击扩展图标时,就能弹出我的博客页面了:

chrome-extension-sample-popup.png

如果我们把页面换成 ChatGPT 的页面,那么一个 ChatGPT 的 Chrome 插件就做好了:

chrome-extension-chatgpt.png

在嵌入 ChatGPT 页面时发现,每次打开扩展都会跳转到登录页面,后来参考 kazuki-sf/ChatGPT_Extension 这里的做法解决了:在 manifest.json 中添加 content_scripts 字段,内容脚本非常简单,只需要一句 "use strict"; 即可。

注意并不是所有的页面都可以通过 iframe 嵌入,比如当我们嵌入 Google 时就会报错:www.google.com 拒绝了我们的连接请求,这是因为 Google 在响应头中添加了 X-Frame-Options: SAMEORIGIN 这样的选项,不允许被嵌入在非同源的 iframe 中。

实现划词翻译功能

我们现在已经学习了如何使用 OpenAI 接口实现翻译功能,也学习了 Chrome 扩展的基本知识,接下来就可以实现划词翻译功能了。

首先我们需要监听用户在页面上的划词动作以及所划的词是什么,这可以通过监听鼠标的 onmouseup 事件来实现。根据前面一节的学习我们知道,内容脚本可以让我们在 Web 页面上运行我们自定义的 Javascript 脚本,从而实现和 Web 页面的交互,所以我们在 manifest.json 中添加 content_scripts 字段:

  "content_scripts": [
    {
      "matches": ["*://*/*"],
      "js": ["content_script.js"]
    }
  ]

content_script.js 文件的内容很简单:

window.onmouseup = function (e) {

    // 非左键,不处理
    if (e.button != 0) {
        return;
    }
    
    // 未选中文本,不处理
    let text = window.getSelection().toString().trim()
    if (!text) {
        return;
    }

    // 翻译选中文本
    let translateText = translate(text)

    // 在鼠标位置显示翻译结果
    show(e.pageX, e.pageY, text, translateText)
}

可以看到实现划词翻译的整体脉络已经非常清晰了,后续的工作就是调用 OpenAI 的接口翻译文本,以及在鼠标位置将翻译结果显示出来。先看看如何实现翻译文本:

async function translate(text) {
    const prompt = `Translate this into Simplified Chinese:\n\n${text}\n\n`
    const body = {
        "model": "text-davinci-003",
        "prompt": prompt,
        "max_tokens": 100,
        "temperature": 0
    }
    const options = {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + OPENAI_API_KEY,
        },
        body: JSON.stringify(body),
    }
    const response = await fetch('https://api.openai.com/v1/completions', options)
    const json = await response.json()
    return json.choices[0].text
}

这里直接使用我们之前所用的提示语,只不过将发请求的 axios 换成了 fetchfetch 是浏览器自带的发请求的 API,但是不能在 Node.js 环境中使用。

接下来我们需要将翻译后的文本显示出来:

function show(x, y, text, translateText) {
    let container = document.createElement('div')
    container.innerHTML = `
    <header>翻译<span class="close">X</span></header>
    <main>
      <div class="source">
        <div class="title">原文</div>
        <div class="content">${text}</div>
      </div>
      <div class="dest">
        <div class="title">简体中文</div>
        <div class="content">${translateText}</div>
      </div>
    </main>
    `
    container.classList.add('translate-panel')
    container.classList.add('show')
    container.style.left = x + 'px'
    container.style.top = y + 'px'
    document.body.appendChild(container)

    let close = container.querySelector('.close')
    close.onclick = () => {
        container.classList.remove('show')
    }
}

我们先通过 document.createElement() 创建一个 div 元素,然后将其 innerHTML 赋值为提前准备好的一段 HTML 模版,并将原文和翻译后的中文放在里面,接着使用 container.classList.add()container.style 设置它的样式以及显示位置,最后通过 document.body.appendChild() 将这个 div 元素添加到当前页面中。实现之后的效果如下图所示:

chrome-extension-translate.png

至此,一个简单的划词翻译 Chrome 插件就开发好了,开发过程中参考了 CaTmmao/chrome-extension-translate 的部分实现,在此表示感谢。

当然这个扩展还有很多需要优化的地方,比如 OpenAI 的 API Keys 是写死在代码里的,可以做一个选项页对其进行配置;另外在选择文本时要等待一段时间才显示出翻译的文本,中间没有任何提示,这里的交互也可以优化一下;还可以为扩展添加右键菜单,进行一些其他操作;有兴趣的朋友可以自己继续尝试改进。

本文所有代码 在这里

参考

扫描二维码,在手机上阅读!