Skip to the content.

Knowledge Graph

The knowledge graph captures relationships between documents, chunks, workflows, and concepts that cannot be expressed as simple vector proximity. It answers questions like “what workflows used this document?” and “what concepts are related to this topic through a chain of references?”

Triple-Store Architecture

Knowledge is stored across three databases, each serving a distinct role:

 +-------------------+      +-------------------+      +-------------------+
 |    PostgreSQL      |      |      Redis        |      |    OrientDB       |
 |   (primary store)  |      |   (hot cache)     |      |  (graph store)    |
 +-------------------+      +-------------------+      +-------------------+
 | knowledge.documents|      | Recent queries    |      | Nodes: Document,  |
 | knowledge.chunks   |      | Frequent edges    |      |   Chunk, Concept, |
 | knowledge.embeddings      | Session state     |      |   Workflow        |
 | workflow.*         |      | Pub/sub channels  |      | Edges: CONTAINS,  |
 | learning.*         |      |                   |      |   REFERENCES,     |
 +-------------------+      +-------------------+      |   DERIVED_FROM,   |
         |                           |                  |   RELATED_TO, ... |
         |                           |                  +-------------------+
         +---------------------------+---------------------------+
                                     |
                              Application Layer
                         (reads/writes all three)

Data flows from PostgreSQL to OrientDB (not the reverse). PostgreSQL is the source of truth for all structured data. OrientDB mirrors relationship data as a graph for efficient traversal queries. Redis caches hot paths for both.

Why Three Databases

Each database was chosen for what it does best. No single database satisfies all three requirements simultaneously:

Requirement PostgreSQL Redis OrientDB
ACID transactions Yes No Partial
Vector similarity search Yes (pgvector) No No
Full-text search Yes (GIN indexes) No Yes (Lucene)
Graph traversal (multi-hop) Poor (recursive CTE) No Native O(1) hops
Sub-millisecond reads No (disk-bound) Yes (in-memory) No
Complex joins Yes No Partial
Horizontal write scaling Moderate Yes Moderate

The critical distinction is graph traversal. PostgreSQL can express graph queries using recursive CTEs, but performance degrades exponentially with hop depth. OrientDB traverses edges in O(1) per hop regardless of graph size.

OrientDB Node Types

The graph contains four node types (vertex classes):

Node Type Properties Source
Document doc_id, title, source_url, source_type knowledge.documents
Chunk chunk_id, doc_id, chunk_index, token_count knowledge.chunks
Concept name, description, category Extracted from chunk content
Workflow workflow_id, name, outcome, duration_ms workflow.executions

OrientDB Edge Types

Seven edge types capture relationships:

Edge Type From To Properties Meaning
CONTAINS Document Chunk chunk_index Document contains this chunk
REFERENCES Chunk Chunk ref_type, confidence One chunk cites or links to another
DERIVED_FROM Document Document derivation_type Document was derived from another (summary, translation)
RELATED_TO Chunk Chunk similarity, method Chunks are semantically similar (from embeddings)
MENTIONS Chunk Concept count, positions Chunk mentions this concept
USED_BY Document Workflow role, phase Document was used in this workflow
PRODUCED_BY Document Workflow output_type Document was produced by this workflow

Graph Queries That SQL Cannot Efficiently Express

Example 1: Concept Neighborhood (Multi-Hop Traversal)

“Find all concepts related to ‘network timeout’ within 3 hops, and the documents that mention them.”

OrientDB (native graph, ~5 ms):

SELECT expand(both('RELATED_TO', 'MENTIONS', 'CONTAINS'))
FROM (
    SELECT FROM Concept WHERE name = 'network timeout'
)
WHILE $depth <= 3

This traverses from the “network timeout” concept node outward along any combination of RELATED_TO, MENTIONS, and CONTAINS edges, up to 3 hops deep. Each hop is O(1) – it follows pointers, not table scans.

PostgreSQL equivalent (recursive CTE, ~850 ms on 100K nodes):

WITH RECURSIVE graph AS (
    -- Base case: start node
    SELECT c.id, c.name, 0 AS depth
    FROM concepts c
    WHERE c.name = 'network timeout'

    UNION ALL

    -- Recursive case: follow edges
    SELECT c2.id, c2.name, g.depth + 1
    FROM graph g
    JOIN concept_edges ce ON ce.from_id = g.id
    JOIN concepts c2 ON c2.id = ce.to_id
    WHERE g.depth < 3
)
SELECT DISTINCT g.name, d.title
FROM graph g
JOIN chunk_concepts cc ON cc.concept_id = g.id
JOIN chunks ch ON ch.id = cc.chunk_id
JOIN documents d ON d.id = ch.document_id;

The PostgreSQL version requires 4 joins per hop level, and the recursive CTE materializes all intermediate results. At 3 hops with a branching factor of 10, this touches 1,000+ rows per query.

Example 2: Provenance Chain

“Trace the full lineage of a document back to its original sources.”

OrientDB (~2 ms):

TRAVERSE in('DERIVED_FROM') FROM (
    SELECT FROM Document WHERE doc_id = 42
)

This follows DERIVED_FROM edges backward (inbound) until it reaches nodes with no inbound DERIVED_FROM edges – the original sources.

PostgreSQL equivalent (~400 ms):

WITH RECURSIVE lineage AS (
    SELECT id, title, NULL::integer AS parent_id, 0 AS depth
    FROM knowledge.documents
    WHERE id = 42

    UNION ALL

    SELECT d.id, d.title, dd.source_id, l.depth + 1
    FROM lineage l
    JOIN document_derivations dd ON dd.derived_id = l.id
    JOIN knowledge.documents d ON d.id = dd.source_id
    WHERE l.depth < 20  -- safety limit
)
SELECT * FROM lineage ORDER BY depth;

