Files
semantica/semantica/context/context_retriever.py
T
KaifAhmad1andClaude Sonnet 4.6 0f0800f109 feat(#402): Temporal GraphRAG Integration — TemporalGraphRetriever & TemporalQueryRewriter
- Add TemporalGraphRetriever to context_retriever.py (no new file per project convention)
  - Drop-in wrapper for ContextRetriever; filters related_entities/related_relationships
    via reconstruct_at_time(); at_time=None is a true passthrough
  - Returns new RetrievedContext objects (no in-place mutation)
  - Graceful ImportError if temporal modules unavailable

- Add at_time + header_template to ContextRetriever._generate_reasoned_response()
  and query_with_reasoning()
  - Temporal header prepended to LLM context block only when at_time is set
  - Naive datetimes normalised to UTC before formatting
  - Header built with str.replace (not .format) to prevent format-string injection

- Add TemporalQueryRewriter + TemporalQueryResult to semantica/kg/
  - Regex-only (default) and LLM-assisted extraction modes
  - Resolves temporal phrases via TemporalNormalizer (deterministic, zero LLM)
  - Word-boundary guards on intent keywords; year fallback for noun-phrase dates
  - Never calls reconstruct_at_time — extraction only

- Export TemporalGraphRetriever from semantica.context
- Export TemporalQueryRewriter, TemporalQueryResult from semantica.kg

- Add 99 tests across two new test files
  - tests/context/test_temporal_retriever.py (56 tests)
  - tests/kg/test_temporal_query_rewriter.py (43 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:20:38 +05:30

2802 lines
115 KiB
Python

"""
Context Retriever for Agents
This module provides comprehensive context retrieval capabilities for agents,
retrieving relevant context from memory, knowledge graphs, and vector stores
to inform decision-making. It supports hybrid retrieval combining multiple
sources for optimal context relevance with high accuracy for GraphRAG use cases.
Algorithms Used:
Vector Retrieval:
- Vector Similarity Search: Cosine similarity search in vector space
- Query Embedding: Embedding generation for search queries using semantic models
- Top-K Retrieval: Top-K result selection based on similarity scores
- Semantic Re-ranking: Query-content semantic similarity for final ranking
Graph Retrieval:
- Semantic Entity Matching: Cosine similarity between query and entity embeddings
- Semantic Relationship Matching: Similarity matching for relationship types and entities
- Graph Traversal: Multi-hop graph expansion for related entities (BFS-based)
- Query Intent Extraction: Domain-agnostic extraction of relationship verbs and question types
- Intent-Guided Boosting: Score boosting for entities/relationships matching query intent
- Relationship-Aware Matching: Finding entities through semantically relevant relationships
- Adaptive Thresholding: Dynamic similarity thresholds (default 0.3) for filtering
Memory Retrieval:
- Memory Search: Vector and keyword search in memory store
- Conversation History: Temporal-based memory retrieval
Result Processing:
- Score Normalization: Per-source score normalization (0-1 range) for fair comparison
- Hybrid Weighting: Configurable hybrid_alpha (0=vector only, 1=graph only, 0.5=balanced)
- Context Boosting: Up to 20% boost for graph results with more related entities/relationships
- Multi-Source Boost: 20% boost for results found in both vector and graph sources
- Semantic Re-ranking: Final ranking using 70% original score + 30% query-content similarity
- Entity-Based Deduplication: Graph results deduplicated by entity ID with relationship merging
- Content-Based Deduplication: Non-graph results deduplicated by content hash
- Metadata Merging: Dictionary-based metadata merging for duplicate results
- Entity Merging: Set-based entity deduplication with relationship preservation
Content Generation:
- Comprehensive Entity Descriptions: Entity name, type, and metadata properties
- Relationship Context: Direction-aware relationship formatting with target entity types
- Multi-Relationship Formatting: Up to 5 relationships per entity with grouping by type
- Related Entity Context: Additional context from related entities when content is sparse
Key Features:
- Domain-Agnostic: Works across any domain (biomedical, finance, tech, legal, etc.)
- Semantic Matching: Uses embeddings for semantic similarity instead of keyword matching
- Hybrid Retrieval: Combines vector + graph + memory with configurable weighting
- Query Understanding: Extracts query intent (relationship types, question types)
- High Accuracy: Optimized for GraphRAG with multi-factor scoring and semantic re-ranking
- Context Richness: Generates comprehensive content from graph structures
- Graph Expansion: Multi-hop traversal following semantically relevant paths
- Result Deduplication: Smart merging of results from multiple sources
- Configurable Strategies: Adjustable hybrid_alpha, max_hops, similarity thresholds
Main Classes:
- RetrievedContext: Retrieved context item data structure with content, score,
source, metadata, related_entities, related_relationships
- ContextRetriever: Context retriever for hybrid retrieval with GraphRAG optimization
Example Usage:
>>> from semantica.context import ContextRetriever
>>> retriever = ContextRetriever(
... memory_store=mem, knowledge_graph=kg, vector_store=vs,
... hybrid_alpha=0.6 # 60% weight on graph, 40% on vector
... )
>>> results = retriever.retrieve("What drugs target COX enzymes?", max_results=5)
>>> for result in results:
... print(f"{result.content}: {result.score:.2f}")
... print(f"Related entities: {len(result.related_entities)}")
... print(f"Related relationships: {len(result.related_relationships)}")
Author: Semantica Contributors
License: MIT
"""
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ..vector_store.hybrid_similarity import HybridSimilarityCalculator
from ..vector_store.decision_embedding_pipeline import DecisionEmbeddingPipeline
from ..kg.path_finder import PathFinder
from ..kg.centrality_calculator import CentralityCalculator
from ..kg.community_detector import CommunityDetector
from ..kg.similarity_calculator import SimilarityCalculator
try:
from ..kg.temporal_query import TemporalGraphQuery as _TemporalGraphQuery
from ..kg.temporal_model import parse_temporal_value as _parse_temporal_value
_TEMPORAL_AVAILABLE = True
except Exception: # pragma: no cover
_TemporalGraphQuery = None # type: ignore[assignment,misc]
_parse_temporal_value = None # type: ignore[assignment]
_TEMPORAL_AVAILABLE = False
@dataclass
class RetrievedContext:
"""Retrieved context item."""
content: str
score: float
source: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
related_entities: List[Dict[str, Any]] = field(default_factory=list)
related_relationships: List[Dict[str, Any]] = field(default_factory=list)
class ContextRetriever:
"""
Context retriever for hybrid retrieval.
• Retrieve context from multiple sources
• Hybrid retrieval (vector + graph)
• Context relevance ranking
• Context aggregation and synthesis
• Ontology-aware context retrieval
• Real-time context updates
"""
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
"""
Initialize context retriever.
Args:
config: Configuration dictionary
**kwargs: Additional configuration options:
- memory_store: Memory store instance
- knowledge_graph: Knowledge graph instance
- vector_store: Vector store instance
- use_graph_expansion: Use graph expansion (default: True)
- max_expansion_hops: Maximum graph expansion hops (default: 2)
- hybrid_alpha: Weight for hybrid retrieval (0=vector only, 1=graph
only, default: 0.5)
"""
self.logger = get_logger("context_retriever")
self.config = config or {}
self.config.update(kwargs)
self.memory_store = self.config.get("memory_store")
self.knowledge_graph = self.config.get("knowledge_graph")
self.vector_store = self.config.get("vector_store")
self.use_graph_expansion = self.config.get("use_graph_expansion", True)
self.max_expansion_hops = self.config.get("max_expansion_hops", 2)
self.hybrid_alpha = self.config.get("hybrid_alpha", 0.5)
# Initialize progress tracker
self.progress_tracker = get_progress_tracker()
# Ensure progress tracker is enabled
if not self.progress_tracker.enabled:
self.progress_tracker.enabled = True
# Initialize decision-specific components
self.hybrid_calculator = HybridSimilarityCalculator()
self.decision_pipeline: Optional[DecisionEmbeddingPipeline] = None
# Initialize KG algorithms if knowledge graph available
if self.knowledge_graph:
self.path_finder = PathFinder()
self.centrality_calculator = CentralityCalculator()
self.community_detector = CommunityDetector()
self.similarity_calculator = SimilarityCalculator()
else:
self.path_finder = None
self.centrality_calculator = None
self.community_detector = None
self.similarity_calculator = None
# Initialize decision pipeline if vector store available
if self.vector_store:
self.decision_pipeline = DecisionEmbeddingPipeline(
vector_store=self.vector_store,
graph_store=self.knowledge_graph,
use_graph_features=True
)
def retrieve(
self,
query: str,
max_results: int = 5,
use_graph_expansion: Optional[bool] = None,
min_relevance_score: float = 0.0,
**options,
) -> List[RetrievedContext]:
"""
Retrieve relevant context for query.
Args:
query: Search query
max_results: Maximum number of results
use_graph_expansion: Use graph expansion (overrides config)
min_relevance_score: Minimum relevance score
**options: Additional options:
- entity_ids: Filter by entity IDs
- node_types: Filter by node types
- max_hops: Maximum expansion hops
Returns:
List of retrieved context items
"""
# Track context retrieval
tracking_id = self.progress_tracker.start_tracking(
file=None,
module="context",
submodule="ContextRetriever",
message=f"Retrieving context for: {query[:50]}...",
)
try:
use_expansion = (
use_graph_expansion
if use_graph_expansion is not None
else self.use_graph_expansion
)
all_results = []
# Vector-based retrieval
self.progress_tracker.update_tracking(
tracking_id, message="Retrieving from vector store..."
)
vector_results = self._retrieve_from_vector(query, max_results * 2)
all_results.extend(vector_results)
# Graph-based retrieval
if self.knowledge_graph and use_expansion:
self.progress_tracker.update_tracking(
tracking_id, message="Retrieving from knowledge graph..."
)
# Extract query intent for better graph traversal
query_intent = self._extract_query_intent(query)
graph_results = self._retrieve_from_graph(
query,
max_results * 2,
max_hops=options.get("max_hops", self.max_expansion_hops),
query_intent=query_intent,
)
all_results.extend(graph_results)
# Memory-based retrieval
if self.memory_store:
self.progress_tracker.update_tracking(
tracking_id, message="Retrieving from memory..."
)
memory_results = self._retrieve_from_memory(query, max_results * 2)
all_results.extend(memory_results)
# Combine and rank results
self.progress_tracker.update_tracking(
tracking_id, message="Ranking and merging results..."
)
ranked_results = self._rank_and_merge(all_results, query)
# Filter by minimum score
filtered_results = [
r for r in ranked_results if r.score >= min_relevance_score
]
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Retrieved {len(filtered_results[:max_results])} results",
)
# Return top results
return filtered_results[:max_results]
except Exception as e:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
raise
def _retrieve_from_vector(
self, query: str, max_results: int
) -> List[RetrievedContext]:
"""Retrieve from vector store."""
if not self.vector_store:
return []
try:
# Simulate vector search (actual implementation would use vector_store)
results = []
# If vector_store has a search method
if hasattr(self.vector_store, "search"):
search_results = self.vector_store.search(
query=query, limit=max_results
)
for result in search_results:
# Handle VectorSearchResult object or dict
if hasattr(result, "content"):
content = result.content
score = result.score
source = f"vector:{result.id}" if hasattr(result, 'id') else "vector:unknown"
metadata = result.metadata or {}
else:
metadata = result.get("metadata", {})
content = result.get("content") or metadata.get("content", "")
score = result.get("score", 0.0)
source = result.get("source") or f"vector:{result.get('id', 'unknown')}"
results.append(
RetrievedContext(
content=content,
score=score,
source=source,
metadata=metadata,
)
)
return results
except Exception as e:
self.logger.warning(f"Vector retrieval failed: {e}")
return []
def _retrieve_from_graph(
self, query: str, max_results: int, max_hops: int = 2, query_intent: Optional[Dict[str, Any]] = None
) -> List[RetrievedContext]:
"""Retrieve from knowledge graph."""
if not self.knowledge_graph:
return []
results = []
try:
# Check if knowledge_graph implements GraphStore protocol (has query method)
if hasattr(self.knowledge_graph, "query"):
graph_results = self.knowledge_graph.query(query)
for res in graph_results:
# Handle both interface dicts and raw dicts
node = res.get("node")
if hasattr(node, "id"): # GraphNodeInterface
node_id = node.id
node_type = node.type
content = node.properties.get("content", "")
metadata = node.properties
else: # Raw dict
node_id = res.get("id") or res.get("node", {}).get("id")
node_type = res.get("type") or res.get("node", {}).get("type")
content = res.get("content") or res.get("node", {}).get(
"content"
)
metadata = res.get("metadata") or res.get("node", {}).get(
"metadata"
)
score = res.get("score", 0.0)
# Get related entities
related_entities = self._get_related_entities(
node_id, max_hops=max_hops
)
results.append(
RetrievedContext(
content=content,
score=score,
source=f"graph:{node_id}",
metadata={
"node_type": node_type,
"node_id": node_id,
**(metadata or {}),
},
related_entities=related_entities,
)
)
# Sort by score
results.sort(key=lambda x: x.score, reverse=True)
return results[:max_results]
# Fallback to dictionary-based graph retrieval
# Handle GraphBuilder format (entities and relationships)
# Get entities and relationships from graph
entities = self.knowledge_graph.get("entities", [])
relationships = self.knowledge_graph.get("relationships", [])
# Use semantic similarity if vector_store is available
if self.vector_store and hasattr(self.vector_store, 'embed'):
try:
import numpy as np
# Generate query embedding
query_embedding = self.vector_store.embed(query)
if query_embedding is not None:
query_embedding = np.array(query_embedding)
# Handle batch embeddings (take first if 2D)
if len(query_embedding.shape) == 2:
query_embedding = query_embedding[0]
query_norm = np.linalg.norm(query_embedding)
if query_norm > 0:
# Calculate semantic similarity for each entity
entity_scores = []
for entity in entities:
entity_name = str(entity.get("name", entity.get("id", "")))
entity_type = str(entity.get("type", ""))
# Create entity text for embedding (include type for better matching)
entity_text = f"{entity_name} {entity_type}".strip()
entity_embedding = self.vector_store.embed(entity_text)
if entity_embedding is not None:
entity_embedding = np.array(entity_embedding)
# Handle batch embeddings
if len(entity_embedding.shape) == 2:
entity_embedding = entity_embedding[0]
entity_norm = np.linalg.norm(entity_embedding)
# Cosine similarity
if entity_norm > 0:
similarity = np.dot(query_embedding, entity_embedding) / (query_norm * entity_norm)
# Boost if entity type is mentioned in query (domain-agnostic)
if query_intent and query_intent.get("entity_types"):
# Check if entity type semantically matches query keywords
entity_type_lower = entity_type.lower()
query_keywords = query_intent.get("keywords", set())
if any(kw in entity_type_lower or entity_type_lower in kw for kw in query_keywords if len(kw) > 2):
similarity *= 1.15 # 15% boost for type-keyword match
entity_scores.append((entity, float(similarity)))
# Also match relationships semantically
relationship_scores = []
for rel in relationships:
rel_type = str(rel.get("type", ""))
source_id = rel.get("source") or rel.get("source_id", "")
target_id = rel.get("target") or rel.get("target_id", "")
# Find entity names for better matching
source_name = source_id
target_name = target_id
for e in entities:
e_id = e.get("id") or e.get("name")
if e_id == source_id:
source_name = e.get("name", source_id)
if e_id == target_id:
target_name = e.get("name", target_id)
# Create relationship text for embedding
rel_text = f"{rel_type} {source_name} {target_name}".strip()
rel_embedding = self.vector_store.embed(rel_text)
if rel_embedding is not None:
rel_embedding = np.array(rel_embedding)
if len(rel_embedding.shape) == 2:
rel_embedding = rel_embedding[0]
rel_norm = np.linalg.norm(rel_embedding)
if rel_norm > 0:
similarity = np.dot(query_embedding, rel_embedding) / (query_norm * rel_norm)
# Boost if relationship type semantically matches query (domain-agnostic)
if query_intent and query_intent.get("relationship_types"):
rel_type_lower = rel_type.lower()
# Check if relationship type or its synonyms appear in query
for rt in query_intent["relationship_types"]:
if rt.lower() in rel_type_lower or rel_type_lower in rt.lower():
similarity *= 1.25 # 25% boost for matching relationship
break
relationship_scores.append((rel, similarity, source_id, target_id))
# Sort by similarity
entity_scores.sort(key=lambda x: x[1], reverse=True)
relationship_scores.sort(key=lambda x: x[1], reverse=True)
# Combine entity and relationship matches
# Include entities from high-scoring relationships
matched_entity_ids = set()
matched_entities = []
# Add top entity matches
for entity, score in entity_scores[:max_results * 2]:
if score > 0.3: # Similarity threshold
entity_id = entity.get("id") or entity.get("name")
matched_entity_ids.add(entity_id)
matched_entities.append((entity, score))
# Add entities from high-scoring relationships
for rel, score, source_id, target_id in relationship_scores[:max_results]:
if score > 0.3:
# Add source entity if not already matched
if source_id not in matched_entity_ids:
for e in entities:
e_id = e.get("id") or e.get("name")
if e_id == source_id:
matched_entity_ids.add(source_id)
# Boost score for relationship match
matched_entities.append((e, score * 0.9))
break
# Add target entity if not already matched
if target_id not in matched_entity_ids:
for e in entities:
e_id = e.get("id") or e.get("name")
if e_id == target_id:
matched_entity_ids.add(target_id)
matched_entities.append((e, score * 0.9))
break
# Sort all matches by score
matched_entities.sort(key=lambda x: x[1], reverse=True)
matched_entities = matched_entities[:max_results * 2]
else:
matched_entities = self._keyword_match_entities(entities, query, max_results * 2)
else:
# Fallback to keyword matching if embedding fails
matched_entities = self._keyword_match_entities(entities, query, max_results * 2)
except Exception as e:
self.logger.warning(f"Semantic matching failed: {e}, falling back to keyword matching")
matched_entities = self._keyword_match_entities(entities, query, max_results * 2)
else:
# Use keyword matching as fallback
matched_entities = self._keyword_match_entities(entities, query, max_results * 2)
# Process matched entities
for entity, score in matched_entities:
# Find related relationships
entity_id = entity.get("id") or entity.get("name")
entity_type = str(entity.get("type", ""))
related_entities = []
related_relationships = []
# Find relationships involving this entity
for rel in relationships:
if (rel.get("source") == entity_id or
rel.get("target") == entity_id or
rel.get("source_id") == entity_id or
rel.get("target_id") == entity_id):
related_relationships.append(rel)
# Get the other entity in the relationship
other_id = rel.get("target") or rel.get("target_id")
if other_id == entity_id:
other_id = rel.get("source") or rel.get("source_id")
# Find the other entity
for e in entities:
e_id = e.get("id") or e.get("name")
if e_id == other_id:
related_entities.append(e)
break
# Generate comprehensive content from entity and relationships
entity_display = entity.get('name', entity_id)
# Start with entity description
entity_desc = f"{entity_display}"
if entity_type:
entity_desc += f" is a {entity_type}"
# Add entity properties/metadata if available
entity_props = entity.get("metadata", {})
if entity_props:
# Include relevant properties
prop_keys = ["description", "summary", "details", "info"]
for key in prop_keys:
if key in entity_props and entity_props[key]:
entity_desc += f". {entity_props[key]}"
break
content_parts = [entity_desc]
if related_relationships:
# Group relationships by type for better content
rels_by_type = {}
for rel in related_relationships:
rel_type = rel.get("type", "related_to")
source_id = rel.get("source") or rel.get("source_id")
target_id = rel.get("target") or rel.get("target_id")
# Determine direction
if source_id == entity_id:
# Entity is source
target_name = target_id
for e in entities:
e_id = e.get("id") or e.get("name")
if e_id == target_id:
target_name = e.get("name", target_id)
target_type = e.get("type", "")
break
else:
target_type = ""
if rel_type not in rels_by_type:
rels_by_type[rel_type] = []
rels_by_type[rel_type].append((target_name, target_type))
else:
# Entity is target
source_name = source_id
for e in entities:
e_id = e.get("id") or e.get("name")
if e_id == source_id:
source_name = e.get("name", source_id)
source_type = e.get("type", "")
break
else:
source_type = ""
# Reverse relationship for readability
reverse_type = f"is {rel_type}ed by" if rel_type else "related to"
if reverse_type not in rels_by_type:
rels_by_type[reverse_type] = []
rels_by_type[reverse_type].append((source_name, source_type))
# Format relationships with more context
for rel_type, targets in list(rels_by_type.items())[:5]: # Show more relationships
if len(targets) == 1:
target_name, target_type = targets[0]
if target_type:
content_parts.append(f"{rel_type} {target_name} ({target_type})")
else:
content_parts.append(f"{rel_type} {target_name}")
elif len(targets) <= 3:
target_list = []
for target_name, target_type in targets:
if target_type:
target_list.append(f"{target_name} ({target_type})")
else:
target_list.append(target_name)
content_parts.append(f"{rel_type} {', '.join(target_list)}")
else:
target_list = []
for target_name, target_type in targets[:3]:
if target_type:
target_list.append(f"{target_name} ({target_type})")
else:
target_list.append(target_name)
content_parts.append(f"{rel_type} {', '.join(target_list)} and {len(targets) - 3} more")
# Add related entity context if available
if related_entities and len(content_parts) < 3:
entity_names = []
for e in related_entities[:3]:
e_name = e.get("name", e.get("id", ""))
e_type = e.get("type", "")
if e_type:
entity_names.append(f"{e_name} ({e_type})")
else:
entity_names.append(e_name)
if entity_names:
content_parts.append(f"Related to: {', '.join(entity_names)}")
content = ". ".join(content_parts) + "."
results.append(
RetrievedContext(
content=content,
score=score,
source=f"graph:{entity_id}",
metadata={
"node_type": entity_type,
"node_id": entity_id,
**entity.get("metadata", {}),
},
related_entities=related_entities[:10], # Limit entities
related_relationships=related_relationships[:10], # Limit relationships
)
)
# Sort by score
results.sort(key=lambda x: x.score, reverse=True)
return results[:max_results]
except Exception as e:
self.logger.warning(f"Graph retrieval failed: {e}")
return []
def _keyword_match_entities(self, entities, query, max_results):
"""Fallback keyword matching for entities."""
query_lower = query.lower()
query_words = set(query_lower.split())
entity_scores = []
for entity in entities:
entity_name = str(entity.get("name", entity.get("id", "")))
entity_name_lower = entity_name.lower()
entity_type = str(entity.get("type", "")).lower()
# Word overlap score
entity_words = set(entity_name_lower.split())
match_score = len(query_words.intersection(entity_words))
# Substring match boost
if any(word in entity_name_lower for word in query_words):
match_score += 1
if match_score > 0:
score = match_score / max(len(query_words), 1)
entity_scores.append((entity, score))
entity_scores.sort(key=lambda x: x[1], reverse=True)
return entity_scores[:max_results]
def _retrieve_from_memory(
self, query: str, max_results: int
) -> List[RetrievedContext]:
"""Retrieve from memory store."""
if not self.memory_store:
return []
try:
# If memory_store has a retrieve method
if hasattr(self.memory_store, "retrieve"):
memory_results = self.memory_store.retrieve(
query=query, max_results=max_results
)
results = []
for result in memory_results:
results.append(
RetrievedContext(
content=result.get("content", ""),
score=result.get("score", 0.0),
source=result.get("source") or f"memory:{result.get('id', 'unknown')}",
metadata=result.get("metadata", {}),
)
)
return results
return []
except Exception as e:
self.logger.warning(f"Memory retrieval failed: {e}")
return []
def _rank_and_merge(
self, results: List[RetrievedContext], query: str
) -> List[RetrievedContext]:
"""Rank and merge results from multiple sources with GraphRAG optimization."""
# Separate results by source (handle None source gracefully)
vector_results = [r for r in results if r.source and r.source.startswith("vector:")]
graph_results = [r for r in results if r.source and r.source.startswith("graph:")]
memory_results = [r for r in results if r.source and r.source.startswith("memory:")]
# Normalize scores within each source (0-1 range)
def normalize_scores(source_results):
if not source_results:
return source_results
scores = [r.score for r in source_results]
if not scores:
return source_results
min_score, max_score = min(scores), max(scores)
if max_score > min_score:
for r in source_results:
r.score = (r.score - min_score) / (max_score - min_score)
return source_results
vector_results = normalize_scores(vector_results)
graph_results = normalize_scores(graph_results)
memory_results = normalize_scores(memory_results)
# Apply hybrid_alpha weighting: 0=vector only, 1=graph only, 0.5=balanced
alpha = self.hybrid_alpha
for r in vector_results:
r.score = r.score * (1 - alpha) # Weight vector results
for r in graph_results:
r.score = r.score * alpha # Weight graph results
# Boost graph results with more context (more related entities/relationships)
context_boost = min(
0.2, # Max 20% boost
(len(r.related_entities) + len(r.related_relationships or [])) * 0.01
)
r.score += context_boost
for r in memory_results:
r.score = r.score * 0.3 # Lower weight for memory
# Deduplicate by entity ID (for graph) or content (for others)
seen_entities = {} # entity_id -> result
seen_content = {} # content_hash -> result
all_results = vector_results + graph_results + memory_results
for result in all_results:
# For graph results, deduplicate by entity ID
if result.source and result.source.startswith("graph:"):
entity_id = result.metadata.get("node_id")
if entity_id:
if entity_id not in seen_entities:
seen_entities[entity_id] = result
else:
# Merge: boost score if found in multiple sources
existing = seen_entities[entity_id]
existing.score = max(existing.score, result.score) * 1.2 # 20% boost for multi-source
existing.metadata.update(result.metadata)
# Merge related entities
existing_ids = {e.get("id") or e.get("name") for e in existing.related_entities}
for entity in result.related_entities:
e_id = entity.get("id") or entity.get("name")
if e_id not in existing_ids:
existing.related_entities.append(entity)
existing_ids.add(e_id)
# Merge relationships
if result.related_relationships:
if not existing.related_relationships:
existing.related_relationships = []
existing_rel_ids = {
(r.get("source"), r.get("target"), r.get("type"))
for r in existing.related_relationships
}
for rel in result.related_relationships:
rel_key = (
rel.get("source") or rel.get("source_id"),
rel.get("target") or rel.get("target_id"),
rel.get("type")
)
if rel_key not in existing_rel_ids:
existing.related_relationships.append(rel)
existing_rel_ids.add(rel_key)
continue
# For non-graph results, deduplicate by content
content_key = result.content[:100] if result.content else ""
if content_key not in seen_content:
seen_content[content_key] = result
else:
existing = seen_content[content_key]
existing.score = max(existing.score, result.score)
existing.metadata.update(result.metadata)
# Combine deduplicated results
merged_results = list(seen_entities.values()) + [
r for r in seen_content.values()
if not (r.source and r.source.startswith("graph:")) or r.metadata.get("node_id") not in seen_entities
]
# Re-rank with query relevance boost
if self.vector_store and hasattr(self.vector_store, 'embed'):
try:
import numpy as np
query_embedding = self.vector_store.embed(query)
if query_embedding is not None:
query_embedding = np.array(query_embedding)
if len(query_embedding.shape) == 2:
query_embedding = query_embedding[0]
query_norm = np.linalg.norm(query_embedding)
if query_norm > 0:
for result in merged_results:
# Re-score based on semantic similarity to query
content_embedding = self.vector_store.embed(result.content[:500])
if content_embedding is not None:
content_embedding = np.array(content_embedding)
if len(content_embedding.shape) == 2:
content_embedding = content_embedding[0]
content_norm = np.linalg.norm(content_embedding)
if content_norm > 0:
semantic_sim = np.dot(query_embedding, content_embedding) / (query_norm * content_norm)
# Blend original score with semantic similarity
result.score = result.score * 0.7 + float(semantic_sim) * 0.3
except Exception as e:
self.logger.debug(f"Query relevance boost failed: {e}")
# Final sort by score
ranked = sorted(merged_results, key=lambda x: x.score, reverse=True)
return ranked
def _extract_query_intent(self, query: str) -> Dict[str, Any]:
"""Extract query intent to guide graph retrieval (domain-agnostic)."""
query_lower = query.lower()
intent = {
"entity_types": [],
"relationship_types": [],
"question_type": "general",
"keywords": set(query_lower.split())
}
# Generic question type detection (domain-agnostic)
question_words = {
"what": ["what", "which"],
"who": ["who"],
"how": ["how"],
"when": ["when"],
"where": ["where"],
"why": ["why"]
}
for q_type, words in question_words.items():
if any(query_lower.startswith(w) for w in words):
intent["question_type"] = q_type
break
# Extract relationship verbs/patterns from query (domain-agnostic)
# Use regex to find common relationship verbs that work across domains
import re
relationship_verbs = re.findall(
r'\b(targets?|inhibits?|treats?|causes?|relates?|connects?|links?|interacts?|'
r'influences?|affects?|depends?|requires?|contains?|includes?|belongs?|'
r'opposes?|supports?|enables?|prevents?|blocks?|activates?|deactivates?|'
r'regulates?|controls?|manages?|owns?|operates?|uses?|produces?|creates?|'
r'develops?|builds?|designs?|implements?|maintains?|supports?|works with)\b',
query_lower
)
for verb in relationship_verbs:
if verb not in intent["relationship_types"]:
intent["relationship_types"].append(verb)
# Note: Entity types are not hardcoded - they will be matched semantically
# based on the actual entities in the knowledge graph, making it domain-agnostic
return intent
def _calculate_graph_relevance(self, node: Dict[str, Any], query: str) -> float:
"""Calculate relevance score for graph node."""
content = node.get("content", "").lower()
query_lower = query.lower()
# Simple word overlap
query_words = set(query_lower.split())
content_words = set(content.split())
if not query_words:
return 0.0
overlap = len(query_words & content_words)
return overlap / len(query_words)
def _get_related_entities(
self, node_id: str, max_hops: int = 2
) -> List[Dict[str, Any]]:
"""Get related entities from graph."""
if not self.knowledge_graph:
return []
# Check if knowledge_graph implements GraphStore protocol
if hasattr(self.knowledge_graph, "get_neighbors"):
return self.knowledge_graph.get_neighbors(node_id, hops=max_hops)
related = []
visited = set()
current_level = {node_id}
for hop in range(max_hops):
next_level = set()
for current_id in current_level:
if current_id in visited:
continue
visited.add(current_id)
# Find edges
edges = self.knowledge_graph.get("edges", [])
for edge in edges:
if edge.get("source") == current_id:
target_id = edge.get("target")
if target_id not in visited:
next_level.add(target_id)
# Get node info
nodes = self.knowledge_graph.get("nodes", [])
for node in nodes:
if node.get("id") == target_id:
related.append(
{
"id": target_id,
"type": node.get("type"),
"content": node.get("content"),
"relationship": edge.get("type"),
"hop": hop + 1,
}
)
elif edge.get("target") == current_id:
source_id = edge.get("source")
if source_id not in visited:
next_level.add(source_id)
nodes = self.knowledge_graph.get("nodes", [])
for node in nodes:
if node.get("id") == source_id:
related.append(
{
"id": source_id,
"type": node.get("type"),
"content": node.get("content"),
"relationship": edge.get("type"),
"hop": hop + 1,
}
)
current_level = next_level
return related
# Search Methods
def search(self, query: str, **options) -> List[RetrievedContext]:
"""
Simple search (alias for retrieve).
Args:
query: Search query
**options: Additional options
Returns:
List of RetrievedContext objects
Example:
>>> results = retriever.search("Python", max_results=10)
"""
return self.retrieve(query, **options)
def vector_search(self, query: str, **options) -> List[RetrievedContext]:
"""
Vector-only search.
Args:
query: Search query
**options: Additional options
Returns:
List of RetrievedContext objects from vector store
Example:
>>> results = retriever.vector_search("Python")
"""
# Temporarily disable graph and memory
original_graph = self.knowledge_graph
original_memory = self.memory_store
self.knowledge_graph = None
self.memory_store = None
try:
results = self.retrieve(query, use_graph_expansion=False, **options)
finally:
self.knowledge_graph = original_graph
self.memory_store = original_memory
return results
def graph_search(self, query: str, **options) -> List[RetrievedContext]:
"""
Graph-only search.
Args:
query: Search query
**options: Additional options
Returns:
List of RetrievedContext objects from graph
Example:
>>> results = retriever.graph_search("Python")
"""
if not self.knowledge_graph:
return []
# Temporarily disable vector and memory
original_vector = self.vector_store
original_memory = self.memory_store
self.vector_store = None
self.memory_store = None
try:
results = self.retrieve(query, use_graph_expansion=True, **options)
finally:
self.vector_store = original_vector
self.memory_store = original_memory
return results
def memory_search(self, query: str, **options) -> List[RetrievedContext]:
"""
Memory-only search.
Args:
query: Search query
**options: Additional options
Returns:
List of RetrievedContext objects from memory
Example:
>>> results = retriever.memory_search("Python")
"""
if not self.memory_store:
return []
# Use memory store's retrieve method
memory_results = self.memory_store.retrieve(query, **options)
# Convert to RetrievedContext
results = []
for mem in memory_results:
results.append(
RetrievedContext(
content=mem.get("content", ""),
score=mem.get("score", 0.0),
source="memory",
metadata=mem.get("metadata", {}),
)
)
return results
def hybrid_search(self, query: str, **options) -> List[RetrievedContext]:
"""
Hybrid search (all sources).
Args:
query: Search query
**options: Additional options
Returns:
List of RetrievedContext objects from all sources
Example:
>>> results = retriever.hybrid_search("Python")
"""
return self.retrieve(query, **options)
# Advanced Retrieval
def find_similar(
self, content: str, limit: int = 5, **options
) -> List[RetrievedContext]:
"""
Find similar content.
Args:
content: Content to find similar items for
limit: Maximum results (default: 5)
**options: Additional options
Returns:
List of similar RetrievedContext objects
Example:
>>> similar = retriever.find_similar("Python programming", limit=5)
"""
return self.retrieve(content, max_results=limit, **options)
def get_context(
self, query: str, max_results: int = 5, **options
) -> List[RetrievedContext]:
"""
Get context for query.
Args:
query: Query string
max_results: Maximum results (default: 5)
**options: Additional options
Returns:
List of RetrievedContext objects
Example:
>>> context_data = retriever.get_context("Python", max_results=10)
"""
return self.retrieve(query, max_results=max_results, **options)
def expand_query(
self, query: str, max_hops: int = 2, **options
) -> List[RetrievedContext]:
"""
Expand query with graph.
Args:
query: Query string
max_hops: Maximum expansion hops (default: 2)
**options: Additional options
Returns:
List of expanded RetrievedContext objects
Example:
>>> expanded = retriever.expand_query("Python", max_hops=3)
"""
return self.retrieve(
query,
use_graph_expansion=True,
max_hops=max_hops,
**options,
)
def get_related(self, entity_id: str, max_hops: int = 2) -> List[Dict[str, Any]]:
"""
Get related entities.
Args:
entity_id: Entity ID
max_hops: Maximum hops (default: 2)
Returns:
List of related entity dicts
Example:
>>> related = retriever.get_related("entity_123", max_hops=2)
"""
if not self.knowledge_graph:
return []
return self._get_related_entities(entity_id, max_hops=max_hops)
def get_path(
self, source_id: str, target_id: str, max_hops: int = 5
) -> List[Dict[str, Any]]:
"""
Get path between entities.
Args:
source_id: Source entity ID
target_id: Target entity ID
max_hops: Maximum hops (default: 5)
Returns:
List of path nodes/edges
Example:
>>> path = retriever.get_path("entity_1", "entity_2", max_hops=5)
"""
if not self.knowledge_graph:
return []
# Simple BFS path finding
from collections import deque
queue = deque([(source_id, [source_id])])
visited = {source_id}
while queue:
current_id, path = queue.popleft()
if len(path) > max_hops:
continue
if current_id == target_id:
# Return path with node info
nodes = self.knowledge_graph.get("nodes", [])
path_info = []
for node_id in path:
for node in nodes:
if node.get("id") == node_id:
path_info.append(
{
"id": node_id,
"content": node.get("content", ""),
"type": node.get("type", ""),
}
)
break
return path_info
# Get neighbors
edges = self.knowledge_graph.get("edges", [])
for edge in edges:
neighbor_id = None
if edge.get("source") == current_id:
neighbor_id = edge.get("target")
elif edge.get("target") == current_id:
neighbor_id = edge.get("source")
if neighbor_id and neighbor_id not in visited:
visited.add(neighbor_id)
queue.append((neighbor_id, path + [neighbor_id]))
return []
# Reasoning Methods
def _build_reasoning_path(
self,
query_entities: List[Dict[str, Any]],
max_hops: int = 2
) -> List[Dict[str, Any]]:
"""
Build multi-hop reasoning path through knowledge graph.
Args:
query_entities: List of entities extracted from query
max_hops: Maximum number of hops to traverse (default: 2)
Returns:
List of reasoning path segments with entity relationships
"""
if not self.knowledge_graph:
return []
reasoning_paths = []
visited_entities = set()
# Get entities and relationships from knowledge graph
# Handle both dict and GraphStore objects
if isinstance(self.knowledge_graph, dict):
entities = self.knowledge_graph.get("entities", [])
relationships = self.knowledge_graph.get("relationships", [])
elif hasattr(self.knowledge_graph, "get_entities") and hasattr(self.knowledge_graph, "get_relationships"):
# GraphStore-like object
entities = self.knowledge_graph.get_entities() or []
relationships = self.knowledge_graph.get_relationships() or []
else:
# Try to access as dict anyway
entities = getattr(self.knowledge_graph, "entities", [])
relationships = getattr(self.knowledge_graph, "relationships", [])
# Create entity lookup
entity_map = {}
for entity in entities:
entity_id = entity.get("id") or entity.get("text") or entity.get("name")
if entity_id:
entity_map[entity_id] = entity
# Create relationship lookup
rel_map = {}
for rel in relationships:
source = rel.get("source") or rel.get("source_id")
target = rel.get("target") or rel.get("target_id")
rel_type = rel.get("type") or rel.get("predicate")
if source and target:
if source not in rel_map:
rel_map[source] = []
rel_map[source].append({"target": target, "type": rel_type, "rel": rel})
# Start BFS from query entities
from collections import deque
queue = deque()
for query_entity in query_entities:
entity_id = query_entity.get("id") or query_entity.get("text") or query_entity.get("name")
if entity_id and entity_id in entity_map:
queue.append((entity_id, 0, [entity_id]))
while queue:
current_id, hop, path = queue.popleft()
if hop >= max_hops:
continue
if current_id in visited_entities:
continue
visited_entities.add(current_id)
# Get relationships from current entity
if current_id in rel_map:
for rel_info in rel_map[current_id]:
target_id = rel_info["target"]
rel_type = rel_info["type"]
rel = rel_info["rel"]
if target_id not in path: # Avoid cycles
new_path = path + [target_id]
# Build relationships list for this path
path_relationships = []
for i in range(len(new_path) - 1):
source_id = new_path[i]
target_id_rel = new_path[i + 1]
# Find the relationship type between these two entities
rel_type_found = None
if source_id in rel_map:
for r_info in rel_map[source_id]:
if r_info["target"] == target_id_rel:
rel_type_found = r_info["type"]
break
path_relationships.append({
"source": source_id,
"target": target_id_rel,
"type": rel_type_found or "related_to"
})
# Add to reasoning paths
reasoning_paths.append({
"path": new_path,
"hops": hop + 1,
"relationships": path_relationships,
"entities": [
entity_map.get(eid, {}) for eid in new_path
]
})
# Continue traversal
if target_id in entity_map and hop + 1 < max_hops:
queue.append((target_id, hop + 1, new_path))
return reasoning_paths
def _generate_reasoned_response(
self,
query: str,
retrieved_context: List[RetrievedContext],
reasoning_paths: List[Dict[str, Any]],
llm_provider: Any,
at_time: Optional[Any] = None,
header_template: str = "[Graph context valid as of: {at_time} UTC | Source: {source}]",
) -> str:
"""
Generate natural language response using LLM with retrieved context and reasoning paths.
Args:
query: User query
retrieved_context: Retrieved context items
reasoning_paths: Multi-hop reasoning paths
llm_provider: LLM provider instance (from semantica.llms)
at_time: Optional point-in-time for the graph snapshot. When set, a
structured temporal header is prepended to the context block so
the LLM knows the validity window of the facts it is reasoning over.
header_template: Format string for the temporal header. Placeholders:
``{at_time}`` (ISO timestamp) and ``{source}`` (snapshot label).
Returns:
Generated natural language response
"""
# Build temporal header when at_time is explicitly provided
temporal_header = ""
if at_time is not None:
if isinstance(at_time, datetime):
# Normalise to UTC so the header always says "UTC" truthfully
if at_time.tzinfo is None:
at_time = at_time.replace(tzinfo=timezone.utc)
else:
at_time = at_time.astimezone(timezone.utc)
at_time_str = at_time.isoformat()
else:
at_time_str = str(at_time)
# Use plain str.replace instead of .format() so that unexpected
# braces in header_template or in at_time_str cannot be
# interpreted as additional format placeholders (injection guard).
temporal_header = (
header_template
.replace("{at_time}", at_time_str)
.replace("{source}", "KnowledgeGraph snapshot")
) + "\n\n"
# Format retrieved context
context_text = "\n\n".join([
f"Context {i+1} (Score: {ctx.score:.2f}):\n{ctx.content}"
for i, ctx in enumerate(retrieved_context[:5])
])
# Format reasoning paths
reasoning_text = ""
if reasoning_paths:
reasoning_text = "\n\nReasoning Paths (Multi-hop connections):\n"
for i, path_info in enumerate(reasoning_paths[:3], 1):
entities = path_info.get("entities", [])
relationships = path_info.get("relationships", [])
if entities:
path_parts = []
for j, entity in enumerate(entities):
entity_name = entity.get('text') or entity.get('name') or 'Unknown'
path_parts.append(entity_name)
# Add relationship after entity (except for last entity)
if j < len(relationships) and relationships[j].get('type'):
rel_type = relationships[j]['type']
path_parts.append(f"--[{rel_type}]-->")
path_str = " ".join(path_parts)
reasoning_text += f"Path {i}: {path_str}\n"
# Construct prompt
prompt = f"""You are a knowledge graph reasoning assistant. Answer the user's question based on the retrieved context and reasoning paths from the knowledge graph.
User Question: {query}
{temporal_header}Retrieved Context:
{context_text}
{reasoning_text}
Instructions:
1. Answer the question using the retrieved context and reasoning paths
2. Cite specific entities and relationships from the reasoning paths
3. Explain the multi-hop connections when relevant
4. Be concise but comprehensive
5. If information is not available in the context, say so
Answer:"""
try:
response = llm_provider.generate(prompt)
return response
except Exception as e:
self.logger.warning(f"LLM generation failed: {e}")
# Fallback: return summary of context
return f"Based on the retrieved context, here are the relevant findings:\n\n{context_text[:500]}..."
def query_with_reasoning(
self,
query: str,
llm_provider: Any,
max_results: int = 10,
max_hops: int = 2,
at_time: Optional[Any] = None,
header_template: str = "[Graph context valid as of: {at_time} UTC | Source: {source}]",
**kwargs,
) -> Dict[str, Any]:
"""
Query with multi-hop reasoning and LLM-based response generation.
Retrieves context, builds reasoning paths through the graph, and generates
a natural language response grounded in the knowledge graph.
Args:
query: User query
llm_provider: LLM provider instance (from semantica.llms)
max_results: Maximum context results to retrieve (default: 10)
max_hops: Maximum graph traversal hops (default: 2)
at_time: Optional point-in-time for the graph snapshot. Accepts a
:class:`~datetime.datetime` (naive datetimes are assumed UTC)
or an ISO 8601 string. When set, a structured temporal header
is prepended to the LLM context block so the model knows the
validity window of the facts it reasons over.
header_template: Template string for the temporal header. Only
``{at_time}`` and ``{source}`` placeholders are substituted;
any other braces are left as-is. Defaults to
``"[Graph context valid as of: {at_time} UTC | Source: {source}]"``.
**kwargs: Additional retrieval options passed to ``retrieve()``
Returns:
Dictionary with:
- response: Generated natural language answer
- reasoning_path: Multi-hop reasoning trace
- sources: Retrieved context items
- confidence: Overall confidence score
Example:
>>> from semantica.llms import Groq
>>> llm = Groq(model="llama-3.1-8b-instant")
>>> result = retriever.query_with_reasoning(
... "What IPs are associated with security alerts?",
... llm_provider=llm,
... max_hops=2
... )
>>> print(result['response'])
"""
tracking_id = self.progress_tracker.start_tracking(
file=None,
module="context",
submodule="ContextRetriever",
message=f"Querying with reasoning: {query[:50]}...",
)
try:
# Step 1: Retrieve initial context
self.progress_tracker.update_tracking(
tracking_id, message="Retrieving context..."
)
retrieved_context = self.retrieve(
query,
max_results=max_results,
use_graph_expansion=True,
**kwargs
)
# Step 2: Extract entities from query and retrieved context
self.progress_tracker.update_tracking(
tracking_id, message="Extracting entities..."
)
query_entities = []
# Extract entities from retrieved context
for ctx in retrieved_context:
query_entities.extend(ctx.related_entities)
# Deduplicate entities
seen_ids = set()
unique_entities = []
for entity in query_entities:
entity_id = entity.get("id") or entity.get("text") or entity.get("name")
if entity_id and entity_id not in seen_ids:
seen_ids.add(entity_id)
unique_entities.append(entity)
# Step 3: Build reasoning paths
self.progress_tracker.update_tracking(
tracking_id, message="Building reasoning paths..."
)
reasoning_paths = self._build_reasoning_path(
unique_entities,
max_hops=max_hops
)
# Step 4: Generate response using LLM
self.progress_tracker.update_tracking(
tracking_id, message="Generating response..."
)
response = self._generate_reasoned_response(
query,
retrieved_context,
reasoning_paths,
llm_provider,
at_time=at_time,
header_template=header_template,
)
# Step 5: Format reasoning path as string
reasoning_path_str = ""
if reasoning_paths:
for path_info in reasoning_paths[:1]: # Show first path
entities = path_info.get("entities", [])
relationships = path_info.get("relationships", [])
if entities:
path_parts = []
for i, entity in enumerate(entities):
entity_name = entity.get("text") or entity.get("name") or "Unknown"
path_parts.append(entity_name)
if i < len(relationships) and relationships[i].get("type"):
path_parts.append(f"--[{relationships[i]['type']}]-->")
reasoning_path_str = " ".join(path_parts)
# Calculate overall confidence
confidence = 0.0
if retrieved_context:
avg_score = sum(ctx.score for ctx in retrieved_context) / len(retrieved_context)
confidence = min(1.0, avg_score * 0.8 + (0.2 if reasoning_paths else 0.0))
self.progress_tracker.stop_tracking(
tracking_id, status="completed", message="Query with reasoning completed"
)
return {
"response": response,
"reasoning_path": reasoning_path_str,
"sources": [
{
"content": ctx.content[:200],
"score": ctx.score,
"source": ctx.source
}
for ctx in retrieved_context[:5]
],
"confidence": confidence,
"num_sources": len(retrieved_context),
"num_reasoning_paths": len(reasoning_paths)
}
except Exception as e:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
self.logger.error(f"Query with reasoning failed: {e}")
# Fallback: return retrieved context without LLM generation
return {
"response": f"Retrieved {len(retrieved_context)} relevant items. LLM generation unavailable.",
"reasoning_path": "",
"sources": [
{
"content": ctx.content[:200],
"score": ctx.score,
"source": ctx.source
}
for ctx in retrieved_context[:5]
],
"confidence": 0.5,
"num_sources": len(retrieved_context),
"num_reasoning_paths": 0
}
# Filter Methods
def filter_by_entity(
self, entity_id: str, query: str, **options
) -> List[RetrievedContext]:
"""
Filter by entity.
Args:
entity_id: Entity ID to filter by
query: Search query
**options: Additional options
Returns:
Filtered RetrievedContext objects
Example:
>>> results = retriever.filter_by_entity("entity_123", "Python")
"""
results = self.retrieve(query, **options)
filtered = []
for result in results:
# Check if entity is in related entities
for entity in result.related_entities:
if entity.get("id") == entity_id:
filtered.append(result)
break
return filtered
def filter_by_type(
self, type: str, query: str, **options
) -> List[RetrievedContext]:
"""
Filter by type.
Args:
type: Node/entity type to filter by
query: Search query
**options: Additional options
Returns:
Filtered RetrievedContext objects
Example:
>>> results = retriever.filter_by_type("PROGRAMMING_LANGUAGE", "Python")
"""
results = self.retrieve(query, **options)
filtered = []
for result in results:
if result.metadata.get("node_type") == type:
filtered.append(result)
return filtered
def filter_by_date(
self,
start_date: Union[str, datetime],
end_date: Union[str, datetime],
query: str,
**options
) -> List[RetrievedContext]:
"""
Filter by date.
Args:
start_date: Start date
end_date: End date
query: Search query
**options: Additional options
Returns:
Filtered RetrievedContext objects
Example:
>>> results = retriever.filter_by_date("2024-01-01", "2024-12-31", "Python")
"""
if isinstance(start_date, str):
from dateutil.parser import parse
start_date = parse(start_date)
if isinstance(end_date, str):
from dateutil.parser import parse
end_date = parse(end_date)
results = self.retrieve(query, **options)
filtered = []
for result in results:
result_date = result.metadata.get("timestamp")
if result_date:
if isinstance(result_date, str):
from dateutil.parser import parse
result_date = parse(result_date)
if start_date <= result_date <= end_date:
filtered.append(result)
return filtered
def filter_by_score(
self,
min_score: float,
query: str,
**options
) -> List[RetrievedContext]:
"""
Filter by score.
Args:
min_score: Minimum score threshold
query: Search query
**options: Additional options
Returns:
Filtered RetrievedContext objects
Example:
>>> results = retriever.filter_by_score(0.7, "Python")
"""
results = self.retrieve(query, min_relevance_score=min_score, **options)
return [r for r in results if r.score >= min_score]
# Batch Operations
def batch_search(
self, queries: List[str], **options
) -> Dict[str, List[RetrievedContext]]:
"""
Search multiple queries.
Args:
queries: List of queries
**options: Additional options
Returns:
Dict mapping query to results
Example:
>>> results = retriever.batch_search(["Python", "Java", "C++"])
"""
results = {}
for query in queries:
results[query] = self.retrieve(query, **options)
return results
def batch_get_context(
self,
queries: List[str],
max_results: int = 5,
**options
) -> Dict[str, List[RetrievedContext]]:
"""
Get context for multiple queries.
Args:
queries: List of queries
max_results: Maximum results per query (default: 5)
**options: Additional options
Returns:
Dict mapping query to context results
Example:
>>> contexts = retriever.batch_get_context(
... ["Python", "Java"], max_results=5
... )
"""
results = {}
for query in queries:
results[query] = self.get_context(query, max_results=max_results, **options)
return results
# Decision Context Methods
def retrieve_decision_precedents(
self,
query: str,
limit: int = 10,
use_hybrid_search: bool = True,
semantic_weight: float = 0.7,
structural_weight: float = 0.3,
max_hops: int = 3,
include_context: bool = True,
filters: Optional[Dict[str, Any]] = None
) -> List[RetrievedContext]:
"""
Retrieve decision precedents using hybrid search.
Args:
query: Decision scenario query
limit: Number of precedents to return
use_hybrid_search: Whether to use hybrid similarity
semantic_weight: Weight for semantic similarity
structural_weight: Weight for structural similarity
max_hops: Maximum hops for context expansion
include_context: Whether to include contextual information
filters: Optional metadata filters
Returns:
List of RetrievedContext objects with decision precedents
"""
if not self.vector_store:
self.logger.warning("No vector store available for precedent search")
return []
# Search for similar decisions
try:
similar_decisions = self.vector_store.search_decisions(
query=query,
semantic_weight=semantic_weight,
structural_weight=structural_weight,
filters=filters,
limit=limit,
use_hybrid_search=use_hybrid_search
)
except (AttributeError, NotImplementedError):
# Fallback to regular vector search
vector_results = self.vector_store.search(query, limit=limit)
similar_decisions = []
for result in vector_results:
similar_decisions.append({
"similarity": result.get("score", 0.0),
"metadata": result.get("metadata", {}),
"id": result.get("id")
})
# Convert to RetrievedContext objects
precedents = []
for decision in similar_decisions:
metadata = decision.get("metadata", {})
scenario = metadata.get("scenario", "")
reasoning = metadata.get("reasoning", "")
outcome = metadata.get("outcome", "")
# Build content
content_parts = [f"Scenario: {scenario}"]
if reasoning:
content_parts.append(f"Reasoning: {reasoning}")
if outcome:
content_parts.append(f"Outcome: {outcome}")
content = "\n".join(content_parts)
# Create RetrievedContext
precedent = RetrievedContext(
content=content,
score=decision.get("similarity", 0.0),
source="decision_precedent",
metadata=metadata
)
# Add context if requested (only when hybrid search is enabled)
if include_context and self.knowledge_graph and use_hybrid_search:
context_entities = self._extract_entities_from_decision(metadata)
if context_entities:
precedent.related_entities = context_entities
# Expand context with graph traversal
if max_hops > 0:
expanded_entities = self._expand_decision_context(
context_entities, max_hops
)
precedent.related_entities.extend(expanded_entities)
precedents.append(precedent)
return precedents
def query_decisions(
self,
query: str,
max_hops: int = 3,
include_context: bool = True,
use_hybrid_search: bool = False,
limit: int = 10,
filters: Optional[Dict[str, Any]] = None
) -> List[RetrievedContext]:
"""
Query decisions with multi-hop reasoning capabilities.
Args:
query: Natural language query
max_hops: Maximum hops for context expansion
include_context: Whether to include contextual information
use_hybrid_search: Whether to use hybrid search
limit: Number of results
filters: Optional metadata filters
Returns:
List of RetrievedContext objects
"""
return self.retrieve_decision_precedents(
query=query,
limit=limit,
use_hybrid_search=use_hybrid_search,
max_hops=max_hops,
include_context=include_context,
filters=filters
)
def get_decision_context(
self,
decision_id: str,
depth: int = 2,
include_entities: bool = True,
include_policies: bool = True,
max_hops: int = 3
) -> RetrievedContext:
"""
Get comprehensive context for a specific decision.
Args:
decision_id: Decision vector ID
depth: Context depth
include_entities: Whether to include entities
include_policies: Whether to include policies
max_hops: Maximum hops for context expansion
Returns:
RetrievedContext with comprehensive decision context
"""
if not self.vector_store:
raise ValueError("Vector store required for decision context")
# Get decision metadata
decision_metadata = self.vector_store.get_metadata(decision_id)
if not decision_metadata:
raise ValueError(f"Decision {decision_id} not found")
# Build content
scenario = decision_metadata.get("scenario", "")
reasoning = decision_metadata.get("reasoning", "")
outcome = decision_metadata.get("outcome", "")
content_parts = [f"Decision ID: {decision_id}"]
content_parts.append(f"Scenario: {scenario}")
if reasoning:
content_parts.append(f"Reasoning: {reasoning}")
if outcome:
content_parts.append(f"Outcome: {outcome}")
content = "\n".join(content_parts)
# Create RetrievedContext
context = RetrievedContext(
content=content,
score=1.0, # Perfect match for exact decision
source="decision_context",
metadata=decision_metadata
)
# Add entities
if include_entities:
entities = self._extract_entities_from_decision(decision_metadata)
context.related_entities = entities
# Expand with graph traversal
if self.knowledge_graph and max_hops > 0:
expanded_entities = self._expand_decision_context(entities, max_hops)
context.related_entities.extend(expanded_entities)
# Add policies if requested
if include_policies and self.knowledge_graph:
policies = self._find_relevant_policies(decision_metadata)
context.related_relationships = policies
return context
def _extract_entities_from_decision(self, metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract entities from decision metadata."""
entities = []
# Get entities from metadata
decision_entities = metadata.get("entities", [])
for entity in decision_entities:
entities.append({
"name": entity,
"type": "entity",
"source": "decision"
})
# Add category as entity
category = metadata.get("category")
if category:
entities.append({
"name": category,
"type": "category",
"source": "decision"
})
return entities
def _expand_decision_context(
self,
entities: List[Dict[str, Any]],
max_hops: int
) -> List[Dict[str, Any]]:
"""Expand decision context using graph traversal and KG algorithms."""
if not self.knowledge_graph:
return []
expanded_entities = []
for entity in entities:
entity_name = entity.get("name")
if not entity_name:
continue
# Find related entities using multiple KG algorithms
try:
# Basic neighbor expansion — prefer get_neighbors > get_neighbor_ids > neighbors
# Supports multi-hop BFS when max_hops > 1
try:
def _get_neighbors(node: str) -> List[Any]:
if hasattr(self.knowledge_graph, 'get_neighbors'):
try:
raw = self.knowledge_graph.get_neighbors(node)
except TypeError:
raw = self.knowledge_graph.get_neighbors(node, hops=1)
if isinstance(raw, list):
return [n.get("id") if isinstance(n, dict) else n for n in raw if n]
elif hasattr(self.knowledge_graph, "get_neighbor_ids"):
return list(self.knowledge_graph.get_neighbor_ids(node))
elif hasattr(self.knowledge_graph, "neighbors"):
return [
n.get("id") if isinstance(n, dict) else n
for n in self.knowledge_graph.neighbors(node)
if n
]
return []
visited: set = {entity_name}
frontier = _get_neighbors(entity_name)
for hop in range(1, max_hops + 1):
next_frontier: List[Any] = []
for neighbor in frontier[:5]: # Limit per level
if neighbor and neighbor not in visited:
visited.add(neighbor)
expanded_entities.append({
"name": neighbor,
"type": "related_entity",
"source": "graph_expansion",
"parent_entity": entity_name,
"relationship_type": "neighbor",
"hop_distance": hop,
})
next_hop = _get_neighbors(neighbor)
next_frontier.extend(next_hop)
frontier = next_frontier
if not frontier:
break
except Exception:
pass # Neighbor expansion is best-effort
# Use path finder for multi-hop relationships
if self.path_finder and max_hops > 1:
# Find entities within specified hop distance
for other_entity in entities:
other_name = other_entity.get("name")
if other_name and other_name != entity_name:
try:
path = self.path_finder.find_shortest_path(
self.knowledge_graph, entity_name, other_name
)
if path and 1 < len(path) <= max_hops + 1:
# Add intermediate entities from path
for intermediate in path[1:-1]:
expanded_entities.append({
"name": intermediate,
"type": "path_intermediate",
"source": "path_finder",
"parent_entity": entity_name,
"path_length": len(path),
"target_entity": other_name
})
except Exception:
continue
# Use community detection for contextually related entities
if self.community_detector:
try:
communities = self.community_detector.detect_communities(self.knowledge_graph)
# Find community of current entity
entity_community = None
for comm_id, comm_nodes in communities.items():
if entity_name in comm_nodes:
entity_community = comm_id
break
# Add other entities from same community
if entity_community is not None:
same_community_entities = communities[entity_community]
for comm_entity in same_community_entities:
if comm_entity != entity_name and comm_entity not in [e["name"] for e in expanded_entities]:
expanded_entities.append({
"name": comm_entity,
"type": "community_related",
"source": "community_detector",
"parent_entity": entity_name,
"community_id": entity_community
})
except Exception:
continue
# Use centrality to rank and prioritize important entities
if self.centrality_calculator and expanded_entities:
try:
# Calculate centrality for expanded entities
centrality_scores = {}
for expanded_entity in expanded_entities:
entity_name = expanded_entity["name"]
if hasattr(self.centrality_calculator, 'calculate_degree_centrality'):
# Simplified centrality calculation
if hasattr(self.knowledge_graph, 'get_neighbors'):
if hasattr(self.knowledge_graph, "neighbors"):
neighbor_ids = [
n.get("id") if isinstance(n, dict) else n
for n in self.knowledge_graph.neighbors(entity_name)
if n
]
elif hasattr(self.knowledge_graph, "get_neighbor_ids"):
neighbor_ids = self.knowledge_graph.get_neighbor_ids(entity_name)
else:
neighbor_details = self.knowledge_graph.get_neighbors(entity_name, hops=1)
neighbor_ids = [
n.get("id") for n in neighbor_details
if isinstance(n, dict) and n.get("id")
]
centrality_scores[entity_name] = len(neighbor_ids)
else:
centrality_scores[entity_name] = 1
# Sort by centrality and keep top entities
expanded_entities.sort(
key=lambda x: centrality_scores.get(x["name"], 0),
reverse=True
)
# Keep only top entities based on centrality
expanded_entities = expanded_entities[:10]
# Add centrality information
for entity in expanded_entities:
entity["centrality_score"] = centrality_scores.get(entity["name"], 0)
except Exception:
continue
except Exception as e:
# Sanitize entity name for logging (remove sensitive data)
safe_entity_name = entity_name[:20] if entity_name else "unknown"
self.logger.warning(f"Failed to expand context for {safe_entity_name}: {type(e).__name__}")
return expanded_entities
def _find_relevant_policies(self, metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Find relevant policies for decision."""
policies = []
if not self.knowledge_graph:
return policies
# Extract category and look for related policies
category = metadata.get("category")
if category:
try:
# Look for policy nodes related to category
if hasattr(self.knowledge_graph, 'get_nodes_by_label'):
policy_nodes = self.knowledge_graph.get_nodes_by_label("Policy")
for policy in policy_nodes[:5]: # Limit results
policy_name = (
policy.get("content")
or policy.get("metadata", {}).get("name", "")
or policy.get("id", "")
) if isinstance(policy, dict) else policy
policies.append({
"name": policy_name,
"type": "policy",
"source": "policy_search",
"related_category": category
})
except Exception as e:
# Sanitize category for logging (remove sensitive data)
safe_category = category[:20] if category else "unknown"
self.logger.warning(f"Failed to find policies for {safe_category}: {type(e).__name__}")
return policies
return policies
# Decision Retrieval Methods
def find_precedents_hybrid(
self,
scenario: str,
category: Optional[str] = None,
limit: int = 10,
use_hybrid_search: bool = True,
max_hops: int = 3
) -> List["Decision"]:
"""
Hybrid precedent search with user controls.
Args:
scenario: Scenario to find precedents for
category: Optional category filter
limit: Maximum number of results
use_hybrid_search: Use hybrid search (semantic + structural)
max_hops: Maximum hops for multi-hop reasoning
Returns:
List of similar decisions
"""
from .decision_models import Decision
if not self.knowledge_graph:
return []
try:
# Use DecisionQuery if available
if hasattr(self.knowledge_graph, 'execute_query'):
from .decision_query import DecisionQuery
query_engine = DecisionQuery(self.knowledge_graph)
return query_engine.find_precedents_hybrid(scenario, category, limit)
else:
# Fallback to simple graph search
return self._fallback_precedent_search(scenario, category, limit)
except Exception as e:
self.logger.error(f"Failed to find precedents: {e}")
return []
def retrieve_decisions(
self,
query: str,
category: Optional[str] = None,
limit: int = 10
) -> List["Decision"]:
"""
Decision-specific retrieval.
Args:
query: Query string
category: Optional category filter
limit: Maximum number of results
Returns:
List of relevant decisions
"""
return self.find_precedents_hybrid(query, category, limit)
def multi_hop_context_assembly(
self,
start_node: str,
query_context: str,
max_hops: int = 3,
include_context: bool = True
) -> Dict[str, Any]:
"""
Dynamic context assembly with user controls.
Args:
start_node: Starting node ID
query_context: Query context
max_hops: Maximum hops to traverse
include_context: Include full context
Returns:
Assembled context with decisions
"""
if not self.knowledge_graph:
return {"context": [], "decisions": [], "metadata": {}}
try:
# Get multi-hop context
context = self.expand_context(start_node, max_hops=max_hops)
# Find related decisions
decisions = []
query_engine = self._get_decision_query()
if query_engine is not None:
try:
decisions = query_engine.multi_hop_reasoning(start_node, query_context, max_hops)
except Exception:
decisions = []
return {
"context": context,
"decisions": decisions,
"metadata": {
"start_node": start_node,
"query_context": query_context,
"max_hops": max_hops,
"context_count": len(context),
"decision_count": len(decisions)
}
}
except Exception as e:
self.logger.error(f"Failed multi-hop context assembly: {e}")
return {"context": [], "decisions": [], "metadata": {}}
def graph_augmented_generation(
self,
query: str,
graph_context: Dict[str, Any]
) -> str:
"""
GAG for reduced hallucinations.
Args:
query: Query string
graph_context: Graph context from multi-hop assembly
Returns:
Augmented response with context
"""
context_text = ""
# Format decisions from context
decisions = graph_context.get("decisions", [])
if decisions:
context_text += "Relevant Decisions:\n"
for decision in decisions[:5]: # Limit to top 5
context_text += f"- {decision.scenario} (Category: {decision.category})\n"
context_text += f" Reasoning: {decision.reasoning[:200]}...\n"
context_text += f" Outcome: {decision.outcome}\n\n"
# Format entities from context
entities = graph_context.get("context", [])
if entities:
context_text += "Related Entities:\n"
for entity in entities[:10]: # Limit to top 10
context_text += f"- {entity.get('name', 'Unknown')} ({entity.get('type', 'Unknown')})\n"
# Generate augmented response
if context_text:
return f"Based on the following context:\n\n{context_text}\nQuery: {query}\n\nResponse would be generated by LLM with this context."
else:
return f"Query: {query}\n\nNo relevant context found."
def explainable_retrieval(self, decision_id: str) -> Dict[str, Any]:
"""
Trace relationship paths for explainability.
Args:
decision_id: Decision ID to trace
Returns:
Explainability information
"""
if not self.knowledge_graph:
return {"error": "Knowledge graph not available"}
try:
# Get decision details
decision_context = self.expand_context(decision_id, max_hops=1)
# Trace paths using graph traversal
paths = []
if hasattr(self.knowledge_graph, 'execute_query'):
query = """
MATCH path = (d:Decision {decision_id: $decision_id})-[*1..3]-(related)
RETURN path, length(path) as path_length
ORDER BY path_length
LIMIT 10
"""
results = self.knowledge_graph.execute_query(query, {"decision_id": decision_id})
paths = [record.get("path") for record in results]
return {
"decision_id": decision_id,
"context": decision_context,
"relationship_paths": paths,
"path_count": len(paths)
}
except Exception as e:
self.logger.error(f"Failed explainable retrieval: {e}")
return {"error": "An error occurred during explainable retrieval. Please check the logs for details."}
def dynamic_context_traversal(
self,
query: str,
entity_types: List[str],
max_hops: int = 4
) -> Dict[str, Any]:
"""
Dynamic context assembly with entity filtering.
Args:
query: Query string
entity_types: List of entity types to filter
max_hops: Maximum hops to traverse
Returns:
Filtered context results
"""
if not self.knowledge_graph:
return {"context": [], "metadata": {}}
try:
# Find relevant entities based on query
relevant_entities = []
# Use vector search to find initial entities
vector_results = self.vector_search(query, limit=20)
for result in vector_results:
if result.get("type") in entity_types:
relevant_entities.append(result)
# Expand context for all relevant entities via single traversal
raw_context = self.expand_context(query, max_hops=max_hops)
expanded_context = [
item for item in raw_context
if item.get("type") in entity_types
]
return {
"query": query,
"entity_types": entity_types,
"max_hops": max_hops,
"context": expanded_context,
"metadata": {
"initial_entities": len(relevant_entities),
"expanded_context": len(expanded_context)
}
}
except Exception as e:
self.logger.error(f"Failed dynamic context traversal: {e}")
return {"context": [], "metadata": {}}
def hybrid_retrieval(
self,
query: str,
use_vector: bool = True,
use_graph: bool = True
) -> Dict[str, Any]:
"""
Combine vector search with graph traversal.
Args:
query: Query string
use_vector: Use vector search
use_graph: Use graph traversal
Returns:
Hybrid retrieval results
"""
results = {"query": query, "vector_results": [], "graph_results": [], "hybrid_results": []}
try:
# Vector search
if use_vector:
results["vector_results"] = self.vector_search(query, limit=20)
# Graph search
if use_graph:
graph_context = self.expand_context(query, max_hops=2)
results["graph_results"] = graph_context
# Combine results
all_results = results["vector_results"] + results["graph_results"]
# Deduplicate and rank
seen = set()
hybrid_results = []
for result in all_results:
result_id = result.get("name") or result.get("id") or str(hash(result.get("content", "")))
if result_id not in seen:
seen.add(result_id)
hybrid_results.append(result)
results["hybrid_results"] = hybrid_results[:20] # Limit final results
except Exception as e:
self.logger.error(f"Failed hybrid retrieval: {e}")
return results
def _fallback_precedent_search(
self,
scenario: str,
category: Optional[str],
limit: int
) -> List["Decision"]:
"""Fallback precedent search when DecisionQuery is not available."""
from .decision_models import Decision
decisions = []
try:
# Simple graph search for decision nodes
if hasattr(self.knowledge_graph, 'get_nodes_by_label'):
decision_nodes = self.knowledge_graph.get_nodes_by_label("Decision")
for node_data in decision_nodes[:limit]:
metadata = {}
if isinstance(node_data, dict):
metadata = node_data.get("metadata") or node_data.get("properties") or {}
# Convert to Decision object
decision = Decision(
decision_id=node_data.get("id", "") if isinstance(node_data, dict) else "",
category=metadata.get("category", ""),
scenario=node_data.get("content", "") if isinstance(node_data, dict) else "",
reasoning=metadata.get("reasoning", ""),
outcome=metadata.get("outcome", ""),
confidence=metadata.get("confidence", 0.0),
timestamp=datetime.now(),
decision_maker=metadata.get("decision_maker", ""),
metadata=metadata
)
# Filter by category if specified
if category and decision.category != category:
continue
decisions.append(decision)
except Exception as e:
self.logger.error(f"Fallback precedent search failed: {e}")
return decisions
def _extract_entities_from_query(self, query: str) -> List[str]:
"""Extract potential entity names from query."""
# Simple entity extraction - could be enhanced with NER
entities = []
# Split query and look for capitalized terms (potential entities)
words = query.split()
for word in words:
# Strip punctuation for length check but keep original
stripped = word.strip(".,;:!?")
if stripped and stripped[0].isupper() and len(stripped) > 2:
entities.append(stripped)
return entities[:10] # Limit entities
def expand_context(
self,
entity_id: str,
max_hops: int = 2
) -> List[Dict[str, Any]]:
"""
Expand context for an entity using graph traversal.
Args:
entity_id: Entity ID or query term to expand context for
max_hops: Maximum hops to traverse
Returns:
List of related context items
"""
if not self.knowledge_graph:
return []
try:
results = []
visited: set = {entity_id}
current_level = [entity_id]
for hop in range(max_hops):
next_level = []
for node in current_level:
try:
neighbors = self.knowledge_graph.get_neighbors(node)
for neighbor in (neighbors or []):
if neighbor not in visited:
visited.add(neighbor)
next_level.append(neighbor)
results.append({
"id": neighbor,
"type": "Unknown",
"content": str(neighbor),
"hop": hop + 1
})
except Exception:
pass
current_level = next_level
if not current_level:
break
return results
except Exception as e:
self.logger.error(f"Context expansion failed: {e}")
return []
def _get_decision_query(self):
"""Get a DecisionQuery instance for the current knowledge graph."""
if not self.knowledge_graph:
return None
try:
from .decision_query import DecisionQuery
return DecisionQuery(self.knowledge_graph)
except Exception:
return None
class TemporalGraphRetriever:
"""
Drop-in temporal wrapper for any ContextRetriever.
Calls ``base_retriever.retrieve(query)``, then filters ``related_entities``
and ``related_relationships`` in each :class:`RetrievedContext` item to
those active at *at_time* using
:meth:`~semantica.kg.temporal_query.TemporalGraphQuery.reconstruct_at_time`.
When no *at_time* is set (neither in the constructor nor at the call site)
the base retriever result is returned unchanged — no copies, no filtering.
Example::
from semantica.context import ContextRetriever, TemporalGraphRetriever
base = ContextRetriever(knowledge_graph=kg)
retriever = TemporalGraphRetriever(base, at_time="2023-06-01")
results = retriever.retrieve("which suppliers were certified?")
# related_entities / related_relationships in each result are valid as
# of 2023-06-01; dangling edges are removed automatically.
"""
_DEFAULT_HEADER = "[Graph context valid as of: {at_time} UTC | Source: {source}]"
def __init__(
self,
base_retriever: "ContextRetriever",
at_time: Optional[Any] = None,
header_template: str = _DEFAULT_HEADER,
):
"""
Args:
base_retriever: Any :class:`ContextRetriever` instance to wrap.
at_time: Default point-in-time for temporal filtering. Accepts a
:class:`~datetime.datetime` or any string parseable by
:func:`~semantica.kg.temporal_model.parse_temporal_value`
(e.g. ``"2023-06-01"``). ``None`` disables filtering.
header_template: Format string used to build the temporal context
header injected into LLM prompts by
:meth:`ContextRetriever.query_with_reasoning`. Placeholders:
``{at_time}`` and ``{source}``.
Example:
>>> from semantica.context import ContextRetriever, TemporalGraphRetriever
>>> # Passthrough — no temporal filtering applied
>>> base = ContextRetriever(knowledge_graph=kg)
>>> retriever = TemporalGraphRetriever(base)
>>> results = retriever.retrieve("active suppliers") # identical to base.retrieve()
>>> # Point-in-time snapshot — ISO string shorthand
>>> retriever = TemporalGraphRetriever(base, at_time="2023-06-01")
>>> results = retriever.retrieve("certified suppliers")
>>> # related_entities/related_relationships are valid as of 2023-06-01
>>> # Custom prompt header for LLM context
>>> retriever = TemporalGraphRetriever(
... base,
... at_time="2023-06-01",
... header_template="[Snapshot: {at_time} | {source}]",
... )
>>> # Combine with TemporalQueryRewriter for end-to-end temporal RAG
>>> from semantica.kg import TemporalQueryRewriter
>>> rw = TemporalQueryRewriter()
>>> parsed = rw.rewrite("which suppliers were certified before 2022?")
>>> retriever = TemporalGraphRetriever(base, at_time=parsed.at_time)
>>> results = retriever.retrieve(parsed.rewritten_query)
"""
if not _TEMPORAL_AVAILABLE:
raise ImportError(
"TemporalGraphRetriever requires the semantica.kg temporal modules "
"(temporal_query, temporal_model). Ensure they are installed and "
"importable without errors."
)
self.base_retriever = base_retriever
self.at_time = at_time
self.header_template = header_template
self._tgq = _TemporalGraphQuery()
def retrieve(
self,
query: str,
at_time: Optional[Any] = None,
**kwargs,
) -> List[RetrievedContext]:
"""
Retrieve context and apply point-in-time temporal filtering.
Args:
query: Search query forwarded to the base retriever.
at_time: Override the instance-level ``at_time`` for this call.
``None`` falls back to the constructor value; if both are
``None`` the base result is returned unchanged.
**kwargs: Forwarded to ``base_retriever.retrieve()``.
Returns:
List of :class:`RetrievedContext` items whose
``related_entities`` and ``related_relationships`` are restricted
to facts valid at *at_time*. Dangling relationships (whose
source or target entity was filtered out) are removed.
Example:
>>> from datetime import datetime, timezone
>>> from semantica.context import ContextRetriever, TemporalGraphRetriever
>>> base = ContextRetriever(knowledge_graph=kg)
>>> retriever = TemporalGraphRetriever(base, at_time="2023-06-01")
>>> # Use constructor at_time
>>> results = retriever.retrieve("drug interactions")
>>> for r in results:
... print(len(r.related_entities), "entities valid as of 2023-06-01")
>>> # Override at_time per call (e.g. from TemporalQueryRewriter output)
>>> results_q1 = retriever.retrieve(
... "capital requirements",
... at_time=datetime(2023, 3, 31, tzinfo=timezone.utc),
... )
>>> # Passthrough: no at_time → base result unchanged
>>> retriever_plain = TemporalGraphRetriever(base)
>>> plain = retriever_plain.retrieve("suppliers") # no temporal filtering
"""
import dataclasses
effective_at_time = at_time if at_time is not None else self.at_time
results = self.base_retriever.retrieve(query, **kwargs)
if effective_at_time is None:
return results
parsed = (
effective_at_time
if isinstance(effective_at_time, datetime)
else _parse_temporal_value(effective_at_time)
)
filtered_results = []
for ctx in results:
subgraph = {
"entities": ctx.related_entities,
"relationships": ctx.related_relationships,
}
filtered = self._tgq.reconstruct_at_time(subgraph, parsed)
# Return a new RetrievedContext rather than mutating the original
# so callers that hold a reference to the base retriever's results
# are not surprised by side-effects.
filtered_results.append(dataclasses.replace(
ctx,
related_entities=filtered["entities"],
related_relationships=filtered["relationships"],
))
return filtered_results