Skip to content

Conversation memory

Turns, pinned facts, recency and feedback. See Keep conversation memory.

vectrixdb.memory

Conversation memory on top of a Vectrix collection.

A vector store remembers everything as equally true forever, which is the wrong shape for a conversation. Three things change when the documents are turns of a chat:

  • Recency matters. What the user said ten minutes ago usually beats what they said in March, so relevance is scaled by a half-life decay. It is blended rather than multiplied outright, so an old memory that is far more relevant still wins.

  • Facts get corrected. "I prefer dark mode" is superseded by "actually, light mode". A correction is stored as a new memory that points back at the old one, and the old one drops out of recall. Nothing is deleted, so the history stays auditable.

  • Recall gets graded. Whether a retrieved memory helped is a signal the caller has and the store does not. feedback() records it and a signed, time-decayed score folds into later ranking. This is Graphify's reflect.py idea (useful / dead_end / corrected with a half-life) applied to prose.

  • Memory is finite. Turns expire on a time-to-live per kind, a forget() deletes on demand, and consolidate() turns a run of old turns into standalone facts and drops the turns. That last one is the real long-term token saver: a month of chat becomes a dozen sentences.

  • Facts conflict. A judge callable, given a new fact and the stored facts nearest to it, says which of them it contradicts; those are marked superseded on write, the same link feedback("corrected") makes.

Consolidation and contradiction detection take any LLM callable and bundle no model: the library does the bookkeeping, you bring the judgement.

Everything is stored as ordinary documents with reserved _vx_* metadata keys, so a memory collection is still a plain collection: search(), delete() and filters all work on it unchanged.

K_KIND = '_vx_kind' module-attribute

K_SESSION = '_vx_session' module-attribute

K_ROLE = '_vx_role' module-attribute

K_TURN = '_vx_turn' module-attribute

K_TS = '_vx_ts' module-attribute

K_FEEDBACK = '_vx_feedback' module-attribute

K_SUPERSEDED_BY = '_vx_superseded_by' module-attribute

K_SUPERSEDES = '_vx_supersedes' module-attribute

K_SCORING = '_vx_scoring' module-attribute

K_EXPIRES = '_vx_expires' module-attribute

K_CONSOLIDATED_FROM = '_vx_consolidated_from' module-attribute

KIND_TURN = 'turn' module-attribute

KIND_FACT = 'fact' module-attribute

KIND_PINNED = 'pinned' module-attribute

KINDS = (KIND_TURN, KIND_FACT, KIND_PINNED) module-attribute

OUTCOMES = ('useful', 'dead_end', 'corrected') module-attribute

ContextBlock dataclass

A ready-to-inject slice of memory, assembled under one token budget.

Order is deliberate: pinned facts first because they always apply, then the most recent turns so the model has the thread, then whatever else the query pulled in with the tokens that remain.

text property

The block as plain text, ready to prepend to a prompt.

ConsolidationReport dataclass

What consolidate() did.

ConversationMemory

Remember, recall, grade, forget, consolidate and assemble memory.

Reached as db.memory; db.remember(), db.recall(), db.feedback(), db.context(), db.forget() and db.consolidate() delegate here.

__init__(db, half_life_days=7.0, recency_weight=0.5, feedback_weight=0.25, feedback_half_life_days=30.0, ttl_days=None, judge=None, judge_candidates=5)

Parameters:

Name Type Description Default
db 'Vectrix'

The collection memories live in.

required
ttl_days Optional[Dict[str, Optional[float]]]

Time to live per kind, e.g. {"turn": 30}. A memory past its TTL is invisible to recall and context at once and is deleted by expire(). Missing or None means forever; the default keeps everything.

None
judge Optional[Callable[[str, List[str]], Sequence[bool]]]

Contradiction detector for facts. Called on every remember() of a fact or pinned fact as judge(new_text, [nearest stored fact texts]) and must return one bool per candidate, True where the old fact is contradicted by the new one. Contradicted facts are marked superseded by the new memory. Any LLM call fits; nothing is bundled.

None
judge_candidates int

How many nearest facts the judge sees.

