文本嵌入和向量搜索技术可以帮助我们根据文档的含义及其相似性来检索文档。但当需要根据日期或类别等特定标准来筛选信息时,这些技术就显得力不从心。为了解决这个问题,我们可以引入元数据过滤或过滤向量搜索,这允许我们根据用户的特定需求来缩小搜索范围。
图片
例如,用户可能想要了解 2021 年实施的新政策。通过使用元数据过滤器,系统可以先筛选出 2021 年的文档,然后在这些文档中执行向量相似性搜索,以找到与用户兴趣最相关的文档。这种先进行元数据过滤再执行向量搜索的两步策略,能够显著提高搜索的相关性和准确性。
近期,Neo4j 引入了基于节点属性的 LangChAIn 元数据过滤支持。由于图形数据库能够存储复杂的结构化和非结构化数据,我们可以利用这些数据来执行更精细的元数据过滤。
图片
以一个包含文章和组织信息的数据集为例,文章节点包含了文本和嵌入值,而与文章相关联的组织节点则包含了日期、情感、作者等更多信息。通过这些信息,我们可以构建复杂的查询,以回答如
等问题。
在本篇博客中,Tomaz Bratanic 将向我们展示如何结合 LangChain 和 OpenAI 函数调用代理来实现基于图的元数据过滤。相关代码已在 https://Github.com/tomasonjo/blogs/blob/master/llm/graph_based_prefiltering.ipynb 上提供。
我们将使用 Neo4j 托管的公共演示服务器上的 companies 图数据集。您可以通过以下凭据访问该数据集:
URI: https://demo.neo4jlabs.com:7473/browser/
用户名: companies
密码: companies
数据库: companies
图片
数据集的完整模式包括以 Organization 节点为中心的丰富信息,涵盖供应商、竞争对手、位置、董事会成员等。此外,还有提及特定组织的文章及其相应的文本块。
我们将实现一个 OpenAI 代理,它可以根据用户输入动态生成 Cypher 语句,并从图形数据库检索相关文本块。这个工具将提供四个可选输入参数:
我们将根据这些输入参数动态构建相应的 Cypher 语句,从图形数据库检索相关信息,并利用大型语言模型(LLM)生成最终答案。
要跟随代码实践,您将需要一个 OpenAI API 密钥。
我们从设置 Neo4j 的连接凭证和相关连接开始。
import os
os.environ["OPENAI_API_KEY"] = "sk-"
os.environ["NEO4J_URI"] = "neo4j+s://demo.neo4jlabs.com"
os.environ["NEO4J_USERNAME"] = "companies"
os.environ["NEO4J_PASSword"] = "companies"
os.environ["NEO4J_DATABASE"] = "companies"
embeddings = OpenAIEmbeddings()
graph = Neo4jGraph()
vector_index = Neo4jVector.from_existing_index(
embeddings,
index_name="news"
)
我们使用 OpenAI 的文本嵌入技术,您需要一个 API 密钥来使用它。接下来,我们定义了与 Neo4j 的连接,这使我们能够执行任意的 Cypher 语句。最后,我们创建了一个 Neo4jVector 连接,它可以通过查询现有的向量索引来检索信息。目前,我们不能将向量索引与预过滤方法结合使用,只能与后过滤方法结合使用。但本文将专注于预过滤方法与全面向量相似性搜索的结合使用。
本文的核心是一个名为 get_organization_news 的函数,它能够根据用户的需求动态生成 Cypher 查询语句并检索相关信息。为了清晰起见,我将代码分成了多个部分。
def get_organization_news(
topic: Optional[str] = None,
organization: Optional[str] = None,
country: Optional[str] = None,
sentiment: Optional[str] = None,
) -> str:
# 如果没有预过滤条件,我们可以直接使用向量索引进行搜索
if topic and not organization and not country and not sentiment:
return vector_index.similarity_search(topic)
# 使用并行运行时(如果可用)
base_query = (
"CYPHER runtime = parallel parallelRuntimeSupport=all "
"MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE "
)
where_queries = []
params = {"k": 5} # 设置要检索的文本块数量
if organization:
# 将组织名称映射到数据库中的候选项
candidates = get_candidates(organization)
if len(candidates) > 1: # 如果候选选项太多,则需要用户进一步明确
return f"请明确指出用户指的是以下哪个组织:{candidates}"
# 添加一个过滤条件,筛选出提及特定组织的 articles
where_queries.Append(f"EXISTS {{(a)-[:MENTIONS]->(:Organization {{name: $organization}})}}")
# 将组织名称作为参数传入
params["organization"] = candidates[0]
如果系统识别出用户感兴趣的特定组织,我们会使用 get_candidates 函数将该组织的名称映射到数据库中的候选项。如果找到多个匹配项,我们会要求用户进一步明确。如果没有找到多个匹配项,我们会添加一个过滤条件,筛选出提及特定组织的 articles。为了安全起见,我们使用参数化查询而不是直接拼接查询字符串。
if country:
# 由于国家名称标准化,不需要额外的映射
where_queries.append(f"EXISTS {{(a)-[:MENTIONS]->(:Organization)-[:IN_CITY]->()-[:IN_COUNTRY]->(:Country {{name: $country}})}}")
params["country"] = country
由于国家名称通常是标准化的,我们不需要将国家名称映射到数据库中的值,因为大型语言模型(LLM)已经熟悉大多数国家的名称。
if sentiment:
if sentiment == "positive":
where_queries.append("a.sentiment > $sentiment")
params["sentiment"] = 0.5
else:
where_queries.append("a.sentiment < $sentiment")
params["sentiment"] = -0.5
我们要求 LLM 仅接受正面或负面两种情感输入值,并将这些值映射到适当的过滤器上。
if topic: # 执行向量比较
vector_snippet = (
"WITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS score "
"ORDER BY score DESC LIMIT toInteger($k)"
)
params["embedding"] = embeddings.embed_query(topic)
else: # 只返回最新的数据
vector_snippet = "WITH c, a ORDER BY a.date DESC LIMIT toInteger($k)"
如果系统识别出用户对新闻中的特定主题感兴趣,我们使用主题输入的文本嵌入来找到最相关的文档。如果没有识别出特定主题,我们简单地返回最新的几篇文章,并避免向量相似性搜索。
return_snippet = "RETURN '#title ' + a.title + 'n#date ' + toString(a.date) + 'n#text ' + c.text AS output"
complete_query = (
base_query + " AND ".join(where_queries) + vector_snippet + return_snippet
)
# 从数据库检索信息
data = graph.query(complete_query, params)
print(f"Cypher: {complete_query}n")
# 在打印前安全地移除嵌入
params.pop('embedding', None)
print(f"参数: {params}")
return "###文章: ".join([el["output"] for el in data])
我们通过组合所有查询片段来构建最终的 complete_query。然后,我们使用动态生成的 Cypher 语句从数据库检索信息并返回结果。让我们通过一个示例输入来看看生成的 Cypher 语句。
get_organization_news(
organizatinotallow='neo4j',
sentiment='positive',
topic='远程工作'
)
# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=all
# MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE
# EXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})} AND
# a.sentiment > $sentiment
# WITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS score
# ORDER BY score DESC LIMIT toInteger($k)
# RETURN '#title ' + a.title + 'n#date ' + toString(a.date) + 'n#text ' + c.text AS output
# 参数: {'k': 5, 'organization': 'Neo4j', 'sentiment': 0.5}
动态查询生成按预期工作,能够从数据库中检索到相关的信息。
接下来,我们将创建一个代理工具,用于处理新闻信息查询。首先,我们需要为输入参数编写一些说明。
fewshot_examples = """{输入:google员工的健康福利在新闻中有哪些?查询:健康福利}
{输入:关于Google的最新正面新闻是什么?查询:无}
{输入:有关VertexAI和Google的新闻有哪些?查询:VertexAI}
{输入:关于Google的新产品有哪些新闻?查询:新产品}
"""
class NewsInput(BaseModel):
topic: Optional[str] = Field(
descriptinotallow="除了组织、国家和情感倾向之外,如果您对其他特定信息或话题感兴趣,请告诉我们。以下是一些示例:"
+ fewshot_examples
)
organization: Optional[str] = Field(
descriptinotallow="您希望了解信息的组织名称"
)
country: Optional[str] = Field(
descriptinotallow="您感兴趣的组织的所在国家。请使用正式的国家名称,例如‘美利坚合众国’或‘法国’。"
)
sentiment: Optional[str] = Field(
descriptinotallow="您想要查询的文章情感倾向", enum=["正面", "负面"]
)
在定义预过滤参数时,我遇到了一些困难,特别是如何让 topic 参数按预期工作。为了解决这个问题,我提供了一些示例,帮助语言模型更好地理解用户的需求。同时,我们还向模型提供了关于国家名称格式的指导,并对情感倾向选项进行了枚举。
现在,我们可以定义一个自定义工具,为其指定一个名称和一段包含使用说明的描述。
class NewsTool(BaseTool):
name = "新闻信息工具"
description = (
"当你需要在新闻中查找相关信息时,这个工具会非常有用。"
)
args_schema:Type[BaseModel] = NewsInput
def _run(
self,
topic: Optional[str] = None,
organization: Optional[str] = None,
country: Optional[str] = None,
sentiment: Optional[str] = None,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> str:
""“使用这个工具来获取新闻信息。”""
return get_organization_news(topic, organization, country, sentiment)
最后,我们需要定义一个代理执行器。这里,我使用了之前实现的 OpenAI 代理的 LCEL 实现。
llm = ChatOpenAI(temperature=0, model="GPT-4-turbo", streaming=True)
tools = [NewsTool()]
llm_with_tools = llm.bind(functinotallow=[format_tool_to_openai_function(t) for t in tools])
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
“你是一个乐于助人的助手,可以找到关于电影的信息并进行推荐。如果工具需要进一步的问题,请确保向用户询问以获得澄清。确保在后续问题中包含任何需要澄清的可用选项。只做用户明确请求的事情。”
),
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)
这个代理工具可以用于检索新闻信息。我们还添加了 聊天记录 消息占位符,这样代理就可以进行对话,并允许提出后续问题和回复。
让我们尝试几个查询,看看生成的 Cypher 语句和参数是什么样的。
agent_executor.invoke(
{"输入": "关于 neo4j 的一些正面新闻是什么?"}
)
# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=all
# MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE
# EXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})} AND
# a.sentiment > $sentiment WITH c, a
# ORDER BY a.date DESC LIMIT toInteger($k)
# RETURN '#标题 ' + a.title + '日期 ' + toString(a.date) + '文本 ' + c.text AS output
# 参数: {'k': 5, 'organization': 'Neo4j', 'sentiment': 0.5}
生成的 Cypher 语句是有效的。由于没有指定具体的主题,它返回了提到 Neo4j 的最后五篇正面文章的文本块。让我们尝试一个更复杂的例子:
agent_executor.invoke(
{"输入": "关于法国公司的员工幸福感,有哪些最新的负面新闻?"}
)
# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=all
# MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE
# EXISTS {(a)-[:MENTIONS]->(:Organization)-[:IN_CITY]->()-[:IN_COUNTRY]->(:Country {name: $country})} AND
# a.sentiment < $sentiment
# WITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS score
# ORDER BY score DESC LIMIT toInteger($k)
# RETURN '#标题 ' + a.title + '日期 ' + toString(a.date) + '文本 ' + c.text AS output
# 参数: {'k': 5, 'country': 'France', 'sentiment': -0.5, 'topic': '员工幸福感'}
语言模型代理正确地生成了预过滤参数,并且还识别出了一个特定的“员工幸福感”主题。这个主题被用作向量相似性搜索的输入,使我们能够进一步优化检索过程。
在这篇博客文章中,我们实现了基于图的元数据过滤器的示例,以提高向量搜索的准确性。数据集拥有广泛且相互关联的选项,这允许进行更精细的预过滤查询。结合图数据表示和语言模型的函数调用功能,可以动态生成 Cypher 语句,从而为结构化过滤器提供了几乎无限的可能性。
此外,你的代理可以拥有检索非结构化文本的工具,如本文所示,以及能够检索结构化信息的其他工具,这使得知识图谱成为许多 RAG应用的理想解决方案。