Skip to content

Quickstart

Five minutes, one small public corpus: ingest documents, search them, compare with SQLite FTS5, encrypt the store, and remember facts that change. Pick Rust, Python or Node.

The corpus is the 30 articles of the Universal Declaration of Human Rights, a United Nations document in the public domain (examples/quickstart/corpus.jsonl). The queries are eight questions in examples/quickstart/queries.json, each with the article that answers it.

This page cannot rot

Every code block below is included from examples/quickstart/, and CI runs those files on every pull request and checks their output against the tables shown here. If a snippet stops working, the build fails before the page does.

Set up

git clone https://github.com/writerslogic/holographic-memory
cd holographic-memory
cargo run --release --manifest-path examples/quickstart/rust/Cargo.toml

The example crate depends on the library by path, with the security feature, plus rusqlite (bundled SQLite) for the comparison. In your own project: cargo add holographic-memory --features security.

The Python package is an HTTP client for hms-server, which stores vectors you supply. Start the server, then run the script:

git clone https://github.com/writerslogic/holographic-memory
cd holographic-memory
cargo build --release --features server --bin hms-server
HMS_API_KEY=quickstart target/release/hms-server --input-dim 384 &
HMS_API_KEY=quickstart uv run --with bindings/python/holographic-sdk \
    python examples/quickstart/python/quickstart.py

The script embeds text with toy_embed, a small hashed bag of words so it needs no model download. It is lexical and only a stand-in: use a real embedding model for semantic search. The holographic-vsa wheel on PyPI is a different thing (the quantized-phase vector substrate) and does not include document search.

git clone https://github.com/writerslogic/holographic-memory
cd holographic-memory
npm ci --omit=optional && npm run build
node examples/quickstart/node/quickstart.mjs

The native module is built from source (Rust toolchain required). The script uses node:sqlite, so it needs Node 24 or newer.

1. Ingest

A store is a directory; it persists there and reopens with the same arguments. Each document has an ID, its text, a source URI, and optional metadata and embeddings. Text is split into chunks (256 words by default) and written in one atomic batch.

    // 1. Ingest. A store is a directory (`None` would use the current directory); it persists
    // there and reopens with the same arguments.
    let store_dir = tempfile::tempdir()?;
    let hms = HmsCore::new(
        DIMENSIONS,
        Some(store_dir.path().display().to_string()),
        None,
    )?;
    let start = Instant::now();
    let inputs: Vec<DocumentInput> = corpus.iter().map(to_input).collect();
    hms.memorize_documents(inputs)?;
    println!(
        "ingested {} documents in {:.1?}",
        corpus.len(),
        start.elapsed()
    );
# 1. Ingest. The server keeps one store per tenant; you send the text and a vector per document.
docs = [
    {"id": d["id"], "vector": toy_embed(d["text"]), "text": d["text"], "metadata": {"title": d["title"]}}
    for d in corpus
]
client = HolographicClient(url=URL, api_key=API_KEY, tenant_id=tenant)
started = time.perf_counter()
client.add_documents(docs)
print(f"ingested {len(docs)} documents in {(time.perf_counter() - started) * 1000:.1f} ms")
// 1. Ingest. A store is a directory; it persists there and reopens with the same arguments.
const memory = new DocumentMemory(new HolographicMemorySystem(4096, mkdtempSync(join(tmpdir(), 'hms-quickstart-'))))
const started = performance.now()
await memory.ingest(corpus.map(toInput))
console.log(`ingested ${corpus.length} documents in ${(performance.now() - started).toFixed(1)} ms`)

2. Search, and compare with SQLite FTS5

The baseline is the same corpus in an FTS5 virtual table ranked by bm25. A bare multi-word FTS5 query means AND, which would unfairly return nothing for most of these questions, so the baseline ORs the words.

    // The same corpus in SQLite FTS5 (bm25 ranking), for comparison.
    let db = Connection::open_in_memory()?;
    db.execute_batch("CREATE VIRTUAL TABLE docs USING fts5(id UNINDEXED, text);")?;
    for doc in &corpus {
        db.execute(
            "INSERT INTO docs (id, text) VALUES (?1, ?2)",
            (&doc.id, &doc.text),
        )?;
    }

    // 2. Search both engines with the same queries.
    println!("\n{:<58} {:<9} {:<9} expected", "query", "HMS", "FTS5");
    let (mut hms_hits, mut fts_hits) = (0, 0);
    for q in &queries {
        let query = q["query"].as_str().context("query")?;
        let expect = q["expect"].as_str().context("expect")?;
        let options = SearchOptions {
            k: Some(3),
            ..Default::default()
        };
        let results = hms.search_documents(query, &options)?;
        let hms_top = results
            .first()
            .map_or("-", |r| r.document_id.as_str())
            .to_owned();
        let fts_top: String = db
            .query_row(
                "SELECT id FROM docs WHERE docs MATCH ?1 ORDER BY bm25(docs) LIMIT 1",
                [fts5_query(query)],
                |row| row.get(0),
            )
            .unwrap_or_else(|_| "-".to_owned());
        hms_hits += usize::from(hms_top == expect);
        fts_hits += usize::from(fts_top == expect);
        println!("{query:<58} {hms_top:<9} {fts_top:<9} {expect}");
    }
    println!(
        "top-1 correct: HMS {hms_hits}/{n}, FTS5 {fts_hits}/{n}",
        n = queries.len()
    );
    ensure!(
        hms_hits >= 5,
        "HMS found the expected article for only {hms_hits} queries"
    );
