Skip to content

SynapCores / Recipe

AI Agent Memory: Catch Contradictions on Write, in One SQL Query

Check every new agent memory against what the agent already believes before you store it: EMBED similarity pairs each new fact with related memories only, NLI_VERIFY flags contradictions, and one SQL query sorts the batch into conflict, already-known and new

All recipes· ml· 20 minutesadvanceden
Instance: localhost:8080

Opens your running SynapCores (AI Agent Memory: Catch Contradictions on Write, in One SQL Query will be staged for a preview — nothing runs until you click Run). No instance yet? Install free in ~30s.

Share

AI Agent Memory: Catch Contradictions on Write, in One SQL Query

Objective

An agent's memory fills up from many writers: the chat, a CRM sync, a scraper, another agent. Sooner or later two of them disagree. The office is in Denver and in Chicago. The plan has a 14-day return window, and also "does not allow returns". Most memory layers store both facts, and recall returns whichever one is most similar to the question. The agent now holds two contradicting beliefs and doesn't know it.

This recipe checks a batch of incoming facts before they're written. A single SQL statement does three things that usually need three systems:

  1. EMBED + COSINE_SIMILARITY pairs each incoming fact with the existing memories on the same topic, and only those.
  2. NLI_VERIFY (a local NLI model running inside the engine) checks the incoming fact against those memories.
  3. SQL sorts the batch into CONFLICT (with the memory it conflicts with), ALREADY_KNOWN and NEW. Only safe facts are stored. Conflicts go to a review queue.

No LLM judge or external API is involved, and the check runs where the memory lives. Self-Consistent Agent Memory covers the single-fact version on MEMORY_STORE. This recipe does a whole batch and adds the similarity gate, which a batch needs (Step 4 shows why).

Before you start: NLI is off by default

The NLI functions need a model installed and enabled. Until you enable it, every NLI call fails with:

Inference error: NLI is disabled; install a model and enable NLI

Install and enable the curated model (DeBERTa-v3-small, Apache-2.0, English, ~577 MB download), then restart:

synapcores --config /etc/synapcores/gateway.toml pull nli-default
synapcores --config /etc/synapcores/gateway.toml models enable-nli nli-default

For Docker or environment-driven installs, set AIDB_NLI_ENABLED=1 and AIDB_NLI_MODEL=nli-default at startup. Check that GET /v1/ai/nli/models reports the model as ready. See Semantic Claim Verification for air-gapped installs.

Step 1: What the agent already believes

Memories are rows with an embedding, so you can join on them.

CREATE TABLE IF NOT EXISTS recipe_memcheck_memories (
    id INT PRIMARY KEY,
    fact TEXT,
    embedding VECTOR(384)
);
INSERT INTO recipe_memcheck_memories (id, fact, embedding) VALUES
  (1, 'The customer prefers to be contacted by email, never by phone.',
      EMBED('The customer prefers to be contacted by email, never by phone.')),
  (2, 'The customer is on the Aurora plan, which includes a 14-day return window.',
      EMBED('The customer is on the Aurora plan, which includes a 14-day return window.')),
  (3, 'The customer manages procurement for a mid-size logistics company.',
      EMBED('The customer manages procurement for a mid-size logistics company.')),
  (4, 'The customer''s office is in Denver.',
      EMBED('The customer''s office is in Denver.'));

Step 2: A batch of incoming facts from different writers

CREATE TABLE IF NOT EXISTS recipe_memcheck_incoming (
    id INT PRIMARY KEY,
    source TEXT,
    fact TEXT,
    embedding VECTOR(384)
);
INSERT INTO recipe_memcheck_incoming (id, source, fact, embedding) VALUES
  (101, 'chat',         'The customer asked us to call them by phone from now on.',
        EMBED('The customer asked us to call them by phone from now on.')),
  (102, 'crm-sync',     'The customer works in logistics procurement.',
        EMBED('The customer works in logistics procurement.')),
  (103, 'webinar-tool', 'The customer attended the October product webinar.',
        EMBED('The customer attended the October product webinar.')),
  (104, 'scraper',      'The customer''s office is in Chicago.',
        EMBED('The customer''s office is in Chicago.')),
  (105, 'agent-b',      'The customer''s plan does not allow returns.',
        EMBED('The customer''s plan does not allow returns.')),
  (106, 'chat',         'The customer is learning to play the cello.',
        EMBED('The customer is learning to play the cello.'));

Facts 101, 104 and 105 conflict with an existing memory. Fact 102 restates one. Facts 103 and 106 are new.

Step 3: Look at the numbers before choosing thresholds

List every incoming/memory pair that the NLI model scores as a contradiction (above 0.6), with its topic similarity:

SELECT i.id AS incoming, m.id AS memory,
       ROUND(COSINE_SIMILARITY(i.embedding, m.embedding), 3) AS similarity,
       ROUND(CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', m.fact, i.fact),
             '$.scores.contradiction') AS DOUBLE), 3) AS contradiction
FROM recipe_memcheck_incoming i
CROSS JOIN recipe_memcheck_memories m
WHERE CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', m.fact, i.fact),
           '$.scores.contradiction') AS DOUBLE) > 0.6
