---
title: "Build a RAG App on Nano v3.32: From fastembed to a Persistent, Compressed Vector Index in 80 Lines of Rust"
description: "End-to-end Rust walkthrough: fastembed BGE-Small embeddings, PersistentVectorIndex::create_with_pq, exact two-stage rerank, online deletes with remove() + compact(), restart proof, and the updated Nano vector skill for Claude Code and Codex."
date: 2026-06-01
updated: 2026-06-01
tag: "Tutorial"
canonical: https://www.heliosdb.com/blog/rag-app-on-nano-3-32-in-80-lines-of-rust.html
---

# Build a RAG App on Nano v3.32: From fastembed to a Persistent, Compressed Vector Index in 80 Lines of Rust

v3.32 ships everything you need to run a production-shaped RAG indexer inside a single Rust process: in-process embeddings via `fastembed`, a persistent PQ-HNSW index, exact two-stage rerank, online deletes, and restart-survivable state. This post walks through the whole thing end-to-end. No Pinecone, no Qdrant, no separate ingest service.

## 1. Dependencies

```
[dependencies]
heliosdb-nano = { version = "3.32.1", features = ["vector-persist"] }
fastembed     = "4"
anyhow        = "1"
```

Two crates do the work. `fastembed` downloads and runs BGE-Small-EN-V1.5 in-process (dim=384, normalised); `heliosdb-nano` stores and searches the vectors. The `vector-persist` feature unlocks the new `PersistentVectorIndex` path — without it, you get the existing in-RAM HNSW.

## 2. Open the index (or create it)

```
use heliosdb_nano::vector::persistent::PersistentVectorIndex;
use heliosdb_nano::vector::{DistanceMetric, PqHnswConfig};

fn open_index(path: &str) -> anyhow::Result<PersistentVectorIndex> {
    if PersistentVectorIndex::exists(path)? {
        Ok(PersistentVectorIndex::open(path)?)
    } else {
        let cfg = PqHnswConfig::new(384, DistanceMetric::L2);
        Ok(PersistentVectorIndex::create_with_pq(path, cfg)?)
    }
}
```

**Note the config.** On v3.32.1+ the defaults are recall-safe — `create_with_pq` with a plain `PqHnswConfig::new(dim, metric)` picks ~4 dimensions per sub-vector (~16× compression) automatically. On v3.32.0 you had to set `num_subquantizers = dim / 4` explicitly or recall collapsed; this is fixed in the patch release. Upgrade.

## 3. Embed and insert

```
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};

fn ingest(index: &mut PersistentVectorIndex, docs: Vec<(u64, String)>)
    -> anyhow::Result<()>
{
    let embedder = TextEmbedding::try_new(
        InitOptions::new(EmbeddingModel::BGESmallENV15)
    )?;
    let texts: Vec<&str> = docs.iter().map(|(_, t)| t.as_str()).collect();
    let vectors = embedder.embed(texts, None)?;
    for ((id, _), vec) in docs.iter().zip(vectors.iter()) {
        index.insert(*id, vec.as_slice())?;
    }
    index.flush()?;
    Ok(())
}
```

Because BGE-Small produces L2-normalised vectors, cosine similarity collapses to L2 distance — the `DistanceMetric::L2` in step 2 is the right choice (PQ is L2-only). `flush()` commits the RocksDB batch; the index is now durable.

## 4. Query

```
fn query(index: &PersistentVectorIndex, embedder: &TextEmbedding, q: &str, k: usize)
    -> anyhow::Result<Vec<(u64, f32)>>
{
    let q_vec = embedder.embed(vec![q], None)?.into_iter().next().unwrap();
    let hits = index.search(&q_vec, k, /* ef */ 100)?;
    Ok(hits)
}
```

`search` traverses the graph using PQ codes (ADC), then reranks the candidates against full disk-resident vectors. You get exact top-k ordering. No extra plumbing.

## 5. Filtered queries

If your corpus is multi-tenant, post-filtering a top-k is wrong — you may return zero rows for one tenant while another has thousands. `search_filtered` evaluates the predicate inside the traversal:

```
let tenant_id: u64 = 42;
let hits = index.search_filtered(
    &q_vec, k, /* ef */ 100,
    |row_id| row_meta.get(&row_id).map(|m| m.tenant_id) == Some(tenant_id),
)?;
```

## 6. Deletes that don't rot recall

```
index.remove(doc_id)?;       // repairs neighbours immediately
if index.tombstone_ratio() > 0.2 {
    index.compact()?;        // reclaim space
}
```

Tombstone-only indexes degrade recall over time. `remove()` rewrites the affected neighbourhood; `compact()` reclaims space when the tombstone ratio gets high. Run `compact()` on a schedule, or after a large delete burst.

## 7. Restart proof

Kill the process. `PersistentVectorIndex::open(path)` on the next start restores the graph, the entry point, the per-element neighbour list, and the codebook from RocksDB. The first query is the same speed as the thousandth. The in-tree validation report's exact phrasing: "drop → open → search returns identical top-10".

## The Claude Code / Codex skill

The Nano skill bundle has an updated `heliosdb-nano-vector` skill (shipped with v3.32.x) that gives Claude Code, Codex, or any MCP-aware agent a copy-pasteable recipe for exactly this flow: open-or-create, ingest, query, filtered query, delete + compact. Install with `heliosdb-nano skills install` (see [Drop a Database into Claude Code in 30 Seconds](https://www.heliosdb.com/blog/claude-code-database-skill-bundles.html) for the broader skill-bundle story). The agent reads the skill and writes you an end-to-end indexer without reading the README.

## What this replaces

An external Pinecone or Qdrant tier; a vector-rebuild job after every restart; a tombstone-only delete path; a separate filter service running over the top-k results. All four collapse into one in-process index that survives restart, compresses 16×, and reranks exactly.

HeliosDB Nano is Apache 2.0. Repository: [github.com/dimensigon/HDB-HeliosDB-Nano](https://github.com/dimensigon/HDB-HeliosDB-Nano).
