本文译自Enhancing Interaction between Language Models and Graph Databases via a Semantic Layer一文。介绍了基于语义层方法来实现LLM与Neo4j的稳定交互,以实现针对图数据库的自然语言交互。本系列合集,点击链接查看

知识图谱被广大用户所喜爱,原因在于它的数据架构灵活,能够存储结构化和非结构化的信息。对于从特定的图数据库(例如Neo4j)获取数据,可以通过使用Cypher语句来实现。

让语言模型(LLMs)生成Cypher语句的做法已经在业界得到了广泛的应用。这一做法无疑为抽取数据的过程带来了较大的灵活性,然而,该方法在持续且准确地生成Cypher语句方面却常有不稳定的问题。

这样的困扰促使我们去寻找新的方法,以确保我们的操作不仅稳定,同时还具有高强度的一致性。为此,我们提出了一种新的想法:如果让LLM不再直接生成Cypher语句呢?相反,它可以根据用户的指令,从输入的信息中抽取出参数,然后将这些参数应用到预设的函数或已有的Cypher模板中。

综上所述,你可以为LLM提供预设的工具,以及如何使用这些工具来处理用户的请求。这些预设的工具和使用它们的方法共同构成了我们所说的“语义层”。

对话Neo4j-图数据库AI助手实战_数据库

img

作为中间阶段,语义层为 LLM 与知识图互动带来了更准确、稳健的操作。

语义层汇集了一系列可供LLM操作知识图的工具,其复杂性各异。你可以把语义层中每个工具理解为一个函数,比如,参考下面的函数。

def get_information(entity: str, type: str) -> str:
    candidates = get_candidates(entity, type)
    if not candidates:
        return "数据库中未找到有关该电影或个人的信息"
    elif len(candidates) > 1:
        newline = "\n"
        return (
            "需要更多信息,你指的是以下哪一位:"
            f"{newline + newline.join(str(d) for d in candidates)}"
        )
    data = graph.query(
        description_query, params={"candidate": candidates[0]["candidate"]}
    )
    return data[0]["context"]

工具可以接收多个输入参数,正如上例所示,你可以根据这些工具构建出复杂的操作。此外,工作流可能包括多个数据库查询,以便你可以根据情况处理任何特例或异常。其优势在于,你将原本多数情况下或许行得通的提示工程问题转变成每次必定按设计执行的编码工程问题。

电影推荐助手

在本文中,我们将展示如何构建一个语义层,使得LLM代理能够与存储有关演员、电影及评分信息的知识图互动。

对话Neo4j-图数据库AI助手实战_全文索引_02

电影推荐助手

电影推荐助手

信息工具:获取电影或个体相关信息,保障代理获取最新、最相关资料。

推荐工具:根据用户喜好和提供的信息推荐电影。

存储工具:把用户喜好数据存至知识图内,使得跨多次互动提供个性化体验成为可能。

代理可以利用信息或推荐工具从数据库获取信息,或者用存储工具在数据库中记录用户偏好。 预制的函数和工具使代理能够精心设计各种用户体验,引导用户实现具体目标或提供与用户旅程中当前阶段相匹配的定制化信息。这种预定策略通过限制LLM的回应自由度,使得响应更结构化地与用户期望流程相统一,从而提升了用户体验。

电影推荐助手的语义层后台已实现,并以 LangChain 模板的形式提供。我用这个模板创建了一个简洁的streamlit聊天应用。

对话Neo4j-图数据库AI助手实战_数据库_03

Streamlit聊天界面

代码可以在 GitHub 上找到。通过定义环境变量并运行以下命令就可以启动项目:

docker-compose up

知识图谱模型

本案例的知识图谱是基于 MovieLens 数据集建立的。它包含了演员、电影以及十万份电影评价信息。

对话Neo4j-图数据库AI助手实战_全文索引_04

知识图谱