ORDER BY similarity DESC;

Observed on 2.3.1 with nli-default:

incoming memory similarity contradiction reality
104 4 0.740 0.999 real conflict (office city)
105 2 0.524 1.000 real conflict (returns)
101 1 0.502 0.999 real conflict (contact channel)
104 2 0.408 0.760 false: office city vs plan
103 3 0.366 0.888 false: webinar vs procurement job
106 4 0.235 0.997 false: cello vs office
106 3 0.228 1.000 false: cello vs procurement job
106 1 0.169 0.999 false: cello vs contact channel
106 2 0.132 0.996 false: cello vs plan

Nine pairs score as contradictions, and only three of them are real. The NLI model scores pairs of sentences. Give it two sentences about different things and it often still returns a confident "contradiction": the cello fact "contradicts" all four memories at 0.996 or higher. Contradiction scores can't separate real from false here, but similarity can. Real conflicts sit at 0.50–0.74, and false ones at 0.41 or less. This recipe sets the floor at 0.45, inside that gap. It's a value read from this data, not a universal constant, so measure your own memories the same way.

Step 4: The trap: verify against every memory

NLI_ANCHORS collects a group's memories into an evidence array, and NLI_VERIFY checks the incoming fact against them. Without a similarity gate, each incoming fact is checked against all memories:

SELECT i.id, i.source,
       JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', i.fact, NLI_ANCHORS(m.fact)),
            '$.verdict')) AS verdict,
       JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', i.fact, NLI_ANCHORS(m.fact)),
            '$.violated_anchor')) AS conflicts_with
FROM recipe_memcheck_incoming i
CROSS JOIN recipe_memcheck_memories m
GROUP BY i.id, i.source, i.fact
ORDER BY i.id;

Observed:

id source verdict conflicts_with
101 chat LIE The customer prefers to be contacted by email, never by phone.
102 crm-sync TRUTH
103 webinar-tool LIE The customer manages procurement for a mid-size logistics company.
104 scraper LIE The customer's office is in Denver.
105 agent-b LIE The customer is on the Aurora plan, which includes a 14-day return window.
106 chat LIE The customer manages procurement for a mid-size logistics company.

Five of six facts are rejected, including the webinar and the cello, which conflict with nothing. NLI_VERIFY gives contradiction precedence, so one unrelated memory scored as a "contradiction" vetoes the fact. A memory that rejects everything new can't learn.

Step 5: The fix: the whole batch in one gated query

Add the similarity floor and a LEFT JOIN, so that a fact with no related memory comes back as NEW instead of disappearing:

SELECT i.id, i.source,
       CASE g.verdict
         WHEN 'LIE'   THEN 'CONFLICT'
         WHEN 'TRUTH' THEN 'ALREADY_KNOWN'
         ELSE 'NEW'
       END AS status,
       g.conflicts_with
FROM recipe_memcheck_incoming i
LEFT JOIN (
  SELECT c.id AS cand_id,
         JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
              '$.verdict')) AS verdict,
         JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
              '$.violated_anchor')) AS conflicts_with
  FROM recipe_memcheck_incoming c
  CROSS JOIN recipe_memcheck_memories m
  WHERE COSINE_SIMILARITY(c.embedding, m.embedding) > 0.45
  GROUP BY c.id, c.fact
) g ON g.cand_id = i.id
ORDER BY i.id;

Observed:

id source status conflicts_with
101 chat CONFLICT The customer prefers to be contacted by email, never by phone.
102 crm-sync ALREADY_KNOWN
103 webinar-tool NEW
104 scraper CONFLICT The customer's office is in Denver.
105 agent-b CONFLICT The customer is on the Aurora plan, which includes a 14-day return window.
106 chat NEW

All six are sorted correctly, and each conflict names the memory it collides with. Fact 103 gets an UNVERIFIED_PLAUSIBLE verdict from the one memory that cleared the floor (office in Denver, similarity 0.487, contradiction 0.488, below the 0.6 threshold). Fact 106 cleared no memory at all, so the LEFT JOIN returns a NULL verdict. Both become NEW.

On 2.3.1, put the similarity condition in WHERE over a CROSS JOIN, as shown. The same condition in JOIN … ON COSINE_SIMILARITY(...) > 0.45 returned zero rows on that build.

Step 6: Route the batch: store what's safe, queue what conflicts

Save the triage once, so the NLI work isn't repeated:

CREATE TABLE IF NOT EXISTS recipe_memcheck_triage (
    id INT PRIMARY KEY,
    source TEXT,
    fact TEXT,
    status TEXT,
    conflicts_with TEXT
);
INSERT INTO recipe_memcheck_triage (id, source, fact, status, conflicts_with)
SELECT i.id, i.source, i.fact,
       CASE g.verdict
         WHEN 'LIE'   THEN 'CONFLICT'
         WHEN 'TRUTH' THEN 'ALREADY_KNOWN'
         ELSE 'NEW'
       END,
       g.conflicts_with
