Arcana.Graph.GraphStore behaviour (Arcana v3.0.1)

Copy Markdown View Source

Behaviour and dispatch module for graph storage backends.

Arcana supports swappable graph storage:

  • :ecto (default) - PostgreSQL via Ecto
  • :memory - In-memory storage for testing
  • Custom module implementing this behaviour

Configuration

# config/config.exs

# Use Ecto/PostgreSQL (default)
config :arcana, :graph_store, :ecto

# With options
config :arcana, :graph_store, {:ecto, repo: MyApp.Repo}

# Custom module
config :arcana, :graph_store, MyApp.CustomGraphStore

Summary

Callbacks

Deletes all graph data for the given chunk IDs.

Deletes all graph data for a collection.

Finds all entities in a collection.

Finds entities related to the given entity within the specified depth.

Retrieves a single community by ID.

Retrieves community summaries for a collection.

Retrieves a single entity by ID.

Retrieves mentions for an entity with chunk context.

Retrieves a single relationship by ID.

Retrieves all relationships for an entity.

Lists communities with optional filtering and pagination.

Lists entities with optional filtering and pagination.

Lists relationships with optional filtering and pagination.

Persists community data for a collection.

Persists entities to the graph store.

Persists entity mentions (links between entities and chunks).

Persists relationships between entities.

Searches for chunks related to the given entity names.

Searches for entities by embedding similarity.

Sweeps orphaned graph data in a collection.

Runs fun while holding the collection's graph write lock.

Functions

Returns the configured graph store backend.

Deletes all graph data for the given chunk IDs.

Deletes all graph data for a collection using the configured backend.

Finds entities using the configured backend.

Finds related entities using the configured backend.

Gets a single community by ID using the configured backend.

Gets community summaries using the configured backend.

Gets a single entity by ID using the configured backend.

Gets mentions for an entity using the configured backend.

Gets a single relationship by ID using the configured backend.

Gets relationships for an entity using the configured backend.

Lists communities using the configured backend.

Lists entities using the configured backend.

Lists relationships using the configured backend.

Sweeps orphaned graph data when the graph is enabled for this call.

Persists communities using the configured backend.

Persists entities using the configured backend.

Persists entity mentions using the configured backend.

Persists relationships using the configured backend.

Searches for chunks using the configured backend.

Searches for entities by embedding similarity using the configured backend.

Sweeps orphaned graph data in a collection using the configured backend.

Runs fun holding the collection's graph write lock on the configured backend.

Callbacks

delete_by_chunks(chunk_ids, opts)

@callback delete_by_chunks(chunk_ids :: [binary()], opts :: keyword()) ::
  :ok | {:error, term()}

Deletes all graph data for the given chunk IDs.

Removes mentions referencing these chunks, then sweeps entities the deletion orphaned. The sweep is scoped to the collections those chunks belonged to, so deleting in one tenant leaves every other tenant's graph alone.

Prefer Arcana.delete/2, which removes the document and its chunks and sweeps in one step. Reach for this only when you are deleting chunks yourself.

The call is not atomic

In the Ecto backend the mention delete and the per-collection sweeps are separate transactions: the collections to sweep aren't known until the delete says which entities it touched, and a collection can't be locked before it has been identified. A crash or a dropped connection between them commits the delete and skips the rest, leaving entities with no mentions behind.

Nothing is lost and nothing is wrongly retained: an entity with no mentions is exactly what a sweep collects, and every sweep is idempotent, so the next one over that collection finishes the job. Those run from Arcana.delete/2 (through maybe_sweep_orphans/3), after a replace: true ingest, and from the dashboard's maintenance page, which also reports the outstanding orphan count.

The memory backend has no such window, since the whole operation is one GenServer call.

delete_by_collection(binary, opts)

@callback delete_by_collection(binary(), opts :: keyword()) :: :ok | {:error, term()}

Deletes all graph data for a collection.

Removes all entities, relationships, mentions, and communities associated with the collection.

find_entities(binary, opts)

@callback find_entities(binary(), opts :: keyword()) :: [map()]

Finds all entities in a collection.

get_community(community_id, opts)

