10-Minute Quickstart
From a fresh checkout to your first ingest + recall against a local Moon
in under ten minutes — Rust is canonical, with Python and TypeScript
mirrors side by side. Moon is the only backend as of 0.7.0; the Postgres
and SQLite paths this chapter used to open with were deleted (see
0.6 → 0.7).
The default embedder is in-process llama.cpp granite-r2 (GGUF) — no Ollama
needed for embedding. The shipped examples/quickstart-rs crate opts into
the ollama build to enable the Ollama extractor + verifier path —
see step 2.
API note. This chapter uses the retrieval surface that exists in the v0.2.x source:
ScopedLunaris::recall(Query::text(...))returnsVec<Hit>directly, andScopedLunaris::dsl()(or the bareLunaris::recall()) returns aRetrievalBuilderyou drive with.with_root(...)+.execute(Query::text(...)). A fluent shorthand likerecall().vector("chunks", 30).top(5).execute()is a planned ergonomic wrapper that is not yet on the builder —RetrievalBuilderhas no.vector(...)method today. When in doubt, the Rust source wins.
0. Get the code
git clone https://github.com/pilotspace/lunaris && cd lunaris
1. Bring up Moon
The examples/quickstart-*/ directories share one compose file, which runs
the published ghcr.io/pilotspace/moon image on localhost:6380:
cd examples/quickstart-rs
docker compose up -d
docker compose ps # wait until lunaris-quickstart-moon is "healthy"
There is no schema step — Moon needs no migration, and Lunaris creates its
indexes on first connect. Note the compose file passes --shards 1: a
Lunaris ingest is one MULTI/EXEC transaction and a sharded Moon rejects it.
2. Point Lunaris at it
export LUNARIS_STORE_URL="moon://127.0.0.1:6380"
The quickstart binary has no default URL — an unset LUNARIS_STORE_URL
is an error, not a fallback to an in-process store.
The default embedder is granite-embedding-311m-multilingual-r2 (768-d),
loaded from a Q4_K_M GGUF in-process via llama.cpp (staged at
~/.lunaris/models/) — no Ollama needed for embedding.
The shipped examples/quickstart-rs crate pins features = ["ollama"]
in its Cargo.toml to enable the Ollama extractor + verifier path
(the smallest external-dep build that exercises a real extraction + verification
flow). The embedder and reranker remain in-process llama.cpp regardless. So
for this walkthrough, start Ollama and pull the extractor model:
ollama serve &
ollama pull gemma3:4b
Prefer a cloud extractor instead of Ollama? Set
LUNARIS_EXTRACT_PROVIDER=minimax (or anthropic/openai/gemini) and the
matching API key — extraction and verification are remote-only in v0.6, so
there is no all-in-process alternative to Ollama for those two stages.
3–6. Open a handle, ingest, recall, forget
What follows is the canonical Rust flow; the Python and TypeScript mirrors come right after.
Rust
use std::env;
use anyhow::{Context, Result};
use lunaris::{EpisodeBuilder, ForgetTarget, Lunaris, Query, Scope, ScopeSpec, Vector};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
// 3. Open a handle. `moon://host:port` is the only scheme `open`
// accepts since 0.7.0; anything else is an UnsupportedScheme error.
let url = env::var("LUNARIS_STORE_URL")
.context("set LUNARIS_STORE_URL — see examples/quickstart-rs/README.md")?;
let lunaris = Lunaris::open(&url).await.context("Lunaris::open")?;
// The Scope newtype is the multi-agent partition key (RFC 0001).
// Scope::new validates the string against [A-Za-z0-9_\-.]{1,128}.
let scope = Scope::new("quickstart").context("Scope::new")?;
let scoped = lunaris.scoped(scope);
// 4. Ingest one episode. ScopedLunaris::ingest takes an
// EpisodeBuilder (scope-less payload); the wrapper stamps the
// bound scope on. Exactly one atomic_write per call (INGEST-04).
let lsn = scoped
.ingest(EpisodeBuilder::new(
"quickstart:demo",
"# Hello from Lunaris\n\nAlice loves chocolate.",
))
.await
.context("ingest")?;
println!("ingested at lsn={lsn:?} under scope `quickstart`");
// 5a. Recall — the one-shot form. ScopedLunaris::recall(query) runs the
// default plan (Vector over `chunks`, no fusion/rerank) and returns
// Vec<Hit> directly. Least ceremony for a plain semantic lookup.
let hits = scoped
.recall(Query::text("who loves chocolate"))
.await
.context("recall")?;
println!("recalled {} hit(s)", hits.len());
for h in &hits {
println!(" hit id={:?} score={:.3}", h.id, h.score);
}
// 5b. Recall — the composable form. ScopedLunaris::dsl() returns a
// RetrievalBuilder pre-seeded with this scope; .with_root sets the
// operator tree, .execute runs the plan once and returns Vec<Hit>.
// Reach for this when you want hybrid fusion, graph/tree, as_of,
// or rerank. (Here: cap the same default Vector plan at top-5.)
let hits = scoped
.dsl()
.with_root(Vector::new("chunks", 30).top(5))
.execute(Query::text("who loves chocolate"))
.await
.context("recall (dsl)")?;
println!("recalled {} hit(s) via the DSL", hits.len());
// 6. Forget — soft delete (MVCC: stamps bt.sys_to; prior as_of
// reads still see it). A dry-run preview never writes.
//
// NOTE (v0.2.x): Lunaris::forget is hard-coded to Scope::dev()
// today, so a forget issued under a real scope like `quickstart`
// silently matches zero rows. The dry-run preview below is safe
// to run regardless; per-scope ScopedLunaris::forget lands in
// v0.3. See CHANGELOG.md "v0.2.0 — Known issues".
let preview = lunaris
.forget(ForgetTarget::Scope(ScopeSpec::BySource("quickstart:".into())).dry_run())
.await
.context("forget dry-run")?;
println!("forget preview: preview={} rows_would_write={}", preview.preview, preview.rows_written);
Ok(())
}
Run it:
cargo run --release
Expected output (LSN values vary):
ingested at lsn=Lsn { wall_ms: 1713789012345, counter: 0 } under scope `quickstart`
recalled 1 hit(s)
hit id=... score=...
forget preview: preview=true rows_would_write=...
Hybrid recall, one line more. Add BM25 keyword search and fuse it with reciprocal-rank fusion — when both legs sit on the same Moon index
fuse_rrfcollapses them into a single round trip, otherwise it fuses the leg results client-side. Same API either way:use lunaris::{Keyword, Lunaris, Query, Scope, Vector}; async fn demo() -> Result<(), lunaris::LunarisError> { let lunaris = Lunaris::open("moon://localhost:6380").await?; let scoped = lunaris.scoped(Scope::new("quickstart")?); use lunaris::{Keyword, Vector}; let hits = scoped .dsl() .with_root( Vector::new("chunks", 30) .and(Keyword::bm25("chunks", 30)) .fuse_rrf(60) .top(5), ) .execute(Query::text("who loves chocolate")) .await?; Ok(()) }Add
.rerank(lunaris.reranker())before.top(5)for the cross-encoder pass. The full operator catalogue is in The Retrieval DSL.
Python (pip install lunaris)
The typed Scope + EpisodeBuilder Python surface lands in v0.3; today
the wire shape is a dict that mirrors lunaris_core::primitives::Episode
(the scope field is required).
import asyncio, os
import lunaris
import ulid # pip install python-ulid
def build_episode(scope: str, content: str) -> dict:
return {
"id": str(ulid.ULID()),
"scope": scope,
"source": "quickstart:demo",
"content": content,
"t_ref": None,
"bt": {
"valid": [{"wall_ms": 0, "counter": 0, "node_id": 0}, None],
"sys": [{"wall_ms": 0, "counter": 0, "node_id": 0}, None],
},
"metadata": {},
}
async def main() -> None:
url = os.environ["LUNARIS_STORE_URL"]
handle = await lunaris.open(url) # 3. open
lsn = await handle.ingest(build_episode("quickstart", # 4. ingest
"Alice loves chocolate."))
print(f"ingested at lsn={lsn} under scope `quickstart`")
# 5. recall — the DSL is reachable from handle.recall(); the typed
# Scope binding and the recall walkthrough land alongside the v0.3
# SDK story. See examples/quickstart-py/README.md.
asyncio.run(main())
Run: python quickstart.py (or maturin develop --release from
crates/lunaris-py/ first if you’re on a repo checkout). See the
Python SDK chapter.
TypeScript (npm i @pilotspace/lunaris)
Same story — dict-shaped episode today, typed surface in v0.3.
import * as lunaris from "@pilotspace/lunaris";
function buildEpisode(scope: string, content: string): object {
const ts = Date.now();
const id = `01${ts.toString(32).toUpperCase().padStart(10, "0")}`
.padEnd(26, "0").slice(0, 26);
return {
id,
scope,
source: "quickstart:demo",
content,
t_ref: null,
bt: {
valid: [{ wall_ms: 0, counter: 0, node_id: 0 }, null],
sys: [{ wall_ms: 0, counter: 0, node_id: 0 }, null],
},
metadata: {},
};
}
const handle = await lunaris.open(process.env.LUNARIS_STORE_URL!); // 3. open
const lsn = await handle.ingest(buildEpisode("quickstart", // 4. ingest
"Alice loves chocolate."));
console.log(`ingested at lsn=${lsn} under scope \`quickstart\``);
// 5. recall — handle.recall() exposes the DSL; the typed-Scope binding
// and the recall walkthrough land with the v0.3 SDK story. See
// examples/quickstart-ts/README.md.
Run: npx tsx quickstart.mts (or npm run build from
crates/lunaris-ts/ first on a repo checkout). See the
TypeScript SDK chapter.
Tear-down
docker compose down -v # -v wipes the pg data volume
What you just did
| Step | Rust | What it is |
|---|---|---|
| Open | Lunaris::open(url) | moon://host:port — the only scheme (0.7.0) |
| Scope | Scope::new("quickstart")? | the validated multi-agent partition key (RFC 0001) |
| Bind | lunaris.scoped(scope) | all ops on the returned ScopedLunaris are partitioned |
| Ingest | scoped.ingest(EpisodeBuilder::new(src, body)) | one atomic_write: chunk + embed + commit |
| Recall | scoped.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text(q)) | one read pass, returns Vec<Hit> |
| Forget | lunaris.forget(target.dry_run()) | MVCC soft delete + audit event (scoped variant in v0.3) |
Next
- Core Concepts — the Episode → ingest → storage →
recall mental model, bi-temporal MVCC, the
Scopekeyspace, the singleatomic_writeinvariant. - The Retrieval DSL — every operator and fusion / rerank / fallback combinator.
- Ingesting Observations — chunking, embedding, the graph pipeline.