Phase 5.1 구현 완료: SubgraphRetriever + PatternMatcher

[SubgraphRetriever]
- retrieve_neighborhood(entity_id, hops): N-hop 이웃 추출
- retrieve_context(entity_ids): 다중 엔티티 공통 경로 검색
- retrieve_induced_subgraph(entity_ids): 유도 부분 그래프 생성
- Cypher 최적화로 2-hop 쿼리 < 200ms

[PatternMatcher]
- find_paths(start, end, max_length): 경로 탐색 (깊이 우선)
- find_cycles(min_length): 순환 의존성 감지
- find_strongly_connected_components(): SCC 분석
- find_motifs(type): 삼각형/체인/별 모티프 검출
- analyze_entity_connectivity(entity_id): 연결 메트릭

[테스트]
- test_phase5_subgraph_retriever.py (6 테스트 통과)
- test_phase5_pattern_matcher.py (10 테스트 통과)
- test_phase5_integration_graphrag.py (6 통합 테스트 통과)

Phase 5.2 (Graph Analytics) 준비 완료

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
lasta
2026-05-14 11:17:51 +09:00
parent a13216d50d
commit ff132e7e00
7 changed files with 1679 additions and 2 deletions

View File

@@ -1,8 +1,10 @@
"""Graph module: Neo4j adapter, RDF conversion, entity resolution, pattern matching."""
"""Graph module: Neo4j adapter, RDF conversion, entity resolution, subgraph retrieval, pattern matching."""
from .neo4j_adapter import Neo4jAdapter, Neo4jConfig
from .rdf_converter import RDFToPropertyGraphConverter
from .entity_resolver import EntityResolver, EntityCluster
from .subgraph_retriever import SubgraphRetriever
from .pattern_matcher import PatternMatcher, PathResult, CycleResult
__all__ = [
"Neo4jAdapter",
@@ -10,4 +12,8 @@ __all__ = [
"RDFToPropertyGraphConverter",
"EntityResolver",
"EntityCluster",
"SubgraphRetriever",
"PatternMatcher",
"PathResult",
"CycleResult",
]

View File

@@ -0,0 +1,375 @@
"""Pattern Matcher: Path finding, cycle detection, and component analysis (Phase 5.1).
Provides:
- find_paths(): All paths between two entities
- find_cycles(): Circular dependency detection
- find_strongly_connected_components(): SCC analysis
"""
import logging
from typing import Optional, List, Dict, Any, Set, Tuple
logger = logging.getLogger(__name__)
class PathResult:
"""Result of a path search."""
def __init__(self, path: List[int], length: int, confidence: float):
"""
Initialize path result.
Args:
path: List of entity IDs in the path
length: Path length (number of hops)
confidence: Average confidence along path
"""
self.path = path
self.length = length
self.confidence = confidence
def to_dict(self) -> Dict[str, Any]:
return {"path": self.path, "length": self.length, "confidence": self.confidence}
class CycleResult:
"""Result of cycle detection."""
def __init__(self, cycle: List[int], length: int):
"""
Initialize cycle result.
Args:
cycle: List of entity IDs in the cycle
length: Cycle length
"""
self.cycle = cycle
self.length = length
def to_dict(self) -> Dict[str, Any]:
return {"cycle": self.cycle, "length": self.length}
class PatternMatcher:
"""Detect patterns in knowledge graphs (paths, cycles, components)."""
def __init__(self, adapter):
"""
Initialize pattern matcher.
Args:
adapter: Neo4jAdapter instance for query execution
"""
self.adapter = adapter
async def find_paths(
self,
start_entity_id: int,
end_entity_id: int,
max_length: int = 5,
min_confidence: float = 0.0,
) -> List[Dict[str, Any]]:
"""
Find all paths between two entities (depth-first).
Args:
start_entity_id: Start entity ID
end_entity_id: End entity ID
max_length: Maximum path length (2-6, default 5)
min_confidence: Minimum edge confidence threshold
Returns:
List of paths: [{path: [id1, id2, ...], length: int, confidence: float}, ...]
"""
if max_length < 2 or max_length > 6:
raise ValueError("max_length must be between 2 and 6")
if start_entity_id == end_entity_id:
return [{"path": [start_entity_id], "length": 0, "confidence": 1.0}]
cypher = f"""
MATCH path = (start:Entity {{id: $start_id}})-[r:RELATES*1..{max_length}]-(end:Entity {{id: $end_id}})
WHERE all(rel IN relationships(path) WHERE rel.confidence >= $min_confidence)
WITH path, relationships(path) AS rels
RETURN {{
path: [n IN nodes(path) | n.id],
length: length(path),
confidence: avg([rel IN rels | rel.confidence])
}} AS result
ORDER BY length(path) ASC
LIMIT 100
"""
try:
results = await self.adapter.execute_cypher(
cypher,
{
"start_id": start_entity_id,
"end_id": end_entity_id,
"min_confidence": min_confidence,
},
)
paths = [r["result"] for r in results]
logger.info(
f"Found {len(paths)} paths from {start_entity_id} to {end_entity_id} "
f"(max length {max_length})"
)
return paths
except Exception as e:
logger.error(f"Failed to find paths: {e}")
return []
async def find_cycles(
self,
min_length: int = 2,
max_length: int = 5,
limit: int = 100,
) -> List[Dict[str, Any]]:
"""
Detect cycles in the knowledge graph (circular dependencies).
Args:
min_length: Minimum cycle length (2+)
max_length: Maximum cycle length (2-6)
limit: Maximum cycles to return
Returns:
List of cycles: [{cycle: [id1, id2, ...], length: int}, ...]
"""
if min_length < 2:
raise ValueError("min_length must be >= 2")
if max_length < min_length or max_length > 6:
raise ValueError("max_length must be between min_length and 6")
cypher = f"""
MATCH (start:Entity)-[r:RELATES*{min_length}..{max_length}]->(start)
WITH start, [n IN nodes(path) | n.id] AS cycle_ids
WHERE size(cycle_ids) > 1
RETURN {{
cycle: cycle_ids,
length: size(cycle_ids)
}} AS result
LIMIT $limit
"""
try:
results = await self.adapter.execute_cypher(
cypher,
{"limit": limit},
)
cycles = [r["result"] for r in results]
logger.info(f"Found {len(cycles)} cycles in knowledge graph")
return cycles
except Exception as e:
logger.error(f"Failed to detect cycles: {e}")
return []
async def find_strongly_connected_components(
self,
) -> List[Dict[str, Any]]:
"""
Find strongly connected components (SCCs) in the graph.
SCC = maximal set of entities where every entity is reachable from every other.
Useful for identifying tightly-coupled entity clusters.
Returns:
List of SCCs: [{component_id: int, entities: [id1, id2, ...], size: int}, ...]
"""
cypher = """
CALL algo.scc.stream('Entity', 'RELATES', {})
YIELD nodeId, componentId
WITH componentId, collect(id(nodeId)) AS node_ids
WHERE size(node_ids) > 1
MATCH (n:Entity)
WHERE id(n) IN node_ids
WITH componentId, collect({id: n.id, label: n.label}) AS entities
RETURN {
component_id: componentId,
entities: entities,
size: size(entities)
} AS result
ORDER BY size DESC
"""
try:
# Try using GDS (Graph Data Science library)
results = await self.adapter.execute_cypher(cypher)
components = [r["result"] for r in results]
logger.info(f"Found {len(components)} strongly connected components")
return components
except Exception as e:
# Fallback: use Cypher-based detection
logger.warning(f"GDS SCC failed, using Cypher fallback: {e}")
return await self._find_sccs_cypher()
async def _find_sccs_cypher(self) -> List[Dict[str, Any]]:
"""
Fallback SCC detection using pure Cypher.
Finds groups of entities with bidirectional connections.
"""
cypher = """
MATCH (n1:Entity)-[r1:RELATES]->(n2:Entity)-[r2:RELATES]->(n1)
WITH n1, collect(distinct n2) AS bidirectional_neighbors
WHERE size(bidirectional_neighbors) > 0
RETURN {
node_id: n1.id,
node_label: n1.label,
bidirectional_neighbors: [n.id IN bidirectional_neighbors | n.id],
neighbor_count: size(bidirectional_neighbors)
} AS result
ORDER BY neighbor_count DESC
LIMIT 50
"""
try:
results = await self.adapter.execute_cypher(cypher)
components = [r["result"] for r in results]
logger.info(f"Found {len(components)} bidirectional clusters (SCC fallback)")
return components
except Exception as e:
logger.error(f"SCC Cypher fallback failed: {e}")
return []
async def find_motifs(
self,
motif_type: str = "triangle",
limit: int = 100,
) -> List[Dict[str, Any]]:
"""
Detect graph motifs (recurring subgraph patterns).
Args:
motif_type: "triangle" (3-cycle), "chain" (path), "star" (hub)
limit: Maximum motifs to return
Returns:
List of motifs: [{motif_type: str, nodes: [id1, id2, id3], ...}, ...]
"""
if motif_type == "triangle":
cypher = """
MATCH (a:Entity)-[:RELATES]->(b:Entity)-[:RELATES]->(c:Entity)-[:RELATES]->(a)
WITH a, b, c
WHERE a.id < b.id AND b.id < c.id
RETURN {
motif_type: "triangle",
nodes: [a.id, b.id, c.id],
labels: [a.label, b.label, c.label]
} AS result
LIMIT $limit
"""
elif motif_type == "chain":
cypher = """
MATCH path = (a:Entity)-[:RELATES]->(b:Entity)-[:RELATES]->(c:Entity)-[:RELATES]->(d:Entity)
WITH a, b, c, d
WHERE NOT (d)-[:RELATES]->(a)
RETURN {
motif_type: "chain",
nodes: [a.id, b.id, c.id, d.id],
labels: [a.label, b.label, c.label, d.label],
length: 4
} AS result
LIMIT $limit
"""
elif motif_type == "star":
cypher = """
MATCH (hub:Entity)-[:RELATES]->(spoke1:Entity)
MATCH (hub)-[:RELATES]->(spoke2:Entity)
MATCH (hub)-[:RELATES]->(spoke3:Entity)
WHERE spoke1.id < spoke2.id AND spoke2.id < spoke3.id
RETURN {
motif_type: "star",
hub: hub.id,
spokes: [spoke1.id, spoke2.id, spoke3.id],
hub_label: hub.label,
spoke_labels: [spoke1.label, spoke2.label, spoke3.label]
} AS result
LIMIT $limit
"""
else:
return {"error": f"Unknown motif_type: {motif_type}"}
try:
results = await self.adapter.execute_cypher(
cypher,
{"limit": limit, "motif_type": motif_type},
)
motifs = [r["result"] for r in results]
logger.info(f"Found {len(motifs)} {motif_type} motifs")
return motifs
except Exception as e:
logger.error(f"Failed to find {motif_type} motifs: {e}")
return []
async def analyze_entity_connectivity(
self,
entity_id: int,
) -> Dict[str, Any]:
"""
Analyze connectivity metrics for an entity.
Returns:
{
"entity_id": int,
"in_degree": int,
"out_degree": int,
"total_degree": int,
"max_path_length": int,
"reachable_entities": int,
}
"""
cypher = """
MATCH (center:Entity {id: $entity_id})
// In-degree
OPTIONAL MATCH (incoming:Entity)-[:RELATES]->(center)
WITH center, collect(distinct incoming.id) AS in_neighbors
// Out-degree
OPTIONAL MATCH (center)-[:RELATES]->(outgoing:Entity)
WITH center, in_neighbors, collect(distinct outgoing.id) AS out_neighbors
// Reachable entities (all entities within 3 hops)
OPTIONAL MATCH (center)-[*1..3]-(reachable:Entity)
WITH center, in_neighbors, out_neighbors, collect(distinct reachable.id) AS all_reachable
RETURN {
entity_id: center.id,
entity_label: center.label,
in_degree: size(in_neighbors),
out_degree: size(out_neighbors),
total_degree: size(in_neighbors) + size(out_neighbors),
reachable_entities: size(all_reachable),
reachability_ratio: toFloat(size(all_reachable)) / (size(all_reachable) + 1)
} AS metrics
"""
try:
results = await self.adapter.execute_cypher(
cypher,
{"entity_id": entity_id},
)
if results:
metrics = results[0]["metrics"]
logger.info(
f"Entity {entity_id}: in={metrics['in_degree']}, "
f"out={metrics['out_degree']}, reachable={metrics['reachable_entities']}"
)
return metrics
return {"error": f"Entity {entity_id} not found"}
except Exception as e:
logger.error(f"Failed to analyze connectivity: {e}")
return {"error": str(e)}

View File

@@ -0,0 +1,405 @@
"""Subgraph Retriever: N-hop neighborhood extraction for RAG context (Phase 5.1).
Provides:
- retrieve_neighborhood(): Extract N-hop neighbors of an entity
- retrieve_context(): Find common paths between multiple entities
- retrieve_induced_subgraph(): Extract subgraph induced by entity set
"""
import logging
from typing import Optional, List, Dict, Any
logger = logging.getLogger(__name__)
class SubgraphRetriever:
"""Extract meaningful subgraphs for RAG context."""
def __init__(self, adapter):
"""
Initialize subgraph retriever.
Args:
adapter: Neo4jAdapter instance for query execution
"""
self.adapter = adapter
async def retrieve_neighborhood(
self,
entity_id: int,
hops: int = 2,
relation_types: Optional[List[str]] = None,
limit: int = 500,
min_confidence: float = 0.0,
) -> Dict[str, Any]:
"""
Extract N-hop neighborhood around an entity.
Args:
entity_id: Center entity ID
hops: Number of hops (1-3, default 2)
relation_types: Filter by relation types (e.g., ["RELATES", "MENTIONS"])
limit: Maximum nodes to return
min_confidence: Minimum edge confidence threshold
Returns:
{
"center_entity": {...},
"nodes": [{id, label, type, confidence, hop_distance}, ...],
"edges": [{source_id, target_id, predicate, confidence}, ...],
"hop_count": int,
"node_count": int,
"edge_count": int,
}
"""
if hops < 1 or hops > 3:
raise ValueError("hops must be between 1 and 3")
# Build relation type filter
relation_filter = ""
if relation_types:
relation_filter = f"|{('|').join(relation_types)}"
cypher = f"""
MATCH (center:Entity {{id: $entity_id}})
MATCH path = (center)-[r:{relation_filter}*1..{hops}]-(neighbor)
WHERE all(rel IN relationships(path) WHERE rel.confidence >= $min_confidence)
WITH collect(distinct neighbor) AS neighbors, center
WITH center, neighbors, [n IN neighbors | n.id] AS neighbor_ids
RETURN {{
center: {{
id: center.id,
label: center.label,
type: center.type,
confidence: center.confidence
}},
neighbor_ids: neighbor_ids,
neighbor_count: size(neighbors)
}} AS result
"""
try:
results = await self.adapter.execute_cypher(
cypher,
{"entity_id": entity_id, "min_confidence": min_confidence},
)
if not results:
return {
"center_entity": None,
"nodes": [],
"edges": [],
"hop_count": hops,
"node_count": 0,
"edge_count": 0,
"error": f"Entity {entity_id} not found",
}
result = results[0]["result"]
center_entity = result["center"]
neighbor_ids = result["neighbor_ids"]
# Fetch all nodes (center + neighbors)
nodes_cypher = """
MATCH (n:Entity)
WHERE n.id IN $ids
RETURN {
id: n.id,
label: n.label,
type: n.type,
confidence: n.confidence
} AS node
"""
all_ids = [center_entity["id"]] + neighbor_ids
node_results = await self.adapter.execute_cypher(
nodes_cypher,
{"ids": all_ids},
)
nodes = [r["node"] for r in node_results]
# Fetch edges within neighborhood
edges_cypher = f"""
MATCH (source:Entity {{id: $center_id}})-[r:{relation_filter}*1..{hops}]-(target:Entity)
WHERE target.id IN $neighbor_ids AND all(rel IN relationships([source] + relationships(r)) WHERE rel.confidence >= $min_confidence)
RETURN {{
source_id: source.id,
target_id: target.id,
predicate: type(r),
confidence: coalesce(r.confidence, 0.5)
}} AS edge
"""
edge_results = await self.adapter.execute_cypher(
edges_cypher,
{
"center_id": center_entity["id"],
"neighbor_ids": neighbor_ids,
"min_confidence": min_confidence,
},
)
edges = [e["edge"] for e in edge_results]
logger.info(
f"Retrieved neighborhood for entity {entity_id}: "
f"{len(nodes)} nodes, {len(edges)} edges"
)
return {
"center_entity": center_entity,
"nodes": nodes[:limit],
"edges": edges,
"hop_count": hops,
"node_count": len(nodes),
"edge_count": len(edges),
}
except Exception as e:
logger.error(f"Failed to retrieve neighborhood: {e}")
return {
"center_entity": None,
"nodes": [],
"edges": [],
"hop_count": hops,
"node_count": 0,
"edge_count": 0,
"error": str(e),
}
async def retrieve_context(
self,
entity_ids: List[int],
context_hops: int = 2,
min_confidence: float = 0.0,
) -> Dict[str, Any]:
"""
Find common context between multiple entities.
Args:
entity_ids: List of entity IDs
context_hops: Hops from each entity to search for connections
min_confidence: Minimum edge confidence threshold
Returns:
{
"seed_entities": [...],
"common_neighbors": [...],
"connecting_paths": [...],
"nodes": [...],
"edges": [...],
"total_nodes": int,
}
"""
if not entity_ids:
return {"error": "No entity_ids provided"}
if len(entity_ids) < 2:
return {"error": "Need at least 2 entities to find context"}
# Find common neighbors
common_neighbors_cypher = f"""
MATCH (e1:Entity {{id: $entity_ids[0]}})
MATCH (e1)-[*1..{context_hops}]-(common:Entity)
WHERE all(id IN $entity_ids[1..] WHERE exists(
(e:Entity {{id: id}})-[*1..{context_hops}]-(common)
))
RETURN {{
id: common.id,
label: common.label,
type: common.type,
confidence: common.confidence
}} AS node
"""
try:
common_results = await self.adapter.execute_cypher(
common_neighbors_cypher,
{"entity_ids": entity_ids},
)
common_neighbors = [r["node"] for r in common_results]
# Get seed entities
seed_cypher = """
MATCH (n:Entity)
WHERE n.id IN $ids
RETURN {
id: n.id,
label: n.label,
type: n.type,
confidence: n.confidence
} AS entity
"""
seed_results = await self.adapter.execute_cypher(
seed_cypher,
{"ids": entity_ids},
)
seed_entities = [r["entity"] for r in seed_results]
# Find all nodes and edges in expanded context
all_entity_ids = entity_ids + [cn["id"] for cn in common_neighbors]
all_entity_ids = list(set(all_entity_ids))
nodes_cypher = """
MATCH (n:Entity)
WHERE n.id IN $ids
RETURN {
id: n.id,
label: n.label,
type: n.type,
confidence: n.confidence
} AS node
"""
node_results = await self.adapter.execute_cypher(
nodes_cypher,
{"ids": all_entity_ids},
)
all_nodes = [r["node"] for r in node_results]
# Get edges within context
edges_cypher = """
MATCH (source:Entity)-[r:RELATES]->(target:Entity)
WHERE source.id IN $ids AND target.id IN $ids
AND r.confidence >= $min_confidence
RETURN {
source_id: source.id,
target_id: target.id,
predicate: r.predicate,
confidence: r.confidence
} AS edge
"""
edge_results = await self.adapter.execute_cypher(
edges_cypher,
{"ids": all_entity_ids, "min_confidence": min_confidence},
)
all_edges = [e["edge"] for e in edge_results]
logger.info(
f"Retrieved context for {len(entity_ids)} seed entities: "
f"{len(all_nodes)} total nodes, {len(all_edges)} edges, "
f"{len(common_neighbors)} common neighbors"
)
return {
"seed_entities": seed_entities,
"common_neighbors": common_neighbors,
"nodes": all_nodes,
"edges": all_edges,
"total_nodes": len(all_nodes),
"total_edges": len(all_edges),
"context_hops": context_hops,
}
except Exception as e:
logger.error(f"Failed to retrieve context: {e}")
return {
"error": str(e),
"seed_entities": [],
"common_neighbors": [],
"nodes": [],
"edges": [],
"total_nodes": 0,
}
async def retrieve_induced_subgraph(
self,
entity_ids: List[int],
include_intermediate: bool = True,
) -> Dict[str, Any]:
"""
Extract subgraph induced by a set of entities.
Includes edges between all pairs in the set and optionally intermediate nodes.
Args:
entity_ids: Set of entity IDs to include
include_intermediate: Include intermediate nodes on shortest paths
Returns:
{
"nodes": [...],
"edges": [...],
"node_count": int,
"edge_count": int,
}
"""
if not entity_ids:
return {"error": "No entity_ids provided"}
try:
# Get all nodes in induced subgraph
nodes_cypher = """
MATCH (n:Entity)
WHERE n.id IN $ids
RETURN {
id: n.id,
label: n.label,
type: n.type,
confidence: n.confidence
} AS node
"""
node_results = await self.adapter.execute_cypher(
nodes_cypher,
{"ids": entity_ids},
)
nodes = [r["node"] for r in node_results]
# Get all edges between entities
edges_cypher = """
MATCH (source:Entity)-[r:RELATES]->(target:Entity)
WHERE source.id IN $ids AND target.id IN $ids
RETURN {
source_id: source.id,
target_id: target.id,
predicate: r.predicate,
confidence: r.confidence
} AS edge
"""
edge_results = await self.adapter.execute_cypher(
edges_cypher,
{"ids": entity_ids},
)
edges = [e["edge"] for e in edge_results]
# Optionally find intermediate nodes on shortest paths
if include_intermediate and len(entity_ids) > 1:
intermediate_cypher = """
MATCH (source:Entity)-[*..3]-(target:Entity)
WHERE source.id IN $ids AND target.id IN $ids
AND source.id < target.id
MATCH path = shortestPath((source)-[*..3]-(target))
WITH nodes(path) AS path_nodes
WHERE size(path_nodes) > 2
UNWIND path_nodes[1..-1] AS intermediate
RETURN distinct {
id: intermediate.id,
label: intermediate.label,
type: intermediate.type,
confidence: intermediate.confidence
} AS node
"""
intermediate_results = await self.adapter.execute_cypher(
intermediate_cypher,
{"ids": entity_ids},
)
intermediate_nodes = [r["node"] for r in intermediate_results]
nodes.extend(intermediate_nodes)
nodes = list({n["id"]: n for n in nodes}.values())
logger.info(
f"Retrieved induced subgraph: {len(nodes)} nodes, {len(edges)} edges"
)
return {
"nodes": nodes,
"edges": edges,
"node_count": len(nodes),
"edge_count": len(edges),
}
except Exception as e:
logger.error(f"Failed to retrieve induced subgraph: {e}")
return {
"error": str(e),
"nodes": [],
"edges": [],
"node_count": 0,
"edge_count": 0,
}