Unifying Postgres, Qdrant, and Neo4j Under a Single-Query Interface for Agent Memory

A practical architecture for layered storage with pgvector, Qdrant, and Neo4j, exposed through a unified query layer.

by
Unifying Postgres, Qdrant, and Neo4j Under a Single-Query Interface for Agent Memory

When building autonomous agents, memory is not a single store. You need relational integrity for structured state, vector search for semantic recall, and graph traversal for relationships. Running three separate databases is manageable until you need to query across them in one logical step. This article shows how we built a single-query interface that unifies PostgreSQL (with pgvector), Qdrant, and Neo4j, exposing them through a single GraphQL endpoint.

Why Not Just One Database?

Each database excels at a different access pattern:

  • PostgreSQL with pgvector gives us ACID transactions, complex filtering, and decent vector search for metadata-heavy queries.
  • Qdrant provides pure vector search with high performance, payload filtering, and built-in sharding for large-scale embeddings.
  • Neo4j handles graph traversals: finding paths between entities, hierarchical relationships, and recursive queries.

A single database would force compromises. pgvector is slower for high-dimensional vectors at scale. Qdrant lacks relational joins. Neo4j has no native vector index. Instead of choosing one, we put a thin orchestration layer in front of all three.

Architecture Overview

The system has four components:

  1. Unified GraphQL schema – defines the memory types and their relationships.
  2. Resolver layer – decomposes a single query into sub-queries for each store.
  3. Data gateway – handles authentication, rate limiting, and connection pooling to each backend.
  4. Storage backends – PostgreSQL, Qdrant, Neo4j.

All writes go through the gateway, which ensures consistency via a two-phase commit pattern (or compensating actions for eventual consistency). Reads are parallelized.

The GraphQL Schema

We define three base types: MemoryDocument, SemanticChunk, and EntityNode. Each is stored in a different primary store.

type MemoryDocument {
  id: ID!
  title: String!
  content: String!
  metadata: JSON!
  created_at: DateTime!
  chunks: [SemanticChunk!]!
  entities: [EntityNode!]!
}

type SemanticChunk {
  id: ID!
  document_id: ID!
  embedding: [Float!]!
  text: String!
  metadata: JSON!
}

type EntityNode {
  id: ID!
  name: String!
  type: String!
  properties: JSON!
  relationships: [EntityRelationship!]!
}

type EntityRelationship {
  target: EntityNode!
  relation_type: String!
  properties: JSON!
}

type Query {
  findDocuments(
    vector: [Float!],
    filters: JSON,
    graphTraversal: GraphTraversalInput
  ): [MemoryDocument!]!
}

The Resolver Orchestration

Consider a query: "Find documents about machine learning that are connected to the entity 'transformer architecture' and have high similarity to a given embedding."

The resolver does:

  1. Graph query – Neo4j: find EntityNode with name 'transformer architecture', then traverse to related MemoryDocument IDs.
  2. Vector query – Qdrant: search for top-K chunks similar to the input embedding, with a filter on document IDs from step 1.
  3. Relational query – PostgreSQL: fetch full MemoryDocument rows for the matched IDs, including metadata.
  4. Merge – Combine results, deduplicate, and return.

Here is the resolver pseudocode:

async function findDocuments(parent, args, context) {
  const { vector, filters, graphTraversal } = args;
  
  // Step 1: Graph traversal
  let graphDocIds = null;
  if (graphTraversal) {
    const neo4jSession = context.neo4j.session();
    const result = await neo4jSession.run(
      `MATCH (e:Entity {name: $name})-[r]-(d:Document) RETURN d.id`,
      { name: graphTraversal.entityName }
    );
    graphDocIds = result.records.map(r => r.get('d.id'));
  }

  // Step 2: Vector search in Qdrant
  let qdrantDocIds = null;
  if (vector) {
    const qdrantClient = context.qdrant;
    const searchResult = await qdrantClient.search('chunks', {
      vector,
      limit: 20,
      filter: graphDocIds ? { must: [{ key: 'document_id', match: { values: graphDocIds } }] } : undefined
    });
    qdrantDocIds = [...new Set(searchResult.map(hit => hit.payload.document_id))];
  }

  // Step 3: Fetch from PostgreSQL
  const pgPool = context.pgPool;
  const ids = qdrantDocIds || graphDocIds || [];
  if (ids.length === 0) return [];
  const { rows } = await pgPool.query(
    `SELECT * FROM documents WHERE id = ANY($1) ORDER BY created_at DESC`,
    [ids]
  );

  // Step 4: Merge and return
  return rows.map(row => ({
    id: row.id,
    title: row.title,
    content: row.content,
    metadata: row.metadata,
    created_at: row.created_at
  }));
}

Consistency and Write Path

Writes are trickier. When a new document is ingested:

  1. Generate embeddings via an external model (not covered here).
  2. Insert into PostgreSQL (documents table).
  3. Insert chunks into Qdrant with the document ID as payload.
  4. Insert/update entities and relationships in Neo4j.

We use a saga pattern: if step 3 fails, we roll back step 2. If step 4 fails, we mark the document as partially indexed and retry later.

-- PostgreSQL schema
CREATE TABLE documents (
  id UUID PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT,
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE chunks (
  id UUID PRIMARY KEY,
  document_id UUID REFERENCES documents(id),
  chunk_index INT,
  text TEXT,
  embedding vector(768)  -- pgvector for local queries, but primary search is in Qdrant
);

Trade-offs and Lessons Learned

Latency – Parallelizing reads helps, but the slowest store dictates overall response time. We added a caching layer (Redis) for frequently accessed graph patterns and document metadata.

Consistency – Eventual consistency is acceptable for most agent memory use cases. If strong consistency is required, consider using PostgreSQL as the source of truth and rebuilding Qdrant/Neo4j indices from it.

Maintenance – Three databases means three backup strategies, three monitoring dashboards, and three sets of connection pools. Automate everything with Infrastructure as Code.

When to skip this – If your agent memory fits in a single PostgreSQL instance with pgvector and JSONB, do that. This architecture is for when you need all three access patterns at scale.

Conclusion

Unifying Postgres, Qdrant, and Neo4j under a single GraphQL interface is not for the faint of heart, but it gives your agent memory the right tool for every job. The code above is a starting point—adapt the resolver logic, error handling, and caching to your scale. The key insight: don't force a single database to do everything; orchestrate multiple stores behind a clean API.

#agent-memory#graphql#layered-storage#neo4j#pgvector#qdrant#unified-query
Share — X / Twitter · LinkedIn · HN · Email
Damir Radulić
Founder of RiNET. On the Croatian internet since 1996 (Kvarner Net). In Amsterdam now, building autonomous AI infrastructure that runs on Monday morning when nobody's watching — sovereign stacks, agent swarms, LoRA fine-tuning, civic-intelligence platforms.

Related