Skip to content

Remember & recall

nidus is a vector store, so it works in vectors: you hand it a Vec<f32> and it answers nearest-neighbour queries. The memory layer adds the step before and after that: text in, relevant text out. You remember a piece of text and nidus embeds it for you (optionally summarizing it first) with the provider you choose; you recall with a natural-language query and get the closest pieces back, ranked by cosine similarity.

It sits on top of the same store: a thin, async convenience layer over the synchronous core. The raw Vec<f32> API underneath never changes, so if you already have your own embeddings you can skip this entirely (see the escape hatch below).

nidus does not embed text itself, and ships no built-in model. It stores vectors and answers queries over them; turning text into a vector is delegated to a provider you choose. So before remember/recall will work, you need exactly one of:

  • A hosted provider and its API key: Voyage, OpenAI, Cohere, Gemini, Mistral, Jina, or any OpenAI-compatible endpoint. Enable its embed-<name> feature and set the key. See the provider table.
  • A local daemon: Ollama. No API key and nothing leaves your machine, at the cost of running a daemon.
  • Your own vectors: skip the memory layer and use the raw Vec<f32> API. See the escape hatch.

There is deliberately no bundled local embedder. A model table worth using is 8–32 MB, which every cargo add nidus would carry whether or not it embeds anything, and static embeddings score meaningfully below a real model, so it would be both the heaviest and the weakest option on the list. Ollama already covers the fully-local case. The reasoning is recorded in SPEC.md §9.1.

cargo add nidus gives you the memory layer and every provider adapter out of the box: memory for the remember/recall surface, every embed-<name> feature, and every summarize-<name> feature. --no-default-features gives you the storage-and-search core alone. If you want to pick individual providers rather than the full set, name them explicitly instead:

