Skip to content

symfonic.capabilities.memory.vector_recall

vector_recall

The vector half of recall: what is embedded, searched, and merged back.

The capability-native store persisted through a graph and ranked by lexical overlap between the turn's cue and the stored text. Every composition the framework ships -- the scaffold template included -- built a vector backend and an embedder alongside it, created the tables, and then handed the store the graph alone. So a project provisioned pgvector, possibly loaded a sentence-transformers model, and recalled by word overlap. "Semantic memory" is what the product promises and what nothing performed.

This is the missing half, and it is a port pair rather than a special case: a store composed with a vector backend and an embedder uses them, and one composed without behaves exactly as before.

Metadata rides with the vector, and that is deliberate. VectorBackend takes a metadata mapping per row, so the vector path can carry record_id, layer, scope_path, origin, salience and the record's own metadata, and hand all of it back. The graph payload drops metadata (task-3-1-9), so this path must not repeat that: a second route that lost the same field would make the bug harder to see rather than easier.

Isolation is checked here too. The backends filter by scope in SQL, and the scope is re-checked against every hit anyway. That is the same posture the graph half takes -- the component that might be wrong is the one below -- and it is what stops a graph outage from turning the vector path into a route that answers without the isolation the graph was enforcing.

VectorRecall

VectorRecall(vectors: Any, embedder: Any)

The embedder and the vector backend, as one collaborator of the store.

Together rather than separately, because neither is useful alone: an embedder with nowhere to put a vector writes nothing, and a vector backend with no embedder cannot be queried. Composing them as a pair is what makes "this deployment does semantic recall" a single yes or no.

Source code in src/symfonic/capabilities/memory/vector_recall.py
def __init__(self, vectors: Any, embedder: Any) -> None:
    self._vectors = vectors
    self._embedder = embedder

forget async

forget(scope: Any, record_ids: tuple[str, ...]) -> None

Remove vector copies for record_ids under scope.

Source code in src/symfonic/capabilities/memory/vector_recall.py
async def forget(self, scope: Any, record_ids: tuple[str, ...]) -> None:
    """Remove vector copies for ``record_ids`` under ``scope``."""
    if record_ids:
        await self._vectors.delete(scope, list(record_ids))

remember async

remember(scope: Any, records: tuple[MemoryRecord, ...]) -> int

Embed and store records. Returns how many vectors were written.

A failure here is logged and swallowed on purpose: the graph write has already happened and is the durable one. Losing the vector costs this memory its semantic reach until it is rewritten; raising would cost the turn a memory it had already recorded.

Source code in src/symfonic/capabilities/memory/vector_recall.py
async def remember(self, scope: Any, records: tuple[MemoryRecord, ...]) -> int:
    """Embed and store ``records``. Returns how many vectors were written.

    A failure here is logged and swallowed on purpose: the graph write has
    already happened and is the durable one. Losing the vector costs this
    memory its semantic reach until it is rewritten; raising would cost
    the turn a memory it had already recorded.
    """
    if not records:
        return 0
    try:
        texts = [record.text for record in records]
        embeddings = await self._embedder.embed_batch(texts)
        await self._vectors.add(
            scope,
            [record.record_id for record in records],
            list(embeddings),
            [vector_metadata(record) for record in records],
            texts,
        )
    except Exception:  # noqa: BLE001 - the graph write stands
        logger.warning("vector write failed; recall stays lexical", exc_info=True)
        return 0
    return len(records)

search async

search(scope: Any, query: MemoryQuery, visible: Any) -> tuple[tuple[RetrievedMemory, ...], bool]

Semantically similar memories for query, and whether it degraded.

Parameters:

Name Type Description Default
visible Any

a predicate answering whether a record's scope is one this query may read. Passed in rather than recomputed, so the vector path and the graph path apply one isolation rule -- two copies of that rule is how they start to disagree.

required

Returns ((), True) when the backend or the embedder could not be reached. Degraded, and reported as such, rather than an empty answer that reads like "nothing is similar".

