Langchain-RAG入门3

文摘   2025-01-08 07:01   四川  
内容继续接上回,对单轮对话的RAG进行扩展,支持会话管理和多轮对话。
像之前一样,我们同样需要选择对应的 chat_model,embedding_model和vector_store,基础的示例课回看上回的代码,这里就不再次展示了。
实际上多轮对话在langchain看来可以用一系列message自然地表示出来,实际上它的核心思路就是这样做的。除了user和assistant的message之外,检索到的documents和其他工件可以通过tools message合并到消息sequence中。
具体来说:
我们把user的input作为HumanMessage;
Vector store query 作为带有tools call 的AIMessage;
Retrieved documents 作为一个ToolMessage;
最后响应一个AIMessage。
因此,这种状态化的model非常灵活,LangGraph 提供了一个内置版本以方便使用,它的核心思路是将对话的 messages 作为 state进行上下文处理,这也是对话系统的核心:
from langgraph.graph import MessagesStateStateGraph
graph_builder = StateGraph(MessagesState)
利用tools call与检索步骤进行交互还有另一个好处,即检索的查询由我们的model生成。这在对话环境中尤其重要,因为用户查询可能需要根据聊天历史进行语境化。举个例子:

Human: "什么是任务分解?"

AI: "任务分解涉及将复杂的任务分解为更小、更简单的步骤,以便代理或模型更易于管理。"

Human: "常见的做法有哪些?"

涉及到详细的做法的时候,模型可以生成诸如“任务分解的常用方法”之类的query,tools call 可以自然地做到。就像RAG 前面的教程的查询分析部分一样,这可以让model将用户query重写为更有效的搜索查询。

因为是使用的model生成query,它还支持不涉及检索步骤的直接响应(例如,响应来自用户的hello问候)。



tools

将检索step变成一个tool:
from langchain_core.tools import tool
@tool(response_format="content_and_artifact")def question_process(query: str):    """All questions are processed by this method."""    retrieved_docs = vector_store.similarity_search(query, k=2)    serialized = "\n\n".join(        (f"Source: {doc.metadata}\n" f"Content: {doc.page_content}")        for doc in retrieved_docs    )    return serialized, retrieved_docs

nodes
现在,我们要构建的 graph 有三个nodes:
一个节点处理用户输入,可以为检索器生成查询或直接响应;
一个节点执行检索步骤的检索工具;
一个节点使用检索到的上下文生成最终响应。
from langchain_core.messages import SystemMessagefrom langgraph.prebuilt import ToolNodefrom langgraph.graph import MessagesState
# Step 1: 生成一个可能包含要发送的tool-call的AIMessage。def query_or_respond(state: MessagesState):    """为检索生成tools_call或者response."""    llm_with_tools = llm_model.bind_tools([question_process])    response = llm_with_tools.invoke(state["messages"])    # MessagesState 将消息appends到状态而不是overwriting,    # 相当于每次都是在原有的基础上append,实现了历史消息也就是上下文的累计    return {"messages": [response]}
# Step 2: 执行检索tools = ToolNode([question_process])
# Step 3: 用检索的内容生成响应def generate(state: MessagesState):    """生成一个回答"""    # 获取生成所有的 ToolMessages    recent_tool_messages = []    for message in reversed(state["messages"]):        if message.type == "tool":            recent_tool_messages.append(message)        else:            break    tool_messages = recent_tool_messages[::-1]    # 格式化为 prompt    docs_content = "\n\n".join(doc.content for doc in tool_messages)    system_message_content = (        "你是一个用于问答任务的助手。"        "用下面的检索到的内容来回答问题。"        "如果你不知道答案,就说你不知道。"        "最多用三句话回答问题,保持简洁。"        "\n\n"        f"{docs_content}"    )    conversation_messages = [        message        for message in state["messages"]        if message.type in ("human""system")        or (message.type == "ai" and not message.tool_calls)    ]    prompt = [SystemMessage(system_message_content)] + conversation_messages    # Run    response = llm_model.invoke(prompt)    return {"messages": [response]}

graph编排
最后,我们将应用程序编译为单个graph对象。在本例中,我们只是将各个步骤连接成一个序列。如果没有生成tools call,我们还允许第一个 query_or_respond 步骤“短路”并直接响应用户。这使得我们的应用程序能够支持对话体验——例如,用户只是发了一个hello。
from langgraph.graph import ENDfrom langgraph.prebuilt import tools_condition
graph_builder.add_node(query_or_respond)graph_builder.add_node(tools)graph_builder.add_node(generate)
# 设置入口点graph_builder.set_entry_point("query_or_respond")# 添加条件边graph_builder.add_conditional_edges(    # 从 query_or_respond 到 tools 或者 直接结束    "query_or_respond",    tools_condition,    {END: END, "tools""tools"},)
# 添加tools到generate的边graph_builder.add_edge("tools""generate")# 添加generate到END的边graph_builder.add_edge("generate", END)graph = graph_builder.compile()

