Post

Beyond Relational: Data Modeling with Vectors, Graphs, and Hybrid Architectures

Beyond Relational: Data Modeling with Vectors, Graphs, and Hybrid Architectures

Introduction

For decades, relational databases and their normalized table structures dominated data modeling. Rows, columns, foreign keys, and JOIN operations formed the mental model for how we organize information. This worked brilliantly for transactional systems, business applications, and structured data.

Then came the AI era, and suddenly we’re storing embeddings, modeling semantic relationships, and querying by similarity rather than exact matches. Traditional relational schemas don’t capture “find documents similar to this query” or “what’s the shortest path between these entities.” We need new data models for new problems.

This guide explores data modeling in 2026: vector databases for semantic search, graph databases for connected data, and hybrid architectures that combine the best of all worlds. This isn’t about replacing relational databases—it’s about knowing when each model excels and how to combine them effectively.

The Three Data Models

Relational: Structured, Normalized Data

Best For:

  • Transactional systems (orders, payments, user accounts)
  • Structured business data
  • Enforcing data integrity through constraints
  • Complex aggregations and reports

Example Schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES users(id),
    total_amount DECIMAL(10,2),
    status VARCHAR(50),
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE order_items (
    id SERIAL PRIMARY KEY,
    order_id INTEGER REFERENCES orders(id),
    product_id INTEGER REFERENCES products(id),
    quantity INTEGER,
    price DECIMAL(10,2)
);

Strengths:

  • ACID guarantees
  • Well-understood query optimization
  • Rich constraint system
  • Decades of tooling

Limitations:

  • No semantic similarity search
  • JOIN-heavy for highly connected data
  • Fixed schema requires migrations

Vector: Semantic Similarity

Best For:

  • Semantic search (find similar documents)
  • Recommendation systems
  • Content deduplication
  • Multi-modal search (text, images, audio)

Data Model:

1
2
3
4
5
6
7
8
9
10
11
# Conceptual vector model
Document {
    id: int,
    content: string,
    embedding: float[1536],  # Vector representation
    metadata: json
}

# Query: Find similar documents
find_similar(query_vector, k=10)
# Returns documents with highest cosine similarity

Example with pgvector:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE documents (
    id BIGSERIAL PRIMARY KEY,
    content TEXT,
    embedding vector(1536),
    metadata JSONB,
    created_at TIMESTAMP DEFAULT NOW()
);

-- HNSW index for fast similarity search
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);

-- Find similar documents
SELECT id, content, 1 - (embedding <=> $1) AS similarity
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;

Strengths:

  • Natural language search
  • Multilingual search (embeddings cross language barriers)
  • Finds conceptually similar items
  • No keyword engineering needed

Limitations:

  • Black box (why is this similar?)
  • Requires embedding generation
  • High memory usage
  • Cold start problem (new items have no embedding)

Graph: Connected Data

Best For:

  • Social networks (friends, followers)
  • Knowledge graphs (entities and relationships)
  • Fraud detection (patterns in transactions)
  • Recommendation engines (collaborative filtering)

Data Model:

1
2
3
4
5
6
7
8
9
10
11
// Nodes and relationships
(User)-[:FOLLOWS]->(User)
(User)-[:LIKES]->(Product)
(Product)-[:BELONGS_TO]->(Category)

// Query: Friends of friends
MATCH (me:User {id: 123})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(fof)
WHERE NOT (me)-[:FOLLOWS]->(fof) AND fof <> me
RETURN fof.name, COUNT(*) AS mutual_friends
ORDER BY mutual_friends DESC
LIMIT 10

Example with Neo4j:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Create nodes
CREATE (alice:Person {name: 'Alice', email: 'alice@example.com'})
CREATE (bob:Person {name: 'Bob', email: 'bob@example.com'})
CREATE (product:Product {name: 'Laptop', price: 1200})

// Create relationships
CREATE (alice)-[:FOLLOWS {since: '2026-01-01'}]->(bob)
CREATE (alice)-[:PURCHASED {date: '2026-03-15'}]->(product)

