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(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),
}
|