# The same corpus in SQLite FTS5 (bm25 ranking), for comparison.
db = sqlite3.connect(":memory:")
db.execute("CREATE VIRTUAL TABLE docs USING fts5(id UNINDEXED, text)")
db.executemany("INSERT INTO docs (id, text) VALUES (?, ?)", [(d["id"], d["text"]) for d in corpus])

# 2. Search both engines with the same queries.
print(f"\n{'query':<58} {'HMS':<9} {'FTS5':<9} expected")
hms_hits = fts_hits = 0
for q in queries:
    matches = client.query(toy_embed(q["query"]), top_k=3)["matches"]
    hms = matches[0]["id"] if matches else "-"
    row = db.execute(
        "SELECT id FROM docs WHERE docs MATCH ? ORDER BY bm25(docs) LIMIT 1", (fts5_query(q["query"]),)
    ).fetchone()
    fts = row[0] if row else "-"
    hms_hits += hms == q["expect"]
    fts_hits += fts == q["expect"]
    print(f"{q['query']:<58} {hms:<9} {fts:<9} {q['expect']}")
print(f"top-1 correct: HMS {hms_hits}/{len(queries)}, FTS5 {fts_hits}/{len(queries)}")
assert hms_hits >= 4, f"HMS found the expected article for only {hms_hits} queries"
// The same corpus in SQLite FTS5 (bm25 ranking), for comparison.
const db = new DatabaseSync(':memory:')
db.exec('CREATE VIRTUAL TABLE docs USING fts5(id UNINDEXED, text)')
const insert = db.prepare('INSERT INTO docs (id, text) VALUES (?, ?)')
for (const doc of corpus) insert.run(doc.id, doc.text)
// FTS5 treats a bare multi-word query as AND; OR of the words is the fair lexical baseline.
const fts5Query = query => query.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map(w => `"${w}"`).join(' OR ')
const ftsTop = db.prepare('SELECT id FROM docs WHERE docs MATCH ? ORDER BY bm25(docs) LIMIT 1')

// 2. Search both engines with the same queries.
console.log(`\n${'query'.padEnd(58)} ${'HMS'.padEnd(9)} ${'FTS5'.padEnd(9)} expected`)
let hmsHits = 0
let ftsHits = 0
for (const { query, expect } of queries) {
  const [hit] = await memory.search(query, { k: 3 })
  const hms = hit?.documentId ?? '-'
  const fts = ftsTop.get(fts5Query(query))?.id ?? '-'
  hmsHits += hms === expect
  ftsHits += fts === expect
  console.log(`${query.padEnd(58)} ${hms.padEnd(9)} ${fts.padEnd(9)} ${expect}`)
}
console.log(`top-1 correct: HMS ${hmsHits}/${queries.length}, FTS5 ${ftsHits}/${queries.length}`)
assert.ok(hmsHits >= 5, `HMS found the expected article for only ${hmsHits} queries`)

Output (identical in Rust and Node, which call the same engine; CI diffs it):

query                                                      HMS       FTS5      expected
right to a fair trial                                      udhr-10   udhr-10   udhr-10
freedom of religion                                        udhr-18   udhr-18   udhr-18
paid holidays and rest from work                           udhr-24   udhr-24   udhr-24
nobody may be tortured                                     udhr-05   udhr-20   udhr-05
everyone may take part in elections                        udhr-21   udhr-21   udhr-21
protection of private correspondence and home              udhr-12   udhr-12   udhr-12
free education for children                                udhr-26   udhr-26   udhr-26
escaping persecution by seeking asylum in another country  udhr-14   udhr-14   udhr-14
top-1 correct: HMS 8/8, FTS5 7/8

