如何通过 gRAG 和 LLM 查询知识图谱

131 阅读27分钟

Preview image

如何通过 gRAG 和 LLM 查询知识图谱

谷歌, 微软, LinkedIn 和更多科技公司都在使用 Graph RAG. 他们为什么这么做呢? 让我们通过从头开始构建一个 gRAG来了解一下吧.

你可能没有意识到, 你与知识图谱(KG)的交互比你想象的要频繁得多. 它们是许多现代搜索引擎, 大型语言模型(LLM)的检索增强生成(RAG)系统以及各种查询工具背后的技术. 但是, 知识图谱到底是什么, 为什么与这些技术如此密不可分? 今天就让我们深入探讨一下吧.

知识图谱简介

知识图谱(KG)是一种结构化的信息表示法, 它捕捉现实世界中的实体及其之间的关系. 想象一下, 在一个网络中, 每个点代表一个实体, 如产品, 个人或概念, 而连接它们的线代表它们之间的关系. 这种相互连接的网络可以实现对数据的丰富语义理解, 重点不仅仅是单个信息, 而是这些信息之间的关系.

节点

知识图谱的核心是节点(实体). 为了说明这一点, 让我们考虑使用亚马逊公开的玩具产品数据集构建知识图谱, 这将是我们稍后在实际应用中使用的数据集. 在这样一个数据集中, 我们会发现什么呢?

当然是产品. 在我们的知识图谱中, 数据集中的每个产品都是一个节点. 这个产品节点包括有关该产品的所有信息, 如描述, 价格, 库存数量和评分.

None

Neo4j 中的产品节点

但产品并不是我们能表示的唯一实体. 知识图谱非常灵活, 允许我们根据需要创建多种类型的节点. 例如, 由于每个产品都有一个制造商, 我们也可以为每个制造商创建节点. 制造商节点可能包括公司名称, 地点和联系信息等属性.

边(Edge)

然而, 节点之间大多可以相互连接. 例如, 产品节点可以连接到制造商节点, 因为制造商生产产品, 而产品由制造商生产. 这种关系被称为知识图谱中的边.

边是定义两个实体之间关系的链接, 在知识图谱中, 这些关系被明确地建模和存储. 与传统的关系数据库相比, 这是一个重大的转变, 在传统的关系数据库中, 这种关系通常是在查询时使用 JOIN 操作推断出来的.

考虑一下产品与其制造商之间的关系. 在关系数据库中, 我们会在查询时通过连接表将产品与其制造商联系起来. 而在知识图谱中, 我们可以通过在产品节点和制造商节点之间创建一条边来直接指定这种关系.

None

2 个产品节点通过边连接到 1 个制造商节点

以玩具数据集为例, 我们知道每个产品都与制造商相关联. 因此, 我们可以创建一条manufactured_by边, 将产品节点与其对应的制造商节点连接起来, 说明谁生产了该产品. 例如, 产品"DJI Phantom 2 with H3-3D Gimbal"将通过这条边与制造商"DJI"相连.

边本身也可以携带属性, 为它们所代表的关系提供额外的上下文. 针对产品与制造商之间的manufacturing_by关系, 我们可以加入since这样的属性, 表示制造关系建立的日期.

知识图谱背后的数学

好了, 你现在应该掌握了知识图谱的基本知识, 但让我们进一步看看数学是如何计算的. 此外, 在我们的示例中, 我们将使用自然语言查询知识图谱, 为此, 我们还需要引入两个组件: 嵌入(embeddings)余弦相似度(cosine similarity).

知识图谱G是一个有向, 有标注的图谱, 可以正式表示为

None

知识图谱

其中

  • V顶点节点的集合.
  • E⊆V×V的集合, 表示节点之间的关系.
  • jV:V→A_V是一个将节点映射到其属性的函数.
  • jE:E→A_E是将边映射到其属性的函数.

每个节点 v∈V 代表一个实体或概念, 可以用一组属性来表征. 在数学上, 节点可定义为:

None

节点公式

其中

  • id_v 是节点的唯一标识符.
  • A_v = { (k,a_k) ∣ k∈ K_v } 是一组属性值对, 其中 K_v 是节点 v 的属性键集.

例如, 一个产品可能是这样的:

None

产品节点

接下来, 边 e∈E 代表两个节点之间的关系, 定义如下:

None

边的公式

其中

  • v_i, v_j ∈ V 是源节点和目标节点.
  • r_ij是关系类型(如MANUFACTURED_BY, BELONGS_TO).
  • A_e = { (k, a_k) ∣ k∈ K_e } 是与边相关的属性值对的集合.

例如, 一条边表示产品由制造商生产:

边示例

其中 A_e 可能包括:

None

边的属性

嵌入和矢量表示

目前, 我们的知识图谱既有结构化(数字)数据, 也有非结构化(文本)数据. 然而, 数学与文本的结合并不十分理想. 为了实现语义搜索和自然语言查询, 我们需要将文本数据转换为数字向量表示. 我们要将 KG 中的文本数据转换为嵌入式数据, 自然语言查询也是如此.