5
half_life_days float

Relevance of a memory halves every this many days. Seven suits a chat product; a knowledge base wants longer.

7.0
recency_weight float

How much a brand-new memory gains over a very old one, on the same 0-to-1 scale relevance is measured on. At 0.5 a memory must be clearly more relevant, not slightly, to beat a fresher one; ties go to the newer.

0.5
feedback_weight float

How much one unit of feedback moves the result on that scale. Bounded to plus or minus two units, so a memory graded useful many times cannot outrank a plainly better match.

0.25
feedback_half_life_days float

Feedback ages too. A dead end reported last month should not still bury a memory today.

30.0

remember(text, session=None, role='user', turn=None, timestamp=None, pinned=False, kind=None, metadata=None, id=None, ttl_days=None, check=None)

Store one memory and return its id.

A turn is numbered automatically within its session when not given. pinned=True stores a fact that context() always includes; kind="fact" stores a standalone fact that is recalled by relevance but not pinned.

ttl_days overrides the per-kind TTL for this memory. check runs the contradiction judge (default: when one is configured and the memory is a fact or pinned); check=False skips it for a memory you know is new.

feedback(id, outcome, correction=None, timestamp=None)

Record how a recalled memory turned out.

useful lifts it in later recall, dead_end lowers it, and corrected lowers it and, when correction text is given, stores the corrected fact as a new memory that supersedes this one. Returns the new memory's id in that case, otherwise None.

forget(session=None, kinds=(KIND_TURN,), older_than=None, ids=None, superseded=False, now=None)

Delete memories and return how many went.

ids names them outright; an id that is not a memory is left alone. Otherwise the selection is the kinds in session (None means every session) older than older_than, a number of days or a datetime; superseded=True selects only memories that a correction or a contradiction has already replaced, which is the audit trail you may keep or clear. Deleting is real: this is the one memory operation that loses information on purpose.

expire(now=None)

Delete every memory past its time to live. Returns the count.

Recall and context already skip expired memories, so this is about space and the audit trail, not correctness; run it whenever suits.

consolidate(session, summarize, keep_recent=6, older_than=None, batch_turns=40, delete=True, pinned=False, now=None)

Turn old turns into standalone facts and drop the turns.

summarize receives a list of {"role", "text", "ts"} dicts, oldest first, and returns the facts worth keeping as separate strings, each one complete on its own ("The user prefers PDF reports", not "they said PDF"). Any LLM call fits. It is called once per batch_turns turns so a long history never becomes one prompt.

The last keep_recent turns stay, so context() keeps its thread; older_than (days or a datetime) narrows further. Facts are stored with kind="fact" (pinned=True to pin them), stamped with the turn ids they came from, and dated at the newest turn they summarise, so recency keeps meaning something. Turns are deleted only after every fact of their batch is stored; delete=False keeps them for a dry run.

turns(session, limit=None)

A session's turns in order; the last limit of them if given.

recall(query, session=None, limit=10, token_budget=None, score_gap=None, kinds=KINDS, include_superseded=False, mode=None, now=None, exclude_ids=())

Relevance, blended with recency and feedback, cut to a budget.

session=None recalls across every session.

Relevance is normalised across the candidates before blending. The bundled embedding models put every cosine score in a narrow band, so an unrelated sentence can sit at 0.76 while the right one sits at 0.85: on absolute scores a multiplicative recency factor let a fresh but irrelevant turn beat a relevant old one. Measured against the spread the candidates actually have, "clearly more relevant" means what it says regardless of model.

context(query, session=None, token_budget=1500, recent_turns=6, limit=10, now=None, **recall_kwargs)

Assemble pinned facts, recent turns and recalled memories under one budget.

Pinned facts are never cut: they are the caller's own declaration of what always matters, and they play the role the top result plays in search(). If they alone exceed the budget the block says so rather than dropping one. Recent turns are cut oldest-first. Whatever is left goes to recall, and a recalled memory that does not fit is left out rather than shipped over budget.

feedback_score(meta, now, half_life_days)

Signed, time-decayed sum of every outcome recorded on a memory.