可视化显示了一个知识图谱,涵盖了演员和电影导演,并按电影类型进一步分类。每个电影节点包含其发布日期、片名及IMDb评分信息。图中还包含了用户评分信息,这可用于提供推荐。

你可以运行项目根目录中的 ingest.py 脚本来填充图。

工具定义

接下来,我们定义代理可用来与知识图互动的工具。我们从信息工具开始。信息工具的设计目标是获取演员、导演和电影的相关信息。Python代码如下所示:

def get_information(entity: str, type: str) -> str:
    # 利用全文索引搜索相关电影或人物
    candidates = get_candidates(entity, type)
    if not candidates:
        return "数据库中未找到有关该电影或个人的信息"
    elif len(candidates) > 1:
        newline = "\n"
        return (
            "需要更多信息,你指的是以下哪一位:"
            f"{newline + newline.join(str(d) for d in candidates)}"
        )
    data = graph.query(
        description_query, params={"candidate": candidates[0]["candidate"]}
    )
    return data[0]["context"]

该程序首先利用全文索引检索相关的人物或电影。Neo4j中的全文索引使用了Lucene作为后端,它支持基于文本距离的搜索,即使用户的某些词拼写不准确也能得到结果。如果没有找到相关实体,就可以直接提供反馈。如果找到多个候选人,那么可以引导代理询问用户更详细的问题以确认用户真正感兴趣的电影或人物。比如用户问:“John是谁?”时的情景。

print(get_information("John", "person"))
# 需要更多信息,你指的是下列哪位: 
# {'candidate': 'John Lodge', 'label': 'Person'}
# {'candidate': 'John Warren', 'label': 'Person'}
# {'candidate': 'John Gray', 'label': 'Person'}

此时,工具提示代理还需要更多信息。通过简单的导向性提示,我们可以让代理再次向用户提问。如果用户的描述足够清晰,工具就能够识别出具体的电影或人物;那么我们就可以用参数化的Cypher陈述来获取相关信息。

print(get_information("Keanu Reeves", "person"))
# 角色:演员
# 姓名:Keanu Reeves
# 年份:
# 出演了:《黑客帝国重装上阵》、《并肩作战》、《黑客帝国革命》、《甜蜜11月》、《替身》、《硬球》、《黑客帝国》、《康斯坦丁》、《比尔和泰德的冒险旅程》、《街头之王》、《时光之屋》、《连锁反应》、《云中漫步》、《小佛》、《比尔和泰德的奇妙冒险》、《魔鬼代言人》、《强尼·卓别林》、《极速》、《内心感应》、《霓虹恶魔》、《浪客剑心》、《亨利的罪行》、《地球停转之日》、《疾速特攻》、《江湖边缘》、《太极侠》、《德古拉》、《疾速追杀》、《惊天动地》、《意志薄弱》、《审视者》、《圣诞快乐》
# 导演:《太极侠》

掌握了这些信息,代理能够回答与 Keanu Reeves 相关的多数问题。

让我们来看看如何高效地引导代理使用这个工具。幸运的是,LangChain的处理过程是直观而高效的。首先,我们得为函数定义输入参数,使用Pydantic模型来完成。

class InformationInput(BaseModel):
    entity: str = Field(description="指问题中提到的电影或个体")
    entity_type: str = Field(
        description="实体类型,可选项为'movie'或'person'"
    )

在这里,我们说明了entity和entity_type参数都是字符串类型。entity参数指的是提问中涉及的电影或人物。而entity_type除了指定是字符串类型外,我们还提供了可选项。在选项数量较少时,即所谓的低基数时,我们可以直接提供选项给LLM使用,从而让其选择有效输入。如我们前面所见,在函数中我们利用全文索引以解决电影或人物的歧义问题,因为候选太多了,无法在提示中直接提供。

下一步是整合信息工具的定义:

