moofile
MooFile is a lightweight embedded document store (BSON) with vector, text and hybrid RRF search plus local ONNX auto-embedding โ a SQLite-shaped alternative for document + semantic workloads.
Downloads: 9 ยท ID: cebe3d8d8edc1fe663000000
MooFile is a lightweight embedded document store (BSON) with vector, text and hybrid RRF search plus local ONNX auto-embedding โ a SQLite-shaped alternative for document + semantic workloads.
Downloads: 9 ยท ID: cebe3d8d8edc1fe663000000
<!-- FILE: moofile_skill.md -->
# MooFile โ Lightweight Embedded Document Store
**Repo:** https://github.com/patw/moofile
**PyPI:** `pip install moofile` or `pip install "moofile[pandas]"`
**Docs:** https://raw.githubusercontent.com/patw/moofile/refs/heads/main/docs/README.md
**Version:** v1.2.4 (v1.2.x = voyage-4-nano auto-embedding via v4nano-embed ONNX crate; GGUF/fastembed backends removed. v1.2.3 halved *open-time peak* RSS, v1.2.4 holds documents as **raw BSON bytes** in memory โ ~1.65x their on-wire size instead of ~10x decoded โ so steady-state RSS dropped 4.7x on a 638k-doc collection)
**Creator:** see the repository
## What It Is
MooFile is a lightweight, embedded, single-file document store with a MongoDB-style query API. No server, no infrastructure โ just a `.bson` file and a Python library. Rust core available (v0.3+).
**File layout:**
```
data.bson โ append-only BSON document store (source of truth)
data.bson.meta โ index configuration (JSON, human-readable)
data.bson.lock โ advisory lock file (prevents concurrent multi-process access)
data.bson.cache โ disposable index snapshot (optional, accelerates cold opens) โ v0.4.0+
```
> **โ ๏ธ Key conceptual shift vs older versions:** Indexes are **never persisted** as a source of truth. They are rebuilt in memory on every open by scanning the BSON file. The `.bson.cache` file is a **disposable** shortcut โ safe to delete at any time, re-created on close if helpful. If the `.meta` file is lost, delete it and reopen; the data is always safe in the `.bson` file.
## When to Use It
- Small datasets (MBs to low GBs)
- Embedded applications โ local tooling, single-process services
- Tests โ ephemeral data that needs indexing
- Anywhere you'd use SQLite but want document-oriented storage
- Vector similarity search, BM25 text search, and **hybrid RRF fusion** (v0.2+)
## Quick Reference
```python
from moofile import Collection, count, mean
# Open a collection with context manager (auto-closes)
db = Collection("data.bson", indexes=["email", "status"])
# Insert
doc = db.insert({"name": "Alice", "email": "alice@example.com", "age": 30})
# doc returned with auto-generated _id
# Insert many
docs = db.insert_many([
{"name": "Bob", "email": "bob@example.com", "age": 22},
{"name": "Carol", "email": "carol@example.com", "age": 40},
])
# Query
results = db.find({"age": {"$gt": 25}}).sort("age").limit(10).to_list()
one = db.find_one({"email": "alice@example.com"})
first = db.find({"age": {"$gt": 25}}).first() # dict | None
count = db.count({"status": "active"})
exists = db.exists({"email": "bob@example.com"})
# Update (DIFFERENT from MongoDB โ uses Python kwargs!)
db.update_one({"email": "alice@example.com"}, set={"age": 31})
db.update_many({"status": "trial"}, set={"status": "expired"})
db.update_one({"email": "bob"}, inc={"score": 5})
db.update_one({"email": "bob"}, unset=["temp_field"])
# Replace entire document (preserves _id)
db.replace_one({"email": "alice@example.com"}, {"name": "Alice", "age": 32})
# Delete
db.delete_one({"email": "carol@example.com"}) # returns bool
db.delete_many({"status": "expired"}) # returns count
# Aggregation with grouping
from moofile import count, sum, mean, min, max, collect, first, last
agg = db.find({}).group("status").agg(
count(), mean("age"), collect("name")
).to_list()
# Vector similarity search (field must be indexed with vector_indexes=)
db2 = Collection("profiles.bson", vector_indexes={"embedding": 384})
results = db2.find({}).vector_search("embedding", [0.1, 0.2, ...], limit=5).to_list()
# Returns: [(doc, similarity_score), ...]
# BM25 text search (field must be indexed with text_indexes=)
db3 = Collection("docs.bson", text_indexes=["content"])
results = db3.find({}).text_search("content", "machine learning", limit=10).to_list()
# Returns: [(doc, relevance_score), ...]
# Hybrid search โ RRF fusion of BM25 + vector cosine similarity
db4 = Collection("docs.bson", text_indexes=["content"], vector_indexes={"embedding": 384})
results = db4.find({}).hybrid_search("content", "embedding", "ml", [0.1, ...], limit=10).to_list()
# Returns: [(doc, rrf_score), ...]
# Autoembedding โ automatic embedding generation from text (v0.5.0+)
db5 = Collection("docs.bson", vector_indexes={"embedding": 1024},
auto_embed={"content": {"target": "embedding", "dims": 1024}})
doc = db5.insert({"content": "Machine learning is fascinating"})
# embedding auto-generated and stored in doc["embedding"]
# Semantic search (autoembedding-based)
results = db5.find({}).semantic("content", "deep learning", limit=5).to_list()
# Returns: [(doc, score), ...]
# Atomic batch writes
with db.batch():
db.insert({"name": "alice", "status": "active"})
db.update_one({"name": "bob"}, set={"status": "active"})
db.delete_one({"name": "charlie"})
# All three committed atomically here.
```
## Key Differences vs MongoDB (Agent Beware!)
| Aspect | MongoDB | MooFile |
|--------|---------|---------|
| **Update syntax** | `update_one(filter, {"$set": {...}})` | `update_one(filter, set={...})` โ **Python kwargs** |
| **update_one strictness** | Silent no-op if no match | Raises `DocumentNotFoundError` |
| **delete_one return** | Result object | `True`/`False` |
| **Vector/text search returns** | Documents | `[(doc, score), ...]` tuples |
| **Thread safety** | Yes | Single-threaded writes (reads OK from multiple threads) |
| **Nested field indexes** | Yes | Top-level fields only |
| **Async** | Yes | Synchronous only |
| **Cross-process safety** | Yes (server-managed) | Advisory lock file โ Python backend: blocking `flock(LOCK_EX)` waits for concurrent writes; Rust backend: raises `ConcurrentAccessError` |
## All Imports
```python
from moofile import (
Collection,
count, sum, mean, min, max, collect, first, last,
MooFileError, DuplicateKeyError, DocumentNotFoundError, ReadOnlyError,
ConcurrentAccessError, InvalidIdError, InvalidFilterError,
)
```
## Return Types Quick Reference
| Method | Returns |
|---|---|
| `find().to_list()` | `list[dict]` |
| `find().first()` | `dict \| None` |
| `find_one()` | `dict \| None` |
| `find().count()` | `int` (0 if no matches) |
| `count()` | `int` (0 if no matches) |
| `exists()` | `bool` |
| `vector_search().to_list()` | `list[tuple[dict, float]]` |
| `text_search().to_list()` | `list[tuple[dict, float]]` |
| `hybrid_search().to_list()` | `list[tuple[dict, float]]` |
| `semantic().to_list()` | `list[tuple[dict, float]]` |
| `insert()` | `dict` (with _id populated) |
| `insert_many()` | `list[dict]` |
| `update_one()` | `bool` (always True, raises DocumentNotFoundError if no match) |
| `update_many()` | `int` (count of updated docs) |
| `replace_one()` | `bool` (always True, raises DocumentNotFoundError if no match) |
| `delete_one()` | `bool` |
| `delete_many()` | `int` |
## Opening a Collection
```python
db = Collection(
path, # path to the .bson file (created if absent)
indexes=[], # list of top-level field names to index
vector_indexes={}, # dict: field -> vector_dimension
text_indexes=[], # list of field names for full-text search
auto_embed={}, # dict: source_field -> config (v0.5.0+)
readonly=False, # True to prevent all writes
schema=None, # optional hints, ignored in v1
durability="os", # "none" | "os" (default) | "fsync"
)
```
**Durability modes:**
- `"none"` โ no flush, fastest, lost on crash (like SQLite `synchronous=OFF`)
- `"os"` (default) โ `flush()` โ OS page cache, survives process crash (like SQLite `synchronous=NORMAL`)
- `"fsync"` โ `sync_all()` after every write, survives power loss (like SQLite `synchronous=FULL`)
For batched durability with the default: call `db.sync()` after a batch of writes to force a single fsync.
## Query Chains
```python
results = (
db.find({"status": "active"})
.sort("age", descending=True)
.skip(20)
.limit(10)
.to_list()
)
```
**Builder methods** (each returns a new `Query`):
| Method | Description |
|---|---|
| `.sort(field, descending=False)` | Sort by field |
| `.skip(n)` | Skip the first n results |
| `.limit(n)` | Return at most n results |
| `.group(field)` | Group results by field |
| `.agg(*funcs)` | Apply aggregation functions to each group |
**Search methods** (return search result objects that yield `[(doc, score)]` tuples):
| Method | Description |
|---|---|
| `.vector_search(field, query_vector, limit=10)` | Cosine similarity vector search |
| `.text_search(field, query, limit=10)` | BM25 full-text search |
| `.hybrid_search(text_field, vec_field, query_text, query_vector, limit=10)` | RRF fusion of BM25 + vector |
| `.semantic(field, query, limit=10)` | Autoembedding-based semantic search (requires `auto_embed` config) |
**Terminal methods** (trigger execution):
| Method | Returns |
|---|---|
| `.to_list()` | `list[dict]` |
| `.first()` | `dict` or `None` |
| `.count()` | `int` |
| `.to_df()` | `pandas.DataFrame` (requires pandas) |
## Filter Operators
### Comparison
```python
{"age": 30} # implicit $eq
{"age": {"$eq": 30}} # explicit $eq
{"age": {"$ne": 30}} # not equal
{"age": {"$gt": 25}} # greater than
{"age": {"$gte": 25}} # greater than or equal
{"age": {"$lt": 40}} # less than
{"age": {"$lte": 40}} # less than or equal
{"age": {"$gte": 25, "$lt": 40}} # range
{"status": {"$in": ["active", "trial"]}}
{"status": {"$nin": ["expired", "archived"]}}
```
### Logical
```python
{"$and": [{"age": {"$gt": 25}}, {"status": "active"}]}
{"$or": [{"status": "active"}, {"status": "trial"}]}
{"$not": {"status": "archived"}}
```
### Element
```python
{"email": {"$exists": True}} # field must be present
{"email": {"$exists": False}} # field must be absent
```
### Array
```python
# At least one element of 'tags' equals "vip"
{"tags": {"$elemMatch": {"$eq": "vip"}}}
# At least one element of 'scores' is > 90
{"scores": {"$elemMatch": {"$gt": 90}}}
# At least one element of 'items' matches a sub-document filter
{"items": {"$elemMatch": {"product": "xyz", "qty": {"$gt": 5}}}}
```
## Aggregation
```python
from moofile import count, sum, mean, min, max, collect, first, last
results = (
db.find({"status": "active"})
.group("city")
.agg(
count(),
mean("age"),
sum("revenue"),
min("created_at"),
max("created_at"),
)
.sort("count", descending=True)
.limit(10)
.to_list()
)
```
| Function | Output field | Description |
|---|---|---|
| `count()` | `"count"` | Number of documents in group |
| `sum("field")` | `"sum_field"` | Sum of field values |
| `mean("field")` | `"mean_field"` | Arithmetic mean |
| `min("field")` | `"min_field"` | Minimum field value |
| `max("field")` | `"max_field"` | Maximum field value |
| `collect("field")` | `"collect_field"` | List of all values |
| `first("field")` | `"first_field"` | First value encountered |
| `last("field")` | `"last_field"` | Last value encountered |
## Autoembedding & Semantic Search (v0.5.0+, Rust core only)
MooFile runs **voyage-4-nano** on-device through ONNX Runtime via the `auto_embed`
parameter โ no external embedding APIs. Requires the native extension
(`moofile._NATIVE_LOADED`); the pure-Python fallback raises `NotImplementedError`.
> โ ๏ธ **Only `voyage-4-nano` is supported** (moofile >= 1.2.0). `model` is OPTIONAL and
> defaults to voyage-4-nano (auto-downloaded from `onnx-community/voyage-4-nano-ONNX`,
> ~422 MB, to `~/.cache/moofile/models/`). Old `hf:...gguf` URIs and fastembed registry
> ids are **rejected** with guidance. A local directory with `model_quantized.onnx` +
> `tokenizer.json` also works. Do not substitute other models.
> โ ๏ธ **Check capability before using auto_embed.** Not all builds have the native extension:
> ```python
> import moofile, inspect
> print(f"version={moofile.__version__}, native={moofile._NATIVE_LOADED}")
> sig = inspect.signature(moofile.Collection.__init__)
> has_autoembed = "auto_embed" in sig.parameters
> print(f"auto_embed available: {has_autoembed}")
> ```
```python
db = Collection("docs.bson",
vector_indexes={"embedding": 512},
auto_embed={
"content": { # source text field
"target": "embedding", # target vector field
"dims": 512, # 2048/1024/512/256 (MRL truncation); 512d/int8 is the recommended starting point
"max_length": 1024, # tokenizer cap (default 1024, max 32768)
"precision": "int8", # "f32" | "int8" | "uint8" | "binary"
"normalize": True,
"query_prefix": "Represent the query for retrieving supporting documents: ",
"doc_prefix": "",
},
})
```
`dims` below the model's 2048 is deliberate MRL truncation (model trained for
2048/1024/512/256); dims above 2048 is a config error. A/B on BotTalk's corpus
(63 docs, int8): hybrid NDCG@5 256=.788, 512=.878, 1024=.839, 2048=.913 with
identical Recall@5 โ truncation costs ranking precision, not recall, and 512 is
the quality/size sweet spot. `max_length` caps tokens
truncated (memory guard: mask is 16ยทTยฒยท4 bytes โ 1024 โ 67 MB, 32k โ 64 GB and OOMs).
`precision` quantizes each stored value and stores the dequantized result (on-disk is
always 8-byte doubles), so stored == compared vectors bit-for-bit.
**On insert/update:** if a document has a source text field, MooFile automatically generates
the embedding and stores it in the target field.
### Semantic search
```python
results = db.find({"year": {"$gte": 2024}}).semantic("content", "deep learning", 5).to_list()
# Returns [(doc, score), ...] โ same format as vector_search
```
The query text is automatically prefixed with `query_prefix` and embedded using the configured model.
**Hybrid search with autoembedding โ pass `None` for query_vector:**
```python
results = db.find({}).hybrid_search("content", "content", "deep learning", None, 10).to_list()
# The vector leg auto-embeds "deep learning" from query_text
```
> โ ๏ธ **`hybrid_search(..., query_vector=None)` requires the native extension to support
> auto-embedding in the hybrid path.** Not all builds do. If it raises
> `TypeError: 'NoneType' object cannot be converted to 'Sequence'`, fall back to
> `.semantic()` for the vector leg and fuse results manually, or generate the query
> vector yourself via `.semantic()` and pass it to `hybrid_search()`.
**Precision comparison (2048-dim vector):**
| Precision | Size | Quality |
|-----------|------|---------|
| `f32` | 4.0 KB | Baseline |
| `int8` | 1.0 KB (25%) | ~1.0000 cosine sim |
| `uint8` | 1.0 KB (25%) | ~1.0000 cosine sim |
| `binary` | 128 B (3.1%) | ~0.9999 cosine sim |
**Error handling:**
- `.semantic()` on an unconfigured source field raises `MooFileError`
- Missing model file raises `MooFileError(ModelNotFound)`
### Changing the embedding model โ `reembed(source_field)` (v1.1.0+)
Vectors of different widths cannot be compared. If you change `dims`/`precision`/model,
the stored vectors no longer match the declared index width, and at open moofile:
1. logs a warning and **disables** that vector index; searching it raises a
`VectorIndexDisabled`-style error naming the expected width, found width and
affected doc count (never a silent empty result set), and
2. waits for you to call `db.reembed("source_field")` โ the recovery path. It
rewrites every stored vector at the new width, retargets the index + `.meta`
entry, and clears the disabled flag. Returns the doc count.
`reembed()` is **never implicit** on open (whole-collection write, minutes on big
collections; a typo'd model would destroy the old vectors). Usage:
```python
# open with the NEW config (vector_indexes + auto_embed), service stopped
db.reembed("content") # source text field, not the vector field
```
Re-embedding is batched, so it is several times faster per doc than re-inserting.
Then compact() to reclaim the dead old vectors (dead_ratio often jumps past 0.30).
## Atomic Batch Writes
The `batch()` context manager buffers all write operations and applies them atomically on commit โ a single storage append, a single flush/fsync, all index mutations applied together.
```python
with db.batch() as b:
db.insert({"name": "alice", "status": "active"})
db.update_one({"name": "bob"}, set={"status": "active"})
db.delete_one({"name": "charlie"})
# All three operations committed atomically
```
**Properties:**
- **Transactional visibility**: reads within the batch see the pre-batch state
- **Batched I/O**: all records appended in a single write with one flush/fsync
- **Rollback on exception**: if the `with` block raises, the batch is discarded entirely
- **Crash semantics**: a crash mid-batch may commit a prefix (same as per-record semantics)
## Maintenance
```python
# Compaction โ reclaim space from dead records (tombstones + old versions)
db.compact() # When dead_ratio exceeds ~0.30
# Reindex โ rebuild in-memory indexes from scratch
db.reindex()
# Force an fsync of the data file
db.sync()
# Stats
print(db.stats())
# {'documents': 42150, 'dead_records': 3201, 'file_size_bytes': 8421000, 'dead_ratio': 0.07}
```
## CLI Tools (installed with package)
| Tool | Purpose |
|------|---------|
| `moosh data.bson` | Interactive Python REPL with `db` pre-loaded |
| `moo2json data.bson data.json` | Export/import between BSON and JSON (or NDJSON) |
| `moo2mongo data.bson --uri mongodb://...` | Export/import to/from MongoDB |
| `moo2sqlite data.bson data.db` | Export/import to/from SQLite |
### moosh โ interactive shell
```
moosh [--indexes FIELDS] [--readonly] <collection.bson>
```
Inside the shell: `db`, `count`, `sum`, `mean`, `min`, `max`, `collect`, `first`, `last`, and exception classes are pre-loaded.
### moo2json
```
moo2json [--import] [--indexes FIELDS] [--quiet] <src> <dst>
```
Export (no flag) or import (--import). Use `-` for stdout/stdin.
### moo2mongo
```
moo2mongo [--import] --uri <uri> --collection <name> [--drop] [--indexes FIELDS] [--quiet] <collection.bson>
```
### moo2sqlite
```
moo2sqlite [--import] [--table <name>] [--drop] [--indexes FIELDS] [--quiet] <src> <dst>
```
Nested docs/arrays are flattened to JSON strings in SQLite; restored on import.
## Projects Using MooFile
- A model-management web UI โ stores model configs in `models.bson`
- A clipboard-sharing service โ stores clip data in a BSON collection
## Error Handling
```python
from moofile import (
MooFileError, # Base exception
DuplicateKeyError, # Insert with existing _id
DocumentNotFoundError, # update_one/replace_one with no match
ReadOnlyError, # Write on read-only collection
ConcurrentAccessError, # Rust backend: raised on concurrent process access; Python backend: blocks via flock
InvalidIdError, # Non-string _id provided
InvalidFilterError, # Malformed query filter
)
try:
db.insert({"_id": "abc", "name": "test"})
except DuplicateKeyError:
print("Already exists!")
```
## _id Behavior
- **Auto-generated type**: 24-character hex string (e.g., `"507f1f77bcf86cd799439011"`)
- **Custom _id**: Must be a **string** โ `_id` is enforced as a string by both backends. Non-string `_id` values raise `InvalidIdError`. (The Rust backend silently skips non-string `_id` records on replay, so both backends reject them up front.)
- **Always present**: `_id` is populated on all returned documents after insert
- **Uniqueness**: Enforced at insert time โ duplicates raise `DuplicateKeyError`
- **Preserved**: `_id` cannot be changed by updates, always preserved during `replace_one()`
## Index Usage
MooFile uses an index automatically when a filter's top-level field is indexed:
```python
db = Collection("data.bson",
indexes=["email", "age"],
vector_indexes={"embedding": 384},
text_indexes=["content"])
# Regular field indexes โ O(log n) lookup
db.find({"email": "alice@example.com"})
db.find({"age": {"$gt": 25}})
# Vector search โ O(n) cosine similarity
db.find({}).vector_search("embedding", query_vector)
# Text search โ BM25 scoring
db.find({}).text_search("content", "machine learning")
# Full scan โ 'name' is not indexed
db.find({"name": "Alice"})
```
**Index rules:**
- **Regular indexes**: Only top-level fields (no nested paths in v1)
- **Vector indexes**: Brute-force cosine similarity on all vectors
- **Text indexes**: BM25 scoring with Porter stemming
- **Autoembedding** (v0.5.0+): Configure via `auto_embed={}` to auto-generate embeddings on insert using local GGUF models
- `_id` is always available for fast lookup regardless of declared indexes
- All indexes are rebuilt in memory on every open (or restored from the disposable `.bson.cache` when valid)
- Declaring additional indexes is cheap โ just reopen the collection
- **The cache is NEVER a source of truth** โ safe to delete at any time. A cache written by the Rust engine (bincode) is rejected by the Python engine (pickle) and vice versa; cross-implementation portability is maintained through the BSON file, not the cache.
## Empty/Edge Case Behavior
- **find() with no matches**: `to_list()` โ `[]`, `first()` โ `None`, `count()` โ `0`
- **find_one() with no matches**: โ `None`
- **count()/exists() with no matches**: โ `0` / `False`
- **update_many() with no matches**: โ `0` (count of updated docs, not an error)
- **group().agg() with no documents**: โ `[]` (empty list, no group rows created)
## Best Practices for Agents
When using moofile in code:
1. **Always call `.to_list()`** (or `.first()`, `.count()`) โ `find()` returns a lazy `Query` object, not results
2. **Use context managers** โ `with Collection(...) as db:` ensures file is closed
3. **Pass `set=` as keyword** โ `update_one(filter, set={"field": val})` NOT `update_one(filter, {"$set": {"field": val}})`
4. **Handle `DocumentNotFoundError`** โ `update_one()`/`replace_one()` raise if no match
5. **Compact periodically** โ dead records accumulate silently (check `dead_ratio` via `db.stats()`)
6. **Check `_id` collisions** โ insert auto-generates `_id` if absent; duplicate raises `DuplicateKeyError`
7. **Batch for atomicity** โ use `db.batch()` context manager for multi-write transactions
8. **Cross-process write safety** โ Python backend uses blocking `flock(LOCK_EX)` (waits for concurrent writes); Rust backend raises `ConcurrentAccessError`. Either way, writes are serialized across processes.