The Python script prints this table, which differs because toy_embed and the engine's text analysis are not the same thing:

query                                                      HMS       FTS5      expected
right to a fair trial                                      udhr-23   udhr-10   udhr-10
freedom of religion                                        udhr-18   udhr-18   udhr-18
paid holidays and rest from work                           udhr-24   udhr-24   udhr-24
nobody may be tortured                                     udhr-04   udhr-20   udhr-05
everyone may take part in elections                        udhr-28   udhr-21   udhr-21
protection of private correspondence and home              udhr-22   udhr-12   udhr-12
free education for children                                udhr-26   udhr-26   udhr-26
escaping persecution by seeking asylum in another country  udhr-14   udhr-14   udhr-14
top-1 correct: HMS 4/8, FTS5 7/8

Read this honestly. Eight hand-written questions on 30 short passages show how the API behaves. They are not a benchmark and say nothing about relevance at scale. HMS's default text search is stemmed BM25, the same family as FTS5, so on keyword-style queries they largely agree. Supply embeddings (see below) and HMS fuses exact cosine with BM25, which is where it can beat a lexical engine on paraphrases. Measured retrieval results on public datasets are on the benchmarks page, including where HMS loses.

Each hit also carries the passage text, its byte range in the source and the score, so you can show a snippet and link back to the source:

    // Hits carry the passage and its byte range in the source text.
    let hit = &hms.search_documents("asylum from persecution", &SearchOptions::default())?[0];
    println!(
        "\n{} bytes {}..{} score {:.3}: {}",
        hit.document_id,
        hit.start_byte,
        hit.end_byte,
        hit.score,
        hit.text.as_deref().unwrap_or("")
    );
# Hits carry the stored text, metadata and the exact cosine score.
top = client.query(toy_embed("asylum from persecution"), top_k=1)["matches"][0]
print(f"\n{top['id']} score {top['score']:.3f}: {top['text']}")
// Hits carry the passage and its byte range in the source text.
const [hit] = await memory.search('asylum from persecution')
console.log(`\n${hit.documentId} bytes ${hit.startByte}..${hit.endByte} score ${hit.score.toFixed(3)}: ${hit.text}`)

3. Encrypt

    // 3. Encrypt: the same corpus in a persistent store with AES-256-GCM (Argon2id key).
    let secret_dir = tempfile::tempdir()?;
    let secret_path = secret_dir.path().display().to_string();
    let mut config = HmsConfig::default();
    config.security.encryption_enabled = true;
    config.security.encryption_passphrase = Some("correct horse battery staple".into());

    let sealed = HmsCore::new(DIMENSIONS, Some(secret_path.clone()), Some(config.clone()))?;
    sealed.memorize_documents(corpus.iter().map(to_input).collect())?;
    sealed.flush()?;
    println!("\nsecurity status: {}", sealed.security_status());
    drop(sealed);
    let needle = b"periodic holidays with pay";
    let leaks = files_containing(secret_dir.path(), needle)?;
    println!("encrypted store, files holding the passage: {leaks:?}");
    // With encryption on, no store file may hold the passage text, the file-backed text segment
    // (`documents.text`) included.
    ensure!(leaks.is_empty(), "plaintext passage found in {leaks:?}");
    println!("no store file holds the passage text");

    let reopened = HmsCore::new(DIMENSIONS, Some(secret_path.clone()), Some(config.clone()))?;
    let hit = &reopened.search_documents("paid holidays", &SearchOptions::default())?[0];
    println!("reopened with the passphrase, top hit: {}", hit.document_id);
    drop(reopened);

    config.security.encryption_passphrase = Some("wrong passphrase".into());
    let Err(error) = HmsCore::new(DIMENSIONS, Some(secret_path), Some(config)) else {
        anyhow::bail!("opening with the wrong passphrase must fail");
    };
    println!("opening with a wrong passphrase fails: {error}");
    ensure!(
        !error.to_string().contains("already open"),
        "failed for the wrong reason: {error}"
    );
# 3. Protect. hms-server has no encryption at rest, so the client masks vectors with a key the
# server never sees. This is obfuscation, not encryption: the server still learns every pairwise
# similarity and stores text and metadata in plaintext (docs/SERVER.md).
masked = HolographicClient(
    url=URL, api_key=API_KEY, tenant_id=tenant + "-masked", zero_trust_key="correct horse battery staple"
)
masked.add_documents(docs)
plain_vec = toy_embed("paid holidays")
assert masked.encrypt_vector(plain_vec) != plain_vec
top = masked.query(plain_vec, top_k=1)["matches"][0]
print(f"masked store, top hit for 'paid holidays': {top['id']}")
assert top["id"] == "udhr-24"

