When Three Stores Disagree: A Conflict-Resolution Protocol for Agent Recall
A practical protocol for reconciling relational, vector, and graph memories
When Three Stores Disagree: A Conflict-Resolution Protocol for Agent Recall
You built an agent with three memory stores: Postgres for facts, pgvector for embeddings, and Neo4j for relationships. Everything works in the demo. Then, in production, the agent recalls a fact that contradicts what it told you yesterday. You dig in: the fact is in Postgres, the vector points to a different fact, and the graph has an edge that doesn't match either. Three stores, three versions of the truth.
This is the classic distributed systems problem of consistency, but with a twist: the stores are not replicas; they serve different query patterns. Yet they must agree on the underlying facts. When they don't, you need a protocol to detect and reconcile conflicts. Here's a practical one.
The Problem: Not a Replica Set
First, understand why conflicts happen. In a typical agent memory design, you have:
- Relational store (Postgres): The source of truth for facts. Each fact has an ID, a subject, a predicate, an object, a timestamp, and a source.
- Vector store (pgvector or Qdrant): Stores embeddings of facts for similarity search. Each vector is tagged with the fact ID.
- Graph store (Neo4j or Memgraph): Stores nodes and edges representing entities and relationships. Each edge references fact IDs.
These stores are not replicas; they are projections of the same logical facts, optimized for different access patterns. But they must be consistent: if a fact is updated in Postgres, the vector and graph must reflect that update. Otherwise, the agent's recall becomes unreliable.
Conflicts arise from:
- Partial writes: You update Postgres but fail to update the vector or graph.
- Race conditions: Two processes update different stores concurrently.
- Legacy data: You added a store later and backfilled it incorrectly.
- Manual edits: Someone (or something) directly modified one store.
You need a protocol that detects these conflicts and resolves them deterministically.
The Protocol: Detect, Compare, Reconcile
I propose a three-phase protocol: Detect, Compare, Reconcile. It runs periodically (e.g., every minute) or on-demand when a recall inconsistency is suspected.
Phase 1: Detect
For each fact (identified by its fact ID), you need to know its state in each store. Build a fact index in Postgres that tracks the last known state per store. Something like:
CREATE TABLE fact_state (
fact_id UUID PRIMARY KEY,
fact_hash TEXT NOT NULL, -- hash of the fact content
relational_updated_at TIMESTAMPTZ,
vector_updated_at TIMESTAMPTZ,
graph_updated_at TIMESTAMPTZ,
last_check_at TIMESTAMPTZ
);When you write a fact to any store, you update this table with the fact's content hash and the store's timestamp. But you can't rely solely on this table; you need to verify by querying the stores directly, because the table itself can be stale.
So, for each fact, you run a verification query against each store:
- Relational:
SELECT fact_hash FROM facts WHERE id = $1 - Vector:
SELECT metadata->>'fact_id' FROM vectors WHERE fact_id = $1(or use the vector's metadata to get the hash) - Graph:
MATCH (f:Fact {id: $1}) RETURN f.hash
If any store returns a hash that differs from the one in fact_state, or doesn't return a hash at all (missing), you have a conflict.
Phase 2: Compare
Now you have three hashes: H_rel, H_vec, H_graph, plus the expected hash from fact_state. The goal is to determine which hash is the authoritative one.
Define a trust hierarchy:
- Relational store is the system of record. If
H_relmatchesfact_state, it's authoritative. - If
H_relis missing or mismatched, then vector or graph might have a newer hash (based on theirupdated_attimestamps). - If all stores disagree, you need a tie-breaker.
But you can't just trust timestamps; clocks can skew. Instead, use a version vector approach: store a monotonic version number per fact, incremented on every write, and propagate it to all stores. When you write a fact, you include the version. For example, in Postgres you have a version column; in the vector metadata, you store version; in the graph node, you store version.
Now compare versions:
- If one store has a higher version than the others, that store is authoritative.
- If versions are equal but hashes differ, you have a true conflict that requires manual resolution or a rule.
Phase 3: Reconcile
Once you've identified the authoritative version, you propagate it to all other stores. This is a write-back operation.
For example, if the graph has the highest version and Postgres is behind, you update Postgres with the graph's fact content. But be careful: the graph might not have the full fact content, only a node with a hash. So you need to retrieve the full fact from the store that has it.
In practice, you might want to keep a fact content cache in Postgres (or in a separate table) that stores the full content for a given hash. That way, you can always retrieve the content from the hash.
Reconciliation steps:
- Determine authoritative store (by version).
- Extract the fact content from that store.
- Write the content to the other stores, updating their version to the authoritative version.
- Update
fact_statewith the new hash and version.
If no store is authoritative (all versions equal but hashes differ), you need a conflict resolution rule. For agent memory, I suggest a last-write-wins rule based on the updated_at timestamp from the store's own metadata, but with a sanity check: if the content is drastically different (e.g., the hash differs by more than X characters), flag it for human review.
Implementation Details
Handling Missing Facts
A store might be missing a fact entirely. For example, you added a fact to Postgres but failed to insert the vector. In the detect phase, if a store returns no hash, you treat it as a conflict and reconcile by inserting the missing record.
Batching
Running this protocol per fact is expensive. Instead, batch it. Use a query to find all facts that have been updated since the last check, or all facts where last_check_at is older than a threshold. Then process them in batches of, say, 1000.
Concurrency
When multiple agents are writing concurrently, you can have race conditions. Use a distributed lock (e.g., via Postgres advisory locks) around the reconciliation of a single fact. Or use an optimistic concurrency control: before writing, compare versions and abort if stale.
Dealing with Soft Conflicts
Not all disagreements are hard conflicts. Sometimes the vector store has a slightly different embedding because the embedding model was updated. That's not a conflict; it's a semantic drift. The protocol should distinguish between content hash and embedding hash. The content hash refers to the fact's textual representation; the embedding hash refers to the model version and parameters. If the content hash matches but the embedding differs, you don't need to reconcile; you just need to re-embed.
In the detect phase, for the vector store, compare the content hash stored in metadata, not the embedding vector itself. If the content hash matches, skip reconciliation.
Case Study: Reconciling After a Failed Migration
I once had a client whose agent memory system used three stores. They migrated from a custom vector store to Qdrant, and the migration script had a bug that dropped 10% of the vectors. The agent started giving inconsistent answers. They ran the protocol and found that for those facts, the vector store was missing. The reconciliation script re-inserted the vectors from Postgres, and consistency was restored. The protocol saved them from a manual data audit.
Tooling
You can implement this protocol as a standalone service (e.g., a Python script or a Go daemon) that runs on a cron schedule. Use the store's native clients. For Postgres, use psycopg or asyncpg; for Qdrant, use the Python client; for Neo4j, use the official driver.
Here's a skeleton in Python:
import asyncio
from asyncpg import create_pool
from qdrant_client import AsyncQdrantClient
from neo4j import AsyncGraphDatabase
async def check_fact(fact_id, expected_hash, stores):
hashes = {}
for name, store in stores.items():
h = await store.get_hash(fact_id)
hashes[name] = h
# compare and reconcileYou'll need to define the store interface with get_hash, write_fact, etc.
When to Run the Protocol
Run it periodically, but also trigger it when a recall inconsistency is detected. For example, if the agent returns a fact that contradicts a previous answer, you can run the protocol on that fact ID to see if it's a store conflict or a genuine change in facts.
Conclusion
Three stores don't have to be a source of chaos. With a deterministic conflict-resolution protocol, you can keep them in sync. The key is to treat the fact ID as the primary key across all stores, track versions, and reconcile by writing back to the lagging stores. It's not glamorous, but it's the kind of engineering that makes self-hosted AI reliable.
Next time your agent contradicts itself, you'll know exactly where to look.