Add long-term memory
In this guide you give a graph a memory that outlasts its threads. You remember past incidents with the text that describes them, recall the incidents most like a new one, and forget records you no longer need. Each tenant only sees its own memories.
Long-term memory is the third place a graph keeps data, beside state and history. State, history and long-term memory explains which data goes where.
The steps follow demo 25, long-term memory (.NET, Rust). The demo runs without a model or an API key.
Before you start
Section titled “Before you start”- Set up a project as in Installation. In .NET,
Datalium.Strata.HostingbringsDatalium.Strata.AIandDatalium.Strata.Storage. In Rust, adddatalium-strata,datalium-strata-aianddatalium-strata-memory. - Have an embedding model: an OpenAI API key, or an OpenAI-compatible server such as Ollama. To try the steps without one, use the scripted embedder in Test without a model.
The pieces
Section titled “The pieces”Memory takes three pieces:
| Piece | What it does | C# | Rust |
|---|---|---|---|
| Embedder | Turns a text into a vector | Any IEmbeddingGenerator<string, Embedding<float>> from Microsoft.Extensions.AI |
The Embedder trait, with OpenAiEmbedder |
| Memory store | Keeps records with their vectors, and searches them by cosine similarity | IMemoryStore: InMemoryMemoryStore, SqliteMemoryStore, PostgresMemoryStore, RedisMemoryStore |
MemoryStore, with stores of the same names |
| Helper | Embeds a text, then writes or searches the store | StrataMemory |
Memory |
The store never calls a model: it takes vectors. The helper makes them.
1. Create an embedder
Section titled “1. Create an embedder”Any IEmbeddingGenerator<string, Embedding<float>> works. This one uses the OpenAI SDK, from the Microsoft.Extensions.AI.OpenAI package:
using System.ClientModel;using System.ClientModel.Primitives;using Microsoft.Extensions.AI;using OpenAI;
IEmbeddingGenerator<string, Embedding<float>> embedder = new OpenAI.Embeddings.EmbeddingClient( "text-embedding-3-small", new ApiKeyCredential(apiKey), new OpenAIClientOptions { RetryPolicy = new ClientRetryPolicy(maxRetries: 0) }) .AsIEmbeddingGenerator();Set Endpoint in OpenAIClientOptions to use another OpenAI-compatible server. As for chat clients, turn the SDK’s own retries off and let the node’s recovery policy retry (see Call a model).
OpenAiEmbedder calls POST /embeddings on OpenAI or on any server that speaks it, such as Ollama or vLLM. It takes the chat client’s OpenAiOptions and a model of its own:
use std::sync::Arc;
use datalium_strata_ai::{Embedder, OpenAiEmbedder, OpenAiOptions};
// OpenAIlet embedder: Arc<dyn Embedder> = Arc::new(OpenAiEmbedder::new( OpenAiOptions::new(api_key), "text-embedding-3-small",));
// Ollamalet local: Arc<dyn Embedder> = Arc::new(OpenAiEmbedder::new( OpenAiOptions::ollama("http://localhost:11434"), "nomic-embed-text",));Anthropic has no embeddings endpoint, so there is no Anthropic embedder. To use another provider, implement Embedder: provider(), default_model() and embed().
The helper labels every vector <provider>/<model>, from the embedder’s default model: openai/text-embedding-3-small for the embedders above. A search reads only records with its own label, so vectors from two models are never compared. To keep a label when the provider’s name changes, or to label an embedder that names no model, set it yourself: StrataMemoryOptions.Model in C#, Memory::with_model in Rust.
Set the label yourself, too, when both engines share a store. The provider name can differ for the same model: the OpenAI SDK pointed at Ollama labels openai/nomic-embed-text, while Rust’s OpenAiOptions::ollama labels ollama/nomic-embed-text.
2. Create a memory store
Section titled “2. Create a memory store”Every store is opened with its dimensions: the length of your model’s vectors. A vector of another length is refused, and so is opening a table made with other dimensions.
using Datalium.Strata.Memory;using Datalium.Strata.Storage;using Datalium.Strata.Storage.Postgres;using Datalium.Strata.Storage.Redis;using Datalium.Strata.Storage.Sqlite;
// In memory: for tests and development. It's gone with the process.IMemoryStore store = new InMemoryMemoryStore(dimensions: 1536);
// SQLite with sqlite-vecstore = await SqliteMemoryStore.OpenAsync("memory.db", new SqliteMemoryOptions { Dimensions = 1536 });
// Postgres with pgvector, here with an HNSW indexstore = await PostgresMemoryStore.OpenAsync( connectionString, new PostgresMemoryOptions { Dimensions = 1536, Index = MemoryIndex.Hnsw() });
// Redis 8 or Redis Stackstore = await RedisMemoryStore.ConnectAsync("redis://localhost:6379", new RedisMemoryOptions { Dimensions = 1536 });OpenAsync and ConnectAsync prepare the store at once, so a missing extension or a size mismatch fails there and not on the first write.
use datalium_strata::memory::InMemoryMemoryStore;use datalium_strata_memory::{ MemoryIndex, PostgresMemoryOptions, PostgresMemoryStore, RedisMemoryOptions, RedisMemoryStore, SqliteMemoryOptions, SqliteMemoryStore,};
// In memory: for tests and development. It's gone with the process.let store = Arc::new(InMemoryMemoryStore::new(1536));
// SQLite with sqlite-vec, which the crate compiles inlet store = Arc::new(SqliteMemoryStore::open("memory.db", SqliteMemoryOptions::new(1536))?);
// Postgres with pgvector, here with an HNSW indexlet options = PostgresMemoryOptions::new(1536).with_index(MemoryIndex::hnsw());let store = Arc::new(PostgresMemoryStore::connect(&url, options).await?);
// Redis 8 or Redis Stacklet options = RedisMemoryOptions::new(1536);let store = Arc::new(RedisMemoryStore::connect("redis://localhost:6379", options).await?);| Store | Needs | Search |
|---|---|---|
| In-memory | Nothing | Exact |
| SQLite | sqlite-vec. Rust compiles it in. In .NET you add it yourself (below). | Exact |
| Postgres | The pgvector extension on the server. The store creates the extension in the database when it’s missing. | Exact by default. HNSW up to 2,000 dimensions. |
| Redis | The Redis Query Engine: Redis 8, or Redis Stack | Exact by default, or HNSW |
Exact search compares every record of the namespace, tenant and model. HNSW is approximate and can miss a near neighbour: use it only when a namespace and tenant hold too many records to compare them all.
Add sqlite-vec in .NET
Section titled “Add sqlite-vec in .NET”Datalium.Strata.Storage doesn’t bring sqlite-vec. Reference its NuGet package in your application, without its content files:
<PackageReference Include="sqlite-vec" Version="0.1.7-alpha.2.1" ExcludeAssets="contentFiles" />ExcludeAssets="contentFiles" stops the package from adding a public Microsoft.Data.Sqlite.SqliteVectorExtensions class to your project. The store loads the extension vec0 into each connection it opens. If your platform has no binary in the package, ship sqlite-vec yourself and give its path in SqliteMemoryOptions.Extension. Without the extension, the store refuses with memory on SQLite needs the sqlite-vec extension: …. See Known limitations.
The Redis store refuses a server without the Query Engine: memory on Redis needs the Query Engine (Redis 8 or Redis Stack): FT.CREATE is unknown. In Rust, the Postgres and Redis stores don’t connect over TLS. Both limits are listed under Stores.
3. Create the helper
Section titled “3. Create the helper”The helper joins the store and the embedder:
using Datalium.Strata.AI;
var memory = new StrataMemory(store, embedder);The constructor throws when the generator names no model and you set no StrataMemoryOptions.Model.
use datalium_strata_ai::Memory;
let memory = Memory::new(store.clone(), embedder);Memory is cheap to clone, so each node can own a copy. Without a model, each call returns an error before it does anything.
4. Remember
Section titled “4. Remember”A memory entry has a namespace, an id and the text to embed. You can add a tenant, your own JSON content and string metadata:
using System.Text.Json.Nodes;
await memory.RememberManyAsync([ new MemoryEntry("incidents", "inc-101", "Checkout pods crash looping, out of memory after deploy") { Tenant = "acme", Content = new JsonObject { ["rootCause"] = "a memory leak in v2.3", ["action"] = "roll back the deploy" }, Metadata = new Dictionary<string, string> { ["service"] = "checkout" }, }, new MemoryEntry("incidents", "inc-103", "Search pods out of memory during the nightly reindex") { Tenant = "acme", Content = new JsonObject { ["rootCause"] = "a reindex batch too large", ["action"] = "halve the batch" }, Metadata = new Dictionary<string, string> { ["service"] = "search" }, }, // ... inc-102 for acme, and inc-101 for globex]);RememberAsync takes one entry.
use datalium_strata_ai::MemoryEntry;use serde_json::{Map, Value, json};
fn content(value: Value) -> Map<String, Value> { match value { Value::Object(map) => map, _ => Map::new(), }}
memory .remember_many( vec![ MemoryEntry::new("incidents", "inc-101", "Checkout pods crash looping, out of memory after deploy") .with_tenant("acme") .with_content(content(json!({ "root_cause": "a memory leak in v2.3", "action": "roll back the deploy" }))) .with_metadata("service", "checkout"), MemoryEntry::new("incidents", "inc-103", "Search pods out of memory during the nightly reindex") .with_tenant("acme") .with_content(content(json!({ "root_cause": "a reindex batch too large", "action": "halve the batch" }))) .with_metadata("service", "search"), // ... inc-102 for acme, and inc-101 for globex ], None, ) .await?;remember takes one entry. The last argument is the node context, or None outside a node.
RememberManyAsync / remember_many checks every entry, embeds all the texts in one call, and writes the records together. Each one becomes a record with these fields (schema/memory-record.schema.json):
| Field | What it holds |
|---|---|
namespace |
The kind of memory, such as incidents or runbooks. A search never crosses one. |
tenant |
Whose memory it is. "" when your application has no tenants. |
id |
Unique within the namespace and tenant. |
text |
The text that was embedded. It isn’t searched. It’s kept so you can embed it again with another model. |
content |
Your JSON. It isn’t searched. |
metadata |
String pairs. A recall can filter on them. |
embedding |
The model label and the vector, stored as float32. |
created_at, updated_at |
Set by the store’s clock. |
Remembering an entry whose namespace, tenant and id already exist replaces that record, but keeps its created_at. An entry no record can have, such as an empty id or a key over 256 bytes, is refused before the embedding call, so it costs nothing. If the embedding call fails, nothing is stored.
5. Recall
Section titled “5. Recall”A recall embeds a text and returns the records most like it:
var matches = await memory.RecallAsync( "incidents", "pods out of memory", new RecallOptions { Tenant = "acme", Limit = 3 });foreach (var match in matches){ Console.WriteLine($"{match.Record.Id} {match.Similarity:0.000} {match.Record.Content?["action"]}");}use datalium_strata_ai::RecallOptions;
let options = RecallOptions::new().tenant("acme").limit(3);let matches = memory.recall("incidents", "pods out of memory", options, None).await?;for found in &matches { let action = found .record .content .as_ref() .and_then(|c| c.get("action")) .and_then(Value::as_str) .unwrap_or_default(); println!("{} {:.3} {action}", found.record.id, found.similarity);}With the scripted embedder, both languages print:
inc-103 0.577 halve the batchinc-101 0.535 roll back the deployinc-102 0.000 raise the pool sizeEach match has the record and its similarity: 1 minus the cosine distance, from −1 to 1. Matches come most similar first, and equal similarities are ordered by id. These options narrow a recall. The filters apply before the ranking:
| C# | Rust | Default | What it does |
|---|---|---|---|
Tenant |
tenant(...) |
The run’s tenant, else "" |
The one tenant searched |
Limit |
limit(...) |
10 | The most matches returned, from 1 to 1,000 |
MinSimilarity |
min_similarity(...) |
None | Drops matches less similar than this |
Exclude |
exclude(...) |
None | Ids never returned, such as the record you are recalling for |
Metadata |
with_metadata(k, v) |
None | Only records with every one of these pairs |
MinSimilarity is off by default. A real model’s similarities can sit close together, so rely on the ranking, and set a threshold only after you’ve looked at your model’s numbers.
6. Use memory in a node
Section titled “6. Use memory in a node”Inside a node, pass the node’s context to the helper. Then:
- The tenant comes from the run’s metadata
tenantwhen the entry or the options don’t set one. To read another key, setStrataMemoryOptions.TenantKeyor callMemory::with_tenant_key. - The embedding call is recorded in the run as
ModelCallStartedandModelCallCompletedevents, with the operationembeddings. - A failed embedding call fails the node like a failed model call: transient with the provider’s retry hint, or fatal. A recovery policy can retry it.
This graph recalls the incidents most like a new one, reuses the closest one’s fix, and remembers the new incident for the next run:
var desk = new StateGraph<Incident>() .AddNode("Recall", async (incident, ctx, ct) => { // No tenant here: StrataMemory reads it from the run's metadata. var matches = await memory.RecallAsync("incidents", incident.Symptoms, new RecallOptions { Limit = 3, MinSimilarity = 0.5, Exclude = [incident.Id], }, ctx, ct); return incident with { Similar = [.. matches.Select(m => $"{m.Record.Id} ({m.Similarity:0.00})")], Action = matches is [var closest, ..] ? closest.Record.Content!["action"]!.GetValue<string>() : "page the on-call engineer", }; }) .AddNode("Remember", async (incident, ctx, ct) => { await memory.RememberAsync(new MemoryEntry("incidents", incident.Id, incident.Symptoms) { Content = new JsonObject { ["action"] = incident.Action }, Metadata = new Dictionary<string, string> { ["service"] = incident.Service }, }, ctx, ct); return incident; }) .AddEdge("Recall", "Remember") .Compile();
var result = await desk.RunAsync( "inc-201", new Incident("inc-201", "checkout", "Checkout pods out of memory again after this morning's deploy"), new InMemoryCheckpointStore(), new RunOptions { Metadata = { ["tenant"] = "acme" } });
sealed record Incident(string Id, string Service, string Symptoms){ public IReadOnlyList<string> Similar { get; init; } = []; public string Action { get; init; } = "";}Use async fallible nodes: ? turns the helper’s MemoryError into a NodeError. A failed embedding call keeps its kind, and a store failure is fatal.
let recall_memory = memory.clone();let desk = StateGraph::<Incident>::new() .add_async_fallible_node("Recall", move |incident: Incident, ctx: NodeContext| { let memory = recall_memory.clone(); async move { // No tenant here: Memory reads it from the run's metadata. let options = RecallOptions::new() .limit(3) .min_similarity(0.5) .exclude(incident.id.clone()); let matches = memory .recall("incidents", &incident.symptoms, options, &ctx) .await?; let action = matches .first() .and_then(|m| m.record.content.as_ref()?.get("action")?.as_str()) .unwrap_or("page the on-call engineer") .to_string(); Ok(Incident { similar: matches .iter() .map(|m| format!("{} ({:.2})", m.record.id, m.similarity)) .collect(), action, ..incident }) } }) .add_async_fallible_node("Remember", move |incident: Incident, ctx: NodeContext| { let memory = memory.clone(); async move { let entry = MemoryEntry::new("incidents", incident.id.clone(), incident.symptoms.clone()) .with_content(content(json!({ "action": incident.action }))) .with_metadata("service", incident.service.clone()); memory.remember(entry, &ctx).await?; Ok(incident) } }) .add_edge("Recall", "Remember") .compile() .expect("the graph is valid");
let run = desk .run_with( "inc-201", incident, Arc::new(InMemoryCheckpointStore::new()), RunConfig::new().with_metadata("tenant", "acme"), ) .await;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]struct Incident { id: String, service: String, symptoms: String, similar: Vec<String>, action: String,}Run the same incident once for acme and once for globex. Both tenants have an inc-101 with the same symptoms and a different fix, and each run finds only its own:
[acme] similar: inc-101 (0.67); action: roll back the deploy[globex] similar: inc-101 (0.67); action: move the pods to another node poolKeep tenants and namespaces apart
Section titled “Keep tenants and namespaces apart”A record’s key is its namespace, tenant and id, so two tenants can use the same id. A search reads one namespace, one tenant and one model label:
- A recall with no tenant, outside a run or in a run without
tenantmetadata, reads only the records whose tenant is"". It never means “every tenant”. In the example above, it finds nothing. - Put different kinds of memory in different namespaces, such as
incidentsandrunbooks. A recall in one never returns records from another. - Strata has no recall across tenants, and a record can’t be shared between tenants.
Recall from an agent’s tool
Section titled “Recall from an agent’s tool”Strata has no built-in memory tool for agents. Write a tool that calls the helper, and give it to the agent as Build an agent shows. A tool gets a ToolCallContext, not a node context, so read the tenant from its metadata and pass it yourself:
using Datalium.Strata.Tools;
public partial class IncidentMemoryTools(StrataMemory memory){ [StrataTool(Name = "incidents.recall", Description = "Past incidents most like the given symptoms, with what fixed them.")] public async Task<string> RecallAsync(string symptoms, ToolCallContext context, CancellationToken cancellationToken) { var matches = await memory.RecallAsync("incidents", symptoms, new RecallOptions { Tenant = context.Metadata.GetValueOrDefault("tenant", ""), Limit = 3, }, cancellationToken: cancellationToken); return string.Join("\n", matches.Select(m => $"{m.Record.Id} ({m.Similarity:0.00}): {m.Record.Content?["action"]}")); }}Register it with new ToolRegistry().Register(new IncidentMemoryTools(memory)).
A #[strata_tool] function can’t capture the helper, so add it to the run as a service with RunConfig::new().with_service(Arc::new(memory)), and read it back in the tool:
use datalium_strata::prelude::*;use serde::Deserialize;
#[derive(Deserialize, StrataSchema)]struct RecallArgs { symptoms: String,}
#[strata_tool( name = "incidents.recall", description = "Past incidents most like the given symptoms, with what fixed them.")]async fn incidents_recall(args: RecallArgs, ctx: ToolCallContext) -> Result<String, String> { let memory = ctx.service::<Memory>().ok_or("no Memory service in the run")?; let tenant = ctx.metadata().get("tenant").cloned().unwrap_or_default(); let options = RecallOptions::new().tenant(tenant).limit(3); let matches = memory .recall("incidents", &args.symptoms, options, None) .await .map_err(|error| error.to_string())?; Ok(matches .iter() .map(|m| { let action = m.record.content.as_ref().and_then(|c| c.get("action")?.as_str()); format!("{} ({:.2}): {}", m.record.id, m.similarity, action.unwrap_or_default()) }) .collect::<Vec<_>>() .join("\n"))}Register the handle with tools.register(Box::new(IncidentsRecallTool)).
Called for acme with pods out of memory, after the graph above has run, the tool returns:
inc-103 (0.58): halve the batchinc-101 (0.53): roll back the deployinc-201 (0.50): roll back the deployinc-201 is the incident the graph remembered. An agent’s tool calls carry the run’s metadata, so the tool reads the run’s tenant. Because the tool passes no node context, its embedding call isn’t recorded among the run’s events.
Forget
Section titled “Forget”Delete one record by its key, or purge records by age. Every built-in store implements the retention port, IMemoryRetention in C# and MemoryRetention in Rust.
var deleted = await store.DeleteAsync(new MemoryKey("incidents", "acme", "inc-102"));
if (store is IMemoryRetention retention){ var purged = await retention.PurgeAsync( new MemoryPurge(DateTimeOffset.UtcNow.AddDays(-365)) { Namespace = "incidents" });}Call purge on the concrete store, such as an Arc<SqliteMemoryStore>. memory.store() returns it as Arc<dyn MemoryStore>, which has no purge.
use chrono::{TimeDelta, Utc};use datalium_strata::memory::{MemoryKey, MemoryPurge, MemoryRetention, MemoryStore};
let deleted = store.delete(&MemoryKey::new("incidents", "acme", "inc-102")).await?;
let cutoff = Utc::now() - TimeDelta::days(365);let purged = store.purge(&MemoryPurge::new(cutoff).namespace("incidents")).await?;A purge deletes the records last written before the cutoff, oldest first. It reads updated_at, so a record you remembered again recently stays. Without a namespace or tenant it covers all of them. It deletes up to its limit, 1,000 by default, and returns how many it deleted: call it again while it returns the limit. The host can run it for you on a timer (next section).
Strata doesn’t re-embed a namespace when you change models. List the records with ListAsync / list, and remember each record’s text again with the new model. The key doesn’t include the model, so each new record replaces the old one.
Run it in a host
Section titled “Run it in a host”Register a memory store, the helper and the retention driver. AddMemory() needs an IEmbeddingGenerator<string, Embedding<float>> in the container.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton(embedder); // an IEmbeddingGenerator<string, Embedding<float>>builder.Services.AddStrata() .AddSqliteCheckpoints() // Strata:Sqlite .AddSqliteMemory(o => o.Dimensions = 1536) // Strata:Memory, in the same file .AddMemory() // StrataMemory over the store and the generator .AddMemoryRetention(o => o.OlderThan = TimeSpan.FromDays(365));
using var host = builder.Build();await host.RunAsync();Take StrataMemory from the container. The other stores are AddInMemoryMemory(), AddPostgresMemory() and AddRedisMemory(), and AddMemoryStore(factory) registers your own. The settings are in Strata:Memory (Dimensions, Index, Table, Prefix, Extension, Model, TenantKey) and Strata:Memory:Retention (OlderThan, Interval, BatchSize, Namespace, Tenant).
The host prepares the store and builds the helper when it starts. A missing pgvector, sqlite-vec or Query Engine, a size mismatch, or a generator that names no model stops it from starting.
Host::retain_memory runs the purge on a timer. Build the helper yourself over the same store:
use datalium_strata_host::{Host, MemoryRetentionDriver};
let store = Arc::new(SqliteMemoryStore::open("memory.db", SqliteMemoryOptions::new(1536))?);let memory = Memory::new(store.clone(), embedder);
let host = Host::new().retain_memory( store.clone(), MemoryRetentionDriver::new() .with_older_than(Duration::from_secs(365 * 24 * 60 * 60)) .with_namespace("incidents"),);From settings, open the store with settings.sqlite.open_memory(&settings.memory) (the Postgres and Redis sections have it too), and pass settings.memory.retention to retain_memory.
The driver purges every hour, 1,000 records at a time, by default. It needs an OlderThan / older_than, which has no default: you decide how long you keep memories. Host Strata in a service covers the host itself.
Test without a model
Section titled “Test without a model”This scripted embedder hashes the words of a text into a vector, so texts that share words point the same way. It needs no network or key, and makes the same vectors in both languages. The outputs on this page come from it, over the in-memory store and over SQLite:
using System.Text;using System.Text.RegularExpressions;using Microsoft.Extensions.AI;
// Each word of four letters or more adds 1 to one of 64 buckets.sealed partial class WordHashEmbedder : IEmbeddingGenerator<string, Embedding<float>>{ public const int Dimensions = 64;
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync( IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { var embeddings = new GeneratedEmbeddings<Embedding<float>>(); foreach (var text in values) { var vector = new float[Dimensions]; foreach (Match word in Words().Matches(text.ToLowerInvariant())) { vector[Bucket(word.Value)] += 1; }
vector[0] += 0.01f; // never all zeros: the store refuses a vector with no direction embeddings.Add(new Embedding<float>(vector)); }
return Task.FromResult(embeddings); }
// The label: test/word-hash. public object? GetService(Type serviceType, object? serviceKey = null) => serviceType == typeof(EmbeddingGeneratorMetadata) ? new EmbeddingGeneratorMetadata("test", null, "word-hash", Dimensions) : null;
public void Dispose() { }
// FNV-1a: the same bucket in every process, unlike string.GetHashCode. private static int Bucket(string word) { var hash = 2166136261u; foreach (var b in Encoding.UTF8.GetBytes(word)) { hash = (hash ^ b) * 16777619u; }
return (int)(hash % Dimensions); }
[GeneratedRegex("[a-z0-9]{4,}")] private static partial Regex Words();}Use it with new InMemoryMemoryStore(WordHashEmbedder.Dimensions) and new StrataMemory(store, new WordHashEmbedder()).
use datalium_strata::BoxFuture;use datalium_strata_ai::{ChatError, Embedder, EmbeddingRequest, EmbeddingResponse};
/// Each word of four letters or more adds 1 to one of 64 buckets.struct WordHash;
impl WordHash { const DIMENSIONS: usize = 64;
fn vector(text: &str) -> Vec<f32> { let mut vector = vec![0.0f32; Self::DIMENSIONS]; let lower = text.to_lowercase(); let words = lower .split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit())) .filter(|word| word.len() >= 4); for word in words { // FNV-1a: the same bucket in every build and process. let mut hash: u32 = 2_166_136_261; for byte in word.bytes() { hash = (hash ^ u32::from(byte)).wrapping_mul(16_777_619); } vector[(hash % Self::DIMENSIONS as u32) as usize] += 1.0; } vector[0] += 0.01; // never all zeros: the store refuses a vector with no direction vector }}
// The label: test/word-hash.impl Embedder for WordHash { fn provider(&self) -> &str { "test" }
fn default_model(&self) -> Option<&str> { Some("word-hash") }
fn embed(&self, request: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse, ChatError>> { let vectors = request.inputs.iter().map(|text| Self::vector(text)).collect(); Box::pin(async move { Ok(EmbeddingResponse::new(vectors)) }) }}Use it with InMemoryMemoryStore::new(WordHash::DIMENSIONS) and Memory::new(store, Arc::new(WordHash)).
Test the flow with it, not the scores. To test retention without waiting, give the in-memory store a clock you move: a TimeProvider such as FakeTimeProvider in C#, InMemoryMemoryStore::with_clock in Rust. Demo 25 does this.
Related
Section titled “Related”- State, history and long-term memory: which data belongs where.
- Call a model: the chat clients whose options the Rust embedder shares, and provider faults.
- Build an agent and Define tools: give an agent the memory tool.
- Choose a store: the checkpoint stores.
- Known limitations: sqlite-vec in .NET, the Redis Query Engine, and TLS in the Rust stores.
- Demo 25, long-term memory: .NET, Rust.
- The design:
design/45-Long-Term-Memory.md.