@callback get_community(community_id :: binary(), opts :: keyword()) ::
  {:ok, map()} | {:error, :not_found}

Retrieves a single community by ID.

get_community_summaries(binary, opts)

@callback get_community_summaries(binary(), opts :: keyword()) :: [map()]

Retrieves community summaries for a collection.

get_entity(binary, opts)

@callback get_entity(binary(), opts :: keyword()) :: {:ok, map()} | {:error, :not_found}

Retrieves a single entity by ID.

get_mentions(binary, opts)

@callback get_mentions(binary(), opts :: keyword()) :: [map()]

Retrieves mentions for an entity with chunk context.

Returns mentions with associated chunk text for display.

get_relationship(relationship_id, opts)

@callback get_relationship(relationship_id :: binary(), opts :: keyword()) ::
  {:ok, map()} | {:error, :not_found}

Retrieves a single relationship by ID.

get_relationships(binary, opts)

@callback get_relationships(binary(), opts :: keyword()) :: [map()]

Retrieves all relationships for an entity.

Returns relationships where the entity is either source or target.

list_communities(opts)

@callback list_communities(opts :: keyword()) :: [map()]

Lists communities with optional filtering and pagination.

Options

  • :collection_id - Filter by collection (nil for all)
  • :level - Filter by hierarchy level
  • :search - Search in summary
  • :limit - Maximum results (default: 50)
  • :offset - Pagination offset (default: 0)

Returns communities with entity counts.

list_entities(opts)

@callback list_entities(opts :: keyword()) :: [map()]

Lists entities with optional filtering and pagination.

Options

  • :collection_id - Filter by collection (nil for all)
  • :type - Filter by entity type
  • :search - Search in entity name
  • :limit - Maximum results (default: 50)
  • :offset - Pagination offset (default: 0)

Returns entities with aggregated counts (mention_count, relationship_count).

list_relationships(opts)

@callback list_relationships(opts :: keyword()) :: [map()]

Lists relationships with optional filtering and pagination.

Options

  • :collection_id - Filter by collection (nil for all)
  • :type - Filter by relationship type
  • :search - Search in entity names or type
  • :strength - Filter by strength (:strong, :medium, :weak)
  • :limit - Maximum results (default: 50)
  • :offset - Pagination offset (default: 0)

Returns relationships with source/target entity names.

persist_communities(binary, list, opts)

@callback persist_communities(binary(), [map()], opts :: keyword()) ::
  :ok | {:error, term()}

Persists community data for a collection.

persist_entities(binary, list, opts)

@callback persist_entities(binary(), [map()], opts :: keyword()) ::
  {:ok, map()} | {:error, term()}

Persists entities to the graph store.

Returns a map of entity names to their assigned IDs.

persist_mentions(list, map, opts)

@callback persist_mentions([map()], map(), opts :: keyword()) :: :ok | {:error, term()}

Persists entity mentions (links between entities and chunks).

persist_relationships(list, map, opts)

@callback persist_relationships([map()], map(), opts :: keyword()) ::
  :ok | {:error, term()}

Persists relationships between entities.

search(list, arg2, opts)

@callback search([String.t()], [binary()] | nil, opts :: keyword()) :: [map()]

Searches for chunks related to the given entity names.

Returns scored chunk results.

search_by_embedding(list, arg2, opts)

@callback search_by_embedding([float()], [binary()] | nil, opts :: keyword()) :: [map()]

Searches for entities by embedding similarity.

Returns entities whose description embeddings are most similar to the query embedding, sorted by similarity descending.

sweep_orphans(binary, opts)

(optional)
@callback sweep_orphans(binary(), opts :: keyword()) :: :ok | {:error, term()}

Sweeps orphaned graph data in a collection.

Deletes entities in the collection that have no remaining mentions (their relationships cascade away), and marks communities whose entity_ids overlap the deleted entities as dirty so the next summarize pass regenerates them.

Intended to run after document deletion or replacement, scoped to the affected collection.

Optional. A store that doesn't implement it simply doesn't sweep: Arcana.delete/2 and the replace: true ingest still succeed, and the collection may keep entities with zero mentions — which is exactly how both paths behaved before this callback existed.

Serialization