嵌入是将离散对象(如单词, 短语或文档)映射到连续向量空间的过程. 在这个空间中, 语义相似的项目彼此靠近. 这是通过在大型文本数据集上训练机器学习模型来学习单词或短语出现的模式和语境来实现的.

嵌入函数 ϕ 将文本描述映射到高维向量空间 R^n 中的点:

None

嵌入函数

其中

  • T是所有可能的文本数据(如产品描述, 查询)的集合.
  • n是嵌入空间的维度, 根据模型的不同, 维度从数百到数千不等.

嵌入是我们架构中的重要元素, 因为它们允许我们对文本数据进行数学运算. 通过将文本表示为向量, 我们可以计算不同文本之间的距离和相似性. 这种功能对于语义搜索等任务来说至关重要, 因为我们希望找到与用户查询语义相似的产品, 即便使用的确切词语不同. 此外, 你会发现不同的嵌入模型会带来截然不同的结果. 在你的 AI 应用中, 可以考虑探索几种模型, 看看哪一种能带来最好的结果. 该领域的一些大公司包括 OpenAI, Gemini 和 Voyage.

余弦相似度

既然我们已经嵌入了查询和节点, 就需要找到一种方法来计算查询和节点之间的相似性. 最常用的方法之一是余弦相似度.

余弦相似度测量的是内积空间中两个非零向量之间夹角的余弦值. 它提供了一种与向量大小无关的标准化相似度量.

给定两个向量A,B∈R^n, 余弦相似度cosθ的定义如下:

None

余弦相似度

余弦相似度的取值范围为-1 到 1, 其中 1 表示向量方向相同(相似度最大), 0 表示正交(不相似), -1 表示向量截然相反(意义相反). 对于由文本数据生成的嵌入, 余弦相似度值通常在 0 到 1 之间, 因为嵌入的分量通常是非负的.

要计算查询嵌入和乘积嵌入之间的余弦相似度, 我们需要遵循以下步骤:

计算点积:

None

计算点积

计算规范:

None

计算规范

计算余弦相似度:

None

计算余弦相似度

假设我们有两个嵌入模型:

None

嵌入向量 A 和 B

计算点积:

None

计算 A 和 B 的点积

计算规范:

计算 A 和 B 的规范

计算余弦相似度:

None

计算余弦相似度

边遍历的数学建模

在知识图谱的高级应用中, 我们通常不仅要考虑实体间的语义相似性(通过嵌入捕捉), 还要考虑图谱本身的结构. 这就需要模拟我们如何遍历图中的边 -- 绕过相似度最高的节点 -- 以了解网络中不同节点的关系和重要性.

共轭矩阵表示法 为了用数学方法表示知识图谱的结构, 我们使用了共轭矩阵. 假设我们的图Gn个节点. 邻接矩阵 A 是一个 n×n 矩阵, 其中每个元素 A_ij 表示节点 v_i 与节点 v_j 之间是否存在直接连接(边):

None

邻接矩阵

考虑一个有三个产品的简单图:

  • 产品 A(v1)
  • 产品 B(v2)
  • 产品 C(v3)

假设

  • 产品 A 与产品 B 相关.
  • 产品 B 与产品 C 相关.

邻接矩阵 A 将是:

None

邻接矩阵 A

该矩阵可帮助我们计算路径, 并确定节点在图中的连接方式.

随机漫步和转换概率 接下来, 为了分析我们从一个节点移动到另一个节点的可能性, 我们使用了随机漫步的概念. 在随机漫步中, 我们从一个节点出发, 随机选择一条出边, 沿着这条出边到达下一个节点.

我们定义过渡概率矩阵 P来表示从一个节点移动到另一个节点的概率:

None

过渡概率矩阵公式

其中

  • A_ij 是节点 v_iv_j 之间的邻接关系.
  • ∑A_ik 是节点 v_i 的出边总数.

该公式对邻接矩阵进行了归一化处理, 使每一行的总和为 1, 从而将其转化为概率分布.

使用之前的邻接矩阵 A:

None

邻接矩阵 A

计算 P:

  • 对于节点 v1: 出边之和 =A12 + A13 = 1 + 0 =1

None

节点 v1 计算

  • 对于节点 v2: 出边之和 =A23 =1

None

节点 v2 计算

  • 对于节点 v3: 没有出边, 因此概率为零.

过渡概率矩阵 P:

None

过渡概率矩阵

个性化 PageRank 算法 最后, 个性化 PageRank 算法 允许我们计算图中每个节点的相关性得分, 同时考虑图的结构和个性化(偏好)向量.

PageRank 向量 π 使用迭代公式计算:

None

个性化 PageRank 公式

其中

  • α跳转概率, 通常设置为 0.15. 它表示跳回个性化起点而不是跟随出边的的概率.
  • π0个性化向量, 表示我们对节点的起始偏好或重要性.
  • P^T过渡概率矩阵P**的变换.
  • π 是 PageRank 向量, 包含节点的相关性得分.