FROM recipe_memcheck_incoming i
LEFT JOIN (
  SELECT c.id AS cand_id,
         JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
              '$.verdict')) AS verdict,
         JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
              '$.violated_anchor')) AS conflicts_with
  FROM recipe_memcheck_incoming c
  CROSS JOIN recipe_memcheck_memories m
  WHERE COSINE_SIMILARITY(c.embedding, m.embedding) > 0.45
  GROUP BY c.id, c.fact
) g ON g.cand_id = i.id;

Only NEW facts become memories. ALREADY_KNOWN is skipped, because it would only add a duplicate:

INSERT INTO recipe_memcheck_memories (id, fact, embedding)
SELECT i.id, i.fact, i.embedding
FROM recipe_memcheck_incoming i
JOIN recipe_memcheck_triage t ON t.id = i.id
WHERE t.status = 'NEW';

Expected: rows_affected = 2 (the webinar and the cello).

The review queue is the conflicts, each paired with the belief it collides with:

SELECT id, source, fact, conflicts_with
FROM recipe_memcheck_triage
WHERE status = 'CONFLICT'
ORDER BY id;

Expected: 3 rows (101 chat, 104 scraper, 105 agent-b).

Step 7: A conflict isn't always an error

Look at the queue again. Fact 101 ("asked us to call them by phone from now on") is probably a real preference change, not a mistake. Fact 104 comes from a scraper and contradicts your CRM. The model can't tell those apart. All it can say is that the two statements can't both be true. What happens next is your policy:

Conflict source Typical policy
The user, first-hand ("from now on…") Supersede: update the old memory, keep a history row
A lower-trust writer (scraper, another agent) Reject and keep the existing memory
Two equally trusted sources Hold for a human, and serve neither as fact

The model's job is to make sure no contradiction is stored without anyone seeing it. Whether the new fact or the old one wins depends on who wrote it, and that decision stays yours.

Step 8: Same check on the built-in MEMORY_* API

If the agent uses MEMORY_STORE / MEMORY_RECALL instead of a table, apply the same floor to recall's similarity column. On the same embedding model, recall gave the same similarity values as Step 3 (0.502 for fact 101 vs memory 1).

SELECT MEMORY_STORE('recipe_memcheck_ns', fact) AS memory_id
FROM recipe_memcheck_memories
WHERE id <= 4;
SELECT JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default',
         'The customer asked us to call them by phone from now on.',
         NLI_ANCHORS(content)), '$.verdict')) AS verdict
FROM MEMORY_RECALL('recipe_memcheck_ns',
       'The customer asked us to call them by phone from now on.', 5)
WHERE similarity > 0.45;

Expected: LIE, which means a conflict, so don't call MEMORY_STORE.

Count before you verify. NLI_VERIFY over an empty anchor set raises an error rather than guessing:

SELECT COUNT(*) AS related
FROM MEMORY_RECALL('recipe_memcheck_ns',
       'The customer is learning to play the cello.', 5)
WHERE similarity > 0.45;

Expected: 0. Nothing related is stored, so treat the cello fact as NEW and store it. MEMORY_RECALL takes its query as a string literal, so this path checks one fact per statement. For a batch in one query, use the table form in Step 5.

Cleanup (Optional)

DROP TABLE IF EXISTS recipe_memcheck_memories;
DROP TABLE IF EXISTS recipe_memcheck_incoming;
DROP TABLE IF EXISTS recipe_memcheck_triage;
SELECT MEMORY_FORGET('recipe_memcheck_ns', id) AS forgotten
FROM MEMORY_RECALL('recipe_memcheck_ns', 'customer', 10);

Honest boundaries

  • The model checks consistency, not truth. CONFLICT means "these can't both be true". It doesn't say which one is right. If the stored memory was wrong to begin with, the correct new fact is the one that gets flagged.
  • Choose thresholds from your own numbers. In this data the gap between real and false conflicts sat between similarity 0.41 and 0.50. That margin is narrow. Re-run Step 3 on a sample of your memories, and raise or lower the floor until unrelated pairs stop appearing.
  • Short, single-claim memories work best. A memory that packs three facts into one sentence gives the model three ways to read it as contradicted.
  • Not a numeric or date engine. "Renewal is on March 1" vs "renewal is on March 3" should be compared exactly as typed values, not with NLI.
  • English model. NLI is off by default, so enable it as described above.

Key Concepts Learned

  • Memory poisoning is easiest to stop when the fact is written. Recall ranks by similarity, not consistency, so once a contradiction is stored it's served alongside the facts.
  • A pairwise NLI model returns confident "contradictions" for unrelated sentences. An EMBED similarity floor makes sure it only compares memories about the same thing.
  • NLI_ANCHORS + GROUP BY checks every incoming fact against its related memories in one statement, and a LEFT JOIN keeps facts with no related memory as NEW.
  • The model only reports the conflict. Whether to supersede, reject or hold is a trust policy you set.

Tags

nliagent-memoryai-agent-memorycontradiction-detectionnli-verifynli-anchorsembedvector-searchmemory-recallsql

Run this on your own machine

Install SynapCores Community Edition free, paste the SQL or Cypher above into the bundled web UI, and watch it run.

Download Free CE