测试
接下来来,测试一下。
首先如果我们简单地向他问好,它不应该走检索,而是直接通过LLM回复
for step in graph.stream(    {"messages": [{"role""user""content": input_message}]},    stream_mode="values",):    step["messages"][-1].pretty_print()
输出:
================================ Human Message =================================
你好================================== Ai Message ==================================
你好!有什么可以帮助你的吗?
当我们问他关于之前在vector中存储的关于养猫的信息:
input_message = "为什么选择猫粮?"
for step in graph.stream(    {"messages": [{"role""user""content": input_message}]},    stream_mode="values",):    step["messages"][-1].pretty_print()
输出:
================================ Human Message =================================
为什么选择猫粮?================================== Ai Message ==================================Tool Calls:  question_process (c833852d-4e41-4ce9-8e9d-3643d673d1cb) Call ID: c833852d-4e41-4ce9-8e9d-3643d673d1cb  Args:    question: 为什么选择猫粮?================================= Tool Message =================================Name: question_process
("Source: {'source': 'https://soulteary.com/2018/07/23/experience-in-breeding-cat.html', 'section': 'beginning'}\nContent: 必须有眼缘、性格好\n最好有白手套\n最好有白肚皮\n最好是开脸\n最好能够是红虎斑(俗称橘猫)\n脸形要突出有层次,第一只猫最好不是扁脸\n\n正巧看到 58心宠 有一家官方认证的宠物店有不少英短,对其中一只小公主颇为满意,于是打车前往,期望能抱得小猫归。\n实施领猫计划\n到了猫舍直奔主题,然而和英短小公主缘分不够:\n我、女票、宠物店主都抱不住它(不能安静的抱在怀里,只想挣脱)。\n猫舍是一个三居室,里面有很多双层的大铁笼,和其他宠物店无二,类似日本胶囊公寓里装满了:英短、美短、折耳。\n想着过来花费了不少时间,看看再走吧,于是就对小猫们逐个看了起来,最终在次卧靠窗的笼子里看到了俩兄弟。\n俩兄弟据说是寄卖在宠物店,只剩他俩了,当晚会再来一位顾客,领走兄弟之一,如果选择的话,只能带走一只。\n但是和其他猫不同的是,抱着俩兄弟到怀里的一瞬间,都会响起“柴油发动机”类似的呼噜声,俩兄弟活似小狐狸,茂密的毛发摸起来很爽。\n考虑一来女票一直以来很喜欢布偶猫,二来当时两个人上班都不在望京,担心小猫一个人在家寂寞,于是和宠物店主简单沟通了一下,“夺人之爱”的一次性购入俩兄弟,虽说不符合之前的领猫硬标准,但是女票开心,也就罢了。\n从望京五点出发,折腾到八点准备回家,来回车程70公里,到家快十一点,不过带着俩主子累并快乐着。\n\n品质待商榷的问题猫粮\n带回来后使用了店主强力推荐的“METZ”玫斯猫粮,第一次装满饭缸,看到他们狼吞虎咽。\n我和女票笑着说:“宠物店是不是太抠门了,把它们饿成这样。”\n记得我还和公司养猫的小姐姐推荐过这个牌子,谁知道这里居然有天坑,在喂了一周左右,和周围养猫的朋友沟通养猫经验,大家均表示没有喂过也不知道这个牌子,虽然猫粮品牌众多,但是还是让我心生疑窦,于是回家各种搜索引擎换着用,发现了一个我不想接受的事实:\n这个猫粮被曝光许久,是一个“假洋牌”,据说掺入了“诱食剂”。\n加上家里的宠物粮包装袋出现了很多猫咬过的痕迹,有网友说家里的同款猫粮也被宠物疯狂咬食过。", [Document(id='3a4569de-b4dd-4aa9-b926-42954723d450', metadata={'source': 'https://soulteary.com/2018/07/23/experience-in-breeding-cat.html', 'section': 'beginning'}, page_content='必须有眼缘、性格好\n最好有白手套\n最好有白肚皮\n最好是开脸\n最好能够是红虎斑(俗称橘猫)\n脸形要突出有层次,第一只猫最好不是扁脸\n\n正巧看到 58心宠 有一家官方认证的宠物店有不少英短,对其中一只小公主颇为满意,于是打车前往,期望能抱得小猫归。\n实施领猫计划\n到了猫舍直奔主题,然而和英短小公主缘分不够:\n我、女票、宠物店主都抱不住它(不能安静的抱在怀里,只想挣脱)。\n猫舍是一个三居室,里面有很多双层的大铁笼,和其他宠物店无二,类似日本胶囊公寓里装满了:英短、美短、折耳。\n想着过来花费了不少时间,看看再走吧,于是就对小猫们逐个看了起来,最终在次卧靠窗的笼子里看到了俩兄弟。\n俩兄弟据说是寄卖在宠物店,只剩他俩了,当晚会再来一位顾客,领走兄弟之一,如果选择的话,只能带走一只。\n但是和其他猫不同的是,抱着俩兄弟到怀里的一瞬间,都会响起“柴油发动机”类似的呼噜声,俩兄弟活似小狐狸,茂密的毛发摸起来很爽。\n考虑一来女票一直以来很喜欢布偶猫,二来当时两个人上班都不在望京,担心小猫一个人在家寂寞,于是和宠物店主简单沟通了一下,“夺人之爱”的一次性购入俩兄弟,虽说不符合之前的领猫硬标准,但是女票开心,也就罢了。\n从望京五点出发,折腾到八点准备回家,来回车程70公里,到家快十一点,不过带着俩主子累并快乐着。\n\n品质待商榷的问题猫粮\n带回来后使用了店主强力推荐的“METZ”玫斯猫粮,第一次装满饭缸,看到他们狼吞虎咽。\n我和女票笑着说:“宠物店是不是太抠门了,把它们饿成这样。”\n记得我还和公司养猫的小姐姐推荐过这个牌子,谁知道这里居然有天坑,在喂了一周左右,和周围养猫的朋友沟通养猫经验,大家均表示没有喂过也不知道这个牌子,虽然猫粮品牌众多,但是还是让我心生疑窦,于是回家各种搜索引擎换着用,发现了一个我不想接受的事实:\n这个猫粮被曝光许久,是一个“假洋牌”,据说掺入了“诱食剂”。\n加上家里的宠物粮包装袋出现了很多猫咬过的痕迹,有网友说家里的同款猫粮也被宠物疯狂咬食过。')])[SystemMessage(content='你是一个用于问答任务的助手。只能用下面的检索到的内容来回答问题。如果你不知道答案,就说你不知道。最多用三句话回答问题,保持简洁。\n\n("Source: {\'source\': \'https://soulteary.com/2018/07/23/experience-in-breeding-cat.html\', \'section\': \'beginning\'}\\nContent: 必须有眼缘、性格好\\n最好有白手套\\n最好有白肚皮\\n最好是开脸\\n最好能够是红虎斑(俗称橘猫)\\n脸形要突出有层次,第一只猫最好不是扁脸\\n\\n正巧看到 58心宠 有一家官方认证的宠物店有不少英短,对其中一只小公主颇为满意,于是打车前往,期望能抱得小猫归。\\n实施领猫计划\\n到了猫舍直奔主题,然而和英短小公主缘分不够:\\n我、女票、宠物店主都抱不住它(不能安静的抱在怀里,只想挣脱)。\\n猫舍是一个三居室,里面有很多双层的大铁笼,和其他宠物店无二,类似日本胶囊公寓里装满了:英短、美短、折耳。\\n想着过来花费了不少时间,看看再走吧,于是就对小猫们逐个看了起来,最终在次卧靠窗的笼子里看到了俩兄弟。\\n俩兄弟据说是寄卖在宠物店,只剩他俩了,当晚会再来一位顾客,领走兄弟之一,如果选择的话,只能带走一只。\\n但是和其他猫不同的是,抱着俩兄弟到怀里的一瞬间,都会响起“柴油发动机”类似的呼噜声,俩兄弟活似小狐狸,茂密的毛发摸起来很爽。\\n考虑一来女票一直以来很喜欢布偶猫,二来当时两个人上班都不在望京,担心小猫一个人在家寂寞,于是和宠物店主简单沟通了一下,“夺人之爱”的一次性购入俩兄弟,虽说不符合之前的领猫硬标准,但是女票开心,也就罢了。\\n从望京五点出发,折腾到八点准备回家,来回车程70公里,到家快十一点,不过带着俩主子累并快乐着。\\n\\n品质待商榷的问题猫粮\\n带回来后使用了店主强力推荐的“METZ”玫斯猫粮,第一次装满饭缸,看到他们狼吞虎咽。\\n我和女票笑着说:“宠物店是不是太抠门了,把它们饿成这样。”\\n记得我还和公司养猫的小姐姐推荐过这个牌子,谁知道这里居然有天坑,在喂了一周左右,和周围养猫的朋友沟通养猫经验,大家均表示没有喂过也不知道这个牌子,虽然猫粮品牌众多,但是还是让我心生疑窦,于是回家各种搜索引擎换着用,发现了一个我不想接受的事实:\\n这个猫粮被曝光许久,是一个“假洋牌”,据说掺入了“诱食剂”。\\n加上家里的宠物粮包装袋出现了很多猫咬过的痕迹,有网友说家里的同款猫粮也被宠物疯狂咬食过。", [Document(id=\'3a4569de-b4dd-4aa9-b926-42954723d450\', metadata={\'source\': \'https://soulteary.com/2018/07/23/experience-in-breeding-cat.html\', \'section\': \'beginning\'}, page_content=\'必须有眼缘、性格好\\n最好有白手套\\n最好有白肚皮\\n最好是开脸\\n最好能够是红虎斑(俗称橘猫)\\n脸形要突出有层次,第一只猫最好不是扁脸\\n\\n正巧看到 58心宠 有一家官方认证的宠物店有不少英短,对其中一只小公主颇为满意,于是打车前往,期望能抱得小猫归。\\n实施领猫计划\\n到了猫舍直奔主题,然而和英短小公主缘分不够:\\n我、女票、宠物店主都抱不住它(不能安静的抱在怀里,只想挣脱)。\\n猫舍是一个三居室,里面有很多双层的大铁笼,和其他宠物店无二,类似日本胶囊公寓里装满了:英短、美短、折耳。\\n想着过来花费了不少时间,看看再走吧,于是就对小猫们逐个看了起来,最终在次卧靠窗的笼子里看到了俩兄弟。\\n俩兄弟据说是寄卖在宠物店,只剩他俩了,当晚会再来一位顾客,领走兄弟之一,如果选择的话,只能带走一只。\\n但是和其他猫不同的是,抱着俩兄弟到怀里的一瞬间,都会响起“柴油发动机”类似的呼噜声,俩兄弟活似小狐狸,茂密的毛发摸起来很爽。\\n考虑一来女票一直以来很喜欢布偶猫,二来当时两个人上班都不在望京,担心小猫一个人在家寂寞,于是和宠物店主简单沟通了一下,“夺人之爱”的一次性购入俩兄弟,虽说不符合之前的领猫硬标准,但是女票开心,也就罢了。\\n从望京五点出发,折腾到八点准备回家,来回车程70公里,到家快十一点,不过带着俩主子累并快乐着。\\n\\n品质待商榷的问题猫粮\\n带回来后使用了店主强力推荐的“METZ”玫斯猫粮,第一次装满饭缸,看到他们狼吞虎咽。\\n我和女票笑着说:“宠物店是不是太抠门了,把它们饿成这样。”\\n记得我还和公司养猫的小姐姐推荐过这个牌子,谁知道这里居然有天坑,在喂了一周左右,和周围养猫的朋友沟通养猫经验,大家均表示没有喂过也不知道这个牌子,虽然猫粮品牌众多,但是还是让我心生疑窦,于是回家各种搜索引擎换着用,发现了一个我不想接受的事实:\\n这个猫粮被曝光许久,是一个“假洋牌”,据说掺入了“诱食剂”。\\n加上家里的宠物粮包装袋出现了很多猫咬过的痕迹,有网友说家里的同款猫粮也被宠物疯狂咬食过。\')])', additional_kwargs={}, response_metadata={}), HumanMessage(content='为什么选择猫粮?', additional_kwargs={}, response_metadata={}, id='931d1538-ee49-4c15-b469-318da61f0f24')]================================== Ai Message ==================================
店主强力推荐了“METZ”玫斯猫粮,但后来发现这个品牌存在问题,疑似掺入了诱食剂,并且品质待商榷。
这里输出的内容有点多了,因为当时拆分的粒度不够细,因为博客中每个问题的内容都比较少,但是核心回答还是非常正确的。

小问题
这里有个小插曲,折磨了我半天:我本来是参照英文官方文档写的代码,但是涉及博客的内容以及其它一些描述我是使用的不同的内容,我这里使用的是Qwen2.5-7b-instruct。
在定义tools的时候,如果描述是:
"根据用户的查询进行检索",这样tools calling是不起作用的,即使我的输入是:“为什么选择猫粮?”,他也不会输出tools calling。
但是如果我的问题是:“why sky is blue?”,它就会识别到这个tools call。
后来我又将描述换成了“所有的问题都由这个函数来处理”,这样其实还是有问题,如果你的问题中包含“你”的关键字,Qwen会直接短路回答说它是阿里的人工智能。
因此,官方的这个实例其实是有一定问题的,如果是产品级别的开发还是需要有一些细节上的处理。
到这里,我们的例子已经完善不少了,但是对于实际情况中,用户的历史聊天往往是水进行持久化存储的,比如存储到数据库,这该如何集成呢?
后面我们将会继续深入揭晓,感兴趣的话,你也动手试试吧!!!

半夏决明
读书,摄影,随笔
 最新文章