// Pathfinding: Shortest path between users
MATCH path = shortestPath(
    (alice:Person {name: 'Alice'})-[*]-(target:Person {name: 'Charlie'})
)
RETURN path

// Pattern matching: Users who bought X also bought Y
MATCH (p1:Person)-[:PURCHASED]->(prod1:Product {name: 'Laptop'})
MATCH (p1)-[:PURCHASED]->(prod2:Product)
WHERE prod2 <> prod1
RETURN prod2.name, COUNT(*) AS co_purchases
ORDER BY co_purchases DESC
LIMIT 5

Strengths:

  • Traversal queries are natural
  • Relationship metadata
  • Pattern matching
  • Flexible schema

Limitations:

  • Sharding is hard
  • No ACID across entire graph
  • Query performance varies wildly
  • Limited aggregation capabilities

Hybrid Architecture Patterns

Pattern 1: Relational + Vector (RAG Systems)

Use Case: Customer support system with semantic search

Architecture:

1
2
3
4
5
6
7
8
PostgreSQL (Relational)          PostgreSQL (Vector)
┌─────────────────────┐          ┌──────────────────────┐
│ users               │          │ knowledge_base       │
│ tickets             │          │ - id                 │
│ conversations       │          │ - content            │
└─────────────────────┘          │ - embedding vector   │
                                 │ - ticket_id (FK)     │
                                 └──────────────────────┘

Implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Relational: Structured ticket data
CREATE TABLE tickets (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT REFERENCES users(id),
    subject VARCHAR(255),
    status VARCHAR(50),
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

CREATE TABLE messages (
    id BIGSERIAL PRIMARY KEY,
    ticket_id BIGINT REFERENCES tickets(id),
    user_id BIGINT REFERENCES users(id),
    content TEXT,
    created_at TIMESTAMP
);

-- Vector: Semantic search over solutions
CREATE TABLE knowledge_articles (
    id BIGSERIAL PRIMARY KEY,
    title VARCHAR(255),
    content TEXT,
    embedding vector(1536),
    category VARCHAR(100),
    created_at TIMESTAMP
);

CREATE INDEX ON knowledge_articles USING hnsw (embedding vector_cosine_ops);

Query Pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class HybridSupport:
    async def find_solution(self, ticket_id: int):
        # Get ticket from relational DB
        ticket = await db.fetchrow(
            "SELECT * FROM tickets WHERE id = $1",
            ticket_id
        )
        
        # Generate embedding for ticket content
        ticket_embedding = generate_embedding(ticket['subject'])
        
        # Semantic search in vector DB
        similar_articles = await db.fetch("""
            SELECT id, title, content, 
                   1 - (embedding <=> $1) AS similarity
            FROM knowledge_articles
            ORDER BY embedding <=> $1
            LIMIT 5
        """, ticket_embedding)
        
        # Enrich with relational metadata
        for article in similar_articles:
            article['usage_count'] = await db.fetchval(
                "SELECT COUNT(*) FROM article_views WHERE article_id = $1",
                article['id']
            )
        
        return similar_articles

Use Case: Research paper recommendation system

Architecture:

1
2
3
4
5
6
7
Neo4j (Graph)                    Qdrant (Vector)
┌──────────────────────┐         ┌──────────────────────┐
│ (Paper)-[:CITES]->(Paper)│      │ paper_embeddings     │
│ (Author)-[:WROTE]->(Paper)│     │ - paper_id           │
│ (Paper)-[:IN_FIELD]->(Topic)│   │ - embedding          │
└──────────────────────┘         │ - abstract           │
                                 └──────────────────────┘

Implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ResearchGraph:
    def __init__(self, neo4j_driver, vector_db):
        self.neo4j = neo4j_driver
        self.vectors = vector_db
    
    async def recommend_papers(self, paper_id: int, user_interests: list[str]):
        # 1. Graph traversal: Find citation network
        with self.neo4j.session() as session:
            citation_network = session.run("""
                MATCH (p:Paper {id: $paper_id})-[:CITES*1..2]->(cited)
                RETURN cited.id AS paper_id, cited.title AS title
                LIMIT 50
            """, paper_id=paper_id).data()
        
        # 2. Vector search: Find semantically similar papers
        interest_embedding = generate_embedding(" ".join(user_interests))
        semantic_matches = await self.vectors.search(
            vector=interest_embedding,
            limit=50
        )
        
        # 3. Hybrid ranking: Combine graph and vector scores
        paper_scores = {}
        
        # Graph-based score (citation proximity)
        for paper in citation_network:
            paper_scores[paper['paper_id']] = paper_scores.get(paper['paper_id'], 0) + 0.7
        
        # Vector-based score (semantic relevance)
        for paper in semantic_matches:
            paper_id = paper.payload['paper_id']
            paper_scores[paper_id] = paper_scores.get(paper_id, 0) + (paper.score * 0.3)
        
        # 4. Re-rank and return top papers
        ranked_papers = sorted(
            paper_scores.items(),
            key=lambda x: x[1],
            reverse=True
        )[:10]
        
        return await self._enrich_with_metadata(ranked_papers)

Pattern 3: Triple Store (Relational + Graph + Vector)

Use Case: E-commerce with product relationships and semantic search

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- PostgreSQL: Products and inventory (relational)
CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    sku VARCHAR(100) UNIQUE,
    name VARCHAR(255),
    price DECIMAL(10,2),
    stock_quantity INTEGER,
    category_id INTEGER
);