传送确保随机漫步者有机会根据我们的偏好跳转到任何节点, 防止它们卡在沉没节点(没有出边的节点).

将嵌入与图结构相结合 为了有效地推荐产品或检索信息, 我们需要将语义相似性(来自嵌入)--衡量产品描述与用户查询的意义匹配程度--与图相关性(来自PageRank)相结合--反映产品在图结构中的重要性, 同时考虑关系和连接性. 我们为每个产品p定义了一个综合得分S(p):

None

综合得分公式

其中

  • λ 是介于 0 和 1 之间的权重参数, 用于平衡语义相似性和图相关性的重要性. 如果 λ=1, 则排名完全依赖语义相似性. 如果 λ=0, 则完全取决于图相关性. 如果 λ=0.5 这样的值, 则两者的权重相同.
  • cosθ 是查询嵌入和产品嵌入之间的余弦相似度.
  • π是产品p的** PageRank 得分**.

假设我们有一个已经计算出嵌入的用户查询. 产品嵌入及其与查询的余弦相似度为

  • 产品 A: cosθ =0.8
  • 产品 B: cosθ=0.6
  • PageRank 得分 πA=0.3
  • PageRank 得分 πB=0.7

λ=0.5:

None

产品 A 和 B 的综合得分

尽管产品 A 的语义相似度更高, 但由于产品 B 的图片相关性更高, 因此其综合得分也更高. 因此, 产品 B 在推荐中的排名会更靠前.

知识图谱的优势

  1. 语义理解 知识图谱最显著的优势之一是能够捕捉不同实体之间的复杂关系和上下文. 这种能力使 LLM 能够以更符合人类思维的方式对数据进行推理. 知识图谱不是将数据点视为孤立的信息块, 而是通过显式关系将实体相互连接起来, 提供对数据的丰富语义理解.