Graph Sync

OrientDB is populated from PostgreSQL, not the reverse. The sync process is non-blocking – it runs in a background worker and does not hold locks on PostgreSQL tables.

WorkflowGraphSync MERGE Pattern

When a workflow completes, its results are synced to the graph using OrientDB’s MERGE operation, which is an upsert:

class WorkflowGraphSync {
    async syncWorkflow(execution) {
        // MERGE creates or updates the vertex
        await this.orientdb.command(
            `MERGE INTO Workflow
             SET workflow_id = :wfId,
                 name = :name,
                 outcome = :outcome,
                 duration_ms = :duration
             UPSERT
             WHERE workflow_id = :wfId`,
            {
                wfId: execution.workflow_id,
                name: execution.workflow_name,
                outcome: execution.outcome,
                duration: execution.total_duration_ms
            }
        );

        // Sync edges for documents used by this workflow
        for (const doc of execution.input_documents) {
            await this.orientdb.command(
                `LET $wf = (SELECT FROM Workflow WHERE workflow_id = :wfId);
                 LET $doc = (SELECT FROM Document WHERE doc_id = :docId);
                 CREATE EDGE USED_BY FROM $doc TO $wf
                 SET role = :role, phase = :phase`,
                {
                    wfId: execution.workflow_id,
                    docId: doc.id,
                    role: doc.role,
                    phase: doc.phase
                }
            );
        }
    }
}

PostgreSQL CTE Fallback

When OrientDB is unavailable, the system falls back to PostgreSQL recursive CTEs for graph queries. This fallback is automatic and transparent to callers:

async function queryGraph(query) {
    try {
        return await orientdb.command(query.orientql);
    } catch (err) {
        if (err.code === 'ECONNREFUSED') {
            console.warn('OrientDB unavailable, falling back to PostgreSQL CTE');
            return await postgres.query(query.fallbackSQL);
        }
        throw err;
    }
}

Every graph query is defined with both an OrientDB query and a PostgreSQL CTE fallback. The OrientDB version is faster for traversals; the PostgreSQL version is correct but slower.

When new embeddings are generated, a background job creates RELATED_TO edges between chunks whose cosine similarity exceeds a threshold:

Threshold: 0.85

-- Find highly similar chunk pairs (PostgreSQL)
SELECT
    a.chunk_id AS chunk_a,
    b.chunk_id AS chunk_b,
    1 - (a.embedding <=> b.embedding) AS similarity
FROM knowledge.embeddings a
CROSS JOIN LATERAL (
    SELECT chunk_id, embedding
    FROM knowledge.embeddings
    WHERE chunk_id != a.chunk_id
    ORDER BY embedding <=> a.embedding
    LIMIT 5
) b
WHERE 1 - (a.embedding <=> b.embedding) >= 0.85;

For each qualifying pair, a RELATED_TO edge is created in OrientDB:

await orientdb.command(
    `LET $a = (SELECT FROM Chunk WHERE chunk_id = :idA);
     LET $b = (SELECT FROM Chunk WHERE chunk_id = :idB);
     CREATE EDGE RELATED_TO FROM $a TO $b
     SET similarity = :sim, method = 'cosine'`,
    { idA: pair.chunk_a, idB: pair.chunk_b, sim: pair.similarity }
);

The 0.85 threshold was empirically tuned: lower values produce too many edges (noisy graph), higher values miss valid relationships. At 0.85, approximately 3% of chunk pairs are linked.

REST API

The graph is exposed via a REST endpoint on the orchestrator:

Endpoint: POST /graph/query

Request body:

{
    "query_type": "neighborhood",
    "start_node": { "type": "Concept", "name": "network timeout" },
    "max_depth": 3,
    "edge_types": ["RELATED_TO", "MENTIONS"],
    "limit": 50
}

Response:

{
    "nodes": [
        { "type": "Concept", "name": "network timeout", "depth": 0 },
        { "type": "Concept", "name": "connection reset", "depth": 1 },
        { "type": "Chunk", "chunk_id": 1842, "depth": 2 }
    ],
    "edges": [
        { "from": "network timeout", "to": "connection reset", "type": "RELATED_TO", "similarity": 0.91 }
    ],
    "query_time_ms": 5
}

Query Types

Query Type Description Parameters
neighborhood All nodes within N hops of a start node start_node, max_depth, edge_types
path Shortest path between two nodes start_node, end_node, edge_types
provenance Full lineage of a document back to original sources document_id
subgraph All nodes connected to a set of start nodes start_nodes, max_depth
concepts Concepts mentioned by a document’s chunks document_id

Why OrientDB Over Neo4j

The system uses OrientDB instead of Neo4j for two reasons:

1. Persistence reliability. During evaluation, Neo4j Community Edition exhibited data loss on unclean shutdown (e.g., power failure, OOM kill). Write-ahead log recovery failed to restore the last several seconds of transactions. OrientDB’s append-only storage format proved more resilient to unclean shutdown in testing on the same hardware.

2. Native REST API. OrientDB exposes a built-in HTTP/REST API that accepts queries and returns JSON. No driver library is needed – any HTTP client can query the graph:

curl -X POST http://orientdb-host:2480/command/knowledge/sql \
     -u admin:admin \
     -H "Content-Type: application/json" \
     -d '{"command": "SELECT FROM Concept WHERE name = '\''network timeout'\''"}'

Neo4j requires the Bolt protocol and a language-specific driver library. In a polyglot fleet (JavaScript orchestrator, Python scrapers, Bash scripts), adding a Neo4j driver to each component introduces dependency overhead. OrientDB’s REST API is callable from all of them with curl or any HTTP library.