Backend: PostgreSQL + Apache AGE
Driver: psycopg2
Apache AGE is a PostgreSQL extension that adds graph database functionality, enabling you to run openCypher queries alongside traditional SQL. This backend lets Semantica use AGE as a property graph store with the same interface as Neo4j and FalkorDB.
Prerequisites
| Component | Version |
|---|
| PostgreSQL | 12+ |
| Apache AGE | 1.4+ (compiled and installed) |
| psycopg2 | 2.9+ |
pip install psycopg2-binary
Quick Start
Unified Facade
Direct (ApacheAgeStore)
Use GraphStore(backend="age", …): the same interface as Neo4j and FalkorDB:from semantica.graph_store import GraphStore
store = GraphStore(
backend="age",
connection_string="host=localhost dbname=agedb user=postgres password=secret",
graph_name="semantica",
)
store.connect()
alice = store.create_node(labels=["Person"], properties={"name": "Alice", "age": 30})
bob = store.create_node(labels=["Person"], properties={"name": "Bob", "age": 25})
store.create_relationship(alice["id"], bob["id"], "KNOWS", {"since": 2023})
result = store.execute_query("MATCH (p:Person) RETURN p", cols="p agtype")
print(result["records"])
store.close()
Import ApacheAgeStore directly when you need AGE-specific behaviour:from semantica.graph_store.age_store import ApacheAgeStore
store = ApacheAgeStore(
connection_string="host=localhost dbname=agedb user=postgres password=secret",
graph_name="my_graph",
)
store.connect()
node = store.create_node(["Entity"], {"semantica_id": "ent-001", "value": "test"})
print(node)
# {"id": 844424930131969, "labels": ["Entity"],
# "properties": {"semantica_id": "ent-001", "value": "test"}}
store.close()
Configuration
Environment Variables
| Variable | Description | Default |
|---|
GRAPH_STORE_AGE_CONNECTION_STRING | PostgreSQL connection string | host=localhost dbname=agedb user=postgres password=postgres |
GRAPH_STORE_AGE_GRAPH_NAME | AGE graph name | semantica |
Programmatic Configuration
from semantica.graph_store.config import graph_store_config
graph_store_config.set("age_connection_string", "host=db.example.com dbname=prod_age user=app")
graph_store_config.set("age_graph_name", "production")
Connection & Initialization
On connect(), the store performs idempotent setup: safe to call repeatedly:
Load the extension
CREATE EXTENSION IF NOT EXISTS age;
Activate AGE in the session
LOAD 'age';
Set the search path
SET search_path = ag_catalog, "$user", public;
Create the graph
Creates the named graph if it does not already exist.
ID Handling
Apache AGE auto-generates internal vertex/edge IDs (large integers). These are not the same as any semantic or application-level ID you may want to assign.
| Concept | Description |
|---|
| AGE internal ID | Auto-generated by AGE. Exposed as "id" in all returned dicts. Used in delete_node(), get_node(), etc. |
| Semantic ID | Application-level identifier. Store it in the semantica_id property. |
node = store.create_node(
labels=["Document"],
properties={"semantica_id": "doc-abc-123", "title": "My Doc"},
)
# node["id"] → AGE internal ID (e.g., 844424930131969)
# node["properties"]["semantica_id"] → "doc-abc-123"
Never mix AGE internal IDs with semantic IDs. Use node["id"] for graph operations (delete, update, traverse) and node["properties"]["semantica_id"] for application-level lookups.
Label Handling
AGE supports exactly one label per vertex. Semantica handles this transparently:
labels[0] → used as the primary AGE vertex label.
labels[1:] → stored in a labels property array on the vertex.
When reading nodes, the store reconstructs the full label list automatically.
node = store.create_node(
labels=["Person", "Employee", "Admin"],
properties={"name": "Alice"},
)
# In AGE: vertex with label "Person" and property labels=["Employee", "Admin"]
# Returned: {"id": ..., "labels": ["Person", "Employee", "Admin"], "properties": {"name": "Alice"}}
Cypher Query Execution
All Cypher queries are executed via AGE’s SQL wrapper:
SELECT * FROM cypher('graph_name', $$ <cypher_query> $$) AS (col1 agtype, ...);
Parameter Substitution
AGE does not support $param style binding inside cypher() calls. The store safely converts parameters to Cypher literals with proper escaping:
result = store.execute_query(
"MATCH (p:Person) WHERE p.age > $min_age RETURN p",
parameters={"min_age": 25},
cols="p agtype",
)
Column Specification
For custom queries, pass the cols option to specify the AS clause:
result = store.execute_query(
"MATCH (a)-[r]->(b) RETURN a, r, b",
cols="a agtype, r agtype, b agtype",
)
If omitted, the store attempts to infer columns from the RETURN clause.
Transactions
The store uses explicit PostgreSQL transactions:
- Success →
COMMIT
- Exception →
ROLLBACK, then re-raise as ProcessingError
- No silent failures
API Reference
All methods match the standard Semantica graph store backend interface:
| Method | Description |
|---|
connect(**options) | Connect and initialize AGE |
close() | Close the connection |
create_node(labels, properties) | Create a vertex |
create_nodes(nodes) | Batch create vertices |
get_node(node_id) | Get vertex by AGE ID |
get_nodes(labels, properties, limit) | Query vertices |
update_node(node_id, properties, merge) | Update vertex properties |
delete_node(node_id, detach) | Delete a vertex |
create_relationship(start_id, end_id, type, properties) | Create an edge |
get_relationships(node_id, rel_type, direction, limit) | Query edges |
delete_relationship(rel_id) | Delete an edge |
execute_query(query, parameters) | Run arbitrary Cypher |
get_neighbors(node_id, rel_type, direction, depth) | Graph traversal |
shortest_path(start_id, end_id, rel_type, max_depth) | Path finding |
create_index(label, property_name, index_type) | Create a PostgreSQL index |
get_stats() | Graph statistics |
Docker Setup
services:
age:
image: apache/age:latest
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: agedb
Then connect:
store = GraphStore(
backend="age",
connection_string="host=localhost port=5432 dbname=agedb user=postgres password=secret",
)