Files
ely-eye/backend/src/ely_eye/retrieval.py
T
ZacharyZhang-NY dc9cfb5a30 Give memories a working tier, a long-term tier, and a record of what disagrees
A memory used to have one state: present. A session could not be reloaded
without searching, nothing separated what a person said today from what the
store ingested a year ago, and two statements that disagree about a number
were both handed to the reader as if the store believed both.

Schema v10. Atoms gain session_id, importance, recall_count, last_recalled_at
and archived_at; two tables arrive beside them — consolidations (one watermark
per holder+scenario lane) and conflicts (a pair, the term they disagree on,
both values, and whether anyone has settled it). v_atoms_current now also
hides archived rows, and apply() drops the view before rebuilding it because
CREATE VIEW IF NOT EXISTS would keep the old body and ignore the archive.

Working memory is a read, not a store: `working <holder> <session>` returns
what that session recorded, in order, with no search involved. Consolidation
rolls a lane's new memories into one standing summary and moves the watermark
to the newest memory actually rolled up — not to the clock of the pass, which
made a second pass count the same memories twice. The archive rule is stated
in the command that runs it (recorded before N days, never returned by a
search, importance at or under a floor) rather than hidden in a decay formula
nobody measured; --dry-run names the victims first.

A conflict is recorded when a new memory repeats a label with a different
number in the same holder's same scenario. Retrieval drops the older side
while the conflict is open and both sides answer again once it is settled:
settling is not superseding, and a word-only disagreement is left alone
because a reworded sentence would fire on every paraphrase. Numbers are keyed
to both the memory's subject and the nearest preceding label, measured: the
two thermostat sentences share only their subject, so a nearest-word-only rule
found nothing.

Search now counts the memories it handed back, which is the only use estimate
the store has, and the archive rule reads it. The dense index applies the same
currency rules as the view, so an archived memory cannot stay findable through
one leg while the other hides it.

Two reads over HTTP (working, conflicts) and two tools over MCP
(working_set, memory_conflicts); curation stays with the operator because an
agent should not decide what its own past is worth. remember and supersede
carry a session id; importance does not cross MCP, because recall_count is
observed and importance is stated.

Gates: 366 passed; ruff clean; mypy 24 errors in 9 files, the recorded 9cb46c1
baseline, net 0; pyright 0 on the touched files; go build/vet/test and gofmt
clean; mcp_write_smoke PASS.
2026-10-09 07:13:11 -04:00

219 lines
9.5 KiB
Python

