Files
AI/참고/neo4j-graphrag-python-main/tests/e2e/experimental/test_simplekgpipeline_e2e.py

482 lines
16 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
# Copyright (c) "Neo4j"
# Neo4j Sweden AB [https://neo4j.com]
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import neo4j
import pytest
from neo4j import Driver
from neo4j_graphrag.experimental.components.text_splitters.fixed_size_splitter import (
FixedSizeSplitter,
)
from neo4j_graphrag.experimental.components.types import LexicalGraphConfig
from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline
from neo4j_graphrag.llm import LLMResponse
@pytest.fixture(scope="function", autouse=True)
def clear_db(driver: Driver) -> Any:
driver.execute_query("MATCH (n) DETACH DELETE n")
yield
@pytest.fixture(scope="module")
def llm_json_response_3_nodes_2_relationships() -> str:
return """{
"nodes": [
{
"id": "0",
"label": "Person",
"properties": {
"name": "Harry Potter"
}
},
{
"id": "1",
"label": "Person",
"properties": {
"name": "Alastor Mad-Eye Moody"
}
},
{
"id": "2",
"label": "Organization",
"properties": {
"name": "The Order of the Phoenix"
}
}
],
"relationships": [
{
"type": "KNOWS",
"start_node_id": "0",
"end_node_id": "1"
},
{
"type": "LED_BY",
"start_node_id": "2",
"end_node_id": "1"
}
]
}"""
@pytest.mark.asyncio
@pytest.mark.usefixtures("setup_neo4j_for_kg_construction")
async def test_pipeline_builder_happy_path_legacy_schema(
harry_potter_text: str,
llm: MagicMock,
embedder: MagicMock,
driver: neo4j.Driver,
llm_json_response_3_nodes_2_relationships: str,
) -> None:
"""When everything works as expected, extracted entities, relations and text
chunks must be in the DB
"""
driver.execute_query("MATCH (n) DETACH DELETE n")
embedder.async_embed_query.return_value = [1, 2, 3]
llm.ainvoke.side_effect = [
LLMResponse(
content=llm_json_response_3_nodes_2_relationships,
)
]
# Instantiate Entity and Relation objects
entities = ["Person", "Organization", "Horcrux", "Location"]
relations = ["SITUATED_AT", "INTERACTS", "OWNS", "LED_BY"]
potential_schema = [
("Person", "SITUATED_AT", "Location"),
("Person", "INTERACTS", "Person"),
("Person", "OWNS", "Horcrux"),
("Organization", "LED_BY", "Person"),
]
# Additional arguments
lexical_graph_config = LexicalGraphConfig(chunk_node_label="chunkNodeLabel")
from_file = False
on_error = "RAISE"
# Create an instance of the SimpleKGPipeline
kg_builder_text = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
entities=entities,
relations=relations,
potential_schema=potential_schema,
from_file=from_file,
on_error=on_error,
lexical_graph_config=lexical_graph_config,
)
# Run the knowledge graph building process with text input
await kg_builder_text.run_async(text=harry_potter_text)
# check the content of the graph:
# check lexical graph content
records, _, _ = driver.execute_query("MATCH (start:chunkNodeLabel) RETURN start")
assert len(records) == 1
# check entity -> chunk relationships
records, _, _ = driver.execute_query(
"MATCH (chunk:chunkNodeLabel)<-[rel:FROM_CHUNK]-(entity:__Entity__) RETURN chunk, rel, entity"
)
assert len(records) == 3 # three entities according to mocked LLMResponse
@pytest.mark.asyncio
@pytest.mark.usefixtures("setup_neo4j_for_kg_construction")
async def test_pipeline_builder_happy_path(
harry_potter_text: str,
llm: MagicMock,
embedder: MagicMock,
driver: neo4j.Driver,
llm_json_response_3_nodes_2_relationships: str,
) -> None:
"""When everything works as expected, extracted entities, relations and text
chunks must be in the DB
"""
driver.execute_query("MATCH (n) DETACH DELETE n")
embedder.async_embed_query.return_value = [1, 2, 3]
llm.ainvoke.side_effect = [
LLMResponse(
content=llm_json_response_3_nodes_2_relationships,
)
]
# Instantiate schema
entities = ["Person"]
relations: list[str] = []
potential_schema: list[tuple[str, str, str]] = []
schema = {
"node_types": entities,
"relationship_types": relations,
"patterns": potential_schema,
"additional_node_types": False,
}
# Additional arguments
lexical_graph_config = LexicalGraphConfig(chunk_node_label="chunkNodeLabel")
from_file = False
on_error = "RAISE"
# Create an instance of the SimpleKGPipeline
kg_builder_text = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
schema=schema, # type: ignore[arg-type]
from_file=from_file,
on_error=on_error,
lexical_graph_config=lexical_graph_config,
)
# Run the knowledge graph building process with text input
await kg_builder_text.run_async(text=harry_potter_text)
# check the content of the graph:
# check lexical graph content
records, _, _ = driver.execute_query("MATCH (start:chunkNodeLabel) RETURN start")
assert len(records) == 1
# check entity -> chunk relationships
records, _, _ = driver.execute_query(
"MATCH (chunk:chunkNodeLabel)<-[rel:FROM_CHUNK]-(entity:__Entity__) RETURN chunk, rel, entity"
)
assert len(records) == 2 # only two persons
@pytest.mark.asyncio
@pytest.mark.usefixtures("setup_neo4j_for_kg_construction")
async def test_pipeline_builder_two_documents(
harry_potter_text_part1: str,
harry_potter_text_part2: str,
llm: MagicMock,
embedder: MagicMock,
driver: neo4j.Driver,
) -> None:
"""When everything works as expected, extracted entities, relations and text
chunks must be in the DB
"""
driver.execute_query("MATCH (n) DETACH DELETE n")
embedder.async_embed_query.return_value = [1, 2, 3]
llm.ainvoke.side_effect = [
# first document
# first chunk
LLMResponse(
content="""{
"nodes": [
{
"id": "0",
"label": "Person",
"properties": {
"name": "Harry Potter"
}
},
],
"relationships": []
}"""
),
# second chunk
LLMResponse(content='{"nodes": [], "relationships": []}'),
# second document
# first chunk
LLMResponse(
content="""{
"nodes": [
{
"id": "0",
"label": "Person",
"properties": {
"name": "Hermione Granger"
}
},
],
"relationships": []
}"""
),
# second chunk
LLMResponse(content='{"nodes": [], "relationships": []}'),
]
# Create an instance of the SimpleKGPipeline
kg_builder_text = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_file=False,
# provide minimal schema to bypass automatic schema extraction
entities=["Person"],
# in order to have 2 chunks:
text_splitter=FixedSizeSplitter(chunk_size=400, chunk_overlap=5),
)
# Run the knowledge graph building process with text input
await kg_builder_text.run_async(text=harry_potter_text_part1)
await kg_builder_text.run_async(text=harry_potter_text_part2)
# check graph content
# check lexical graph content
records, _, _ = driver.execute_query(
"MATCH (start:Chunk)-[rel:NEXT_CHUNK]->(end:Chunk) RETURN start, rel, end"
)
assert len(records) == 2 # one for each run
# check entity -> chunk relationships
records, _, _ = driver.execute_query(
"MATCH (chunk:Chunk)<-[rel:FROM_CHUNK]-(entity:__Entity__) RETURN chunk, rel, entity"
)
assert len(records) == 2 # two entities according to mocked LLMResponse
@pytest.mark.asyncio
@pytest.mark.usefixtures("setup_neo4j_for_kg_construction")
async def test_pipeline_builder_same_document_two_runs(
harry_potter_text_part1: str,
llm: MagicMock,
embedder: MagicMock,
driver: neo4j.Driver,
) -> None:
"""When everything works as expected, extracted entities, relations and text
chunks must be in the DB
"""
driver.execute_query("MATCH (n) DETACH DELETE n")
embedder.async_embed_query.return_value = [1, 2, 3]
llm.ainvoke.side_effect = [
# first run
# first chunk
LLMResponse(
content="""{
"nodes": [
{
"id": "0",
"label": "Person",
"properties": {
"name": "Harry Potter"
}
},
],
"relationships": []
}"""
),
# second chunk
LLMResponse(content='{"nodes": [], "relationships": []}'),
# second run
# first chunk
LLMResponse(
content="""{
"nodes": [
{
"id": "0",
"label": "Person",
"properties": {
"name": "Harry Potter"
}
},
],
"relationships": []
}"""
),
# second chunk
LLMResponse(content='{"nodes": [], "relationships": []}'),
]
# Create an instance of the SimpleKGPipeline
kg_builder_text = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_file=False,
# provide minimal schema to bypass automatic schema extraction
entities=["Person"],
# in order to have 2 chunks:
text_splitter=FixedSizeSplitter(chunk_size=400, chunk_overlap=5),
)
# Run the knowledge graph building process with text input
await kg_builder_text.run_async(text=harry_potter_text_part1)
await kg_builder_text.run_async(text=harry_potter_text_part1)
# check lexical graph content
records, _, _ = driver.execute_query(
"MATCH (start:Chunk)-[rel:NEXT_CHUNK]->(end:Chunk) RETURN start, rel, end"
)
assert len(records) == 2 # one for each run
# check entity -> chunk relationships
records, _, _ = driver.execute_query(
"MATCH (chunk:Chunk)<-[rel:FROM_CHUNK]-(entity:__Entity__) RETURN chunk, rel, entity"
)
assert len(records) == 2 # two entities according to mocked LLMResponse
@pytest.mark.asyncio
@pytest.mark.usefixtures("setup_neo4j_for_kg_construction")
async def test_pipeline_builder_with_automatic_schema_extraction(
harry_potter_text_part1: str,
llm: MagicMock,
embedder: MagicMock,
driver: neo4j.Driver,
) -> None:
"""Test pipeline with automatic schema extraction (no schema provided).
This test verifies that the pipeline correctly handles automatic schema extraction.
"""
driver.execute_query("MATCH (n) DETACH DELETE n")
embedder.async_embed_query.return_value = [1, 2, 3]
# set up mock LLM responses for both schema extraction and entity extraction
llm.ainvoke.side_effect = [
# first call - schema extraction response
LLMResponse(
content="""{
"node_types": [
{
"label": "Person",
"description": "A character in the story",
"properties": [
{"name": "name", "type": "STRING"},
{"name": "age", "type": "INTEGER"}
]
},
{
"label": "Location",
"description": "A place in the story",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "LOCATED_AT",
"description": "Indicates where a person is located",
"properties": []
}
],
"patterns": [
["Person", "LOCATED_AT", "Location"]
]
}"""
),
# second call - entity extraction for first chunk
LLMResponse(
content="""{
"nodes": [
{
"id": "0",
"label": "Person",
"properties": {
"name": "Harry Potter"
}
},
{
"id": "1",
"label": "Location",
"properties": {
"name": "Hogwarts"
}
}
],
"relationships": [
{
"type": "LOCATED_AT",
"start_node_id": "0",
"end_node_id": "1"
}
]
}"""
),
# third call - entity extraction for second chunk (if text is split)
LLMResponse(content='{"nodes": [], "relationships": []}'),
]
# create an instance of the SimpleKGPipeline with NO schema provided
kg_builder_text = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_file=False,
# use smaller chunk size to ensure we have at least 2 chunks
text_splitter=FixedSizeSplitter(chunk_size=400, chunk_overlap=5),
)
# run the knowledge graph building process with text input
await kg_builder_text.run_async(text=harry_potter_text_part1)
# verify LLM was called for schema extraction
assert llm.ainvoke.call_count >= 2
# verify entities were created
records, _, _ = driver.execute_query("MATCH (n:Person) RETURN n")
assert len(records) == 1
# verify locations were created
records, _, _ = driver.execute_query("MATCH (n:Location) RETURN n")
assert len(records) == 1
# verify relationships were created
records, _, _ = driver.execute_query(
"MATCH (p:Person)-[r:LOCATED_AT]->(l:Location) RETURN p, r, l"
)
assert len(records) == 1
# verify chunks and relationships to entities
records, _, _ = driver.execute_query(
"MATCH (c:Chunk)<-[:FROM_CHUNK]-(e) RETURN c, e"
)
assert len(records) >= 1