# Cargo.toml: only the memory layer, OpenAI embeddings, and
# Anthropic summarization, nothing else:
[dependencies]
nidus = { version = "0.96", default-features = false, features = ["memory", "embed-openai", "summarize-anthropic"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Naming providers this way keeps the build to just the adapters you use: each embed-<name> compiles only that one. The umbrella features embed-all and summarize-all, both in the default build, pull in every shipped adapter at once.

Both the embedder and the summarizer are chosen at runtime through a closed enum: no Box<dyn>, no dynamic dispatch cost. Build one from a provider and a config:

use nidus::embed::{AnyEmbedder, EmbedConfig, EmbedProvider};
// Leaving the model empty uses the provider default (below).
let embedder = AnyEmbedder::build(
EmbedProvider::OpenAi,
EmbedConfig::new("").api_key(std::env::var("OPENAI_API_KEY")?),
).await?;
# anyhow::Ok(())

Embedding providers and their default models

Section titled “Embedding providers and their default models”
ProviderFeatureEnumDefault model
Voyageembed-voyageEmbedProvider::Voyagevoyage-4
OpenAIembed-openaiEmbedProvider::OpenAitext-embedding-3-small
Ollamaembed-ollamaEmbedProvider::Ollamanomic-embed-text
Cohereembed-cohereEmbedProvider::Cohereembed-english-v3.0
Geminiembed-geminiEmbedProvider::Geminitext-embedding-004
Mistralembed-mistralEmbedProvider::Mistralmistral-embed
Jinaembed-jinaEmbedProvider::Jinajina-embeddings-v3
OpenAI-compatibleembed-openai-compatEmbedProvider::OpenAiCompat(none: set a model)

Voyage’s current generation is the Voyage 4 family (voyage-4-large, voyage-4, voyage-4-lite, voyage-code-4, and the open-weight voyage-4-nano): 1024 dimensions natively, a 32K-token context, and Matryoshka truncation to 256, 512, or 2048 via EmbedConfig::output_dimension (the --embed-dimension flag). Since the store pins its dimension at creation, choose the width before the first upsert.

let cfg = EmbedConfig::new("voyage-4-large")
.api_key(std::env::var("VOYAGE_API_KEY")?)
.output_dimension(256);

OpenAI honours output_dimension too, on text-embedding-3-small and text-embedding-3-large. Those take any width from 1 up to the model’s native size (1536 and 3072 respectively) rather than a fixed set, so output_dimension(768) is valid against text-embedding-3-large where the Voyage equivalent is not.

Asking for a width a model cannot honour is an error, not a silent fallback: fixed-width Voyage models, text-embedding-ada-002, and every provider that does not advertise the capability reject output_dimension at construction rather than pinning the store to a dimension the API will not fill.

The OpenAI-compatible adapter is the catch-all: point its base_url at any service that speaks the standard /v1/embeddings shape: Azure OpenAI, Together, Fireworks, vLLM, LiteLLM, DeepInfra, and so on. It has no default model, so pass one explicitly.

Summarization providers and their default models

Section titled “Summarization providers and their default models”
ProviderFeatureEnumDefault model
Anthropicsummarize-anthropicSummarizeProvider::Anthropicclaude-haiku-4-5-20251001
OpenAIsummarize-openaiSummarizeProvider::OpenAigpt-4o-mini

The OpenAI summarizer speaks the chat-completions shape, so its base_url also reaches Azure, LiteLLM, vLLM, and Ollama’s /v1 endpoint.

Both EmbedConfig and SummarizeConfig are fluent builders over the same knobs:

use nidus::embed::EmbedConfig;
let config = EmbedConfig::new("text-embedding-3-large") // model (empty = default)
.api_key("sk-...") // bearer token
.base_url("https://my-gateway.example.com") // route via a gateway
.header("x-org-id", "acme"); // extra header per request
# let _ = config;
  • api_key: the bearer token. Keyless providers (Ollama, and some OpenAI-compatible gateways) leave it empty.
  • base_url: override the provider’s default endpoint. This is how you route through a self-hosted proxy or gateway, reach an OpenAI-compatible service, or point at a mock in tests.
  • header(name, value): extra headers applied to every request, for gateway auth or tenant routing. Chain it more than once.

Ollama needs no API key. Leave api_key empty and set base_url to your Ollama host (it defaults to http://localhost:11434):

use nidus::embed::{AnyEmbedder, EmbedConfig, EmbedProvider};
let embedder = AnyEmbedder::build(
EmbedProvider::Ollama,
EmbedConfig::new("nomic-embed-text")
.base_url("http://localhost:11434"),
).await?;
# anyhow::Ok(())

Ollama (and the OpenAI-compatible adapter) probe their embedding dimension with a live call while building, so build is async and will surface a clear error if the host is unreachable.

Wrap a store and an embedder in a Memory, then remember text and recall it:

use std::collections::BTreeMap;
use nidus::{Config, Memory, Nidus, RecallOpts, RememberMode};
use nidus::embed::{AnyEmbedder, EmbedConfig, EmbedProvider, Embedder};
# async fn run() -> anyhow::Result<()> {
let embedder = AnyEmbedder::build(
EmbedProvider::OpenAi,
EmbedConfig::new("").api_key(std::env::var("OPENAI_API_KEY")?),
).await?;
// Open the store to match the embedder's dimension (see pinning, below).
let db = Nidus::open(Config::new("./store", embedder.dimension()))?;
let mut memory = Memory::new(db, embedder);
// Text in.
memory.remember(
"notes", "login",
"Users authenticate with a bearer token issued at login.",
BTreeMap::new(),
RememberMode::Raw,
).await?;
// Relevant text out.
let hits = memory.recall("notes", "how do users sign in?", &RecallOpts {
top_k: 3,
..Default::default()
}).await?;
for h in &hits {
println!("{:.3} [{}] {}", h.score, h.collection, h.id);
}
# anyhow::Ok(())
# }

remember creates the collection if it does not exist, embeds the text, and upserts a record under your id with your attrs. recall embeds the query (using the provider’s query side, where it distinguishes document from query vectors) and runs a vector search. RecallOpts maps straight onto the store’s search options: top_k, a min_score floor, and an optional metadata Filter. Two fields are sentinels: top_k: 0 (the default) means 10, and min_score: 0.0 means no floor.

recall also filters expiry automatically: an entry whose nidus.expires_at is in the past is invisible to it (and to the server’s memory reads), while the raw search/list store API still returns it. Only the HTTP and MCP write paths set that attr today; see the parity note below.

RecallOpts::reinforce marks the entries a call actually returned as useful, so a memory that keeps coming back can outrank one nothing has touched in months. Setting it stamps two reserved attrs on every returned entry:

  • nidus.access_count: an integer, how many reinforced recalls have returned this entry so far. Absent means never reinforced.
  • nidus.last_accessed: a DateTime (UTC epoch ms), the last reinforced recall.

RecallOpts::extend_ttl_seconds goes with it: when reinforce is set, it pushes an existing nidus.expires_at forward to now plus that many seconds. It never gives an expiry to an entry that had none, and never moves one backwards, so a note with no TTL stays permanent no matter how often it is reinforced.

Both attrs are stamped only by a reinforced recall, and stripped from any attrs a caller supplies to remember, the same as nidus.created_at/nidus.updated_at.

A reinforced recall is a write: it takes the writer lock to apply the stamp. What a read-only store does about that depends on who asked. In process, Memory::recall skips the stamp with a warning rather than failing the call, since a library caller may not own the open mode and optional bookkeeping must not sink an otherwise good recall. The HTTP and MCP surfaces instead refuse the request: it named reinforce, so reporting success without stamping would be indistinguishable from having stamped. On the CLI, nidus recall --reinforce opens the store read-write, and that open is refused outright if a live nidus serve already holds the writer lock (a plain nidus recall, with no --reinforce, is unaffected).

Reinforcement pairs with Decay::count_field to rank on nidus.access_count directly:

use nidus::{Decay, RankBy, RecallOpts, SearchOpts};
# async fn run(mut memory: nidus::Memory) -> anyhow::Result<()> {
let now = 1_770_000_000_000_i64;
let week = 7 * 24 * 60 * 60 * 1000;
// Recall, and record that these hits were useful.
let hits = memory.recall("notes", "how do users sign in?", &RecallOpts {
top_k: 3,
reinforce: true,
..Default::default()
}).await?;
// Later: rank by recency, with reinforced entries paying a smaller penalty.
let query = vec![0.1_f32; memory.db().dimension()];
let _ranked = memory.db().search("notes", &query, &SearchOpts {
rank_by: Some(RankBy::Decay(
Decay::new("updated_at", now, week).count_field("nidus.access_count"),
)),
..Default::default()
})?;
# let _ = hits;
# anyhow::Ok(())
# }

RememberMode chooses what actually gets embedded:

  • Raw embeds the text exactly as given. Best when the text is already the right size and shape for retrieval.
  • Summarize first runs the text through a summarizer, embeds the summary, and stores both the summary and the original text alongside the record (under the nidus.summary and nidus.text attrs) so a hit stays explainable back to what you ingested. Use it for long or noisy inputs where a dense summary is a better embedding target than the raw text.

Summarize mode needs a summarizer attached:

use nidus::summarize::{AnySummarizer, SummarizeConfig, SummarizeProvider};
use nidus::{Memory, RememberMode};
# use std::collections::BTreeMap;
# async fn run(mut memory: Memory) -> anyhow::Result<()> {
let summarizer = AnySummarizer::build(
SummarizeProvider::Anthropic,
SummarizeConfig::new("").api_key(std::env::var("ANTHROPIC_API_KEY")?),
).await?;
let mut memory = memory.with_summarizer(summarizer);
memory.remember(
"notes", "auth-history",
"In 2019 the team migrated the auth service off session cookies onto \
short-lived bearer tokens, cutting a class of CSRF bugs.",
BTreeMap::new(),
RememberMode::Summarize,
).await?;
# anyhow::Ok(())
# }

Requesting Summarize without a summarizer attached is an error: the message tells you to add one with with_summarizer.

Vectors from different models live in incomparable spaces, so mixing them in one collection would make cosine ranking meaningless. The memory layer guards against that on two axes:

  • Dimension. The embedding dimension is pinned into the store at creation. If the embedder’s dimension does not match the store’s, the first remember fails with an error naming both. Opening the store to embedder.dimension() (as above) keeps them in lockstep.
  • Embedder identity. On the first write into a collection, nidus records the embedder’s "provider/model" identity in the collection metadata (under nidus.embedder). Every later write re-checks it and refuses if a different embedder is now in play, catching an accidental cross-model write before it corrupts a collection’s ranking. To switch models, use a separate collection.

A collection written straight through upsert, by nidus or by any other tool, carries no nidus.embedder at all, so neither check has anything to compare: a recall with a mismatched embedder returns plausible-looking scores from two different spaces. That case logs a warning (once per collection and embedder) and otherwise proceeds, since refusing it would break every store built on raw upserts. Set Config::strict_embedder_identity (--strict-embedder-identity, NIDUS_STRICT_EMBEDDER_IDENTITY) to refuse instead: an unpinned collection then errors on recall, and a remember into one that already holds rows errors rather than stamping nidus’s own identity onto vectors it did not produce.

Memory is strictly additive. The underlying Nidus store (with its raw, synchronous, dependency-free Vec<f32> API) is always right there:

use nidus::{Memory, Nidus};
# fn f(memory: Memory) {
let db: &Nidus = memory.db(); // borrow it
# }
# fn g(mut memory: Memory) {
let db: &mut Nidus = memory.db_mut(); // mutably borrow it
# }
# fn h(memory: Memory) {
let db: Nidus = memory.into_inner(); // unwrap back to the bare store
# }

So if you already produce your own embeddings, or want a model nidus ships no adapter for, keep using Nidus directly: upsert your own vectors and search with your own query vectors, with zero async and zero provider dependencies. Embedding is a property of this handle, not of the on-disk store: one process can wrap a store with an OpenAI embedder while another opens the same directory raw.

Nothing about this layer assumes a provider is reachable. Building an embedder that requires a key without one, or pointing at a host that is down, returns a typed, descriptive error (EmbedError / SummarizeError, each with Config, Backend, Api { status, body }, and Decode variants), not a panic. Transient failures (HTTP 429 and 5xx) are retried with backoff before the error surfaces. Match on the variant to decide whether to fall back, retry, or fail.

remember/recall exist on four surfaces: the Rust Memory API you have been reading about, the HTTP /remember + /recall routes, the MCP memory tools, and the nidus remember/nidus recall CLI subcommands. They agree on almost everything, but not everything, and the differences are exactly the places callers get burned. Verify against source rather than assuming one surface behaves like another.

Rust MemoryHTTPMCPCLI
ttl_secondsRememberOpts.ttl_secondsRememberRequest.ttl_secondsremember arg ttl_seconds--ttl-seconds
dedupe_thresholdRememberOpts.dedupe_thresholdRememberRequest.dedupe_thresholdremember arg dedupe_threshold--dedupe-threshold
nidus.textstamped on every writestamped on every writestamped on every writestamped on every write
nidus.sourcenever stamped (legacy)never stamped (legacy)never stamped (legacy)never stamped (legacy)
Derived idsno, id is requiredno, id is requiredyes, from content when omittedyes, from content when omitted
TTL-on-readonly Memory::recallonly /recallrecall, get, browse, text_search, hybrid_searchonly recall

A couple of things the table cannot show:

  • ttl_seconds counts from the moment of the write, not from whenever a caller happens to read the entry back.
  • dedupe_threshold is a cosine floor: a write that lands within it of an existing entry updates that entry in place instead of inserting a competitor, so the id you get back may not be the id you sent.

Memory::remember and the HTTP RememberRequest both take id as a required string; there is no server-side derivation, so an omitted id is a caller error on either surface. The MCP remember tool and the nidus remember CLI subcommand both derive a stable id from the text when the caller omits one, using the same DefaultHasher-based scheme, so the same fact written from either entry point lands on the same record instead of accumulating duplicates. This is why the CLI gets its own column above rather than being folded into “Rust”: it behaves like MCP here, not like the library it is built on.

nidus.text is always stamped; nidus.source never is

Section titled “nidus.text is always stamped; nidus.source never is”

Every surface stamps nidus.text with the raw remembered text on every write, regardless of Raw or Summarize mode. nidus.source is not written by any surface. It is a legacy attr, kept only so records written before the fix in nidus-133 remain readable; do not expect a current write to produce it.

The not-expired filter is only AND-ed into the memory-shaped read paths: /recall over HTTP, Memory::recall in Rust and in the nidus recall CLI subcommand built on it, and the recall, get, browse, text_search, and hybrid_search tools over MCP. It is never applied to the generic vector-search, list, full-text, or hybrid-search routes, over either HTTP (/search, /list, /text-search, /hybrid-search) or the CLI (search, list, text-search, hybrid-search). A record with a past nidus.expires_at that has not yet been swept is invisible to recall but still returned by a plain search against the same collection.

The repository ships a runnable end-to-end example. It is offline-safe: with no provider configured it still runs the bring-your-own-vector section and prints a clear message for the provider-backed part.

Terminal window
cargo run --example memory --features memory,embed-all,summarize-all

Point it at a provider with environment variables. See the comments at the top of examples/memory.rs.