如何构建一个图-RAG-应用程序

如何构建一个图 RAG 应用程序

原文链接

应用程序和笔记本的配套代码在此处

知识图谱(KG)和大型语言模型(LLM)是天作之合。我在之前的这些文章中详细讨论了这两种技术的互补性,但简而言之是,“LLM 的一些主要弱点,即它们是黑盒模型且难以处理事实性知识,正是知识图谱的最大优势。本质上,知识图谱是事实的集合,它们是完全可解释的。”

本文主要介绍如何构建一个简单的图 RAG 应用程序。什么是 RAG?RAG,即检索增强生成,是指检索与提示相关的信息以增强发送给大型语言模型(LLM)的提示,从而生成响应。图 RAG 是一种使用知识图谱作为检索部分的 RAG。如果你从未听说过图 RAG,或者想要复习一下,我建议你观看这个视频

基本思路是,你不必直接将提示发送到未经你数据训练的 LLM,而是可以用相关数据来补充你的提示,以便 LLM 能够准确回答你的提示。我经常使用的例子是将一份工作描述和我的简历复制到 ChatGPT 中,让它帮我写一封求职信。如果我能给它我的简历以及我申请的工作描述,LLM 就能对我的提示“帮我写一封求职信”提供更加相关的响应。由于知识图谱是为了存储知识而构建的,因此它们是存储内部数据并补充 LLM 提示以提供额外上下文的完美方式,这可以提高响应的准确性和上下文理解。

这种技术有非常广泛的应用,例如客户服务机器人药物 发现生命科学中的自动化监管报告生成人力资源的人才招聘和管理法律研究和写作以及财富顾问助手。由于其广泛的应用性和提高 LLM 工具性能的潜力,Graph RAG(我在这里将使用这个术语)在人气上急剧上升。以下是根据谷歌搜索趋势显示的兴趣随时间变化的图表。

图片

来源:trends.google.com/

Graph RAG 的搜索兴趣激增,甚至超过了知识图谱和检索增强生成等术语。请注意,谷歌趋势衡量的是相对搜索兴趣,而不是搜索的绝对数量。2024 年 7 月对 Graph RAG 的搜索激增与微软宣布其 GraphRAG 应用程序将在GitHub上提供的那个星期相吻合。

围绕 Graph RAG 的兴奋不仅仅局限于微软。2024 年 7 月,三星收购了知识图谱公司 RDFox。宣布该收购的文章没有明确提及 Graph RAG,但在 2024 年 11 月发布的福布斯文章中,三星发言人表示:“我们计划开发知识图谱技术,这是个性化 AI 的主要技术之一,并将其与生成 AI 有机地连接起来,以支持用户特定的服务。”

2024 年 10 月,领先的图数据库公司 Ontotext 和语义网公司 PoolParty(一个知识图谱编辑平台)的制造商合并,成立了Graphwise。根据新闻稿,此次合并的目的是“使 Graph RAG 作为一类产品的演变民主化”。

尽管围绕 Graph RAG 的炒作可能部分源于对聊天机器人和生成式 AI 的广泛兴奋,但它反映了知识图谱在解决复杂、现实世界问题中的应用方式的真正演变。一个例子是 LinkedIn 应用 Graph RAG来改善他们的客户服务技术支持。因为该工具能够检索相关数据(如之前解决的类似工单或问题),以供 LLM 使用,所以响应更加准确,平均解决时间从 40 小时缩短到 15 小时。

这篇帖子将介绍一个相当简单但我觉得具有说明性的例子,说明 Graph RAG 在实际中是如何工作的。最终结果是用户可以与之交互的应用程序。像我的上一篇文章一样,我将使用 PubMed 中的医学期刊文章数据集。这个想法是,这是一个医学领域的某人可以使用来进行文献综述的应用程序。然而,相同的原理可以应用于许多用例,这就是为什么 Graph RAG 如此令人兴奋。

应用程序的结构以及这篇帖子如下:

第零步是准备数据。我将在下面解释细节,但总体目标是矢量化原始数据,并将其单独转换为 RDF 图。只要我们在矢量化之前将 URI 与文章关联起来,我们就可以在文章图和文章向量空间中导航。然后,我们可以:

  1. 搜索文章:使用向量数据库的力量根据搜索词进行初步搜索相关文章。我将使用向量相似性检索与搜索词向量最相似的文章。

  2. 细化术语:探索医学主题词(MeSH)生物医学词汇以选择用于过滤步骤 1 中文章的术语。这个受控词汇包含医学术语、别名、更窄的概念以及许多其他属性和关系。

  3. 过滤与总结:使用 MeSH 术语过滤文章以避免‘上下文中毒’。然后将剩余的文章连同额外的提示“用项目符号总结”一起发送给 LLM。