例如, 想象一下空调知识图谱. 单个产品节点不仅仅是一个独立的项目, 它还与制造商, 特性, 类别子类别相连. 这种相互关联性使系统能够理解, 一台特定的空调与其他实体(如品牌, 功能(如"节能"或"遥控”))以及所属类别(如"便携式空调”)相关联. 语义的这种深度丰富性使我们能够对用户查询做出更准确, 更能感知上下文的响应, 从而显著提升用户体验.

  1. 灵活性 知识图谱的另一个优势是其固有的灵活性. 知识图谱可以轻松添加新的节点或关系, 而无需对现有模式进行重大修改. 在数据不断变化的动态环境中, 这一特点尤为有利.

例如, 假设我们决定在知识图谱中加入客户评论. 我们只需添加新的Review节点, 并建立REVIEWED_BY等关系, 将产品节点与客户节点连接起来. 无需重新设计整个数据模型或迁移现有数据. 这种适应性使知识图谱非常适合需求和数据结构不断变化的应用.

  1. 高效查询 知识图谱针对实体间关系的查询进行了优化, 从而提高了数据检索的效率, 尤其是对于涉及多个相互关联实体的复杂查询. 在处理复杂的查询时, 这种效率会变得非常明显, 而在传统数据库中, 这种查询是非常麻烦的.

假设我们想查找"CoolTech Industries"生产的所有具有"节能"功能的空调. 在传统的关系数据库中, 执行这一查询需要对多个表进行复杂的 JOIN 操作, 既耗时又耗资源.

相比之下, 知识图谱则大大简化了这一过程:

  1. 从制造商节点开始: 首先找到制造商节点, 其中name ="CoolTech Industries”.
  2. 遍历关系: 沿着MANUFACTURED_BY边移动, 找到所有相连的产品节点.
  3. 按特征筛选: 对于这些产品, 遍历HAS_FEATURE边, 找出与name ="Energy Efficient“Feature节点相连的产品.

这种直接遍历关系的方式无需进行昂贵的 JOIN 操作, 从而提高了查询速度和效率. 无缝浏览相互连接数据的能力不仅提高了性能, 还增强了从复杂数据关系中获得洞察力的能力.

实际应用: 利用嵌入构建和查询知识图谱

在本节中, 我们将使用公开的亚马逊玩具产品数据集 (License) 创建一个知识图谱. 我们将添加嵌入, 以实现语义搜索, 并使用自然语言查询数据库. 在本节结束时, 你将了解如何构建知识图谱, 添加嵌入和执行语义搜索以查找符合自然语言查询的产品.

设置环境

在我们开始之前, 请确保你已经安装和配置了必要的工具:

克隆代码仓库 数据集和代码在 GitHub 知识库 rag-knowledge-graph 中提供. 将该版本库克隆到本地计算机上:

git clone https://github.com/cristianleoo/rag-knowledge-graph.git

如果你不喜欢使用终端, 或者没有安装 git, 请点击以下链接下载软件源:

GitHub - cristianleoo/rag-knowledge-graph

** 安装 Neo4j** 下载 并从官方网站安装 Neo4j. 按照操作系统的具体安装说明进行操作.

启动Neo4j服务器 安装完成后, 启动Neo4j服务器. 你可以通过Neo4j 桌面应用程序或从命令行运行服务器来实现:

neo4j start

写下你将用于 DataFrame 的密码, 因为在后面的步骤中我们会用到它.

安装所需的 Python 库 浏览到克隆的版本库目录并建立虚拟环境. 使用 pip 安装所需的库:

cd rag-knowledge-graph
python -m venv venv
source venv/bin/activate  # On Windows, use venv\Scripts\activate
pip install -r requirements.txt
第 1 步: 加载和预处理数据集

我们首先导入必要的库并加载数据集.

import pandas as pd
pd.set_option('display.max_columns', None)
from IPython.display import display
import matplotlib.pyplot as plt
import networkx as nx
from py2neo import Graph, Node, Relationship
import google.generativeai as genai
import time
from tqdm import tqdm
from ratelimit import limits, sleep_and_retry
import os

加载数据集

df = pd.read_csv('dataset/products.csv')
df.head()

在这里, 我们使用 pandas 读取包含亚马逊玩具产品的 CSV 文件, 并显示前几行:

uniq_id                                   product_name  ... sellers
0  eac7efa5dbd3d667f26eb3d3ab504464                      Hornby 2014 Catalogue  ... {"seller"=>[{"Seller_name_1"=>"Amazon.co.uk", ...
1  b17540ef7e86e461d37f3ae58b7b72ac  FunkyBuys® Large Christmas Holiday Express...  ... {"seller"=>{"Seller_name_1"=>"UHD WHOLESALE", ...
2  348f344247b0c1a935b1223072ef9d8a   CLASSIC TOY TRAIN SET TRACK CARRIAGES LIGHT...  ... {"seller"=>[{"Seller_name_1"=>"DEAL-BOX", "Sel...
3  e12b92dbb8eaee78b22965d2a9bbbd9f        HORNBY Coach R4410A BR Hawksworth Corridor  ... NaN
4  e33a9adeed5f36840ccc227db4682a36  Hornby 00 Gauge 0-4-0 Gildenlow Salt Co. Steam...  ... NaN

[5 rows x 18 columns]

数据概览 我们检查数据集, 以了解其结构并识别缺失值.

for col in df.columns:
    print(f"{col:<50} | {df[col].isna().sum() / len(df):>6.2%} missing | {df[col].nunique():>6} unique values | {df[col].dtype}")

这将打印出来:

uniq_id                                           |  0.00% missing | 10000 unique values | object
product_name                                      |  0.00% missing | 9964 unique values | object
manufacturer                                      |  0.07% missing | 2651 unique values | object
price                                             | 14.35% missing | 2625 unique values | object
number_available_in_stock                         | 25.00% missing |   89 unique values | object
number_of_reviews                                 |  0.18% missing |  194 unique values | object
number_of_answered_questions                      |  7.65% missing |   19 unique values | float64
average_review_rating                             |  0.18% missing |   19 unique values | object
amazon_category_and_sub_category                  |  6.90% missing |  255 unique values | object
customers_who_bought_this_item_also_bought        | 10.62% missing | 8755 unique values | object
description                                       |  6.51% missing | 8514 unique values | object
product_information                               |  0.58% missing | 9939 unique values | object
product_description                               |  6.51% missing | 8514 unique values | object
items_customers_buy_after_viewing_this_item       | 30.65% missing | 6749 unique values | object
customer_questions_and_answers                    | 90.86% missing |  910 unique values | object
customer_reviews                                  |  0.21% missing | 9901 unique values | object
sellers                                           | 30.82% missing | 6581 unique values | object

从这里, 我们可以看到缺失值的百分比, 唯一值的数量以及每列的数据类型. 这有助于我们确定哪些列是必要的, 以及如何处理缺失数据.

数据清理和预处理 我们通过提取有用信息和处理缺失值来清理数据.

# Extract currency symbol and price into separate columns
df['currency'] = df['price'].str.extract(r'([^0-9]+)')
df['price_value'] = df['price'].str.extract(r'(\d+.?\d*)').astype(float)
df['stock_type'] = df['number_available_in_stock'].str.extract(r'([^0-9]+)')
df['stock_availability'] = df['number_available_in_stock'].str.extract(r'(\d+.?\d*)')

# Clean up average review rating
df['average_review_rating'] = df['average_review_rating'].str.replace(' out of 5 stars', '').astype(float)
# Clean up number of reviews
df['number_of_reviews'] = df['number_of_reviews'].str.replace(',', '').fillna(0).astype(int)

特别是, 我们从pricenumber_available_in_stock列中的字符串中提取数值, 以便将这些列视为 int 和 float.

然后, 我们清理 average_review_ratingnumber_of_reviews 列, 确保它们是数值.

我们删除不必要的列, 并处理缺失数据.

# Drop irrelevant columns
df = df.drop(['price', 'number_available_in_stock', 'customers_who_bought_this_item_also_bought',
              'items_customers_buy_after_viewing_this_item', 'customer_questions_and_answers', 'sellers'], axis=1)

# Drop rows with essential missing data
df.dropna(subset=['product_information', 'price_value', 'description', 'amazon_category_and_sub_category'], how='any', inplace=True)

在此示例中, 我们放弃了一些功能, 以保持简单. 但是, 在生产中, 你可能希望保留尽可能多的相关特征, 因为它们可以为分析和模型提供有用的见解.

我们再次检查数据, 以确保其干净.

# Fill missing values with defaults
df['amazon_category_and_sub_category'] = df['amazon_category_and_sub_category'].fillna('')
df['manufacturer'] = df['manufacturer'].fillna('Unknown')
df['number_of_answered_questions'] = df['number_of_answered_questions'].fillna(0.0)
df['average_review_rating'] = df['average_review_rating'].fillna(0.0)
df['description'] = df['description'].fillna('')
df['product_description'] = df['product_description'].fillna('')
df['product_information'] = df['product_information'].fillna('')
df['customer_reviews'] = df['customer_reviews'].fillna('')
df['stock_availability'] = df['stock_availability'].astype(float).fillna(0.0)
df['stock_type'] = df['stock_type'].fillna('Out of stock')

接下来, 我们用默认字符串填充分类列中的缺失值, 用零值填充数值列中的缺失值, 这样就不会将空值带入我们的 KG 中.

# Function to combine product title and description
def complete_product_description(row):
    return f"Product Title: {row['product_name']}\nProduct Description: {row['product_description']}"

# Apply the function to create a new column
df['description_complete'] = df.apply(complete_product_description, axis=1)

# Display the first few rows
df.head()

最后, 我们定义了一个名为complete_product_description的函数, 该函数从 DataFrame 中获取一条记录, 并将product_nameproduct_description合并为一个字符串. 然后, 我们将此函数应用于数据帧 df 中的每一行, 创建名为 description_complete 的新列. 这一新列包含每个产品的完整描述, 我们稍后将用它来生成嵌入.

让我们调用 df.head() 并显示数据帧的前几行, 以验证新列是否已正确添加.

uniq_id                                   product_name  ...                                 description_complete
0  eac7efa5dbd3d667f26eb3d3ab504464                      Hornby 2014 Catalogue  ...  Product Title: Hornby 2014 Catalogue\nProduct Description: Hornby 2014 Catalogue Box Contains 1 x Hornby 2014 Catalogue
1  b17540ef7e86e461d37f3ae58b7b72ac  FunkyBuys® Large Christmas Holiday Express...  ...  Product Title: FunkyBuys® Large Christmas Holiday Express Festival Deluxe Railway Train Set\nProduct Description: Size Name:Large FunkyBuys® Large Christmas Holiday Express Festival Deluxe Railway Train Set Light Up with Realistic Sounds Xmas Tree Decoration For Kids Gift
2  348f344247b0c1a935b1223072ef9d8a   CLASSIC TOY TRAIN SET TRACK CARRIAGES LIGHT...  ...  Product Title: CLASSIC TOY TRAIN SET TRACK CARRIAGES LIGHT ENGINE SOUNDS KIDS XMAS GIFT\nProduct Description: BIG CLASSIC TOY TRAIN SET TRACK CARRIAGE LIGHT ENGINE SOUNDS KIDS XMAS GIFT This is a classic train set with a steam engine that features working headlights and realistic engine sounds. The tracks can be assembled in various configurations. Great gift for kids.
3  e12b92dbb8eaee78b22965d2a9bbbd9f        HORNBY Coach R4410A BR Hawksworth Corridor  ...  Product Title: HORNBY Coach R4410A BR Hawksworth Corridor 3rd\nProduct Description: Hornby 00 Gauge BR Hawksworth 3rd Class W 2107 Corridor Coach R4410A
4  e33a9adeed5f36840ccc227db4682a36  Hornby 00 Gauge 0-4-0 Gildenlow Salt Co. Steam...  ...  Product Title: Hornby 00 Gauge 0-4-0 Gildenlow Salt Co. Steam Locomotive R9671\nProduct Description: Hornby RailRoad 0-4-0 Gildenlow Salt Co. Steam Locomotive R9671

[5 rows x 13 columns]
第 2 步: 连接 Neo4j 并准备数据库

我们连接到 Neo4j 图形数据库, 并在其中存储知识图谱.

# Connect to Neo4j (adjust credentials as needed)
graph = Graph("bolt://localhost:7687", auth=("neo4j", "YOUR_PASSWORD")) # replace this with your password

# Clear existing data (optional)
graph.run("MATCH (n) DETACH DELETE n")

使用 py2neo 库, 建立与 Neo4j 数据库的连接. 将YOUR_PASSWORD替换为实际的 Neo4j 密码. 命令graph.run(”MATCH (n) DETACH DELETE n“)会删除数据库中所有现有的节点和关系, 为我们的新知识图谱提供一个干净的界面. 这一步是可选的, 但建议避免与现有数据冲突.

第三步: 创建知识图谱

我们为产品, 制造商和类别创建节点, 并建立它们之间的关系.

def create_knowledge_graph(df):
    # Create unique constraints
    try:
        # For Neo4j 5.x and later
        graph.run("CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.uniq_id IS UNIQUE")
        graph.run("CREATE CONSTRAINT manufacturer_name IF NOT EXISTS FOR (m:Manufacturer) REQUIRE m.name IS UNIQUE")
        graph.run("CREATE CONSTRAINT category_name IF NOT EXISTS FOR (c:Category) REQUIRE c.name IS UNIQUE")
    except Exception as e:
        # For Neo4j 4.x
        try:
            graph.run("CREATE CONSTRAINT ON (p:Product) ASSERT p.uniq_id IS UNIQUE")
            graph.run("CREATE CONSTRAINT ON (m:Manufacturer) ASSERT m.name IS UNIQUE")
            graph.run("CREATE CONSTRAINT ON (c:Category) ASSERT c.name IS UNIQUE")
        except Exception as e:
            print(f"Warning: Could not create constraints: {e}")

    for _, row in df.iterrows():
        # Create Product node
        product = Node(
            "Product",
            uniq_id=row['uniq_id'],
            name=row['product_name'],
            description=row['product_description'],
            price=float(row['price_value']),
            currency=row['currency'],
            review_rating=float(row['average_review_rating']),
            review_count=int(row['number_of_reviews']),
            stock_type=row['stock_type'] if pd.notna(row['stock_type']) else None,
            description_complete=row['description_complete']
        )

        # Create Manufacturer node
        manufacturer = Node("Manufacturer", name=row['manufacturer'])

        # Create Category nodes from hierarchy
        categories = row['amazon_category_and_sub_category'].split(' > ')
        previous_category = None
        for cat in categories:
            category = Node("Category", name=cat.strip())
            graph.merge(category, "Category", "name")
            if previous_category:
                # Create hierarchical relationship between categories
                rel = Relationship(previous_category, "HAS_SUBCATEGORY", category)
                graph.merge(rel)
            previous_category = category
        # Merge nodes and create relationships
        graph.merge(product, "Product", "uniq_id")
        graph.merge(manufacturer, "Manufacturer", "name")
        # Connect product to manufacturer
        graph.merge(Relationship(product, "MANUFACTURED_BY", manufacturer))
        # Connect product to lowest-level category
        graph.merge(Relationship(product, "BELONGS_TO", previous_category))
# Create the knowledge graph
create_knowledge_graph(df)

该函数create_knowledge_graph会遍历数据库df中的每一行. 对于每个产品:

  • 我们创建一个具有 uniq_id, name, description, price, currency, review_rating, review_count, stock_typedescription_complete 等属性的 Product 节点.
  • 我们根据manufacturer字段创建一个manufacturer节点.
  • 我们处理amazon_category_and_sub_category字段, 创建Category节点的层次结构. 我们用>分隔符分割类别, 并为每个类别级别创建节点.
  • 我们在类别之间建立HAS_SUBCATEGORY关系来表示层次结构.
  • 我们在产品与其制造商(MANUFACTURED_BY)之间以及产品与最特定类别(BELONGS_TO)之间创建关系.
  • 我们使用 graph.merge 来确保只有在节点和关系不存在的情况下才会创建它们, 从而防止图中出现重复.
第四步: 查询知识图谱并可视化结果

现在, 让我们运行一个示例查询, 从图中检索数据并将其可视化.

def run_query_with_viz(query, title, viz_query=None):
    print(f"\n=== {title} ===")
    # Run and display query results as a DataFrame
    results = graph.run(query).data()
    df = pd.DataFrame(results)
    display(df)

    # Create visualization
    plt.figure(figsize=(12, 8))
    G = nx.Graph()
    # Add nodes and edges
    for record in results:
        product_name = record['Product']
        manufacturer_name = record['Manufacturer']
        G.add_node(product_name, label=product_name[:30], type='Product')
        G.add_node(manufacturer_name, label=manufacturer_name, type='Manufacturer')
        G.add_edge(product_name, manufacturer_name)
    # Draw graph
    pos = nx.spring_layout(G)
    nx.draw_networkx_nodes(G, pos, nodelist=[n for n, attr in G.nodes(data=True) if attr['type'] == 'Product'],
                           node_color='lightblue', node_size=500, label='Products')
    nx.draw_networkx_nodes(G, pos, nodelist=[n for n, attr in G.nodes(data=True) if attr['type'] == 'Manufacturer'],
                           node_color='lightgreen', node_size=700, label='Manufacturers')
    nx.draw_networkx_edges(G, pos)
    nx.draw_networkx_labels(G, pos)
    plt.title(title)
    plt.legend()
    plt.axis('off')
    plt.show()

# Find most expensive products
query1 = """
MATCH (p:Product)-[:MANUFACTURED_BY]->(m:Manufacturer)
RETURN m.name as Manufacturer, p.name as Product, p.price as Price
ORDER BY p.price DESC
LIMIT 5
"""
run_query_with_viz(query1, "Most Expensive Products")

你可能会注意到, 我们正在创建另一个函数 run_query_with_viz . 我们这样做并不只是为了创建函数, 而是因为这将让我们既能针对数据库运行查询, 又能创建一个辅助函数来绘制结果. 不过, 你也可以在 Neo4j 中运行查询, 从而获得更好的可视化效果.

在该函数中, 我们运行 Cypher 查询, 并在 pandas DataFrame 中显示结果. 我们使用 NetworkX 和 Matplotlib 处理可视化方面的问题, 显示产品和制造商之间的关系.

然后, 我们调用传递查询的函数, 以检索最昂贵的前 5 种产品及其制造商和价格. 这将返回:

=== Most Expensive Products ===
Manufacturer Product Price
0 DJI DJI Phantom 2 with H3-3D Gimbal 995.11
1 Sideshow Indiana Jones - 12 Inch Action Figures: Indian... 719.95
2 AUTOart Autoart 70206 - Aston Martin V12 Vantage - 201... 648.95
3 Bushiroad Weiss Schwarz Extra Booster Clannad Vol.3 629.95
4 Dragon Panzer II - Kpfw - Ausf.C - DX'10 - 1:6th Scale 592.95
Number of visualization records: 10

None

最昂贵产品可视化

在本例中, 输出 DataFrame 显示了最昂贵的前 5 种产品的制造商, 产品和价格. 可视化是一个图, 其中产品节点与制造商节点相连, 不同的颜色代表不同类型的节点.

第 5 步: 生成和存储嵌入词

我们为产品描述生成嵌入信息, 以便进行语义搜索. 在这一部分, 你需要获得 Google AI Studio 的 API Key. 别担心, 这是完全免费的, 只需几分钟即可获得:

获取 Gemini API 密钥

# Configure the embedding API (replace with your actual API key)
os.environ["GOOGLE_API_KEY"] = "your_api_key_here"
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))

# Test the embedding API
result = genai.embed_content(
    model="models/text-embedding-004",
    content="What is the meaning of life?",
    task_type="retrieval_document",
    title="Embedding of single string"
)
# Print a portion of the embedding vector
print(str(result['embedding'])[:50], '... TRIMMED]')

我们通过使用你的 API 密钥配置 genai 库(用你的实际密钥替换 “your_api_key_here”)来设置嵌入式 API. 我们通过为示例文本生成嵌入并打印嵌入向量的一部分来测试嵌入 API, 以验证其是否有效.

[-0.02854543, 0.044588115, -0.034197364, -0.004266 ... TRIMMED]

此输出显示了样本文本嵌入向量的起始部分. 向量的实际长度为 768, 因为这是text-embedding-004的维数. 因此, 它们可能会提供不同的结果.

接下来, 我们定义函数来生成产品描述的嵌入, 并将其存储在知识图谱中.

# Rate limiter decorator
@sleep_and_retry
@limits(calls=1500, period=60)
def get_embedding(text):
    try:
        result = genai.embed_content(
            model="models/text-embedding-004",
            content=text,
            task_type="retrieval_document",
        )
        return result['embedding']
    except Exception as e:
        print(f"Error getting embedding: {e}")
        return None

def add_embeddings_to_products(batch_size=50):
    # Get the total number of products to process
    total_query = """
    MATCH (p:Product)
    WHERE p.description_embedding IS NULL
      AND p.description IS NOT NULL
    RETURN count(p) AS total
    """
    total_result = graph.run(total_query).data()
    total_to_process = total_result[0]['total'] if total_result else 0
    print(f"Total products to process: {total_to_process}\n")
    total_processed = 0
    # Initialize tqdm progress bar
    with tqdm(total=total_to_process, desc='Processing products', unit='product') as pbar:
        while True:
            # Get batch of products
            query = """
            MATCH (p:Product)
            WHERE p.description_embedding IS NULL
              AND p.description IS NOT NULL
            RETURN p.uniq_id AS id, p.description AS description
            LIMIT $batch_size
            """
            products = graph.run(query, parameters={'batch_size': batch_size}).data()
            if not products:
                break
            # Process each product in the batch
            for product in products:
                try:
                    if product['description']:
                        embedding = get_embedding(product['description'])
                        if embedding:
                            # Update product with embedding
                            graph.run("""
                            MATCH (p:Product {uniq_id: $id})
                            SET p.description_embedding = $embedding
                            """, parameters={
                                'id': product['id'],
                                'embedding': embedding
                            })
                    total_processed += 1
                    pbar.update(1)  # Update the progress bar
                except Exception as e:
                    print(f"Error processing product {product['id']}: {e}")
            # Add a small delay between batches
            time.sleep(1)
    print(f"\nTotal products processed: {total_processed}")
    return total_processed
# Add embeddings to products
print("Adding embeddings to products...\n")
total_processed = add_embeddings_to_products()
print(f"\nProcess completed. Total products processed: {total_processed}")

get_embedding在使用ratelimit库遵守 API 速率限制的情况下检索给定文本的嵌入.

add_embeddings_too_products 函数:

  • 读取需要嵌入的产品总数.
  • 批量处理产品, 为其描述生成嵌入词.
  • 用新的嵌入更新图中的Product节点.
  • 使用进度条显示处理状态.
  • 在批次之间添加少量延迟, 以符合 API 速率限制.
Adding embeddings to products...

Total products to process: 7434
Processing products:   0%|          | 0/7434 [00:00<?, ?product/s]
Processing products: 100%|██████████| 7434/7434 [27:20<00:00, 4.53product/s]
Total products processed: 7434
Process completed. Total products processed: 7434

输出显示所有产品均已处理, 嵌入也已添加.

第 6 步: 验证嵌入效果

我们将检查有多少产品现在有嵌入.

# Verify embeddings
print("\nVerifying embeddings:")
result = graph.run("""
MATCH (p:Product)
WHERE p.description_embedding IS NOT NULL
RETURN count(p) as count
""").data()
print(f"Products with embeddings: {result[0]['count']}")

这个 Cypher 查询会统计 description_embedding 不为空的 Product 节点的数量. 我们将打印该计数, 以验证嵌入已成功添加到产品中.

Verifying embeddings:
Products with embeddings: 7434

这就确认所有产品现在都有嵌入.

第 7 步: 执行语义搜索

我们根据用户的自然语言查询, 使用嵌入式内容执行语义搜索.

def semantic_search(query_text, n=5):
    # Get query embedding
    query_embedding = get_embedding(query_text)
    if not query_embedding:
        print("Failed to get query embedding")
        return []
    
    # Search for similar products using dot product and magnitude for cosine similarity
    results = graph.run("""
    MATCH (p:Product)
    WHERE p.description_embedding IS NOT NULL
    WITH p,
    reduce(dot = 0.0, i in range(0, size(p.description_embedding)-1) |
    dot + p.description_embedding[i] * $embedding[i]) /
    (sqrt(reduce(a = 0.0, i in range(0, size(p.description_embedding)-1) |
    a + p.description_embedding[i] * p.description_embedding[i])) *
    sqrt(reduce(b = 0.0, i in range(0, size($embedding)-1) |
    b + $embedding[i] * $embedding[i])))
    AS similarity
    WHERE similarity > 0
    RETURN
    p.name as name,
    p.description as description,
    p.price as price,
    similarity as score
    ORDER BY similarity DESC
    LIMIT $n
    """, parameters={'embedding': query_embedding, 'n': n}).data()
    return results

# Test the search with debug info
print("\nTesting semantic search:")
results = semantic_search("Give me a set of cards", n=2)
print(f"\nNumber of results: {len(results)}")
for r in results:
    print(f"\nProduct: {r.get('name', 'No name')}")
    print(f"Price: ${r.get('price', 'N/A')}")
    print(f"Score: {r.get('score', 'N/A'):.3f}")
    desc = r.get('description', 'No description')
    print(f"Description: {desc}")

函数 semantic_search 为用户的查询生成一个嵌入. 它使用 Cypher 查询来计算查询嵌入和每个产品描述嵌入之间的余弦相似度. 由于 Neo4j 的标准库可能没有内置余弦相似度函数, 因此我们使用点积和幅度手动计算. 然后, 它会过滤具有正相似度得分的产品, 并返回按相似度排序的前n个结果.

我们用查询"给我一组卡片"来测试该函数, 并打印出结果, 包括产品名称, 价格, 相似度得分和描述.

Testing semantic search:

Number of results: 2
Product: Yu-Gi-Oh Metal Raiders Booster
Price: $9.76
Score: 0.852
Description: 9 Cards Per Pack.
Product: AKB48 Trading Card Game & Collection vol.1 Booster (15packs)
Price: $12.25
Score: 0.827
Description: 15 packs, 6 cards per pack.

该输出显示, 语义搜索成功检索到了与套牌相关的产品, 即使确切措辞与查询不同.

总结一下

在这次练习中, 我们加载并预处理了亚马逊玩具产品数据集. 接下来, 我们创建了一个知识图谱, 为产品, 制造商和类别添加了节点, 并建立了它们之间的关系. 对于每个节点, 我们都生成了产品描述的嵌入, 并将其存储在知识图谱中. 最后, 我们使用嵌入执行语义搜索, 找到与自然语言查询相匹配的产品.

在这个应用中, 展示了使用余弦相似度实现 Graph RAG 的基本方法. 简单的同时也有局限性. 你可能不希望在业务中使用这种方法. 相反, 你可以考虑使用更先进的方法, 利用 PageRank 算法或其他检索功能. 或者考虑使用像 Llama Index 或 Langchain 这样易于使用的框架, 它们可以与嵌入模型和 Neo4j 无缝集成.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy Coding! Stay GOLDEN!