-- pgvector: Product descriptions (vector)
CREATE TABLE product_embeddings (
    product_id BIGINT PRIMARY KEY REFERENCES products(id),
    description TEXT,
    embedding vector(1536)
);

-- PostgreSQL with recursive CTEs: Product relationships (graph-like)
CREATE TABLE product_relationships (
    product_id BIGINT REFERENCES products(id),
    related_product_id BIGINT REFERENCES products(id),
    relationship_type VARCHAR(50), -- 'frequently_bought_together', 'similar', 'accessory'
    weight FLOAT,
    PRIMARY KEY (product_id, related_product_id, relationship_type)
);

Hybrid Query:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class EcommerceHybridSearch:
    async def smart_search(self, query: str, user_id: int):
        # 1. Vector search: Semantic matching
        query_embedding = generate_embedding(query)
        semantic_results = await db.fetch("""
            SELECT p.id, p.name, p.price,
                   1 - (pe.embedding <=> $1) AS semantic_score
            FROM products p
            JOIN product_embeddings pe ON p.id = pe.product_id
            WHERE p.stock_quantity > 0
            ORDER BY pe.embedding <=> $1
            LIMIT 20
        """, query_embedding)
        
        # 2. Graph traversal: Find related products
        product_ids = [r['id'] for r in semantic_results]
        related_products = await db.fetch("""
            WITH RECURSIVE product_graph AS (
                SELECT product_id, related_product_id, weight, 1 AS depth
                FROM product_relationships
                WHERE product_id = ANY($1)
                
                UNION
                
                SELECT pr.product_id, pr.related_product_id, 
                       pr.weight * pg.weight AS weight, pg.depth + 1
                FROM product_relationships pr
                JOIN product_graph pg ON pr.product_id = pg.related_product_id
                WHERE pg.depth < 2
            )
            SELECT related_product_id, SUM(weight) AS relationship_score
            FROM product_graph
            GROUP BY related_product_id
        """, product_ids)
        
        # 3. Relational: User's purchase history for personalization
        user_preferences = await db.fetch("""
            SELECT p.category_id, COUNT(*) AS purchase_count
            FROM orders o
            JOIN order_items oi ON o.id = oi.order_id
            JOIN products p ON oi.product_id = p.id
            WHERE o.user_id = $1
            GROUP BY p.category_id
        """, user_id)
        
        # 4. Hybrid ranking
        return self._rank_results(
            semantic_results,
            related_products,
            user_preferences
        )

Data Modeling Best Practices