在我们开始之前,关于这个应用和教程的一些注意事项:

  • 这个设置仅使用知识图谱进行元数据。这之所以可能,是因为我的数据集中的每篇文章都已经用属于丰富受控词汇的术语进行了标记。我正在使用图来构建结构和语义,使用向量数据库进行基于相似性的检索,确保每种技术都用于其最擅长的领域。向量相似性可以告诉我们“食管癌”在语义上与“口腔癌”相似,但知识图谱可以告诉我们“食管癌”和“口腔癌”之间关系的细节。

  • 我为这个应用程序使用的数据是从 PubMed 收集的医疗期刊文章集合(关于数据的更多信息见下文)。我选择这个数据集是因为它是结构化的(表格形式),同时也包含每篇文章的摘要形式的文本,并且它已经用与一个建立良好的控制词汇表(MeSH)对齐的主题术语进行了标记。因为这些是医学文章,所以我将这个应用程序称为“医学 Graph RAG”。但同样的结构可以应用于任何领域,并不特定于医学领域。

  • 我希望这个教程和应用程序展示的是,通过在检索步骤中整合知识图谱,你可以提高你的 RAG 应用程序在准确性和可解释性方面的结果。我将展示知识图谱如何以两种方式提高 RAG 应用程序的准确性:一是为用户提供一种过滤上下文的方法,以确保 LLM 只接收最相关的信息;二是使用由领域专家维护和整理的具有密集关系的特定领域控制词汇表来进行过滤。

  • 这个教程和应用程序没有直接展示的是知识图谱可以增强 RAG 应用程序的另外两个重要方式:治理、访问控制和合规性;以及效率和可扩展性。对于治理,知识图谱不仅可以过滤内容以提高准确性,还可以强制执行数据治理政策。例如,如果用户没有权限访问某些内容,那么这些内容就可以从他们的 RAG 管道中排除。在效率和可扩展性方面,知识图谱可以帮助确保 RAG 应用程序不会胎死腹中。虽然创建一个令人印象深刻的单次 RAG 应用程序很容易(这正是这个教程的目的),但许多公司都面临着 POCs(原型)激增的问题,这些 POCs 缺乏一个统一的框架、结构或平台。这意味着许多这些应用程序将不会长期生存。由知识图谱驱动的元数据层可以打破数据孤岛,为有效地构建、扩展和维护 RAG 应用程序提供所需的基础。使用像 MeSH 这样的丰富控制词汇为这些文章的元数据标签是确保这个 Graph RAG 应用程序可以与其他系统集成并降低其成为孤岛风险的一种方式。

第 0 步:准备数据

准备数据的代码在这个笔记本中。

如前所述,我再次决定使用这个数据集,该数据集包含来自 PubMed 存储库的 50,000 篇研究文章(许可CC0:公共领域)。该数据集包含文章的标题、摘要以及用于元数据标签的字段。这些标签来自医学主题词表(MeSH)受控词汇表。PubMed 文章实际上只是文章的元数据——每篇文章都有摘要,但我们没有全文。数据已经以表格格式呈现并标记了 MeSH 术语。

我们可以直接矢量化这个表格数据集。在我们矢量化之前,我们可以将其转换为图(RDF),但我没有在这个应用程序中这样做,也不知道这对这种类型的数据的最终结果是否有帮助。矢量化原始数据最重要的地方是,我们首先为每篇文章添加唯一资源标识符(URIs)。URI 是用于导航 RDF 数据的唯一 ID,对于我们来说,在图中的向量和实体之间来回移动是必要的。此外,我们将在矢量数据库中为 MeSH 术语创建一个单独的集合。这将使用户能够在没有先验知识的情况下搜索相关术语。以下是我们在准备数据时所做的示意图。

图片

图片由作者提供

在我们的矢量数据库中有两个集合可供查询:文章和术语。我们还有以 RDF 格式表示的数据图。由于 MeSH 有 API,我直接查询 API 以获取术语的替代名称和更窄的概念。

在 Weaviate 中矢量化数据

首先导入所需的包并设置 Weaviate 客户端:

