Ankita v2.7.5 如何以 0.85 門檻合併重複記憶、標記衝突而不自動刪除
How I built memory that stops repeating itself: dedup at 0.85, conflicts flagged, nothing deleted
作者在 Ankita v2.7.5 中為個人記憶加入寫入前比對,相似度高於 0.85 時更新既有事實;疑似矛盾時則保留並標記兩筆資料,不自動刪除。系統可使用已配置的嵌入向量比對,並提供確定性的詞彙比對後備;衝突判定另設 0.55 至 0.85 的區間,以及詞數、重合度和事實類型等條件。發現衝突後,兩筆資料會互相連結,並提示智能體詢問用戶哪項資料仍有效;比對失敗不會阻止寫入。
My desktop companion remembers things about me. That is the whole point of it — until it remembers the same thing twice, and then remembers the opposite of it, and then confidently asserts whichever one it read last.
That is the failure mode I fixed in Ankita v2.7.5. The problem was not that the memory store lost facts. It was that it never questioned them. Every new fact went in as a new row, so "I use Postgres" and "Database is Postgres" sat side by side as two facts, and a later "I moved to SQLite" sat next to both of them, unchallenged. The companion would then answer from whichever row surfaced first and sound certain about it.
The decision: merge, flag, never silently delete
The new rule, in one sentence: a new fact is compared against what is already stored before it is written. If it is near-identical to an existing fact, it updates that fact instead of creating a second one. If it looks related but disagrees, both facts are kept and linked as conflicting, and the companion is told to ask which is current rather than assert either. Nothing is deleted or rewritten automatically, ever.
That last clause is the decision worth arguing about. The tempting design is auto-resolution: newest wins, old fact dropped. It is cleaner, and it is wrong for a personal store, because "newest" is not the same as "true". People change their minds and then change them back. A migration gets reverted. A preference stated in passing is not an instruction to erase the one before it. So the store keeps both and wears the uncertainty in public.
How the comparison works
The comparison lives in src/memory/memory-similarity.mjs, and it is deliberately two-layered. Embeddings are used when an embedder is configured — v2.7.5 also adds offline semantic memory, so you can point EMBED_LOCAL_BASE at an Ollama or LM Studio server instead of a Cloudflare account — but the module always has a deterministic lexical fallback, so memory quality never depends on a provider being configured.
The lexical path works on content words only. A stop-word list strips the filler ("I", "my", "use", "database", "project", "stuff"), so "I use Postgres" and "Database is Postgres" both reduce to {postgres}. Two words match when they are equal, or when the shorter is a prefix of the longer and at least four characters long, which catches postgres/postgresql and migrate/migrated without matching on stubs.
From there, three rules:
-
Merge above 0.85.
DEFAULT_DEDUP_THRESHOLD = 0.85— above this similarity, one fact, not two. The score is the best of Jaccard overlap and a harsher containment term that only fires when every content word of the shorter fact appears in the longer one, which is the common shape of a repeated preference. A single shared distinctive word can link two short facts at 0.6, but only when both sides are short, so long incidental sentences cannot match on one word. - Conflict between 0.55 and the merge line, with conditions. Lexically, a conflict needs at least three meaningful words, at least two agreements, and a Jaccard of 0.5 or more — two-word facts cannot contradict, because one shared word is ambiguous. A conflict is only claimed between facts of the same declared kind, so unrelated facts that happen to share vocabulary are not a disagreement. And when the winning score came from an embedder, the cosine threshold is only trusted if the score actually came from the embedder; a cold or partial embedding index mixing cosine and lexical scores is not allowed to manufacture conflicts.
- A provider failure never blocks a write. If the embedder throws, the score falls back to lexical. If the whole comparison finds nothing, the fact is stored plainly. The memory write is the thing that must not fail; the dedup is a quality gate on top of it.
What the flag looks like in practice
When a conflict is found, both facts get each other's id in a conflictsWith list (src/memory/profile.mjs links them both ways, and clearing the flag removes it from both sides). When either fact is later forgotten, the dangling pointers are cleaned up, so a resolved conflict cannot haunt later recalls. And when conflicting facts are recalled into context, the prompt carries a plain notice: "Some facts conflict with each other. Ask the user which is current instead of asserting either."
That notice is the whole user-facing behaviour. The companion does not pick. It asks. The cost is one clarifying question, occasionally, in exchange for never confidently stating something the store itself knows is disputed.
There are honest limits. The lexical fallback cannot see that "Postgres" and "PostgreSQL" are the same word unless the prefix rule catches it, and it cannot tell a genuine contradiction from two facts that merely share vocabulary until the same-kind and minimum-word gates filter them. The merge threshold is a single number, 0.85, applied to every kind of fact. And a conflict flag is only as good as the next conversation: if the user never answers the question, both facts keep sitting there, flagged, forever. Pins are exempt from staleness for the same reason — a deliberate fact should not quietly expire.
What else shipped in v2.7.5
The memory change is one part of the release. Also in 2.7.5: an editable personality card in Settings (caring attitude, teasing, language, example exchanges, shared by chat and voice); voice that keeps listening for a short follow-up window (25 seconds by default) instead of needing the wake word every time, with a quiet "Say Ankita first" hint in standby; the open_app launcher that actually opens apps by name from a discovered, validated index; voice wake/stop words and auto-mute; scheduled results that appear in Scheduled tasks as soon as they finish; heartbeats that retry instead of being dropped, with [SILENT] runs that record without posting; long-conversation compaction on by default in the desktop app; and shared Telegram, Discord and Slack channels on one local policy and delivery core. Fixes include a stuck voice process no longer freezing the queue, and npm test scoped to the project's own test/ directory.
Ankita is open source (MIT) and local-first: github.com/akyourowngames/A.N.K.I.T.A. The release is at github.com/akyourowngames/A.N.K.I.T.A/releases/tag/v2.7.5, and the site is ankita-sable.vercel.app.
Tell me I am wrong
Here is the claim to break: a personal memory store should never auto-resolve a contradiction, even when the newer fact looks obviously right — because "newest wins" silently destroys the one record that could have corrected it. Is that caution, or is it cowardice? If you would let the store auto-rewrite on a high enough similarity score, where would you draw that line — and what would you do about the user who changed their mind back? I would genuinely like to know where this breaks first.
來源:DEV Community · dev.to