"""Retrieval: SQLite FTS5 does the matching, RRF merges the signals that exist.
The first version pulled every atom into Python and rebuilt a BM25 index per query, so search
cost grew with the corpus instead of with the answer, and a Chinese query matched nothing
because the tokenizer had no CJK rule. Both are fixed below the score line: the index and the
query share `text_index`, and the ranking is a SQL query.
"""
from __future__ import annotations
import base64
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
from . import cartridge_store, lifecycle_store
from .config import Settings, get_settings
from .db import Database
from .embeddings import EmbeddingService
from .schemas import EvidenceAtom, RetrievalHit, SearchFilters, SearchResult, utc_now
from .store_search import FtsPage, search_atoms
from .vector_index import VectorIndex
from .visual_index import VisualIndex
RRF_K = 60
CANDIDATE_WINDOW = 10
# Words decide most searches; a picture vector is only worth its encoding cost when words fell short.
THIN_SPARSE_PAGE = 2
class RetrievalService:
def __init__(self, settings: Settings | None = None, database: Database | None = None) -> None:
self.settings = settings or get_settings()
self.db = database or Database(self.settings)
self.embeddings = EmbeddingService(self.settings, self.db)
self.vectors = VectorIndex(self.settings, self.db, self.embeddings)
self.visual = VisualIndex(self.settings, self.db)
def search(
self,
query: str,
top_k: int | None = None,
*,
filters: SearchFilters | None = None,
cursor: str | None = None,
image: Path | None = None,
) -> SearchResult:
"""Answer one query against the memories that are open right now.
The candidate window is wider than the page so fusion has something to fuse; the cursor
advances one page at a time and re-reads the tail of the window it came from, which is
why no result is ever skipped between pages.
"""
limit = top_k or self.settings.retrieval_top_k
if limit <= 0:
raise ValueError(f"search needs a positive limit, got {limit}")
if not query.strip() and image is None:
raise ValueError("search needs words or a picture to ask with, and got neither")
offset = decode_cursor(cursor)
criteria = (filters or SearchFilters()).model_dump(exclude_none=True)
scope = criteria.get("cartridge_ids")
if scope is None:
scope = cartridge_store.mounted_ids(self.db) or None
criteria["cartridge_ids"] = scope
# A picture-only question has no words for BM25, so the word ranking is skipped rather than matching an empty expression.
page = (
search_atoms(self.db, query, limit=limit * CANDIDATE_WINDOW, offset=offset, **criteria)
if query.strip()
else FtsPage(rows=[], has_more=False)
)
rankings: list[list[str]] = [[atom_id for atom_id, _ in page.rows]]
scores: dict[str, dict[str, float]] = defaultdict(dict)
for atom_id, bm25 in page.rows:
scores[atom_id]["sparse_score"] = bm25
if self.embeddings.enabled():
dense = self._dense_search(query, limit * CANDIDATE_WINDOW, criteria)
rankings.append(list(dense))
for atom_id, cosine in dense.items():
scores[atom_id]["dense_score"] = cosine
visual = self._visual_search(
query,
image,
limit * CANDIDATE_WINDOW,
scope,
thin=len(rankings[0]) < limit * THIN_SPARSE_PAGE,
)
if visual:
rankings.append(list(visual))
for atom_id, cosine in visual.items():
scores[atom_id]["visual_score"] = cosine
hits = self._settle_conflicts(self._rank(rankings, scores, limit * CANDIDATE_WINDOW))[:limit]
lifecycle_store.touch_recalls(self.db, [hit.atom.atom_id for hit in hits], utc_now())
more = page.has_more or len(rankings[0]) > limit
return SearchResult(
query=query,
status="hits" if hits else "no_match",
hits=hits,
next_cursor=encode_cursor(offset + limit) if hits and more else None,
)
def _dense_search(self, query: str, limit: int, criteria: dict[str, Any]) -> dict[str, float]:
"""The vectors sit beside the atoms, so a memory written a moment ago is findable.
Every filter is applied inside the engine, in the same statement as the nearest-neighbour
walk, so a scoped search cannot return a memory the caller was never allowed to see. The
index catches up inside `search`, so nothing here has to rebuild it first.
"""
query_vector = self.embeddings.embed_query(query)
return self.vectors.search(
query_vector,
limit,
holder=criteria.get("holder"),
scenario=criteria.get("scenario"),
source_id=criteria.get("source_id"),
modality=criteria.get("modality"),
since=_text(criteria.get("since")),
until=_text(criteria.get("until")),
cartridge_ids=criteria.get("cartridge_ids"),
as_holder=criteria.get("as_holder"),
)
def _visual_search(
self,
query: str,
image: Path | None,
limit: int,
scope: list[str] | None,
*,
thin: bool,
) -> dict[str, float]:
"""Ask the picture index when words are not enough.
A screenshot with no text on it has nothing for FTS to match, so the sparse page comes
back short; that is the signal to spend the encoding. An image query always asks for it.
"""
if image is None and not thin:
return {}
if not self.visual.available() or self.visual.load() is None:
return {}
if image is not None:
try:
ranked = self.visual.search_image(image, limit)
except (OSError, ValueError) as error:
raise ValueError(f"the query image {image} cannot be read: {error}") from error
else:
ranked = self.visual.search_text(query, limit)
if scope:
allowed = cartridge_store.atoms_of_cartridges(self.db, scope)
ranked = [(atom_id, score) for atom_id, score in ranked if atom_id in allowed]
return dict(ranked)
def _rank(self, rankings: list[list[str]], scores: dict[str, dict[str, float]], limit: int) -> list[RetrievalHit]:
"""Reciprocal rank fusion: rank position is comparable across signals, raw scores are not.
The old blend multiplied BM25 by 0.55 and cosine by 0.35, which compared a term-frequency
count to a bounded cosine and let the larger number decide. graph_score stays 0.0 until
evidence edges are retrieved as their own ranking.
"""
fused: dict[str, float] = {}
for ranking in rankings:
for position, atom_id in enumerate(ranking):
fused[atom_id] = fused.get(atom_id, 0.0) + 1.0 / (RRF_K + position + 1)
order = sorted(fused, key=lambda atom_id: (-fused[atom_id], atom_id))[:limit]
atoms = {atom.atom_id: atom for atom in self.db.get_atoms(order)}
hits: list[RetrievalHit] = []
for atom_id in order:
atom = atoms.get(atom_id)
if atom is None:
raise ValueError(f"search returned {atom_id} but the atom is not in the store")
signal = scores.get(atom_id, {})
hits.append(
RetrievalHit(
atom=atom,
sparse_score=signal.get("sparse_score", 0.0),
dense_score=signal.get("dense_score", 0.0),
visual_score=signal.get("visual_score", 0.0),
final_score=fused[atom_id],
)
)
return hits
def _settle_conflicts(self, hits: list[RetrievalHit]) -> list[RetrievalHit]:
"""Drop the older side of a disagreement nobody settled, so one page cannot contradict itself.
Both memories stay in the store and this only filters the page; the conflict row names
which side was dropped and why. Settling is the holder's call, and until it happens the
newer statement is what a reader should be shown.
"""
losers = lifecycle_store.losing_sides(self.db, [hit.atom.atom_id for hit in hits])
if not losers:
return hits
return [hit for hit in hits if hit.atom.atom_id not in losers]
def recall(self, query: str, top_k: int | None = None, *, filters: SearchFilters | None = None) -> list[EvidenceAtom]:
"""The atoms a search would put in front of a model, no scoring attached."""
return [hit.atom for hit in self.search(query, top_k, filters=filters).hits]
def encode_cursor(offset: int) -> str:
return base64.urlsafe_b64encode(str(offset).encode()).decode()
def decode_cursor(cursor: str | None) -> int:
if cursor is None or cursor == "":
return 0
try:
offset = int(base64.urlsafe_b64decode(cursor.encode()).decode())
except (ValueError, UnicodeDecodeError) as error:
raise ValueError(f"search cursor is unreadable: {cursor!r}") from error
if offset < 0:
raise ValueError(f"search cursor is unreadable: {cursor!r}")
return offset
def _text(moment: datetime | None) -> str | None:
"""A time bound reaches SQLite as the same ISO text the atoms table stores."""
return None if moment is None else moment.isoformat()