When to Use Each Model

Data Model Use When Avoid When
Relational Structured data, ACID required, complex aggregations Highly interconnected data, semantic search
Vector Semantic search, similarity matching, embeddings Need to explain “why similar”, exact matches better
Graph Path finding, relationship-heavy, pattern matching Simple lookups, high-volume writes, need SQL

Choosing the Right Database

Start with PostgreSQL:

1
2
3
4
-- PostgreSQL can do all three!
-- Relational (built-in)
-- Vector (pgvector extension)
-- Graph-like (recursive CTEs)

When to add specialized databases:

  1. Add Qdrant/Weaviate when:
    • Vectors > 10M
    • Need <10ms p95 search latency
    • Require advanced filtering
    • Multi-tenancy needed
  2. Add Neo4j when:
    • Complex graph algorithms (PageRank, community detection)
    • Deep traversals (5+ hops)
    • Graph-native operations critical
    • Visual graph exploration needed
  3. Add Redis when:
    • Need <1ms lookups
    • Caching layer
    • Real-time counters
    • Session storage

Migration Strategies

Phase 1: PostgreSQL Everything (Weeks 1-8)

1
2
3
4
5
-- Start simple: One database
CREATE TABLE users (...);
CREATE TABLE products (...);
CREATE TABLE product_embeddings (...);  -- Vector
CREATE TABLE product_relationships (...);  -- Graph-like

Phase 2: Extract Vector Layer (Months 3-6)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# As vector operations scale, migrate to Qdrant
class VectorMigration:
    async def migrate_to_qdrant(self):
        # Read from PostgreSQL
        embeddings = await pg.fetch(
            "SELECT id, embedding, metadata FROM product_embeddings"
        )
        
        # Write to Qdrant
        points = [
            PointStruct(
                id=row['id'],
                vector=row['embedding'],
                payload=row['metadata']
            )
            for row in embeddings
        ]
        
        qdrant_client.upsert(collection="products", points=points)
        
        # Verify migration
        # Dual-write to both systems during transition
        # Switch reads to Qdrant
        # Drop PostgreSQL vector column

Phase 3: Extract Graph Layer (Months 6-12)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Migrate complex graph operations to Neo4j
class GraphMigration:
    def migrate_to_neo4j(self):
        # Export relationships from PostgreSQL
        relationships = pg.fetch(
            "SELECT * FROM product_relationships"
        )
        
        # Import to Neo4j
        with neo4j_driver.session() as session:
            for rel in relationships:
                session.run("""
                    MATCH (p1:Product {id: $id1}), (p2:Product {id: $id2})
                    CREATE (p1)-[r:%s {weight: $weight}]->(p2)
                """ % rel['relationship_type'], 
                id1=rel['product_id'],
                id2=rel['related_product_id'],
                weight=rel['weight'])

Production Patterns

Pattern 1: Polyglot Persistence

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class PolyglotDataLayer:
    """Abstract data access across multiple databases"""
    
    def __init__(self):
        self.postgres = asyncpg.create_pool(...)  # Relational + Vector
        self.neo4j = GraphDatabase.driver(...)     # Graph
        self.redis = aioredis.from_url(...)       # Cache
    
    async def get_user(self, user_id: int):
        """Get user from relational DB"""
        return await self.postgres.fetchrow(
            "SELECT * FROM users WHERE id = $1",
            user_id
        )
    
    async def find_similar_users(self, user_id: int):
        """Find similar users via vector similarity"""
        user = await self.get_user(user_id)
        user_embedding = await self._get_embedding(user_id)
        
        return await self.postgres.fetch("""
            SELECT u.*, 1 - (ue.embedding <=> $1) AS similarity
            FROM users u
            JOIN user_embeddings ue ON u.id = ue.user_id
            WHERE u.id != $2
            ORDER BY ue.embedding <=> $1
            LIMIT 10
        """, user_embedding, user_id)
    
    async def get_social_network(self, user_id: int, depth: int = 2):
        """Get social connections via graph DB"""
        with self.neo4j.session() as session:
            result = session.run("""
                MATCH (user:User {id: $user_id})-[:FOLLOWS*1..$depth]-(connected)
                RETURN connected.id AS id, connected.name AS name
            """, user_id=user_id, depth=depth)
            
            return [dict(record) for record in result]
    
    async def get_cached_recommendations(self, user_id: int):
        """Get recommendations from cache"""
        key = f"recs:{user_id}"
        cached = await self.redis.get(key)
        
        if cached:
            return json.loads(cached)
        
        # Generate and cache
        recs = await self._generate_recommendations(user_id)
        await self.redis.setex(key, 3600, json.dumps(recs))
        
        return recs