wrong = HolographicClient(url=URL, api_key=API_KEY, tenant_id=tenant + "-masked", zero_trust_key="wrong key")
top = wrong.query(toy_embed("paid holidays"), top_k=1)["matches"][0]
print(f"same store queried with the wrong key, top hit: {top['id']}")
assert top["id"] != "udhr-24"
// 3. Encrypt: the same corpus in a persistent store with AES-256-GCM (Argon2id key).
const needle = Buffer.from('periodic holidays with pay')
const filesHolding = dir => readdirSync(dir, { recursive: true, withFileTypes: true })
  .filter(entry => entry.isFile() && readFileSync(join(entry.parentPath, entry.name)).includes(needle))
  .map(entry => entry.name)
  .sort()

const sealedDir = mkdtempSync(join(tmpdir(), 'hms-sealed-'))
execFileSync(process.execPath, [fileURLToPath(import.meta.url), '--ingest', sealedDir, 'encrypted'], { stdio: 'inherit' })
const leaks = filesHolding(sealedDir)
console.log(`\nencrypted store, files holding the passage: ${JSON.stringify(leaks)}`)
// With encryption on, no store file may hold the passage text, the file-backed text segment
// (`documents.text`) included.
assert.deepEqual(leaks, [])
console.log('no store file holds the passage text')

// A wrong passphrase must not open the store; the right one must.
const wrong = { ...sealedConfig, encryptionPassphrase: 'wrong passphrase' }
assert.throws(() => new HolographicMemorySystem(4096, sealedDir, wrong), error => {
  console.log(`opening with a wrong passphrase fails: ${error.message}`)
  return !/already open/.test(error.message)
})
const reopened = new HolographicMemorySystem(4096, sealedDir, sealedConfig)
assert.equal(reopened.securityStatus().encryptionActive, true)
const [again] = await new DocumentMemory(reopened).search('paid holidays')
console.log(`reopened with the passphrase, top hit: ${again.documentId}`)
assert.equal(again.documentId, 'udhr-24')

Rust and Node enable AES-256-GCM with an Argon2id-derived key (the security feature, which the npm build includes). The script then proves two things: a wrong passphrase cannot open the store, and the right one can. Prefer encryptionPassphraseEnv (Node) or encryption_passphrase_env (Rust) over a literal passphrase in real code.

The script also scans every file in the encrypted store for a known passage and asserts none contains it, the text segment included. Read the privacy limits for what encryption does not cover (file names, sizes, access patterns).

The Python client cannot encrypt, because hms-server has no encryption at rest. It offers client-side vector masking with zero_trust_key, which is obfuscation: the server still learns every pairwise similarity and stores text and metadata in plaintext (server limits).

Where next

4. Remember what changed

Agents need facts that change. The memory API keeps old facts as history, lets a newer fact replace an older one, and answers "what was true as of" a date.

    let dir = tempfile::tempdir()?;
    let core = Arc::new(HmsCore::new(
        4096,
        Some(dir.path().display().to_string()),
        None,
    )?);
    let memory = Memory::new(core);

    memory.remember(RememberInput {
        id: Some("home-1".into()),
        text: "Alice lives in Paris".into(),
        subject: Some("alice/city".into()),
        valid_from: Some(1_577_836_800_000), // 2020-01-01
        ..Default::default()
    })?;
    memory.remember(RememberInput {
        id: Some("home-2".into()),
        text: "Alice lives in Berlin".into(),
        subject: Some("alice/city".into()),
        valid_from: Some(1_735_689_600_000), // 2025-01-01
        supersedes: Some(vec!["home-1".into()]),
        ..Default::default()
    })?;

    let question = "where does Alice live";
    let now = memory.recall(question, &RecallOptions::default())?;
    let then = memory.recall(
        question,
        &RecallOptions {
            as_of: Some(1_600_000_000_000), // 2020-09-13
            ..Default::default()
        },
    )?;
    println!("now:  {}", now[0].text.as_deref().unwrap_or(""));
    println!("then: {}", then[0].text.as_deref().unwrap_or(""));

Output (CI diffs it):

now:  Alice lives in Berlin
then: Alice lives in Paris

forget and explain complete the set; see the memory API.

5. Use real embeddings

The Python script above uses a toy hashed embedding. For semantic search on your machine with no API key and no service, build with the local-embed feature and set text_encoder: TextEncoder::LocalEmbedV1: chunks and queries are embedded by a bundled model and search fuses cosine with BM25. Options, model choice and offline packs are in local embeddings.