---
title: "Giving an eve agent a memory with Postgres and pgvector — Hypermetron"
description: "Our Slack support assistant forgot everything between threads. A small memory layer fixed that: one Postgres table, pgvector for similarity search, and a few dozen lines at the channel boundary."
canonical: "https://hypermetron.com/blog/eve-agent-memory-postgres-pgvector"
lang: "en"
updated: "2026-10-27"
---

# Giving an eve agent a memory with Postgres and pgvector

Published 2026-10-27 · AI, Engineering

Our Slack support assistant forgot everything between threads. A small memory layer fixed that: one Postgres table, pgvector for similarity search, and a few dozen lines at the channel boundary.

In [an earlier post](https://hypermetron.com/blog/eve-slack-socket-mode) we connected a support assistant built on [eve](https://github.com/vercel/eve) to Slack over Socket Mode. Each Slack thread maps to one eve session, so a follow-up in the thread keeps its context. A new thread starts from nothing.

That is mostly what you want. But teams keep telling the assistant the same things: the staging database is read-only on Fridays, refunds over a limit need a second approver, the billing service logs to a different project. Repeating that in every thread gets old fast.

So we gave it a small, explicit memory. Someone writes “remember that…” in a thread, the assistant stores the note, and later threads in that channel get the relevant notes in their context. No knowledge graph, no automatic extraction. Just notes, found by meaning instead of keywords.

## Why Postgres and pgvector

Notes the team relies on should outlive the machine the agent runs on, and they should sit with the rest of the data we already back up, query and audit. That place is Postgres. [pgvector](https://github.com/pgvector/pgvector) adds a `vector` column type and distance operators to it, so a note and its embedding are one row, and finding similar notes is an ordinary `SELECT`.

It also means a second agent process, or a dashboard that lists what the assistant remembers, reads the same table. Nothing new to deploy beyond one extension.

## The schema

One table. The embedding lives next to the text it was made from:

```sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS memories (
  id         bigserial PRIMARY KEY,
  channel    text NOT NULL,
  author     text NOT NULL,
  text       text NOT NULL,
  embedding  vector(1536) NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS memories_channel_idx ON memories (channel);
```

1536 is the size of the vectors `text-embedding-3-small` returns. If you change embedding models, the dimension changes too, and every stored note has to be embedded again. Write the model name down next to the table.

There is no vector index yet, on purpose. For a few hundred notes per channel, Postgres compares against every row in the channel faster than you will notice, and the results are exact. Add an index when the table is big enough to need one (more on that below).

## Remembering

Storing a note means embedding its text and inserting one row. We use the AI SDK’s `embed`, which keeps the provider swappable, and the `pgvector` package to pass vectors through node-postgres:

```js
import pg from "pg";
import pgvector from "pgvector/pg";
import { embed } from "ai";

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
pool.on("connect", async client => {
  await pgvector.registerTypes(client);
});

const MODEL = "openai/text-embedding-3-small";

async function embedText(value) {
  const { embedding } = await embed({ model: MODEL, value });
  return pgvector.toSql(embedding);
}

export async function remember({ channel, author, text }) {
  const embedding = await embedText(text);
  await pool.query(
    "INSERT INTO memories (channel, author, text, embedding) VALUES ($1, $2, $3, $4)",
    [channel, author, text, embedding]
  );
}
```

Because the note and its vector are one row, there is no moment where one exists without the other, and no transaction to manage. The embedding call happens before the query, so no connection is held while the model answers.

## Recalling

Recall embeds the incoming message and asks Postgres for the nearest notes in the same channel. `<=>` is pgvector’s cosine distance: 0 for the same direction, 2 for opposite.

```js
// A starting point, not a law: tune it on your own notes.
const MAX_DISTANCE = 0.5;

export async function recall({ channel, text, limit = 5 }) {
  const embedding = await embedText(text);
  const { rows } = await pool.query(
    `SELECT text, created_at, embedding <=> $1 AS distance
       FROM memories
      WHERE channel = $2
      ORDER BY embedding <=> $1
      LIMIT $3`,
    [embedding, channel, limit]
  );
  return rows.filter(row => row.distance <= MAX_DISTANCE);
}
```

The channel filter is part of the query, so the five notes you get back are the five closest in that channel, not the closest five overall with the wrong ones thrown away.

The distance cut-off is the part that needs judgement. Without it, the assistant always gets five notes, related or not, and starts weaving unrelated rules into its answers. Set too tight, it gets nothing. Look at the distances your real notes produce for real questions before you pick a number.

## Wiring it into the channel

Both functions plug into the internal route from the Socket Mode post. eve itself doesn’t change: the agent simply receives a message that starts with the relevant notes. `slack` is a `WebClient` from `@slack/web-api`.

```js
POST("/internal/slack", async (request, { from }) => {
  const { channel, thread, user, text } = await request.json();
  const message = text.replace(/<@[A-Z0-9]+>/g, "").trim();

  const note = message.match(/^remember(?: that)?[:,]?\s+(.+)/is);
  if (note) {
    await remember({ channel, author: user, text: note[1] });
    await slack.chat.postMessage({ channel, thread_ts: thread, text: "Noted." });
    return new Response(null, { status: 202 });
  }

  const notes = await recall({ channel, text: message });
  const context = notes.length
    ? "Notes this team asked you to remember (with the date added):\n" +
      notes.map(n => `- ${n.text} (${n.created_at.toISOString().slice(0, 10)})`).join("\n") +
      "\n\nMessage:\n"
    : "";

  await from(`${channel}:${thread}`).send(context + message, { auth: null });
  return new Response(null, { status: 202 });
})
```

The date goes in with every note on purpose. A note from March about how deploys work may be wrong by October, and the model can only weigh that if it can see how old the note is.

## When the table grows

Once exact search gets slow, add an approximate index:

```sql
CREATE INDEX memories_embedding_idx
  ON memories USING hnsw (embedding vector_cosine_ops);
```

One catch: with an approximate index, Postgres applies the `WHERE channel = …` filter after the index has picked its candidates, so a small channel can come back with fewer notes than it has. pgvector 0.8 added iterative index scans for exactly this. `SET hnsw.iterative_scan = strict_order;` lets the scan keep going until it has enough rows that pass the filter.

## What to be careful about

Memory turns a chat message into lasting instructions, so it deserves more care than the rest of the plumbing:

- **Decide who may write memories.** Anything stored is fed back to the agent as context in every related thread. Check the Slack user against the same allowlist you use for approvals before calling `remember`.
- **Keep memories per channel.** A note from the payments channel shouldn’t steer answers in a public help channel. The `channel` column and the `WHERE` clause are the whole mechanism.
- **Never store secrets.** People will paste tokens into “remember that…”. Reject anything that looks like a key before it reaches the database.
- **Make forgetting easy.** Add a “forget” command that shows the closest matching notes and deletes the one the user picks. A memory that can’t be corrected becomes a liability.
- **Label recalled notes as notes.** The agent should treat them as team context, not as instructions that override its system prompt.

None of this needed changes to eve. The memory lives at the edge, in the same channel code that already translates Slack into eve messages. If we switch embedding models later, that file and one migration are all that change.

Working on something with the same kind of problem? Start a project: https://hypermetron.com/#contact
