Packages

PostgreSQL + pgvector backend for the Mnemosyne agentic memory library.

Current section

Files

Jump to
mnemosyne_postgres lib mnemosyne_postgres backend.ex
Raw

lib/mnemosyne_postgres/backend.ex

defmodule MnemosynePostgres.Backend do
@moduledoc """
PostgreSQL/pgvector implementation of `Mnemosyne.GraphBackend`.
Persists knowledge graph nodes in a single polymorphic `nodes` table
with JSONB data, vector embeddings, and JSONB link maps. Metadata
is stored in a separate `node_metadata` table.
## Telemetry
See `MnemosynePostgres.Telemetry` for the full list of events and their
metadata.
## Error handling
Callbacks whose behaviour spec includes `{:error, ...}` returns
(`apply_changeset`, `delete_nodes`, `find_candidates`, `get_nodes_by_type`)
catch exceptions and return `{:error, StorageError.t()}`. Callbacks that
only define `{:ok, ...}` returns (`get_node`, `get_linked_nodes`,
`get_metadata`, `update_metadata`, `delete_metadata`) let exceptions
propagate, as the caller is expected to handle crashes via supervision.
"""
@behaviour Mnemosyne.GraphBackend
import Ecto.Query
alias Mnemosyne.Errors.Framework.StorageError
alias Mnemosyne.Graph.Node, as: NodeProtocol
alias Mnemosyne.Graph.Similarity
alias Mnemosyne.NodeMetadata
alias MnemosynePostgres.NodeSerializer
alias MnemosynePostgres.Queries.MetadataQueries
alias MnemosynePostgres.Queries.NodeQueries
alias MnemosynePostgres.Telemetry
@required_opts [:repo, :repo_id]
@impl true
def init(opts) do
with :ok <- validate_opts(opts) do
{:ok,
%{
repo: opts[:repo],
tenant_id: Keyword.get(opts, :tenant_id, "default"),
repo_id: opts[:repo_id],
prefix: Keyword.get(opts, :prefix, "mnemosyne_")
}}
end
end
@impl true
def apply_changeset(changeset, state) do
metadata = %{tenant_id: state.tenant_id, repo_id: state.repo_id}
Telemetry.span(:apply_changeset, metadata, fn ->
result =
state.repo.transaction(fn ->
insert_nodes(changeset.additions, state)
apply_links(changeset.links, state)
upsert_metadata(changeset.metadata, state)
end)
|> case do
{:ok, _} -> {:ok, state}
{:error, reason} -> {:error, storage_error(:apply_changeset, reason)}
end
{result, %{nodes_inserted: length(changeset.additions)}}
end)
end
@impl true
def delete_nodes(node_ids, state) when is_list(node_ids) do
metadata = %{
tenant_id: state.tenant_id,
repo_id: state.repo_id,
node_count: length(node_ids)
}
Telemetry.span(:delete_nodes, metadata, fn ->
result =
state.repo.transaction(fn ->
clean_stale_links(node_ids, state)
delete_metadata_for_ids(node_ids, state)
delete_nodes_by_ids(node_ids, state)
end)
|> case do
{:ok, _} -> {:ok, state}
{:error, reason} -> {:error, storage_error(:delete_nodes, reason)}
end
{result, %{}}
end)
end
@impl true
def find_candidates(node_types, query_embedding, tag_embeddings, vf_config, _opts, state) do
metadata = %{
node_types: node_types,
tenant_id: state.tenant_id,
repo_id: state.repo_id
}
Telemetry.span(:find_candidates, metadata, fn ->
vf_module = Map.get(vf_config, :module, Mnemosyne.ValueFunction.Default)
pgvector_query = Pgvector.new(query_embedding)
try do
rows_by_type =
Enum.map(node_types, fn type ->
params = get_in(vf_config, [:params, type]) || %{}
top_k = Map.get(params, :top_k, 20)
limit = top_k * 2
rows =
NodeQueries.vector_search(state, type, pgvector_query, limit)
|> state.repo.all()
{type, rows}
end)
all_node_ids =
rows_by_type
|> Enum.flat_map(fn {_type, rows} -> Enum.map(rows, & &1.id) end)
|> Enum.uniq()
metadata_map = fetch_metadata_map(all_node_ids, state)
candidates =
Enum.flat_map(rows_by_type, fn {type, rows} ->
params = get_in(vf_config, [:params, type]) || %{}
threshold = Map.get(params, :threshold, 0.0)
top_k = Map.get(params, :top_k, 20)
rows
|> Enum.map(fn row ->
node = NodeSerializer.from_row(row)
emb = NodeProtocol.embedding(node)
relevance = compute_relevance(emb, query_embedding, tag_embeddings)
node_meta = Map.get(metadata_map, node.id)
score = vf_module.score(relevance, node, node_meta, params)
{node, score}
end)
|> Enum.filter(fn {_node, score} -> score >= threshold end)
|> Enum.sort_by(&elem(&1, 1), :desc)
|> Enum.take(top_k)
end)
deduped =
Enum.uniq_by(candidates, fn {node, _score} -> NodeProtocol.id(node) end)
result = {:ok, deduped, state}
{result, %{candidate_count: length(deduped)}}
rescue
e -> {{:error, storage_error(:find_candidates, e)}, %{}}
end
end)
end
@impl true
def get_node(id, state) do
metadata = %{tenant_id: state.tenant_id, repo_id: state.repo_id, node_id: id}
Telemetry.span(:get_node, metadata, fn ->
row =
state
|> NodeQueries.base()
|> NodeQueries.by_ids([id])
|> state.repo.one()
node = if row, do: NodeSerializer.from_row(row)
{{:ok, node, state}, %{}}
end)
end
@impl true
def get_linked_nodes(node_ids, _edge_type, state) do
unique_ids = Enum.uniq(node_ids)
nodes =
state
|> NodeQueries.base()
|> NodeQueries.by_ids(unique_ids)
|> state.repo.all()
|> Enum.map(&NodeSerializer.from_row/1)
|> Enum.uniq_by(&NodeProtocol.id/1)
{:ok, nodes, state}
end
@impl true
def get_nodes_by_type(node_types, state) do
metadata = %{
tenant_id: state.tenant_id,
repo_id: state.repo_id,
node_types: node_types
}
Telemetry.span(:get_nodes_by_type, metadata, fn ->
try do
nodes =
state
|> NodeQueries.scoped()
|> NodeQueries.by_types(node_types)
|> state.repo.all()
|> Enum.map(&NodeSerializer.from_row/1)
{{:ok, nodes, state}, %{}}
rescue
e -> {{:error, storage_error(:get_nodes_by_type, e)}, %{}}
end
end)
end
@impl true
def get_metadata(node_ids, state) do
rows =
state
|> MetadataQueries.base()
|> MetadataQueries.by_node_ids(node_ids)
|> state.repo.all()
result = Map.new(rows, fn row -> {row.node_id, row_to_node_metadata(row)} end)
{:ok, result, state}
end
@impl true
def update_metadata(entries, state) when map_size(entries) == 0, do: {:ok, state}
def update_metadata(entries, state) do
now = DateTime.utc_now()
source = MetadataQueries.source(state)
rows =
Enum.map(entries, fn {node_id, %NodeMetadata{} = meta} ->
%{
tenant_id: state.tenant_id,
node_id: node_id,
access_count: meta.access_count,
last_accessed_at: meta.last_accessed_at,
created_at: meta.created_at || now,
cumulative_reward: meta.cumulative_reward,
reward_count: meta.reward_count
}
end)
replace_fields = [
:access_count,
:last_accessed_at,
:cumulative_reward,
:reward_count
]
state.repo.insert_all(source, rows,
on_conflict: {:replace, replace_fields},
conflict_target: [:tenant_id, :node_id]
)
{:ok, state}
end
@impl true
def delete_metadata(node_ids, state) do
state
|> MetadataQueries.base()
|> MetadataQueries.by_node_ids(node_ids)
|> state.repo.delete_all()
{:ok, state}
end
# -- Private helpers --
defp validate_opts(opts) do
missing = Enum.reject(@required_opts, &Keyword.has_key?(opts, &1))
case missing do
[] -> :ok
keys -> {:error, storage_error(:init, "missing required options: #{inspect(keys)}")}
end
end
defp insert_nodes([], _state), do: :ok
defp insert_nodes(additions, state) do
rows = Enum.map(additions, &NodeSerializer.to_row(&1, state.tenant_id, state.repo_id))
source = NodeQueries.source(state)
state.repo.insert_all(source, rows)
end
defp apply_links([], _state), do: :ok
defp apply_links(links, state) do
link_map = build_link_map(links)
affected_ids = Map.keys(link_map)
current_links_by_id =
state
|> NodeQueries.base()
|> NodeQueries.by_ids(affected_ids)
|> select([n], {n.id, n.links})
|> state.repo.all()
|> Map.new()
source = NodeQueries.source(state)
Enum.each(link_map, fn {node_id, new_links} ->
current = Map.get(current_links_by_id, node_id, Mnemosyne.Graph.Edge.empty_links())
merged = merge_links(current, new_links)
from(n in source, where: n.id == ^node_id and n.tenant_id == ^state.tenant_id)
|> state.repo.update_all(set: [links: merged])
end)
end
defp build_link_map(links) do
Enum.reduce(links, %{}, fn {id_a, id_b, edge_type}, acc ->
acc
|> Map.update(id_a, %{edge_type => MapSet.new([id_b])}, fn existing ->
Map.update(existing, edge_type, MapSet.new([id_b]), &MapSet.put(&1, id_b))
end)
|> Map.update(id_b, %{edge_type => MapSet.new([id_a])}, fn existing ->
Map.update(existing, edge_type, MapSet.new([id_a]), &MapSet.put(&1, id_a))
end)
end)
end
defp merge_links(current_links, new_links) do
Enum.reduce(new_links, current_links, fn {edge_type, id_set}, acc ->
existing = Map.get(acc, edge_type, MapSet.new())
Map.put(acc, edge_type, MapSet.union(existing, id_set))
end)
end
defp clean_stale_links(deleted_ids, state) do
deleted_set = MapSet.new(deleted_ids)
source = NodeQueries.source(state)
rows =
state
|> NodeQueries.scoped()
|> where([n], n.id not in ^deleted_ids)
|> where(
[n],
fragment(
"""
EXISTS (
SELECT 1
FROM jsonb_each(?) AS e(k,v),
jsonb_array_elements_text(e.v) AS elem
WHERE elem = ANY(?)
)
""",
n.links,
^deleted_ids
)
)
|> select([n], {n.id, n.links})
|> state.repo.all()
Enum.each(rows, fn {node_id, links} ->
cleaned =
Map.new(links, fn {edge_type, id_set} ->
{edge_type, MapSet.difference(id_set, deleted_set)}
end)
if cleaned != links do
from(n in source, where: n.id == ^node_id and n.tenant_id == ^state.tenant_id)
|> state.repo.update_all(set: [links: cleaned])
end
end)
end
defp delete_metadata_for_ids(node_ids, state) do
state
|> MetadataQueries.base()
|> MetadataQueries.by_node_ids(node_ids)
|> state.repo.delete_all()
end
defp delete_nodes_by_ids(node_ids, state) do
state
|> NodeQueries.base()
|> NodeQueries.by_ids(node_ids)
|> state.repo.delete_all()
end
defp upsert_metadata(metadata, _state) when map_size(metadata) == 0, do: :ok
defp upsert_metadata(metadata, state) do
now = DateTime.utc_now()
source = MetadataQueries.source(state)
rows =
Enum.map(metadata, fn {node_id, %NodeMetadata{} = meta} ->
%{
tenant_id: state.tenant_id,
node_id: node_id,
access_count: meta.access_count,
last_accessed_at: meta.last_accessed_at,
created_at: meta.created_at || now,
cumulative_reward: meta.cumulative_reward,
reward_count: meta.reward_count
}
end)
replace_fields = [
:access_count,
:last_accessed_at,
:cumulative_reward,
:reward_count
]
state.repo.insert_all(source, rows,
on_conflict: {:replace, replace_fields},
conflict_target: [:tenant_id, :node_id]
)
end
defp fetch_metadata_map([], _state), do: %{}
defp fetch_metadata_map(node_ids, state) do
rows =
state
|> MetadataQueries.base()
|> MetadataQueries.by_node_ids(node_ids)
|> state.repo.all()
Map.new(rows, fn row -> {row.node_id, row_to_node_metadata(row)} end)
end
defp compute_relevance(nil, _query_embedding, _tag_embeddings), do: 0.0
defp compute_relevance(emb, query_embedding, tag_embeddings) do
query_sim = Similarity.cosine_similarity(query_embedding, emb)
tag_sim =
tag_embeddings
|> Enum.map(&Similarity.cosine_similarity(&1, emb))
|> Enum.max(fn -> 0.0 end)
max(query_sim, tag_sim) |> max(0.0)
end
defp row_to_node_metadata(row) do
%NodeMetadata{
access_count: row.access_count,
last_accessed_at: row.last_accessed_at,
created_at: row.created_at,
cumulative_reward: row.cumulative_reward,
reward_count: row.reward_count
}
end
defp storage_error(operation, reason) do
%StorageError{operation: operation, reason: reason}
end
end