A sweep must not interleave with a graph build for the same collection: a build inserts an entity before its mentions, so a sweep landing in that window would delete an entity that is about to be referenced.

The :ecto backend serializes both sides through with_write_lock/3 (a transaction-scoped Postgres advisory lock keyed on the collection). The :memory backend serializes individual calls through its GenServer but leaves the window between the entity and mention calls open, which is fine for a test backend. Custom backends get the full guarantee only if they implement with_write_lock/3.

with_write_lock(binary, opts, function)

(optional)
@callback with_write_lock(binary(), opts :: keyword(), (-> result)) :: result
when result: term()

Runs fun while holding the collection's graph write lock.

Optional. Backends that can serialize concurrent graph writes implement this so sweep_orphans/2 and the entity/mention persist path cannot interleave. Backends that don't implement it simply run fun.

The lock is meant to cover DB writes only, never extraction, so callers keep the wrapped work short.

Guarantees

The contract is mutual exclusion per collection, nothing more. Atomicity is best-effort and store-dependent: callers must not assume that a failure inside fun rolls back the writes it already made.

The :ecto backend does give both, because it takes the advisory lock inside a transaction. The :memory backend implements neither (it falls through to running fun), so a mid-fun failure leaves partial graph data behind, which is fine for a test backend.

A custom store that wants the full guarantee has to do what the :ecto backend does: hold a per-collection exclusive lock and run fun inside a transaction that rolls back on failure, releasing the lock when the transaction ends. Doing only one of the two is worse than doing neither, since it reads as if both were covered.

Functions

backend()

Returns the configured graph store backend.

delete_by_chunks(chunk_ids, opts \\ [])

Deletes all graph data for the given chunk IDs.

The orphan sweep is scoped to the collections the chunks belonged to. It used to run across every collection in the database, which made this impossible to call safely in a multi-tenant app.

Arcana.delete/2 is usually what you want: it removes the document, its chunks and their graph data together.

delete_by_collection(collection_id, opts \\ [])

Deletes all graph data for a collection using the configured backend.

find_entities(collection_id, opts \\ [])

Finds entities using the configured backend.

get_community(community_id, opts \\ [])

Gets a single community by ID using the configured backend.

get_community_summaries(collection_id, opts \\ [])

Gets community summaries using the configured backend.

get_entity(entity_id, opts \\ [])

Gets a single entity by ID using the configured backend.

get_mentions(entity_id, opts \\ [])

Gets mentions for an entity using the configured backend.

get_relationship(relationship_id, opts \\ [])

Gets a single relationship by ID using the configured backend.

get_relationships(entity_id, opts \\ [])

Gets relationships for an entity using the configured backend.

list_communities(opts \\ [])

Lists communities using the configured backend.

list_entities(opts \\ [])

Lists entities using the configured backend.

list_relationships(opts \\ [])

Lists relationships using the configured backend.

maybe_sweep_orphans(collection_id, repo, opts)

Sweeps orphaned graph data when the graph is enabled for this call.

Shared by Arcana.delete/2 and the replace: true ingest path: both drop documents whose chunks cascade away, which can strand zero-mention entities. Returns :ok when there is nothing to sweep (no collection, or the graph is disabled), otherwise the backend's result.

persist_communities(collection_id, communities, opts \\ [])

Persists communities using the configured backend.

persist_entities(collection_id, entities, opts \\ [])

Persists entities using the configured backend.

persist_mentions(mentions, entity_id_map, opts \\ [])

Persists entity mentions using the configured backend.

persist_relationships(relationships, entity_id_map, opts \\ [])

Persists relationships using the configured backend.

search(entity_names, collection_ids, opts \\ [])

Searches for chunks using the configured backend.

search_by_embedding(query_embedding, collection_ids, opts \\ [])

Searches for entities by embedding similarity using the configured backend.

sweep_orphans(collection_id, opts \\ [])

Sweeps orphaned graph data in a collection using the configured backend.

with_write_lock(collection_id, opts, fun)

Runs fun holding the collection's graph write lock on the configured backend.

Backends that don't implement with_write_lock/3 just run fun, with no locking and no rollback. See with_write_lock/3 for what each backend actually guarantees.