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'sreflect.pyidea (useful/dead_end/correctedwith a half-life) applied to prose. -
Memory is finite. Turns expire on a time-to-live per kind, a
forget()deletes on demand, andconsolidate()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
judgecallable, 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 linkfeedback("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. |
None
|
judge
|
Optional[Callable[[str, List[str]], Sequence[bool]]]
|
Contradiction detector for facts. Called on every
|
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.