Migrating from Cognee
Adapted from
docs/MIGRATING-FROM-COGNEE.md(kept in the repo as the standalone version).
Cognee and Lunaris both treat the knowledge graph as a first-class substrate. The difference is the surface: Cognee is a Python pipeline (“Tasks → DataPoints → Pipelines”) that produces a queryable graph; Lunaris is an embedded Rust core with a composable retrieval DSL that lets you query a bi-temporal graph + vector + keyword store in a single fused call.
This page maps Cognee concepts to their Lunaris equivalents so a team already running Cognee can evaluate the switch with concrete code.
TL;DR — if your agent depends on Cognee’s pipeline plug-in ecosystem (custom extractors, custom chunkers, custom graph builders), Cognee is well-positioned; the pipeline composition is its strength. If you want sub-25 ms recall over a bi-temporal store + a retrieval DSL where vector + graph + keyword fuse in one typed query, Lunaris’s composable operator surface is the simpler model.
At a glance
| Concern | Cognee | Lunaris |
|---|---|---|
| Runtime | Python | Embedded Rust core + Python (PyO3) + TypeScript (NAPI) bindings |
| Storage | Vector DB (LanceDB / Qdrant / Weaviate / …) + Graph DB (Neo4j / FalkorDB / Memgraph / …) — pluggable | Moon (one substrate, FT.* + graph + KV native) |
| Composition model | Pipeline of Tasks operating on DataPoints | Composable retrieval DSL (vector, keyword, graph + .and / .or / .then / .fuse_rrf) |
| Bi-temporal | Not first-class (DataPoints can carry timestamps but the engine doesn’t model (valid_time, sys_time) tuples) | First-class (valid_time, sys_time) per row; .as_of(ts) is one combinator |
| Recall latency | Depends on backend (~50 ms LanceDB local, ~200 ms cloud) | p50 ≤ 25 ms / p99 ≤ 100 ms on laptop-arm64 |
| Atomicity | Per-store best-effort | One atomic_write covers vector + KV + graph + audit + queue. CI gate enforces single call site |
| Tenancy | dataset string on the API | Scope newtype ([A-Za-z0-9_\-.]{1,128}) threaded through every storage call + per-scope Moon keyspace |
| Graph query language | Cypher (via backend) | Moon native graph (Cypher dialect); the Graph::anchored(entity_ids, hops) operator lowers to it |
| Custom pipeline tasks | First-class — register Tasks, compose with await cognee.cognify() | Override Extractor trait (Phase 3); recall DSL is fixed surface |
| License | Apache 2.0 | Apache 2.0 |
Where Cognee and Lunaris differ in spirit
Cognee is pipeline-oriented: your agent’s ingest path is a
sequence of Tasks (chunk, extract, embed, link, store), and the
power is that you can compose, replace, or insert Tasks as your
domain evolves. The cost is that the surface is wide and a typo in
one Task can break the entire cognify() call.
Lunaris is operator-oriented: the ingest path is fixed
(Lunaris::ingest() chunks + embeds + writes in one
atomic_write); the power is in the retrieval DSL where you
compose vector, keyword, graph, fusion, and time-travel into a
typed query tree. The cost is that custom ingest logic means a
custom Extractor impl rather than a Task plug-in.
Both are right for different shapes. Cognee is the answer when your domain logic lives at ingest time (custom DataPoint relationships, domain-specific Tasks). Lunaris is the answer when your domain logic lives at recall time (hybrid search with custom fusion, bi-temporal audit queries, graph-anchored exploration).
Code-side comparison
Ingest
Cognee
import cognee
await cognee.add("Alice joined Acme on 2024-04-01.", dataset_name="bio")
await cognee.cognify(["bio"]) # runs the default Tasks pipeline:
# chunk → extract → embed → link → store
Lunaris
import lunaris
mem = lunaris.Lunaris.open("moon://localhost:6380")
scope = lunaris.Scope("bio")
mem.scoped(scope).ingest(
lunaris.EpisodeBuilder("chat:session-1/turn-1",
"Alice joined Acme on 2024-04-01.")
)
# Chunking + embedding + atomic write happen inside `ingest()`.
# Graph extraction is opt-in via the graph pipeline toggle.
Recall — semantic
Cognee
results = await cognee.search(
query_text="when did Alice join Acme?",
search_type=cognee.SearchType.GRAPH_COMPLETION,
)
Lunaris
hits = await (
mem.scoped(scope)
.recall() # pre-bound builder, default root Vector("chunks", 30)
.top(5)
.execute() # plan collapses to one FFI call; no query-text arg yet
)
Recall — hybrid semantic + graph
Cognee
results = await cognee.search(
query_text="who does Alice work with at Acme?",
search_type=cognee.SearchType.GRAPH_COMPLETION,
)
# search_type=GRAPH_COMPLETION runs an LLM over the graph context;
# you can't compose "vector AND graph anchored on Alice" without
# dropping to the underlying backends.
Lunaris
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.and_(lunaris.Graph.anchored(entity_ids=[alice_id], hops=2))
.fuse_rrf(60)
.top(5)
.execute()
)
The .and_ fans out vector + graph branches concurrently; .fuse_rrf
folds them with Reciprocal Rank Fusion. The whole pipeline is one
typed expression; no LLM round-trip for the fusion step. See
The Retrieval DSL and
The Graph Pipeline.
Custom extraction
This is where Cognee’s pipeline model shines. If you need a
domain-specific entity extractor, in Cognee you write a Task; in
Lunaris you implement the Extractor trait.
Cognee
from cognee.tasks.documents import classify_documents
from cognee.modules.pipelines import Pipeline
async def my_task(data_points):
# ... domain-specific logic
return data_points
pipeline = Pipeline(tasks=[classify_documents, my_task])
await pipeline.run(dataset_name="bio")
Lunaris
class MyExtractor(lunaris.Extractor):
async def extract(self, content: str) -> lunaris.ExtractionResult:
# ... domain-specific logic
return lunaris.ExtractionResult(entities=[...], relations=[...])
mem = lunaris.Lunaris.open("moon://localhost:6380").with_extractor(MyExtractor())
If you have N custom Tasks composing into a pipeline, Cognee’s
model maps cleaner — composing N Lunaris extractors requires
wrapping them in a single trait impl that fans out internally.
v0.3 RFC 0007 (FallbackExtractor /
FallbackEmbedder combinators) adds the fan-out primitive.
Time-travel recall
Backend note (v0.6.2).
.as_of(<past timestamp>)needs a backend that keeps a KV version chain to hydrate the historical rows, and no 0.7.0 backend does: the call returnsStorageError::NotSupported(HTTP501 not_supported). Moon stores Lunaris rows as plain hashes and refuses a historical pin rather than silently answering with present-time data; the Postgres and SQLite backends that answered it were deleted in 0.7.0. The search and graph lanes stay temporal (FT.SEARCH AS_OF,GRAPH.QUERY VALID_AT).
Cognee doesn’t model bi-temporal queries first-class. The closest is
filtering DataPoints by a created_at field post-search.
Lunaris
snapshot_ms = int(snapshot_ts.timestamp() * 1000)
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.as_of(snapshot_ms) # ms since the Unix epoch
.execute()
)
The temporal cut happens at the storage layer (native bi-temporal on Moon). On 1M-fact corpora the latency difference matters.
Migration checklist
- Stand up Lunaris alongside Cognee. Use
examples/quickstart-py. - Map
dataset→Scope. Same[A-Za-z0-9_\-.]{1,128}alphabet constraint as the Zep migration. - Port custom Cognee Tasks to a Lunaris
Extractorimpl. This is the largest migration cost — a Cognee deployment with 5 custom Tasks becomes ~150 lines of trait impl. If you have no custom Tasks, this step is zero work. - Mirror writes. Every
cognee.add(...)+cognee.cognify(...)is also dispatched tomem.scoped(scope).ingest(...). - Shadow reads. Every
cognee.search(...)is also issued to Lunaris’s recall DSL. Diff in your eval harness. - Cutover and decommission. You now run one Rust process + Moon instead of Cognee + (vector DB) + (graph DB).
When to stay on Cognee
- Your custom Tasks are non-trivial and porting them to a single
Extractorimpl is too much migration cost. - You’re using Cognee’s pipeline plug-in ecosystem (community Tasks) and that’s load-bearing for your stack.
- You’re committed to a specific vector DB / graph DB combination that Lunaris doesn’t ship a backend for, and you don’t want to operate Moon.
- Pure Python deploy, no Rust binary in the build pipeline.
Known gaps vs Cognee today
- No Task plug-in ecosystem. Lunaris ships a fixed ingest path
(
Lunaris::ingest). Custom logic goes in theExtractortrait impl — one impl, not a chain. v0.3 RFC 0007 adds composable fallback combinators for resilience but does not introduce a pipeline DSL. - Backend matrix is one. Lunaris ships Moon and nothing else as
of 0.7.0 — no LanceDB / Qdrant / Weaviate adapter, and the
Postgres adapter was removed. The
StoragePorttrait is still the extension point: a third-party crate can implement it for any backend, but none does today. - Graph-completion search. Cognee’s
GRAPH_COMPLETIONsearch type wraps an LLM call over the graph context. Lunaris exposes the graph traversal as an operator (Graph::anchored) and leaves the LLM call to the caller. If you want one-call “graph-and-summarize”, you’d composerecall() + extractor.summarize()yourself.
See the Mem0 and Zep pages for the parallel migration stories from the other two incumbents. The trio covers the three distinct positioning conversations: Mem0 (no bi-temporal upgrade required), Zep (latency + substrate simplification), Cognee (pipeline-vs-DSL tradeoff).