class InformationTool(BaseTool):
    name = "信息工具"
    description = "有助于回答有关各种演员或电影的问题"
    args_schema: Type[BaseModel] = InformationInput

    def _run(
        self,
        entity: str,
        entity_type: str,
        run_manager: Optional[CallbackManagerForToolRun] = None,
    ) -> str:
        """使用工具。"""
        return get_information(entity, entity_type)

确切、清晰的工具定义是语义层的重要组成部分,可以确保代理在需要时能正确选择并运用相关工具。

然后是推荐工具,这个稍微复杂一点儿。

def recommend_movie(movie: Optional[str] = None, genre: Optional[str] = None) -> str:
    """
    根据用户历史记录和对特定电影及/或类型的偏好推荐电影。
    返回:
        str: 包含推荐电影列表或错误信息的字符串。
    """
    user_id = get_user_id()
    params = {"user_id": user_id, "genre": genre}
    if not movie and not genre:
        # 尝试基于数据库中的信息推荐电影
        response = graph.query(recommendation_query_db_history, params)
        try {
            return ", ".join([el["movie"] for el in response])
        } except Exception {
            return "你能告诉我们一些你喜欢的电影吗?"
        }
    if not movie and genre:
        # 为用户推荐之前未看过的类别中评分最高的电影
        response = graph.query(recommendation_query_genre, params)
        try {
            return ", ".join([el["movie"] for el in response])
        } except Exception {
            return "出了点问题"
        }

    candidates = get_candidates(movie, "movie")
    if not candidates {
        return "你提到的电影没在我们数据库中找到"
    }
    params["movieTitles"] = [el["candidate"] for el in candidates]
    query = recommendation_query_movie(bool(genre))
    response = graph.query(query, params)
    try {
        return ", ".join([el["movie"] for el in response])
    } except Exception {
        return "出了点问题"
    }

首先要注意的是,两个输入参数都是可选的。因此,我们需要设立工作流以应对所有可能的输入组合及它们的缺失情况。要生成个性化的推荐,我们首先获取user_id,随后将其用于后续Cypher推荐语句。

与前面相同,我们需要向代理介绍函数的输入:

class RecommenderInput(BaseModel):
    movie: Optional[str] = Field(description="用于推荐的电影名称")
    genre: Optional[str] = Field(
        description=(
            "用于推荐的电影类型。可选项包括:" f"{all_genres}"
        )
    )

鉴于只有 20 种可用类型,我们在提示中直接提供这些类型的值。对于电影的歧义,我们再次在函数内使用全文索引。和前面一样,我们最后完成工具定义,指导LLM何时使用它。

class RecommenderTool(BaseTool):
    name = "推荐工具"
    description = "在需要推荐电影时派上用场"
    args_schema: Type[BaseModel] = RecommenderInput

    def _run(
        self,
        movie: Optional[str] = None,
        genre: Optional[str] = None,
        run_manager: Optional[CallbackManagerForToolRun] = None,
    ) -> str {
        """使用工具。"""
        return recommend_movie(movie, genre)
    }

至此,我们已经定义了两个工具以从数据库中提取数据。但信息流动不应只有单向。比如说,当用户告知代理他们观看过某电影并且喜欢时,我们可以将这个信息存入数据库,将来在提供推荐时使用。此时,存储工具就派上了用场。

