微信扫码
添加专属顾问
我要投稿
文本嵌入和向量相似性搜索通过理解文档的含义以及它们之间的相似性来帮助我们查找文档。然而,当基于特定标准(如日期或类别)对信息进行排序时,文本嵌入的效果并不理想;例如,如果你需要找到在特定年份创建的所有文档,或者标记在特定类别下(如“科幻小说”)的文档。
这就是元数据过滤或过滤后的向量搜索发挥作用的地方,因为它可以有效地处理这些结构化过滤器,允许用户根据特定属性缩小他们的搜索结果。
如上图所示中,流程从用户询问2021年是否有实施新政策开始。然后使用元数据过滤器根据指定的年份(在这个案例中是2021年)来筛选大量索引文档。这将产生仅来自那一年的过滤后的子集文档。
为了进一步精确找到最相关的文档,在这一子集中进行向量相似性搜索。这种方法允许系统在2021年的语境相关文档池中找到与感兴趣的话题密切相关的文档。这个两步过程,即先进行元数据过滤,然后进行向量相似性搜索,提高了搜索结果的准确性和相关性。
最近,我们为基于节点属性的Neo4j中的元数据过滤引入了LangChain支持。然而,像Neo4j这样的图形数据库可以存储高度复杂且相互连接的结构化数据和非结构化数据。让我们来看下面的例子:
数据集的非结构化部分代表文章及其文本块,位于可视化的右上角。文本块节点包含文本及其文本嵌入值,并与文章节点相连,在文章节点上有关于文章的更多信息,如日期、情感、作者等。
然而,这些文章还与它们提及的组织进一步关联。在这个例子中,文章提到了Neo4j。此外,我们的数据集还包括了关于Neo4j的大量结构化信息,如其投资者、董事会成员、供应商等等。
因此,我们可以利用这些丰富的结构化信息来执行复杂的元数据过滤,使我们能够使用结构化标准精确地细化我们的文档选择,例如:
Rod Johnson是董事会成员的任何公司是否实施了新的在家工作政策?有没有关于Neo4j投资的公司的负面新闻?对于供应现代汽车的公司,是否有与供应链问题相关的显著新闻?有了所有这些示例问题,你可以使用基于图形的结构化元数据过滤器大大缩小相关文档子集。
在这篇文章中,将向您展示如何使用LangChain结合OpenAI函数调用代理来实现基于图形的元数据过滤。代码可以在GitHub上找到。
我们将使用所谓的公司图形数据集,该数据集可在Neo4j托管的公共演示服务器上获得。您可以使用以下凭据访问它。
Neo4j Browser URI: https://demo.neo4jlabs.com:7473/browser/username: companiespassword: companiesdatabase: companies
数据集的完整模式如下:
图形模式围绕着组织节点展开。关于它们的供应商、竞争对手、位置、董事会成员等有着广泛的信息可供查阅。正如之前提到的,还有提到特定组织的文章以及它们相应的文本块。
我们将实现一个OpenAI代理,它有一个工具,可以根据用户输入动态生成Cypher语句,并从图形数据库中检索相关的文本块。在这个例子中,该工具将有四个可选的输入参数:
主题:用户感兴趣的除了组织、国家和情感之外的任何具体信息或主题。组织:用户想要查找信息的组织。国家:用户感兴趣的组织的国家。使用全名,如美利坚合众国和法国。情感:文章的情感。根据这四个输入参数,我们将动态但确定地构造相应的Cypher语句,以从图形中检索相关信息,并使用它作为上下文来生成最终答案,使用LLM(大型语言模型)。
您将需要一个OpenAI API密钥才能跟随代码进行操作。
函数实现我们将首先定义凭据和与Neo4j的相关连接。
import osos.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 there is no prefiltering, we can use vector indexif topic and not organization and not country and not sentiment:return vector_index.similarity_search(topic)# Uses parallel runtime where availablebase_query = ("CYPHER runtime = parallel parallelRuntimeSupport=all ""MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE ")where_queries = []params = {"k": 5} # Define the number of text chunks to retrieve
我们首先定义输入参数。如你所见,它们都是可选的字符串。主题参数用于在文档中查找特定信息。在实践中,我们会嵌入主题参数的值,并将其用作向量相似性搜索的输入。其他三个参数将用于演示预过滤方法。
如果所有预过滤参数都为空,我们可以使用现有的向量索引来找到相关文档。否则,我们开始准备将用于预过滤元数据方法的基本Cypher语句。子句CYPHER runtime = parallel parallelRuntimeSupport=all指示Neo4j数据库在可用时使用并行运行时。接下来,我们准备一个匹配语句,选择Chunk节点及其相应的Article节点。
现在,我们已经准备好动态地向Cypher语句附加元数据过滤器。我们将从Organization过滤器开始。
if organization:# Map to databasecandidates = get_candidates(organization)if len(candidates) > 1: # Ask for follow up if too many optionsreturn ("Ask a follow up question which of the available organizations "f"did the user mean. Available options: {candidates}")where_queries.append("EXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})}")params["organization"] = candidates[0]
如果LLM识别出用户感兴趣的任何特定组织,我们必须首先使用get_candidates函数将值映射到数据库。在内部,get_candidates函数使用全文索引进行关键字搜索来查找候选节点。如果找到多个候选,我们指示LLM向用户提出后续问题,以澄清他们确切指的是哪个组织。
否则,我们将一个存在性子查询附加到过滤器列表中,该子查询过滤提到特定组织的文章。为了防止任何Cypher注入,我们使用查询参数而不是连接查询。
接下来,我们处理用户希望基于所提到的组织的国家对文本块进行预过滤的情况。
if country:# No need to disambiguatewhere_queries.append("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.5else:where_queries.append("a.sentiment < $sentiment")params["sentiment"] = -0.5
我们将指示LLM仅对情感输入值使用两个值,即正面或负面。然后,我们将这两个值映射到适当的过滤值。
我们稍微不同地处理主题参数,因为它不是用于预过滤,而是用于向量相似性搜索。
if topic: # Do vector comparisonvector_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: # Just return the latest datavector_snippet = " WITH c, a ORDER BY a.date DESC LIMIT toInteger($k) "
如果LLM确定用户对新闻中的特定主题感兴趣,我们使用主题输入的文本嵌入来找到最相关的文档。另一方面,如果没有确定具体的主题,我们只返回最新的几篇文章,并完全避免向量相似性搜索。
现在,我们必须把Cypher语句放在一起,并用它从数据库检索信息。
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)# Retrieve information from the databasedata = graph.query(complete_query, params)print(f"Cypher: {complete_query}\n")# Safely remove embedding before printingparams.pop('embedding', None)print(f"Parameters: {params}")return "###Article: ".join([el["output"] for el in data])
我们通过组合所有查询代码片段来构造最终的完整查询。之后,我们使用动态生成的Cypher语句从数据库检索信息,并将其返回给LLM。让我们检查一个示例输入的生成的Cypher语句。
get_organization_news(organization='neo4j',sentiment='positive',topic='remote work')# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=allMATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHEREEXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})} ANDa.sentiment > $sentimentWITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS scoreORDER BY score DESC LIMIT toInteger($k)RETURN '#title ' + a.title + '\ndate ' + toString(a.date) + '\ntext ' + c.text AS output# Parameters: {'k': 5, 'organization': 'Neo4j', 'sentiment': 0.5}
动态查询生成按预期工作,并能够从数据库检索相关信息。
定义OpenAI代理接下来,我们需要将该函数包装为一个代理工具。首先,我们将添加输入参数描述。
fewshot_examples = """{Input:What are the health benefits for Google employees in the news? Query: Health benefits}{Input: What is the latest positive news about Google? Query: None}{Input: Are there any news about VertexAI regarding Google? Query: VertexAI}{Input: Are there any news about new products regarding Google? Query: new products}"""class NewsInput(BaseModel):topic: Optional[str] = Field(description="Any specific information or topic besides organization, country, and sentiment that the user is interested in. Here are some examples: "+ fewshot_examples)organization: Optional[str] = Field(description="Organization that the user wants to find information about")country: Optional[str] = Field(description="Country of organizations that the user is interested in. Use full names like United States of America and France.")sentiment: Optional[str] = Field(description="Sentiment of articles", enum=["positive", "negative"])
预过滤参数的描述相当简单,但我在使主题参数按预期工作时遇到了一些问题。最后,我决定添加一些示例,以便LLM更好地理解它。此外,您可以看到我们向LLM提供了有关国家命名格式的信息,并为情感提供了一个枚举。
现在,我们可以通过为其命名并包含指示LLM何时使用的说明来定义一个自定义工具。
class NewsTool(BaseTool):name = "NewsInformation"description = ("useful for when you need to find relevant information in the news")args_schema: Type[BaseModel] = NewsInputdef _run(self,topic: Optional[str] = None,organization: Optional[str] = None,country: Optional[str] = None,sentiment: Optional[str] = None,run_manager: Optional[CallbackManagerForToolRun] = None,) -> str:"""Use the tool."""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(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)
代理有一个工具可以用来检索有关新闻的信息。我们还添加了chat_history消息占位符,使代理具有对话性,允许后续问题和回复。
实现测试让我们运行几个输入并检查生成的Cypher语句和参数。
agent_executor.invoke({"input": "What are some positive news regarding neo4j?"})# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=allMATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHEREEXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})} ANDa.sentiment > $sentiment WITH c, aORDER BY a.date DESC LIMIT toInteger($k)RETURN '#title ' + a.title + 'date ' + toString(a.date) + 'text ' + c.text AS outputParameters: {'k': 5, 'organization': 'Neo4j', 'sentiment': 0.5}
生成的Cypher语句是有效的。由于我们没有指定任何特定主题,它返回了提到Neo4j的正面文章中最后五个文本块。让我们做一些更复杂的事情:
agent_executor.invoke({"input": "What are some of the latest negative news about employee happiness for companies from France?"})# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=allMATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHEREEXISTS {(a)-[:MENTIONS]->(:Organization)-[:IN_CITY]->()-[:IN_COUNTRY]->(:Country {name: $country})} ANDa.sentiment < $sentimentWITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS scoreORDER BY score DESC LIMIT toInteger($k)RETURN '#title ' + a.title + 'date ' + toString(a.date) + 'text ' + c.text AS outputParameters: {'k': 5, 'country': 'France', 'sentiment': -0.5, 'topic': 'employee happiness'}
LLM代理正确生成了预过滤参数,但也识别出了一个特定的员工幸福感主题。这个主题被用作向量相似性搜索的输入,允许我们进一步优化检索过程。
总结在这篇博客文章中,我们实现了基于图形的元数据过滤器示例,提高了向量搜索的准确性。然而,数据集具有广泛且相互连接的选项,允许进行更复杂的预过滤查询。有了图形数据表示,结合LLM函数调用特性动态生成Cypher语句,结构化过滤器的可能性几乎无限。
此外,您的代理可以拥有检索非结构化文本的工具,如本篇博客文章所示,以及其他可以检索结构化信息的工具,使知识图谱成为许多RAG应用的绝佳解决方案。
具体代码:https://github.com/tomasonjo/blogs/blob/master/llm/graph_based_prefiltering.ipynb
53AI,企业落地大模型首选服务商
产品:场景落地咨询+大模型应用平台+行业解决方案
承诺:免费POC验证,效果达标后再合作。零风险落地应用大模型,已交付160+中大型企业
2025-08-21
2025-08-20
2025-09-07
2025-08-21
2025-08-19
2025-08-05
2025-09-16
2025-10-02
2025-08-20
2025-09-08