import weaviate<br>from weaviate.util import generate_uuid5<br>from weaviate.classes.init import Auth<br>import os<br>import json<br>import pandas as pd<br><br>client = weaviate.connect_to_weaviate_cloud(<br>    cluster_url="XXX",  # Replace with your Weaviate Cloud URL<br>    auth_credentials=Auth.api_key("XXX"),  # Replace with your Weaviate Cloud key<br>    headers={'X-OpenAI-Api-key': "XXX"}  # Replace with your OpenAI API key<br>)

读取 PubMed 期刊文章。我正在使用 Databricks 运行这个笔记本,所以你可能需要根据你运行的位置进行更改。这里的目的是只是将数据放入 pandas DataFrame。

df = spark.sql("SELECT * FROM workspace.default.pub_med_multi_label_text_classification_dataset_processed").toPandas()

如果你是在本地运行,只需这样做:

df = pd.read_csv("PubMed Multi Label Text Classification Dataset Processed.csv")

然后稍微清理一下数据:

import numpy as np<br># Replace infinity values with NaN and then fill NaN values<br>df.replace([np.inf, -np.inf], np.nan, inplace=True)<br>df.fillna('', inplace=True)<br><br># Convert columns to string type<br>df['Title'] = df['Title'].astype(str)<br>df['abstractText'] = df['abstractText'].astype(str)<br>df['meshMajor'] = df['meshMajor'].astype(str)

现在我们需要为每篇文章创建一个 URI 并将其添加为新列。这是很重要的,因为 URI 是我们连接文章的向量表示和文章的知识图谱表示的方式。

import urllib.parse<br>from rdflib import Graph, RDF, RDFS, Namespace, URIRef, Literal<br><br><br># Function to create a valid URI<br>def create_valid_uri(base_uri, text):<br>    if pd.isna(text):<br>        return None<br>    # Encode text to be used in URI<br>    sanitized_text = urllib.parse.quote(text.strip().replace(' ', '_').replace('"', '').replace('<', '').replace('>', '').replace("'", "_"))<br>    return URIRef(f"{base_uri}/{sanitized_text}")<br><br><br># Function to create a valid URI for Articles<br>def create_article_uri(title, base_namespace="http://example.org/article/"):<br>    """<br>    Creates a URI for an article by replacing non-word characters with underscores and URL-encoding.<br><br>    Args:<br>        title (str): The title of the article.<br>        base_namespace (str): The base namespace for the article URI.<br><br>    Returns:<br>        URIRef: The formatted article URI.<br>    """<br>    if pd.isna(title):<br>        return None<br>    # Replace non-word characters with underscores<br>    sanitized_title = re.sub(r'\W+', '_', title.strip())<br>    # Condense multiple underscores into a single underscore<br>    sanitized_title = re.sub(r'_+', '_', sanitized_title)<br>    # URL-encode the term<br>    encoded_title = quote(sanitized_title)<br>    # Concatenate with base_namespace without adding underscores<br>    uri = f"{base_namespace}{encoded_title}"<br>    return URIRef(uri)<br><br># Add a new column to the DataFrame for the article URIs<br>df['Article_URI'] = df['Title'].apply(lambda title: create_valid_uri("http://example.org/article", title))

我们还希望创建一个包含所有用于标记文章的 MeSH 术语的 DataFrame。这将在我们想要搜索类似 MeSH 术语时很有帮助。

# Function to clean and parse MeSH terms<br>def parse_mesh_terms(mesh_list):<br>    if pd.isna(mesh_list):<br>        return []<br>    return [<br>        term.strip().replace(' ', '_')<br>        for term in mesh_list.strip("[]'").split(',')<br>    ]<br><br># Function to create a valid URI for MeSH terms<br>def create_valid_uri(base_uri, text):<br>    if pd.isna(text):<br>        return None<br>    sanitized_text = urllib.parse.quote(<br>        text.strip()<br>        .replace(' ', '_')<br>        .replace('"', '')<br>        .replace('<', '')<br>        .replace('>', '')<br>        .replace("'", "_")<br>    )<br>    return f"{base_uri}/{sanitized_text}"<br><br># Extract and process all MeSH terms<br>all_mesh_terms = []<br>for mesh_list in df["meshMajor"]:<br>    all_mesh_terms.extend(parse_mesh_terms(mesh_list))<br><br># Deduplicate terms<br>unique_mesh_terms = list(set(all_mesh_terms))<br><br># Create a DataFrame of MeSH terms and their URIs<br>mesh_df = pd.DataFrame({<br>    "meshTerm": unique_mesh_terms,<br>    "URI": [create_valid_uri("http://example.org/mesh", term) for term in unique_mesh_terms]<br>})<br><br># Display the DataFrame<br>print(mesh_df)

矢量化文章 DataFrame:

from weaviate.classes.config import Configure<br><br><br>#define the collection<br>articles = client.collections.create(<br>    name = "Article",<br>    vectorizer_config=Configure.Vectorizer.text2vec_openai(),  # If set to "none" you must always provide vectors yourself. Could be any other "text2vec-*" also.<br>    generative_config=Configure.Generative.openai(),  # Ensure the `generative-openai` module is used for generative queries<br>)<br><br>#add ojects<br>articles = client.collections.get("Article")<br><br>with articles.batch.dynamic() as batch:<br>    for index, row in df.iterrows():<br>        batch.add_object({<br>            "title": row["Title"],<br>            "abstractText": row["abstractText"],<br>            "Article_URI": row["Article_URI"],<br>            "meshMajor": row["meshMajor"],<br>        })

现在矢量化 MeSH 术语:

#define the collection<br>terms = client.collections.create(<br>    name = "term",<br>    vectorizer_config=Configure.Vectorizer.text2vec_openai(),  # If set to "none" you must always provide vectors yourself. Could be any other "text2vec-*" also.<br>    generative_config=Configure.Generative.openai(),  # Ensure the `generative-openai` module is used for generative queries<br>)<br><br>#add ojects<br>terms = client.collections.get("term")<br><br>with terms.batch.dynamic() as batch:<br>    for index, row in mesh_df.iterrows():<br>        batch.add_object({<br>            "meshTerm": row["meshTerm"],<br>            "URI": row["URI"],<br>        })

在这个阶段,你可以直接对矢量化数据集运行语义搜索、相似度搜索和 RAG。这里我不会详细说明所有这些,但你可以在我的配套笔记本中查看相关代码。

将数据转换为知识图谱

我只是使用了我们在上一篇文章中使用的相同代码来做这件事。我们基本上将数据中的每一行都转换成我们 KG 中的“文章”实体。然后,我们为每篇文章提供标题、摘要和 MeSH 术语属性。我们还将每个 MeSH 术语转换成实体。此代码还为每篇文章添加了随机日期作为“发布日期”属性,以及 1 到 10 之间的随机数字作为“访问”属性。我们不会在这个演示中使用这些属性。下面是我们从数据中创建的图的视觉表示。

下面是如何遍历 DataFrame 并将其转换为 RDF 数据:

from rdflib import Graph, RDF, RDFS, Namespace, URIRef, Literal<br>from rdflib.namespace import SKOS, XSD<br>import pandas as pd<br>import urllib.parse<br>import random<br>from datetime import datetime, timedelta<br>import re<br>from urllib.parse import quote<br><br># --- Initialization ---<br>g = Graph()<br><br># Define namespaces<br>schema = Namespace('http://schema.org/')<br>ex = Namespace('http://example.org/')<br>prefixes = {<br>    'schema': schema,<br>    'ex': ex,<br>    'skos': SKOS,<br>    'xsd': XSD<br>}<br>for p, ns in prefixes.items():<br>    g.bind(p, ns)<br><br># Define classes and properties<br>Article = URIRef(ex.Article)<br>MeSHTerm = URIRef(ex.MeSHTerm)<br>g.add((Article, RDF.type, RDFS.Class))<br>g.add((MeSHTerm, RDF.type, RDFS.Class))<br><br>title = URIRef(schema.name)<br>abstract = URIRef(schema.description)<br>date_published = URIRef(schema.datePublished)<br>access = URIRef(ex.access)<br><br>g.add((title, RDF.type, RDF.Property))<br>g.add((abstract, RDF.type, RDF.Property))<br>g.add((date_published, RDF.type, RDF.Property))<br>g.add((access, RDF.type, RDF.Property))<br><br># Function to clean and parse MeSH terms<br>def parse_mesh_terms(mesh_list):<br>    if pd.isna(mesh_list):<br>        return []<br>    return [term.strip() for term in mesh_list.strip("[]'").split(',')]<br><br># Enhanced convert_to_uri function<br>def convert_to_uri(term, base_namespace="http://example.org/mesh/"):<br>    """<br>    Converts a MeSH term into a standardized URI by replacing spaces and special characters with underscores,<br>    ensuring it starts and ends with a single underscore, and URL-encoding the term.<br><br>    Args:<br>        term (str): The MeSH term to convert.<br>        base_namespace (str): The base namespace for the URI.<br><br>    Returns:<br>        URIRef: The formatted URI.<br>    """<br>    if pd.isna(term):<br>        return None  # Handle NaN or None terms gracefully<br>    <br>    # Step 1: Strip existing leading and trailing non-word characters (including underscores)<br>    stripped_term = re.sub(r'^\W+|\W+$', '', term)<br>    <br>    # Step 2: Replace non-word characters with underscores (one or more)<br>    formatted_term = re.sub(r'\W+', '_', stripped_term)<br>    <br>    # Step 3: Replace multiple consecutive underscores with a single underscore<br>    formatted_term = re.sub(r'_+', '_', formatted_term)<br>    <br>    # Step 4: URL-encode the term to handle any remaining special characters<br>    encoded_term = quote(formatted_term)<br>    <br>    # Step 5: Add single leading and trailing underscores<br>    term_with_underscores = f"_{encoded_term}_"<br>    <br>    # Step 6: Concatenate with base_namespace without adding an extra underscore<br>    uri = f"{base_namespace}{term_with_underscores}"<br><br>    return URIRef(uri)<br><br># Function to generate a random date within the last 5 years<br>def generate_random_date():<br>    start_date = datetime.now() - timedelta(days=5*365)<br>    random_days = random.randint(0, 5*365)<br>    return start_date + timedelta(days=random_days)<br><br># Function to generate a random access value between 1 and 10<br>def generate_random_access():<br>    return random.randint(1, 10)<br><br># Function to create a valid URI for Articles<br>def create_article_uri(title, base_namespace="http://example.org/article"):<br>    """<br>    Creates a URI for an article by replacing non-word characters with underscores and URL-encoding.<br><br>    Args:<br>        title (str): The title of the article.<br>        base_namespace (str): The base namespace for the article URI.<br><br>    Returns:<br>        URIRef: The formatted article URI.<br>    """<br>    if pd.isna(title):<br>        return None<br>    # Encode text to be used in URI<br>    sanitized_text = urllib.parse.quote(title.strip().replace(' ', '_').replace('"', '').replace('<', '').replace('>', '').replace("'", "_"))<br>    return URIRef(f"{base_namespace}/{sanitized_text}")<br><br># Loop through each row in the DataFrame and create RDF triples<br>for index, row in df.iterrows():<br>    article_uri = create_article_uri(row['Title'])<br>    if article_uri is None:<br>        continue<br>    <br>    # Add Article instance<br>    g.add((article_uri, RDF.type, Article))<br>    g.add((article_uri, title, Literal(row['Title'], datatype=XSD.string)))<br>    g.add((article_uri, abstract, Literal(row['abstractText'], datatype=XSD.string)))<br>    <br>    # Add random datePublished and access<br>    random_date = generate_random_date()<br>    random_access = generate_random_access()<br>    g.add((article_uri, date_published, Literal(random_date.date(), datatype=XSD.date)))<br>    g.add((article_uri, access, Literal(random_access, datatype=XSD.integer)))<br>    <br>    # Add MeSH Terms<br>    mesh_terms = parse_mesh_terms(row['meshMajor'])<br>    for term in mesh_terms:<br>        term_uri = convert_to_uri(term, base_namespace="http://example.org/mesh/")<br>        if term_uri is None:<br>            continue<br>        <br>        # Add MeSH Term instance<br>        g.add((term_uri, RDF.type, MeSHTerm))<br>        g.add((term_uri, RDFS.label, Literal(term.replace('_', ' '), datatype=XSD.string)))<br>        <br>        # Link Article to MeSH Term<br>        g.add((article_uri, schema.about, term_uri))<br><br># Path to save the file<br>file_path = "/Workspace/PubMedGraph.ttl"<br><br># Save the file<br>g.serialize(destination=file_path, format='turtle')<br><br>print(f"File saved at {file_path}")

好的,现在我们有了数据的向量化版本和数据的图(RDF)版本。每个向量都有一个与之关联的 URI,这对应于 KG 中的一个实体,因此我们可以在数据格式之间来回转换。

构建应用

我决定使用Streamlit来构建这个图 RAG 应用的界面。与上一篇博客文章类似,我保持了相同的用户流程。

  1. 搜索文章:首先,用户使用搜索词搜索文章。这完全依赖于向量数据库。用户的搜索词(s)被发送到向量数据库,并返回与向量空间中该术语最近的十篇文章。

  2. 细化术语:其次,用户决定使用哪些 MeSH 术语来过滤返回的结果。由于我们也对 MeSH 术语进行了向量化,因此用户可以输入一个自然语言提示来获取最相关的 MeSH 术语。然后,我们允许用户扩展这些术语以查看它们的替代名称和更窄的概念。用户可以根据他们的过滤标准选择他们想要的任何术语。

  3. 过滤与总结:第三,用户将选定的术语作为过滤器应用于原始的十篇期刊文章。我们可以这样做,因为 PubMed 文章被标记了 MeSH 术语。最后,我们让用户输入一个额外的提示,并将其与过滤后的期刊文章一起发送给 LLM。这是 RAG 应用的生成步骤。

让我们一步一步地通过这些步骤。你可以在我的 GitHub 上看到完整的应用和代码,但以下是结构:

-- app.py (a python file that drives the app and calls other functions as needed)<br>-- query_functions (a folder containing python files with queries)<br>  -- rdf_queries.py (python file with RDF queries)<br>  -- weaviate_queries.py (python file containing weaviate queries)<br>-- PubMedGraph.ttl (the pubmed data in RDF format, stored as a ttl file)

搜索文章

首先,想要实现的是 Weaviate 的向量相似度搜索。由于我们的文章已经向量化,我们可以将搜索词发送到向量数据库,并返回相似的文章。

图片由作者提供

app.py中,主要的功能是在向量数据库中搜索相关的期刊文章:

# --- TAB 1: Search Articles ---<br>with tab_search:<br>    st.header("Search Articles (Vector Query)")<br>    query_text = st.text_input("Enter your vector search term (e.g., Mouth Neoplasms):", key="vector_search")<br><br>    if st.button("Search Articles", key="search_articles_btn"):<br>        try:<br>            client = initialize_weaviate_client()<br>            article_results = query_weaviate_articles(client, query_text)<br><br>            # Extract URIs here<br>            article_uris = [<br>                result["properties"].get("article_URI")<br>                for result in article_results<br>                if result["properties"].get("article_URI")<br>            ]<br><br>            # Store article_uris in the session state<br>            st.session_state.article_uris = article_uris<br><br>            st.session_state.article_results = [<br>                {<br>                    "Title": result["properties"].get("title", "N/A"),<br>                    "Abstract": (result["properties"].get("abstractText", "N/A")[:100] + "..."),<br>                    "Distance": result["distance"],<br>                    "MeSH Terms": ", ".join(<br>                        ast.literal_eval(result["properties"].get("meshMajor", "[]"))<br>                        if result["properties"].get("meshMajor") else []<br>                    ),<br><br>                }<br>                for result in article_results<br>            ]<br>            client.close()<br>        except Exception as e:<br>            st.error(f"Error during article search: {e}")<br><br>    if st.session_state.article_results:<br>        st.write("**Search Results for Articles:**")<br>        st.table(st.session_state.article_results)<br>    else:<br>        st.write("No articles found yet.")

此函数使用存储在 weaviate_queries 中的查询来建立 Weaviate 客户端(initialize_weaviate_client)并搜索文章(query_weaviate_articles)。然后我们在表格中显示返回的文章,包括它们的摘要、距离(它们与搜索词的接近程度)以及它们标记的 MeSH 术语。

查询 Weaviate 的函数在 weaviate_queries.py 中如下所示:

# Function to query Weaviate for Articles<br>def query_weaviate_articles(client, query_text, limit=10):<br>    # Perform vector search on Article collection<br>    response = client.collections.get("Article").query.near_text(<br>        query=query_text,<br>        limit=limit,<br>        return_metadata=MetadataQuery(distance=True)<br>    )<br><br>    # Parse response<br>    results = []<br>    for obj in response.objects:<br>        results.append({<br>            "uuid": obj.uuid,<br>            "properties": obj.properties,<br>            "distance": obj.metadata.distance,<br>        })<br>    return results

如您所见,我这里只设置了十个结果以简化显示,但您可以更改这个设置。这只是在 Weaviate 中使用向量相似度搜索来返回相关结果。

应用程序中的最终结果如下:

图片

图片由作者提供

作为演示,我将搜索“口腔癌治疗方法”这个术语。如您所见,返回了 10 篇文章,大部分相关。这展示了基于向量的检索的优势和劣势。

其优势在于我们可以以最小的努力在我们的数据上构建语义搜索功能。如您所见,我们所做的只是设置客户端并将数据发送到向量数据库。一旦我们的数据被向量化,我们就可以进行语义搜索、相似度搜索,甚至 RAG。我在这篇帖子附带的笔记本中放了一些示例,但 Weaviate 的官方文档中还有更多内容。

基于向量的检索的劣势,如我上面提到的,是它们是黑盒且难以处理事实性知识。在我们的例子中,看起来大部分文章都是关于某种癌症的治疗或疗法。一些文章是关于特定类型的口腔癌,如牙龈癌(牙龈癌)和腭癌(腭癌)。但也有关于鼻咽癌(上 throat 癌)、下颌癌(颌癌)和食管癌(食管癌)的文章。这些(上 throat、颌或食管)都不被认为是口腔癌。一个关于鼻咽部肿瘤的特定癌症放射治疗的文章被认为是与提示“口腔癌治疗方法”相似,但如果您只寻找口腔癌的治疗方法,则可能不相关。如果我们直接将这些十篇文章插入到我们对 LLM 的提示中并要求它“总结不同的治疗方法”,我们会得到错误的信息。

RAG(检索增强生成)的目的是给 LLM(大型语言模型)提供一组非常具体的附加信息,以更好地回答您的问题——如果这些信息是不正确或不相关的,它可能导致 LLM 给出误导性的回答。这种情况通常被称为“上下文中毒”。上下文中毒特别危险的是,响应并不一定是事实上的不准确(LLM 可能准确地总结了我们提供给它治疗选项),也不一定是基于不准确的数据(假设期刊文章本身是准确的),它只是使用错误的数据来回答您的问题。在这个例子中,用户可能会阅读如何治疗错误类型的癌症,这看起来非常糟糕。

精炼术语

知识图谱(KGs)可以帮助提高响应的准确性,并减少结果从向量数据库中精炼后的上下文中毒的可能性。下一步是选择我们想要用于过滤文章的 MeSH 术语。首先,我们在术语集合上对向量数据库进行另一个向量相似度搜索。这是因为用户可能不熟悉 MeSH 受控词汇。在我们上面的例子中,我搜索了“口腔癌的治疗”,但“口腔癌”不是 MeSH 中的一个术语——他们使用“Mouth Neoplasms”。我们希望用户能够在没有事先了解它们的情况下开始探索 MeSH 术语——这无论使用什么元数据来标记内容都是一种良好的实践。

图片

图片由作者提供

获取相关 MeSH 术语的函数几乎与之前的 Weaviate 查询相同。只需将“文章”替换为“术语”:

# Function to query Weaviate for MeSH Terms<br>def query_weaviate_terms(client, query_text, limit=10):<br>    # Perform vector search on MeshTerm collection<br>    response = client.collections.get("term").query.near_text(<br>        query=query_text,<br>        limit=limit,<br>        return_metadata=MetadataQuery(distance=True)<br>    )<br><br>    # Parse response<br>    results = []<br>    for obj in response.objects:<br>        results.append({<br>            "uuid": obj.uuid,<br>            "properties": obj.properties,<br>            "distance": obj.metadata.distance,<br>        })<br>    return results

这是在应用中的样子:

图片

图片由作者提供

如您所见,我搜索了“口腔癌”,并返回了最相似术语。由于“口腔癌”不是 MeSH 中的一个术语,因此没有返回,但“Mouth Neoplasms”在列表中。

下一步是允许用户扩展返回的术语,以查看替代名称和更窄的概念。这需要查询MeSH API。这是这个应用中最棘手的部分,原因有很多。最大的问题是 Streamlit 要求所有内容都有一个唯一的 ID,但 MeSH 术语可以重复——如果返回的概念是另一个概念的子概念,那么当你展开父概念时,你将会有子概念的重复。我认为我已经解决了大部分大问题,应用应该可以正常工作,但在这个阶段可能还有待发现的错误。

我们所依赖的函数位于 rdf_queries.py 中。我们需要一个函数来获取术语的替代名称:

# Fetch alternative names and triples for a MeSH term<br>def get_concept_triples_for_term(term):<br>    term = sanitize_term(term)  # Sanitize input term<br>    sparql = SPARQLWrapper("https://id.nlm.nih.gov/mesh/sparql")<br>    query = f"""<br>    PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#><br>    PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#><br>    PREFIX meshv: <http://id.nlm.nih.gov/mesh/vocab#><br>    PREFIX mesh: <http://id.nlm.nih.gov/mesh/><br><br>    SELECT ?subject ?p ?pLabel ?o ?oLabel<br>    FROM <http://id.nlm.nih.gov/mesh><br>    WHERE {{<br>        ?subject rdfs:label "{term}"@en .<br>        ?subject ?p ?o .<br>        FILTER(CONTAINS(STR(?p), "concept"))<br>        OPTIONAL {{ ?p rdfs:label ?pLabel . }}<br>        OPTIONAL {{ ?o rdfs:label ?oLabel . }}<br>    }}<br>    """<br>    try:<br>        sparql.setQuery(query)<br>        sparql.setReturnFormat(JSON)<br>        results = sparql.query().convert()<br><br>        triples = set()<br>        for result in results["results"]["bindings"]:<br>            obj_label = result.get("oLabel", {}).get("value", "No label")<br>            triples.add(sanitize_term(obj_label))  # Sanitize term before adding<br><br>        # Add the sanitized term itself to ensure it's included<br>        triples.add(sanitize_term(term))<br>        return list(triples)<br><br>    except Exception as e:<br>        print(f"Error fetching concept triples for term '{term}': {e}")<br>        return []

我们还需要函数来获取给定术语的更窄(子)概念。我有两个函数可以实现这一点——一个获取术语的直接子概念,另一个递归函数返回给定深度的所有子概念。

# Fetch narrower concepts for a MeSH term<br>def get_narrower_concepts_for_term(term):<br>    term = sanitize_term(term)  # Sanitize input term<br>    sparql = SPARQLWrapper("https://id.nlm.nih.gov/mesh/sparql")<br>    query = f"""<br>    PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#><br>    PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#><br>    PREFIX meshv: <http://id.nlm.nih.gov/mesh/vocab#><br>    PREFIX mesh: <http://id.nlm.nih.gov/mesh/><br><br>    SELECT ?narrowerConcept ?narrowerConceptLabel<br>    WHERE {{<br>        ?broaderConcept rdfs:label "{term}"@en .<br>        ?narrowerConcept meshv:broaderDescriptor ?broaderConcept .<br>        ?narrowerConcept rdfs:label ?narrowerConceptLabel .<br>    }}<br>    """<br>    try:<br>        sparql.setQuery(query)<br>        sparql.setReturnFormat(JSON)<br>        results = sparql.query().convert()<br><br>        concepts = set()<br>        for result in results["results"]["bindings"]:<br>            subject_label = result.get("narrowerConceptLabel", {}).get("value", "No label")<br>            concepts.add(sanitize_term(subject_label))  # Sanitize term before adding<br><br>        return list(concepts)<br><br>    except Exception as e:<br>        print(f"Error fetching narrower concepts for term '{term}': {e}")<br>        return []<br><br># Recursive function to fetch narrower concepts to a given depth<br>def get_all_narrower_concepts(term, depth=2, current_depth=1):<br>    term = sanitize_term(term)  # Sanitize input term<br>    all_concepts = {}<br>    try:<br>        narrower_concepts = get_narrower_concepts_for_term(term)<br>        all_concepts[sanitize_term(term)] = narrower_concepts<br><br>        if current_depth < depth:<br>            for concept in narrower_concepts:<br>                child_concepts = get_all_narrower_concepts(concept, depth, current_depth + 1)<br>                all_concepts.update(child_concepts)<br><br>    except Exception as e:<br>        print(f"Error fetching all narrower concepts for term '{term}': {e}")<br><br>    return all_concepts

第 2 步的另一个重要部分是允许用户选择要添加到“所选术语”列表中的术语。这些术语将出现在屏幕左侧的侧边栏中。有很多事情可以改进这一步骤,例如:

  • 除了清除缓存或刷新浏览器外,没有其他清除所有内容的方法。

  • 没有办法选择所有更窄的概念,这将是有帮助的。

  • 没有添加过滤规则的选项。目前,我们只是假设文章必须包含术语 A OR 术语 B OR 术语 C 等。最后的排名是基于文章标记的术语数量。

在 app 中看起来是这样的:

图片

图片由作者提供

我可以展开“口腔癌”以查看所有替代名称,在本例中为“口腔癌”,以及所有更窄的概念。正如您所看到的,大多数更窄的概念都有自己的子概念,您也可以展开它们。为了演示目的,我将选择“口腔癌”的所有子概念。

图片

图片由作者提供

这一步骤很重要,不仅因为它允许用户过滤搜索结果,而且因为它也是用户探索 MeSH 图本身并从中学习的一种方式。例如,这里将是用户学习鼻咽癌不是口腔癌子集的地方。

过滤 & 总结

现在您已经获得了您的文章和过滤术语,您可以应用过滤并总结结果。这就是我们将第一步返回的原始 10 篇文章与经过精炼的 MeSH 术语列表结合在一起的地方。我们允许用户在发送到 LLM 之前向提示添加额外的上下文。

图片

图片由作者提供

我们进行这种过滤的方式是,我们需要从原始搜索中获取 10 篇文章的 URI。然后我们可以查询我们的知识图,看看哪些文章被标记了相关的 MeSH 术语。此外,我们保存这些文章的摘要以供下一步使用。这将是我们可以根据访问控制或其他用户控制的参数(如作者、文件类型、发布日期等)进行过滤的地方。我没有在这个应用程序中包含任何这些,但我添加了访问控制和发布日期的属性,以防我们想在 UI 中稍后添加这些。

这里是 app.py 中的代码样子:

 if st.button("Filter Articles"):<br>            try:<br>                # Check if we have URIs from tab 1<br>                if "article_uris" in st.session_state and st.session_state.article_uris:<br>                    article_uris = st.session_state.article_uris<br><br>                    # Convert list of URIs into a string for the VALUES clause or FILTER<br>                    article_uris_string = ", ".join([f"<{str(uri)}>" for uri in article_uris])<br><br>                    SPARQL_QUERY = """<br>                    PREFIX schema: <http://schema.org/><br>                    PREFIX ex: <http://example.org/><br><br>                    SELECT ?article ?title ?abstract ?datePublished ?access ?meshTerm<br>                    WHERE {{<br>                      ?article a ex:Article ;<br>                               schema:name ?title ;<br>                               schema:description ?abstract ;<br>                               schema:datePublished ?datePublished ;<br>                               ex:access ?access ;<br>                               schema:about ?meshTerm .<br><br>                      ?meshTerm a ex:MeSHTerm .<br><br>                      FILTER (?article IN ({article_uris}))<br>                    }}<br>                    """<br>                    # Insert the article URIs into the query<br>                    query = SPARQL_QUERY.format(article_uris=article_uris_string)<br>                else:<br>                    st.write("No articles selected from Tab 1.")<br>                    st.stop()<br><br>                # Query the RDF and save results in session state<br>                top_articles = query_rdf(LOCAL_FILE_PATH, query, final_terms)<br>                st.session_state.filtered_articles = top_articles<br><br>                if top_articles:<br><br>                    # Combine abstracts from top articles and save in session state<br>                    def combine_abstracts(ranked_articles):<br>                        combined_text = " ".join(<br>                            [f"Title: {data['title']} Abstract: {data['abstract']}" for article_uri, data in<br>                             ranked_articles]<br>                        )<br>                        return combined_text<br><br><br>                    st.session_state.combined_text = combine_abstracts(top_articles)<br><br>                else:<br>                    st.write("No articles found for the selected terms.")<br>            except Exception as e:<br>                st.error(f"Error filtering articles: {e}")

这在 rdf_queries.py 文件中的 query_rdf 函数中使用。该函数看起来像这样:

# Function to query RDF using SPARQL<br>def query_rdf(local_file_path, query, mesh_terms, base_namespace="http://example.org/mesh/"):<br>    if not mesh_terms:<br>        raise ValueError("The list of MeSH terms is empty or invalid.")<br><br>    print("SPARQL Query:", query)<br><br>    # Create and parse the RDF graph<br>    g = Graph()<br>    g.parse(local_file_path, format="ttl")<br><br>    article_data = {}<br><br>    for term in mesh_terms:<br>        # Convert the term to a valid URI<br>        mesh_term_uri = convert_to_uri(term, base_namespace)<br>        #print("Term:", term, "URI:", mesh_term_uri)<br><br>        # Perform SPARQL query with initBindings<br>        results = g.query(query, initBindings={'meshTerm': mesh_term_uri})<br><br>        for row in results:<br>            article_uri = row['article']<br>            if article_uri not in article_data:<br>                article_data[article_uri] = {<br>                    'title': row['title'],<br>                    'abstract': row['abstract'],<br>                    'datePublished': row['datePublished'],<br>                    'access': row['access'],<br>                    'meshTerms': set()<br>                }<br>            article_data[article_uri]['meshTerms'].add(str(row['meshTerm']))<br>        #print("DEBUG article_data:", article_data)<br><br>    # Rank articles by the number of matching MeSH terms<br>    ranked_articles = sorted(<br>        article_data.items(),<br>        key=lambda item: len(item[1]['meshTerms']),<br>        reverse=True<br>    )<br>    return ranked_articles[:10]

正如您所看到的,此功能还将 MeSH 术语转换为 URI,这样我们就可以使用图进行过滤。在将术语转换为 URI 的方式上要小心,并确保它与其他功能保持一致。

在 app 中看起来是这样的:

图片

图片由作者提供

如您所见,我们从上一步选出的两个 MeSH 术语在这里。如果我点击“过滤文章”,它将使用我们在第二步中设置的过滤标准过滤原始的 10 篇文章。文章将返回其完整的摘要,以及其标记的 MeSH 术语(见下图)。

图片

图片由作者提供

返回了 5 篇文章。其中两篇被标记为“口腔肿瘤”,一篇为“牙龈肿瘤”,还有两篇为“硬腭肿瘤”。

现在我们已经整理出了一份我们想要用来生成响应的文章列表,我们可以进入最后一步。我们希望将这些文章发送给一个 LLM 来生成响应,但我们也可以在提示中添加额外的上下文。我有一个默认提示,内容是:“在这里用项目符号总结关键信息。使其对没有医学学位的人也能理解。”对于这次演示,我将调整提示以反映我们的原始搜索词:

图片

结果如下:

图片

结果看起来比我预期的要好,主要是因为我知道我们正在总结的文章很可能是关于口腔癌的治疗。数据集不包含实际的期刊文章,只有摘要。因此,这些结果只是摘要的摘要。这可能有一些价值,但如果我们要构建一个真正的应用程序而不是仅仅是一个演示,那么这就是我们可以整合文章全文的步骤。或者,这也是用户/研究人员自己阅读这些文章而不是完全依赖 LLM 进行总结的地方。

结论

本教程演示了如何结合向量数据库和知识图谱可以显著增强 RAG 应用。通过利用向量相似性进行初始搜索和结构化知识图谱元数据进行过滤和组织,我们可以构建一个提供准确、可解释和特定领域结果的系统。将 MeSH(一个成熟的受控词汇)集成其中,突出了领域专业知识在编制元数据方面的力量,这确保了检索步骤与应用程序的独特需求保持一致,同时保持与其他系统的互操作性。这种方法不仅限于医学——其原则可以应用于任何存在结构化数据和文本信息的领域。

本教程强调了利用每种技术发挥其最佳作用的重要性。向量数据库擅长基于相似性的检索,而知识图谱在提供上下文、结构和语义方面表现出色。此外,扩展 RAG 应用需要元数据层来打破数据孤岛并实施治理政策。基于特定领域元数据和稳健治理的精心设计是构建既准确又可扩展的 RAG 系统的途径。

posted @ 2026-03-28 09:24  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报