def store_movie_rating(movie: str, rating: int):
    user_id = get_user_id()
    candidates = get_candidates(movie, "movie")
    if not candidates {
        return "我们数据库中未有此电影"
    }
    response = graph.query(
        store_rating_query,
        params={"user_id": user_id, "candidates": candidates, "rating": rating},
    )
    try {
        return response[0]["response"]
    } except Exception as e {
        print(e)
        return "Something went wrong"


class MemoryInput(BaseModel):
    movie: str = Field(description="movie the user liked")
    rating: int = Field(
        description=(
            "Rating from 1 to 5, where one represents heavy dislike "
            "and 5 represent the user loved the movie"
        )
    )

存储工具有两个必需的输入参数,定义了电影和其评分。这是一个直接了当的工具。我需要提一下的是在测试中我注意到可能有意义的是提供当给出特定评分时的例子,因为原生的 LLM 在这方面不是最擅长的。

代理

现在让我们将所有的内容整合在一起,使用 LangChain 表达式语言 (LCEL) 定义代理。

llm = ChatOpenAI(temperature=0, model="gpt-4", streaming=True)
tools = [InformationTool(), RecommenderTool(), MemoryTool()]

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

prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You are a helpful assistant that finds information about movies "
            " and recommends them. If tools require follow up questions, "
            "make sure to ask the user for clarification. Make sure to include any "
            "available options that need to be clarified in the follow up questions "
            "Do only the things the user specifically requested. ",
        ),
        MessagesPlaceholder(variable_name="chat_history"),
        ("user", "{input}"),
        MessagesPlaceholder(variable_name="agent_scratchpad"),
    ]
)

agent = (
    {
        "input": lambda x: x["input"],
        "chat_history": lambda x: _format_chat_history(x["chat_history"])
        if x.get("chat_history")
        else [],
        "agent_scratchpad": lambda x: format_to_openai_function_messages(
            x["intermediate_steps"]
        ),
    }
    | prompt
    | llm_with_tools
    | OpenAIFunctionsAgentOutputParser()
)

agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True).with_types(
    input_type=AgentInput, output_type=Output
)

LangChain 表达式语言使定义代理和暴露所有功能变得非常方便。我们不会深入探讨 LCEL 语法,因为那已超出这篇博文的范围。

电影代理的后端通过 LangServe 以 API 端点的形式暴露。

Streamlit 聊天应用

现在我们只需实现一个 streamlit 应用,使之能够连接到 LangServe API 端点即可。我们将查看用于检索代理响应的异步函数。

async def get_agent_response(
    input: str, stream_handler: StreamHandler, chat_history: Optional[List[Tuple]] = []
):
    url = "http://api:8080/movie-agent/"
    st.session_state["generated"].append("")
    remote_runnable = RemoteRunnable(url)
    async for chunk in remote_runnable.astream_log(
        {"input": input, "chat_history": chat_history}
    ):
        log_entry = chunk.ops[0]
        value = log_entry.get("value")
        if isinstance(value, dict) and isinstance(value.get("steps"), list):
            for step in value.get("steps"):
                stream_handler.new_status(step["action"].log.strip("\n"))
        elif isinstance(value, str):
            st.session_state["generated"][-1] += value
            stream_handler.new_token(value)

get_agent_response 函数旨在与电影代理 API 互动。它向 API 发送包含用户输入和聊天历史的请求,然后异步处理响应。该函数处理不同类型的响应,更新带有新状态的流处理器,将生成的文本追加到会话状态中,允许我们将结果实时传递给用户。

让我们现在来测试它

对话Neo4j-图数据库AI助手实战_数据库_05

电影代理在行动中。图片由作者创建。

结果显示,电影代理为用户提供了令人惊讶的良好和有引导性的互动体验。

结论

总而言之,通过语义层在语言模型与图数据库交互中的集成,就像我们的电影代理示例中展示的那样,代表着在增强用户体验和数据交互效率方面的重大进步。通过从生成任意 Cypher 语句转向利用结构化、预定义的工具和函数的套件,语义层带来了精准和一致性的新水平。这种方法不仅简化了从知识图中提取相关信息的流程,还确保了更以目标为导向、用户为中心的体验。

语义层充当了一座桥梁,将用户意图翻译成特定的、可操作的查询,由语言模型以准确和可靠的方式执行。结果,用户得益于一个不仅更有效理解他们查询的系统,还能引导他们更轻松、更少含糊地实现所需结果。此外,通过在这些预定义工具的参数内约束语言模型的响应,我们减轻了错误或无关输出的风险,从而提高了系统的可信赖性和可靠性。

代码可在 GitHub 上获取。