Pattern 2: Event-Driven Sync

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class DataSyncService:
    """Keep multiple databases in sync via events"""
    
    async def on_product_created(self, product: dict):
        """Product created - sync across databases"""
        # 1. Insert into PostgreSQL (source of truth)
        product_id = await postgres.fetchval("""
            INSERT INTO products (name, description, price)
            VALUES ($1, $2, $3)
            RETURNING id
        """, product['name'], product['description'], product['price'])
        
        # 2. Generate and store embedding (async)
        await self._generate_embedding_async(product_id, product['description'])
        
        # 3. Create graph node
        with neo4j.session() as session:
            session.run("""
                CREATE (p:Product {
                    id: $id,
                    name: $name,
                    category: $category
                })
            """, id=product_id, name=product['name'], category=product['category'])
        
        # 4. Invalidate cache
        await redis.delete(f"product:{product_id}")

Pattern 3: Read Path Optimization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class OptimizedReadPath:
    """Optimize read paths with smart caching and routing"""
    
    async def get_product_details(self, product_id: int):
        """Multi-tier read path"""
        # Tier 1: Redis (hot cache)
        cached = await redis.get(f"product:{product_id}")
        if cached:
            return json.loads(cached)
        
        # Tier 2: PostgreSQL (source of truth)
        product = await postgres.fetchrow(
            "SELECT * FROM products WHERE id = $1",
            product_id
        )
        
        if not product:
            return None
        
        # Tier 3: Enrich with vector search (similar products)
        similar = await self._find_similar_products(product_id)
        
        # Tier 4: Enrich with graph data (frequently bought together)
        related = await self._get_related_products(product_id)
        
        # Combine and cache
        enriched = {
            **product,
            'similar_products': similar,
            'related_products': related
        }
        
        await redis.setex(
            f"product:{product_id}",
            3600,
            json.dumps(enriched)
        )
        
        return enriched

Common Pitfalls

1. Over-Engineering Early

1
2
3
4
5
# ❌ Don't: Start with 5 databases
postgres + qdrant + neo4j + redis + elasticsearch

# ✅ Do: Start with PostgreSQL
postgres (with pgvector for vectors, recursive CTEs for graphs)

2. Ignoring Data Consistency

1
2
3
4
5
6
7
8
# ❌ Don't: Fire-and-forget to multiple DBs
await postgres.execute("INSERT ...")
await qdrant.insert(...)  # What if this fails?

# ✅ Do: Use eventual consistency with retry
await postgres.execute("INSERT ...")
await message_queue.publish("product.created", product_id)
# Worker ensures Qdrant gets updated

3. Wrong Database for the Job

1
2
3
4
5
# ❌ Don't: Use graph DB for simple lookup
neo4j: MATCH (u:User {id: 123}) RETURN u

# ✅ Do: Use relational DB
postgres: SELECT * FROM users WHERE id = 123

Conclusion

Data modeling in 2026 isn’t about choosing one database paradigm—it’s about understanding when relational, vector, and graph models each excel and combining them intelligently. PostgreSQL with pgvector handles 80% of use cases. Add specialized databases only when PostgreSQL becomes the bottleneck.

Start simple. Measure performance. Add complexity only when justified by real constraints. The best architecture is the simplest one that meets your requirements.

Suggested Reading

This post is licensed under CC BY 4.0 by the author.