mnemostack

PyPI Python versions CI License: Apache 2.0

Self-hosted hybrid memory & retrieval for AI apps.

mnemostack is a durable retrieval layer over your own Qdrant (and optional Memgraph): semantic, keyword (BM25), temporal, and graph recall, fused with Reciprocal Rank Fusion and refined by an 8-stage ranking pipeline — with payload filters for multi-tenant isolation, optional LLM answer synthesis (confidence + citations), and an ingest path that enriches and projects structured fields. One recall(query) call, usable as a Python library, an HTTP service, or an MCP server.

Flagship use case — durable memory for AI agents. Long-running agents hit the same wall: context gets compacted, sessions restart, useful decisions disappear, and the next run pays the re-orientation tax again. mnemostack gives them a persistent memory layer to query when the context window is not enough — durable, searchable, scoped, and explainable, not just embedded and hoped for.

The same engine backs other retrieval-heavy work: RAG over mixed corpora, multi-tenant or per-user knowledge stores, and time-aware search backends — anywhere pure vector similarity falls short on its own.

Status: Actively developed — public API is stable; new functionality lands additively in minor releases. Breaking changes are rare and called out in CHANGELOG.md.

Quickstart: agent memory over MCP

The fastest on-ramp is the MCP server — it gives Claude Desktop, Claude Code, Cursor, ChatGPT, or another MCP-capable agent durable memory in a few commands. Building an app instead of wiring up an agent? Use the HTTP API or the Python library over the same collection.

1. Install

pip install 'mnemostack[mcp]'

Run a local Qdrant for the vector store:

docker run -p 6333:6333 qdrant/qdrant:latest

Optional: run Memgraph for graph-backed memory:

docker run -p 7687:7687 memgraph/memgraph:latest

2. Start the MCP server

export GEMINI_API_KEY=your-key-here
mnemostack mcp-serve --provider gemini --collection my-memory

Claude Desktop config example:

{
  "mcpServers": {
    "mnemostack": {
      "command": "mnemostack",
      "args": ["mcp-serve", "--provider", "gemini", "--collection", "my-memory"],
      "env": {
        "GEMINI_API_KEY": "your-key-here"
      }
    }
  }
}

Claude will then be able to call mnemostack_search, mnemostack_answer, and graph tools.

3. Store memory

Index a folder of notes, docs, transcripts, or project context:

mnemostack index ./my-notes/ --provider gemini --collection my-memory --recreate

--recreate drops the existing collection, so it asks for confirmation first; pass --yes to skip the prompt (required in scripts/CI — non-interactive runs without it exit with code 2).

For a running app or assistant, use the streaming Ingestor API shown below to store messages as they arrive.

4. Recall memory

From an agent, ask a memory-style question and let the MCP tools retrieve the right facts.

From the shell, test the same collection directly:

mnemostack search "what did we decide about auth" --provider gemini --collection my-memory
mnemostack answer "what did we decide about auth" --provider gemini --collection my-memory

Why hybrid memory?