Source code in src/symfonic/capabilities/memory/vector_recall.py
async def search(
    self, scope: Any, query: MemoryQuery, visible: Any
) -> tuple[tuple[RetrievedMemory, ...], bool]:
    """Semantically similar memories for ``query``, and whether it degraded.

    Args:
        visible: a predicate answering whether a record's scope is one this
            query may read. Passed in rather than recomputed, so the vector
            path and the graph path apply *one* isolation rule -- two
            copies of that rule is how they start to disagree.

    Returns ``((), True)`` when the backend or the embedder could not be
    reached. Degraded, and reported as such, rather than an empty answer
    that reads like "nothing is similar".
    """
    if not query.cue.strip():
        # No cue, nothing to be similar to. Not degraded: this is the
        # cold-start turn, and the graph half still answers it.
        return (), False
    try:
        embedding = await self._embedder.embed(query.cue)
        hits = await self._vectors.search(
            scope, list(embedding), top_k=query.candidate_limit
        )
    except Exception:  # noqa: BLE001 - the graph half still answers
        logger.warning("vector search failed; recall stays lexical", exc_info=True)
        return (), True

    found: list[RetrievedMemory] = []
    for hit in (hits or ())[:query.candidate_limit]:
        record = _record_from_hit(hit)
        if record is None or record.layer not in query.layers:
            continue
        distance = visible(record)
        if distance < 0:
            # The backend filtered by scope in SQL and is checked anyway:
            # the component that might be wrong is the one below, and a
            # vector route that answered without this check would be a
            # way around the isolation the graph enforces.
            continue
        similarity = float(hit.get("score", 0.0) or 0.0)
        found.append(
            RetrievedMemory(
                record=record,
                score=similarity * _SIMILARITY_WEIGHT,
                scope_distance=distance,
            )
        )
    return tuple(found), False

merge

merge(graph: tuple[RetrievedMemory, ...], vector: tuple[RetrievedMemory, ...]) -> tuple[tuple[RetrievedMemory, ...], int]

One memory per record_id, under :data:MERGE_RULE.

Returns the merged candidates and how many were the same memory seen twice. A memory the graph holds and the vector index also holds is one memory: emitting it twice would spend two lines of a budgeted block on one fact and let a duplicate outrank a distinct memory beneath it.

Source code in src/symfonic/capabilities/memory/vector_recall.py
def merge(
    graph: tuple[RetrievedMemory, ...], vector: tuple[RetrievedMemory, ...]
) -> tuple[tuple[RetrievedMemory, ...], int]:
    """One memory per ``record_id``, under :data:`MERGE_RULE`.

    Returns the merged candidates and how many were the same memory seen
    twice. A memory the graph holds and the vector index also holds is one
    memory: emitting it twice would spend two lines of a budgeted block on
    one fact and let a duplicate outrank a distinct memory beneath it.
    """
    by_id: dict[str, RetrievedMemory] = {}
    order: list[str] = []
    for memory in graph:
        by_id[memory.record.record_id] = memory
        order.append(memory.record.record_id)
    duplicates = 0
    for memory in vector:
        key = memory.record.record_id
        existing = by_id.get(key)
        if existing is None:
            by_id[key] = memory
            order.append(key)
            continue
        duplicates += 1
        if (memory.score or 0.0) > (existing.score or 0.0):
            by_id[key] = RetrievedMemory(
                record=existing.record,
                score=memory.score,
                scope_distance=existing.scope_distance,
            )
    return tuple(by_id[key] for key in order), duplicates

vector_metadata

vector_metadata(record: MemoryRecord) -> dict[str, Any]

Everything about a record that has to survive the round trip.

Named fields rather than a blob: a reader of a stored row can tell what the capability meant by each, and a field the capability stops using disappears from new rows instead of lingering as an uninterpreted key.

metadata is nested under its own key so a producer's vocabulary can never collide with the capability's -- a record whose metadata had an origin key would otherwise silently overwrite the record's.

Source code in src/symfonic/capabilities/memory/vector_recall.py
def vector_metadata(record: MemoryRecord) -> dict[str, Any]:
    """Everything about a record that has to survive the round trip.

    Named fields rather than a blob: a reader of a stored row can tell what
    the capability meant by each, and a field the capability stops using
    disappears from new rows instead of lingering as an uninterpreted key.

    ``metadata`` is nested under its own key so a producer's vocabulary can
    never collide with the capability's -- a record whose metadata had an
    ``origin`` key would otherwise silently overwrite the record's.
    """
    return {
        "record_id": record.record_id,
        "layer": record.layer.value,
        # The *legacy materialised* path, not this capability's. Every vector
        # backend isolates on this key with ``metadata_scope_path``, which
        # compares it against ``TenantScope.ancestor_prefix_paths()`` -- so a
        # row carrying "acme/alice" where the reader expects
        # "tenant\x1facme\x1fsub_tenant\x1falice" is invisible to every
        # search at every scope. Written in the capability's own format until
        # now, which made the in-memory backend return nothing and report it
        # as ``vector: 0`` -- indistinguishable from "the index had nothing
        # similar". Postgres was unaffected only because it isolates on a
        # column it stamps itself.
        "scope_path": legacy_scope_path(record.scope),
        "origin": record.origin,
        "importance": salience_to_importance(record.salience),
        "revision": record.revision,
        # Stripped of the capability's own names for the same reason the
        # graph payload is: a producer that could write ``scope_path`` here
        # would be naming the scope its memory is recalled into.
        "metadata": producer_metadata(record.metadata),
    }