Vector search answers "what sounds similar?" Real retrieval over a growing corpus needs to answer "what actually matters for this query?" — which takes exact matches, semantic similarity, relationship tracking, recency, user/project scope, and feedback from past recalls. Mnemostack uses hybrid retrieval so recall is reliable instead of embedding roulette. (Agent memory is the most demanding version of this problem, which is why it's the flagship use case.)

Use cases

Agent and chatbot memory (flagship):

  • Long-running coding agents that need to survive compaction and session restarts.
  • Chat assistants and conversational bots that remember a user's earlier messages, preferences, and decisions across sessions — scope each user's memory with payload filters so one user never sees another's history.
  • Personal assistant memory for preferences, recurring tasks, and long-horizon context.
  • Multi-agent context sharing through one durable memory backend.
  • Session compaction recovery when the useful details no longer fit in the prompt.

Beyond agents — the same engine as a retrieval backend:

  • A searchable knowledge base in memory: ingest your docs/notes/FAQ once, then serve hybrid search or grounded answer() (with confidence and source citations) over it from the CLI, HTTP, or library.
  • RAG over mixed corpora (code, docs, transcripts) where exact-token and temporal recall beat pure vector similarity.
  • Multi-tenant or per-user knowledge stores — payload filters isolate each tenant's data inside every retriever, or enable service-key auth (serve --auth) for a hard, key-resolved tenant boundary with optional per-tenant quotas (see the HTTP API).
  • Time-aware search and knowledge bases — "what changed last week", point-in-time graph facts, freshness-weighted ranking.
  • Team or project knowledge recall across docs, notes, tickets, and chat history.

Three ways to use Mnemostack

  1. MCP server — for agent users. Start mnemostack mcp-serve, connect your agent, and use memory tools from the chat/runtime you already use.
  2. HTTP API — for app developers. Run mnemostack serve and call /recall, /answer, /feedback, /health, /metrics, or /docs from any language.
  3. Python SDK — for library users. Compose retrievers, stores, rerankers, graph tools, and the streaming ingest API inside your own Python application.

Architecture

Mental model

Think of it as a storage hierarchy for agent memory:

  • Context window = RAM. Fast, limited (typically 100K–200K tokens for many agent models; larger windows exist, but usable working context is often much smaller after tools, instructions, MCP output, and context rot — ~45K usable tokens on a 200K window is a realistic working number in long-running agent sessions. Clears on session restart.
  • mnemostack corpus = Disk. Persistent, searchable, grows forever — every fact the agent has ever seen, queryable on demand.
  • recall(query) = page fault handler. When the agent needs something that isn't in the current context, it pulls the exact fact from storage with a single hybrid query — not a grep, not a reload of the whole corpus.

The practical effect: you stop re-explaining your project to the agent after every /compact. You stop losing momentum to the re-orientation tax that shows up in any agent with session compaction. mnemostack solves it at the library level, not tied to any single agent runtime.

How it works, in one paragraph

On each recall(query): the configured retrievers (Vector and Temporal by default, with BM25 and Memgraph when configured) run in parallel and return ranked lists. Reciprocal Rank Fusion merges them. The optional 8-stage pipeline can reweight results using query classification, exact-token rescue, gravity/hub dampening, freshness, inhibition-of-return, curiosity boosts, Q-learning weights supplied through its state store, and graph resurrection. An optional LLM reranker does a final ordering pass. You get a list of RecallResult with source, score, and provenance — ready to hand to a model.

mnemostack architecture

Where mnemostack fits

Most memory tools in the agent ecosystem pick one axis and optimize for it: simple vector similarity for RAG, framework-bound memory tied to a specific agent library, platform-level runtimes with audit and compliance features, or CLI wrappers over a single vendor's session store. Each makes sense for its scope.

mnemostack takes a different slice: it is a recall quality layer, offered as a plain Python package. Four retrievers (Vector + BM25 + Memgraph + Temporal), RRF fusion, an 8-stage pipeline, and an optional LLM reranker — composed to handle mixed workloads on the same corpus: exact-token lookups, semantic queries, temporal questions, and multi-hop reasoning, without forcing you to choose one mode over another.

We are not a replacement for your agent framework and not a full platform runtime. We are the piece that actually finds the right fact in a growing corpus. Drop mnemostack into your own Python agent or application, or let a higher-level service call recall() over a plain function boundary. The retrievers, pipeline, and reranker are individually composable — take only the parts you need.

Design

See ARCHITECTURE.md for detailed design: pipeline stages, Qdrant schema, Memgraph temporal model, consolidation runtime, MCP tools.

Storage, index, and retrieval layers

  • Storage/index layer: Qdrant stores vector points and payloads; BM25 indexes exact-token corpora; Memgraph stores temporal graph facts; the Temporal retriever handles time-aware vector recall.
  • Fusion layer: Reciprocal Rank Fusion merges ranked lists from Vector, BM25, Memgraph, and Temporal retrievers, with optional static or adaptive weights.
  • Recall pipeline: the 8-stage pipeline can classify the query, rescue exact tokens, dampen gravity/hubs, blend freshness, apply inhibition-of-return, add curiosity boosts, use Q-learning state, and resurrect graph-linked memories.
  • Feedback loop: HTTP and MCP recall can apply existing state; explicit /feedback or mnemostack feedback updates usefulness signals without silently training on every response.
  • Inference layer: optional LLM reranking and answer generation sit on top of recall, so retrieval still works when the LLM is unavailable.

Pipeline state

The 8-stage pipeline can use a small state store between calls (Q-learning weights, inhibition-of-return history, per-document gravity/hub counters). FileStateStore(path) persists it to a JSON file. HTTP recall applies existing state and can record inhibition-of-return exposure with --auto-record-ior; Q-learning updates only through explicit /feedback calls. CLI/MCP recall still apply existing state but do not collect feedback automatically. For deterministic benchmarks, call build_full_pipeline(enable_stateful_stages=False) so IoR/Q-learning/curiosity state cannot affect scores. For multi-process servers, implement your own StateStore (three methods: get(), set(), update()) backed by Redis or your database.

Graceful degradation

Any retriever can fail (Memgraph down, Qdrant unreachable, BM25 corpus empty). Recaller logs and continues with the remaining sources. The LLM reranker is wrapped in try/except by convention — if the LLM is rate-limited, the pre-rerank order is returned. This is deliberate: a memory stack that goes dark because one component hiccuped is worse than a slightly degraded one.

One exception: query expansion runs before retrieval, so a misconfigured expansion step (query_expansion=True without an expansion_llm, or a provider error inside it) surfaces as an error instead of degrading silently — see ARCHITECTURE.md for the full fail-open contract. Degradations themselves are visible, not silent: every HTTP/MCP response carries degraded tags, and the full per-retriever trace is available opt-in via include_trace.

Comparison and benchmarks

On LoCoMo, Mnemostack reaches 82.9% strict accuracy in our evaluation setup. The table below includes our baseline runs and externally reported numbers for context. Results depend on dataset version, configuration, judge model, scoring rules, and query type. Treat externally reported numbers as directional unless they were run with the same harness and settings.

Benchmarks

Full LoCoMo runs use the official SNAP-Research dataset (10 samples / 1986 QA) from a clean state. Across the tables below: Strict = exact match, Combined = strict + partial. Counts in cells are correct / total.

Some LoCoMo cat_5 questions have empty ground-truth answers. Under the current scorer, these are counted as correct because there is no expected answer to match. To avoid overstating recall quality, we also report signal-only scores with those questions removed. Signal-only scores are computed on the 1,540 questions with non-empty ground-truth answers.

LoCoMo, current judge (gemini-3-flash-preview)

Run Strict (full) Combined (full) Strict (signal-only) Combined (signal-only)
Baseline v0.3.0 (Vector + BM25 + 8-stage pipeline) 76.7% (1524 / 1986) 88.1% (1750 / 1986) 70.0% (1078 / 1540) 84.7% (1304 / 1540)
Retrieval improvements (window_size=3, query expansion, top-K 25) 82.5% (1639 / 1986) 92.2% (1832 / 1986) 77.5% (1193 / 1540) 90.0% (1386 / 1540)
v0.4.5 + photo captions (same config as above) 82.9% (1647 / 1986) 92.7% (1842 / 1986) 78.0% (1201 / 1540) 90.6% (1396 / 1540)

Honest numbers disclaimer. (full) is the headline aggregate across all 1986 questions, the format vendors typically report — some publish only their strongest sub-category, we publish the full aggregate because it's what actually predicts behavior on mixed workloads. (signal-only) strips the cat_5 auto-pass artifact described above, so what you read there is the real recall quality on questions that have a ground-truth answer.

Per-category breakdown (v0.4.5 + photo captions run):

Category Strict Combined
cat_1 single-hop lists 51.4% 88.3%
cat_2 temporal 79.8% 85.7%
cat_3 open-domain reasoning 62.5% 79.2%
cat_4 multi-hop reasoning 88.0% 94.6%
cat_5 adversarial open-domain 100.0% 100.0%

Notes:

  • Judge model matters: gemini-3-flash-preview is more accurate than the previous Gemini Flash judge on synonyms, partial matches, and empty ground truth.
  • cat_5 questions have empty ground truth in this new run and are auto-scored as correct by the benchmark harness. That makes the new cat_5 strict score (446 / 446, 100.0%) useful for aggregate harness accounting, but not directly comparable to the historical cat_5 strict score (89.7%) from the older adversarial-question evaluation.
  • Pipeline: Vector retrieval with Gemini embeddings + BM25 + RRF + 8-stage reranking pipeline. The LLM reranker is not part of the benchmark loop (it is a runtime/server feature), so rerank_mode does not affect these numbers.
  • The v0.4.5 run additionally ingests the photo captions (blip_caption) that LoCoMo attaches to image-sharing turns — 697 of the 1540 signal questions cite image turns as evidence, and earlier runs silently dropped that content. Answer prompts also show the time of day of each memory since v0.4.5.

Historical LoCoMo results (gemini-2.5-flash judge)

Metric First full run mnemostack 0.2.1
Strict 66.4% (1319 / 1986) 67.8% (1346 / 1986)
Partial 12.8% (254 / 1986) 12.6% (250 / 1986)
Wrong 20.8% (413 / 1986) 19.6% (390 / 1986)
Combined 79.2% (1573 / 1986) 80.4% (1596 / 1986)

By question category (combined not tracked for the first full run):

Category First run Strict 0.2.1 Strict 0.2.1 Combined Δ Strict
cat_1 single-hop lists 34.8% 34.4% 74.1% −0.4pp
cat_2 temporal 64.5% 69.8% 77.9% +5.3pp
cat_3 open-domain reasoning 31.2% 41.7% 49.0% +10.5pp
cat_4 multi-hop reasoning 69.2% 69.6% 82.0% +0.4pp
cat_5 adversarial open-domain 90.1% 89.7% 89.7% −0.4pp

Last historical run: 2026-04-27, mnemostack 0.2.1, same dataset, judged by gemini-2.5-flash.

Comparison with reported numbers from other systems

Caveat: different judges, evaluation protocols, and in some cases category cherry-picking. Vendor numbers below are taken at face value from their published material.

System LoCoMo correct
Hindsight (reported range) 78–85%
Memobase (temporal subset) 85%
mnemostack 82.9%
Letta filesystem agent 74%
Mem0 graph variant ~68.5%
Zep (independently replicated) 58.4%

Real-corpus needle benchmark

LoCoMo measures generic long-term dialogue recall. We also run a private needle-in-haystack benchmark on the production workload that drove the original design — a ~17k-point memory stack indexed from a long-running assistant. Queries mix exact tokens (IP addresses, tickers), telegram IDs, paraphrased facts, and temporal probes.

Metric Value
recall@1 90% (9/10)
recall@5 100% (10/10)
recall@10 100% (10/10)
Query latency p50 1.26 s
Query latency max 1.70 s

Honest numbers disclaimer. Reporting only recall@5 = 100% would look impressive, but it would also hide the harder top-1 behavior. recall@1 = 90% is what an agent reading only the top hit actually experiences, and the gap between @1 and @5 is where reranker quality (or the lack of it) shows up. We publish all three so you can read the metric that matches your downstream usage.

Useful because LoCoMo's failure modes (list exhaustion, open-domain reasoning) are orthogonal to what production memory stacks actually spend time on (find the specific fact the user mentioned weeks ago). This benchmark is not in the public repo; its methodology is in benchmarks/synthetic_longhorizon.py, which is the closest reproducible approximation.

Reproduce LoCoMo from a fresh clone

pip install -e '.[dev]'
bash benchmarks/download_locomo.sh   # fetches SNAP Research's public dataset
export GEMINI_API_KEY=...
bash benchmarks/run_locomo.sh        # full 10-sample run, writes results/ts.{json,log}

Details, category definitions, and notes on the judge protocol: benchmarks/README.md.

Who is this for?

Build it in if you need:

  • Long-lived agent memory that survives session restarts and doesn't drift into irrelevance as the corpus grows.
  • Recall quality on mixed workloads — exact-token lookups (IDs, tickers, error strings), semantic queries, temporal questions, multi-hop reasoning — not just one of them.
  • A stack you can plug into your own infrastructure: bring your own embedding model, LLM, vector store, or graph DB.

Not the best fit if you only need a single call to text-embedding-3-small + cosine similarity — something simpler will do. mnemostack earns its complexity on mixed, long-horizon workloads.

Features

  • 🧠 4-source hybrid retrieval — Vector (Qdrant) + BM25 (exact tokens) + Memgraph (knowledge graph) + Temporal (time-aware vector), all fused via Reciprocal Rank Fusion. Pluggable Retriever abstraction — add your own sources.
  • ⚖️ Weighted & adaptive RRF fusionreciprocal_rank_fusion(weights=[...]) lets you lift sources you trust more; Recaller(adaptive_weights=True) picks a per-query-shape profile (exact-token / person / temporal / general). See the honest write-up below for where this helps and where it doesn't.
  • 🧪 HyDE retriever (opt-in) — embeds a hypothetical answer instead of the query. Useful for query↔document vocabulary gaps in documentation-style corpora; does not reliably help on dialogue-backed memory and always costs one extra LLM roundtrip per search(). Not included in the default Recaller.
  • 🪜 8-stage recall pipeline — ClassifyQuery → ExactTokenRescue → GravityDampen → HubDampen → FreshnessBlend → InhibitionOfReturn → CuriosityBoost → QLearningReranker. Opt-in; stateful HTTP feedback is explicit via /feedback, and recall exposure logging is off unless --auto-record-ior is enabled.
  • 🔁 Reranking — Gemini Flash (or any LLM) reorders top-K by relevance, or plug a cross-encoder / hosted rerank service through the score-based ScoringReranker. See docs/recipes.md for a runnable bge-reranker-v2-m3 example.
  • 🔤 Pluggable BM25 analyzer — the default is lowercase + Unicode word split (great for exact tokens); pass BM25Retriever(tokenizer=...) for stemming / lemmatization / language routing. Core stays dependency-free; docs/recipes.md has per-language recipes.
  • Async API — every blocking surface has a signature-stable async mirror: Recaller.recall_async, recall_flow_async, Ingestor.ingest_async / ingest_one_async, AnswerGenerator.generate_async, synthesize_async, plus AsyncVectorStore over the native async Qdrant client. Retrievers dispatch in parallel; five concurrent HTTP recalls finish in roughly one single-recall wall-clock.
  • 🕰️ Stale-fact invalidation — mark superseded memories stale without deleting or re-embedding them: store.invalidate(ids, valid_until=...) sets bi-temporal payload keys (invalidated_at system-time, valid_until/valid_from world-time) via a cheap merge write. Recall hides invalidated facts by default; include_invalidated=True shows them and as_of="<iso>" reconstructs what was valid at a past instant. The vector-side twin of the graph's valid_until model. CLI mnemostack invalidate <id>... and MCP mnemostack_invalidate; HTTP stays read-only.
  • 🌍 Unicode-aware entity resolution — Memgraph retriever probes by telegram_id, handle, and precomputed name_lower so non-ASCII names match correctly (Memgraph's toLower() lower-cases ASCII only).
  • 📥 Streaming Ingestor API — batched, idempotent, LRU-cached ingest from any Python code. Lazy iterator means large corpora ingest with bounded memory. Same (source, offset, text) → same deterministic UUID-shaped content id, so re-runs are no-ops.
  • 📝 Markdown indexermnemostack index-markdown <dir> indexes a folder of markdown with structure: YAML frontmatter → payload filters, header-aware chunking with heading paths, and [[wikilinks]] / [text](https://github.com/udjin-labs/mnemostack/blob/main/note.md)File -[LINKS_TO]-> File graph edges (with a Memgraph URI). Generic for any markdown folder; Obsidian vaults work as a side effect. Depends only on the already-present pyyaml.
  • 🌐 HTTP API (optional) — pip install 'mnemostack[server]' gives you /recall, /answer, /health, /docs, plus /metrics in Prometheus text format. See the HTTP server section below.
  • 🔌 Pluggable embeddings — Gemini, Ollama, or HuggingFace (local GPU), via provider registry
  • 🤖 Pluggable LLM — Gemini Flash / Ollama for answer generation and reranking
  • 📚 Temporal knowledge graph — facts have valid_from/valid_until, query point-in-time state; graph resurrection stage recovers evicted-but-relevant memories.
  • 💬 Answer mode — inference layer synthesizes concise factual answers with source citations and confidence. Category-aware prompts (lists / temporal / multi-hop / inference / adversarial), specificity resolver, and cat_3 inference retry with query decomposition are on by default.
  • 📋 Knowledge synthesissynthesize(entity) rolls up everything memory knows about a person, project, or topic into a structured profile (SynthesisFact / SynthesisResult, markdown or JSON). CLI: mnemostack synthesize <entity>. Optional related-entities expansion via graph and LLM summarization pass.
  • 📏 Progressive Tiers APIsearch --tier {1,2,3} and answer --tier {1,2,3} bound output size (~50 / ~200 / ~500 tokens) so agents can pay only for the detail they actually need. Omit --tier for unchanged full output.
  • ✂️ Chunkers + sliding window — plain, fixed-size, and MessagePairChunker for chat transcripts (keeps user↔assistant pairs together). The new vector.window_size config carries adjacent-turn context inside each chunk; window_size=3 was worth +5.8pp strict / +4.1pp combined on LoCoMo (v0.4.0).
  • 🔎 Query expansion + smart retryRecaller(expansion_llm=...) widens recall with reformulated queries; AnswerGenerator(retry_with_expansion=True) retries low-confidence answers with the expanded query and a HyDE-style hypothetical before giving up. Opt-in via --query-expansion on mnemostack answer.
  • Consolidation runtime — phase orchestrator for nightly memory lifecycle
  • 🔌 MCP server — expose memory tools to Claude Desktop, ChatGPT, Cursor, etc.
  • 🛡 Graceful degradation — retrieval keeps working if graph or any retriever is down
  • 🔐 Multi-tenancy — soft filters or a hard auth boundaryfilters={"tenant": "a"} applies inside every retriever (exact match + ranges) on HTTP/MCP/CLI/library; results never include points outside the scope, verified by adversarial isolation tests. Filters are caller-supplied, so for a real trust boundary run the server with service-key auth (serve --auth / mcp-serve --auth): the tenant is resolved from the key (a client can't assert another's), enforced across the vector store, the knowledge graph, and per-tenant learning state. Optional per-tenant storage quotas apply at ingest, and request-rate quotas on the authenticated HTTP surface (serve --auth). Off by default. See the HTTP API section.
  • 🧩 Ingest enrichment + answer projectionIngestor(enrich=callable) extracts structured facts into payloads at ingest (fail-open, --refresh-payloads updates existing collections without re-embedding); context_fields=[...] shows them to the answer LLM; rewrite_followup() resolves conversational follow-ups before recall.
  • 🧠 Reasoning-model friendly — Ollama think is off by default (reasoning models otherwise burn the whole token budget on thoughts and return empty text); options={...} passes any generation option through.

Recall tuning: fusion weights & HyDE

Some of the newer knobs help in specific workloads and do nothing (or mildly hurt) in others. Measured, not promised — both are opt-in by design, and the default Recaller stays classical equal-weight RRF over Vector + BM25 (+ Memgraph + Temporal when supplied).

Recaller(adaptive_weights=True) — picks a weight profile per query shape:

Query shape Detection Profile (bm25 / memgraph / vector / temporal)
exact_token IPv4 / port / version / UUID / API-style tokens 1.4 / 1.4 / 1.0 / 0.9
person "who is", @handle, username, contact, etc. 1.0 / 1.5 / 1.0 / 0.9
temporal "when", "yesterday", "today", dates 1.0 / 1.0 / 1.0 / 1.4
general everything else classical equal-weight RRF

Measured on a real production corpus with 10 needle probes: recall@1 went 50% → 60%, recall@5 stayed at 90% (zero regression). On LoCoMo (pure dialogue questions, all classified general), adaptive weights had no effect — the profile simply isn't triggered. Rule of thumb: turn it on for production ops-style workloads (IPs, tickers, IDs, named entities); leave it off, or don't expect a lift, for dialogue benchmarks. Static retriever_weights={...} always wins over adaptive when both are set.

HyDERetriever — generates a short hypothetical answer via your LLM and embeds that instead of the raw query, then fuses alongside the other retrievers. Useful when the question and the stored answer use very different vocabulary (documentation corpora, FAQ-style content). On our LoCoMo cat_3 smoke (conv-43, 14 open-domain reasoning questions) it moved accuracy from 14.3% to 21.4% (+1 correct answer); on dialogue-backed memory overall it's roughly a wash. It always costs one extra LLM call per search(), so budget accordingly and treat it as a tool for specific workloads rather than a default.

Utilities

Agent runtimes often wrap transcript messages in metadata envelopes before the real body, which can dominate embeddings and make unrelated turns look similar. Clean messages before chunking/indexing with strip_metadata_blocks():

from mnemostack.utils import strip_metadata_blocks

clean = strip_metadata_blocks(raw_message)

Built-in profiles cover OpenClaw webchat and Telegram envelopes; pass profiles= or extra_patterns= to tune the cleanup for your runtime.

Environment

Variable Purpose Required for
GEMINI_API_KEY Google Generative AI key Gemini embedding + Gemini Flash LLM
OLLAMA_HOST Ollama server URL (default http://localhost:11434) Ollama embeddings / LLM
MNEMOSTACK_COLLECTION Qdrant collection name (default mnemostack) CLI convenience
MNEMOSTACK_QDRANT_URL Qdrant URL (default http://localhost:6333) Remote Qdrant
MNEMOSTACK_GRAPH_URI / MNEMOSTACK_MEMGRAPH_URI Memgraph bolt URI Graph retriever / GraphStore
MNEMOSTACK_PROVIDER / MNEMOSTACK_EMBEDDING_PROVIDER Embedding provider CLI / HTTP / MCP
MNEMOSTACK_LLM / MNEMOSTACK_LLM_PROVIDER LLM provider Answer generation / reranking
MNEMOSTACK_BM25_PATHS BM25 corpus paths separated by os.pathsep (: on Unix) CLI / HTTP / MCP BM25 retriever
MNEMOSTACK_AUTO_RECORD_IOR true/false toggle for HTTP recall exposure logging HTTP stateful pipeline
MNEMOSTACK_EMBEDDING_MODEL / MNEMOSTACK_LLM_MODEL Override the embedding / LLM model name CLI / HTTP / MCP
MNEMOSTACK_VECTOR_HOST / MNEMOSTACK_VECTOR_COLLECTION Aliases for the Qdrant URL / collection CLI / HTTP / MCP
MNEMOSTACK_VECTOR_FLOOR Keep top-N raw vector hits in results even when fusion/rerank would drop them (0 = off) Recall tuning
MNEMOSTACK_RERANK_MODE LLM reranker mode: relevant_only (default) or full_reorder HTTP / MCP runtime reranker
MNEMOSTACK_TOKEN_BUDGET Default recall token budget — cut results to the ranked prefix that fits (unset = off) CLI / HTTP / MCP recall surfaces
MNEMOSTACK_GRAPH_TIMEOUT / MNEMOSTACK_GRAPH_HEALTH_TIMEOUT Memgraph query / health-check timeouts in seconds Graph retriever
MNEMOSTACK_CONFIG Path to the YAML config file All entry points

Only the providers you actually use need their keys. HuggingFace local-GPU embeddings need no keys at all. mnemostack init writes the same settings as YAML; explicit CLI flags override config/env defaults.

Setup and usage details

Try it in 30 seconds (Docker)

Fastest way to kick the tyres. No Python install, no manual Qdrant / Memgraph setup.

git clone https://github.com/udjin-labs/mnemostack && cd mnemostack
cp README.md examples/notes/              # any markdown will do
GEMINI_API_KEY=your-key docker compose -f examples/docker-compose.yml up -d --build

# Index the notes volume and ask a question over HTTP
docker compose -f examples/docker-compose.yml exec mnemostack \
    mnemostack index /data --provider gemini --collection demo

curl -s http://localhost:8000/recall \
    -H 'content-type: application/json' \
    -d '{"query":"what is this about","limit":5}' | jq

The mnemostack container runs the HTTP API on port 8000 by default. Interactive docs are at http://localhost:8000/docs. Use docker compose exec mnemostack mnemostack <cmd> for CLI-style operations (index, search, health) against the same stack.

Tear down with docker compose -f examples/docker-compose.yml down -v (the -v wipes Qdrant + Memgraph state).

Prefer Ollama (no cloud key needed)? Run Ollama on the host, set OLLAMA_HOST=http://host.docker.internal:11434, and pass --provider ollama everywhere instead of gemini.

Reasoning models (qwen3, deepseek-r1 and similar): mnemostack disables thinking by default (think=False in OllamaLLM) — with thinking on, these models spend the whole token budget on thoughts and return empty text, silently degrading reranking, expansion and extraction. Pass get_llm("ollama", think=None) to keep the model's own default, or think=True to force it on models that support thinking. Extra generation options go through options={...} (e.g. {"num_ctx": 8192}).

Installation

# From PyPI
pip install mnemostack

# Optional extras
pip install 'mnemostack[huggingface]'  # local GPU embeddings
pip install 'mnemostack[mcp]'          # MCP server
pip install 'mnemostack[dev]'          # tests + linters

Run a local Qdrant for the vector store:

docker run -p 6333:6333 qdrant/qdrant:latest

Optionally a Memgraph for the knowledge graph:

docker run -p 7687:7687 memgraph/memgraph:latest

CLI quick start

# Health check
mnemostack health --provider ollama

# Index a directory of notes
mnemostack index ./my-notes/ --provider gemini --collection my-memory --recreate

# Hybrid recall
mnemostack search "what did we decide about auth" --provider gemini --collection my-memory

# Synthesize answer
mnemostack answer "what is the capital of France" --provider gemini --collection my-memory

# Record explicit feedback into the same state file used by the HTTP/MCP pipeline
mnemostack feedback <hit-id> --signal clicked --query "what did we decide about auth" \
  --source-list vector --source-list bm25

# MCP server (for Claude Desktop, Cursor, etc.)
mnemostack mcp-serve --provider gemini --collection my-memory

Progressive tiers — pay only for the detail you need

search and answer accept an optional --tier {1,2,3} flag that bounds how much output a call produces. Useful when a recall is called from a long-running agent loop where full recall output would burn context unnecessarily.

# Tier 1 (~50 tokens) — just "is there anything in memory about this?"
# Returns id, score, source labels; no text.
mnemostack search "VPN failover" --tier 1 --provider gemini

# Tier 2 (~200 tokens) — triage with short snippets (~40 chars each)
mnemostack search "VPN failover" --tier 2 --provider gemini

# Tier 3 (~500 tokens) — full 200-char previews, up to 10 results
mnemostack search "VPN failover" --tier 3 --provider gemini

Omit --tier to get the full, uncapped output (backward compatible). Rule of thumb for agents: tier 1 for navigation / existence checks, tier 3 only when you actually need to read the memories. answer is already compressed, so it needs a tier less often — use --tier 1 there to drop the SOURCES: block when only the answer text is wanted.

Streaming ingest API

When you want to feed items into mnemostack from code — a chatbot that logs every message, a scraper, a daemon tailing a log — use the Ingestor. It handles batching, deduplication, and idempotency for you.

from mnemostack.embeddings import get_provider
from mnemostack.vector import VectorStore
from mnemostack import Ingestor, IngestItem

emb = get_provider("gemini")
store = VectorStore(collection="my-memory", dimension=emb.dimension)
store.ensure_collection()

ing = Ingestor(embedding=emb, vector_store=store, batch_size=64)

stats = ing.ingest([
    IngestItem(text="alice joined acme on 2024-03-01", source="notes/alice.md",
               timestamp="2024-03-01T09:00:00Z"),  # event time — drives temporal recall
    IngestItem(text="alice left acme on 2025-06-15", source="notes/alice.md", offset=100),
])
print(stats)  # IngestStats(seen=2, embedded=2, upserted=2, skipped=0, failed=0)

Guarantees:

  • Idempotent. Each item gets a deterministic UUID-shaped content id computed from (source, offset, text). Re-running with the same input is a no-op: Qdrant upsert replaces the point onto itself, and an in-process LRU cache skips even the embedding call for items already seen in this session.
  • Batched. Items are embedded in batches of batch_size, so provider HTTP overhead amortises across many items.
  • Dated. Every payload records indexed_at (UTC). Pass timestamp= (or metadata={"timestamp": ...}) to set the event time the temporal retriever filters on. With window_size > 1, sliding-window chunks also carry the window's temporal range as window_start_ts / window_end_ts payload keys.

Images in the input (optional)

A memory stack that indexes only text answers "Not in memory" to questions whose answer lived in a photo. If your data contains images, describe them at ingest time and index the description:

from mnemostack.llm import get_llm

llm = get_llm("gemini")                 # or get_llm("ollama", model="llava") with a local vision model
desc = llm.describe_image(photo_bytes, mime_type="image/jpeg")  # one vision call per image
caption = f" [shared a photo: {desc.text}]" if desc.ok and desc.text else ""
item = IngestItem(text=f"{message_text}{caption}", source=..., timestamp=...)

describe_image is fully opt-in — nothing in the ingest or recall paths calls it, and text-only pipelines are unaffected. It works with any provider that has vision support (Gemini; Ollama vision models such as llava, llama3.2-vision, qwen2.5-vl) — providers without it return a normal fail-open error response. The default prompt produces a dense, index-oriented description (objects, any text/signs verbatim, setting, actions); pass prompt= to customize.

  • Streaming-friendly. ing.stream(item_iter) yields per-batch stats so long feeds can be monitored without waiting for the whole stream to drain.
  • Graceful. If a single item fails to embed, it is counted as failed but the rest of the batch still lands.
# Long-lived feed (e.g. inside a FastAPI or Celery worker)
for item in your_firehose():
    ing.ingest_one(IngestItem(text=item.body, source=item.channel, metadata={
        "user_id": item.user_id,
        "ts": item.ts.isoformat(),
    }))

Python API

from mnemostack.embeddings import get_provider
from mnemostack.vector import VectorStore
from mnemostack.recall import Recaller, AnswerGenerator
from mnemostack.llm import get_llm

emb = get_provider("gemini")
store = VectorStore(collection="my-memory", dimension=emb.dimension)
store.ensure_collection()

# ... index data here ...

recaller = Recaller(embedding_provider=emb, vector_store=store)
results = recaller.recall("what did we decide", limit=10)

# Each result: .id .text .score .source ("vector" | "bm25" | "memgraph" | "temporal") .metadata

# Optional: synthesize a concise answer
gen = AnswerGenerator(llm=get_llm("gemini"))
answer = gen.generate("what did we decide", results)
print(answer.text, answer.confidence, answer.sources)

Count and "list all X" questions need set completeness, which similarity top-K does not guarantee — the model counts what it sees and undercounts, returning a subset. For those, retrieve a wide candidate pool and enable the two-pass extract-and-aggregate mode:

gen = AnswerGenerator(llm=get_llm("gemini"), list_extract_mode=True)
pool = recaller.recall("how many trips did user A take", limit=150)   # wide pool, not top-10
answer = gen.generate("how many trips did user A take", pool)

list_extract_mode routes count/list questions through an extract pass (pulls every matching item as JSON) and a finalize pass (formats the list or count); other question categories are unaffected. The extract pass walks the **who