Semantic Search CLI Notebook in Rust

Build a CLI that finds similar notes by meaning using vector embeddings.

Introduction

30 Second Summary

You search your notes for "tired" but the one about being "burnt out after that marathon meeting" never shows up. Traditional search matches keywords, not meaning.

In this project, you will build a Rust CLI tool that stores personal notes and retrieves them by meaning using real machine learning embeddings and vector search.

What You'll Build

A command-line notebook called notevec that converts text into 384-dimensional vectors and uses cosine distance to find semantically similar notes.

By the end of this project, you'll have:

  • A Rust binary with 4 commands: add, search, list, and forget.
  • A SQLite database with a regular table for notes and a vec0 virtual table for storing embeddings.
  • An embedding pipeline that converts text to 384-dimensional vectors offline using MiniLM-L6-v2.
  • A KNN search that finds the top 3 most similar notes by cosine distance.
  • Secret Mission: Add a similar command that shows related notes for a given note ID with similarity scores displayed as percentages.

Are there any prerequisites?

You should be comfortable with Rust fundamentals: structs, enums, Result/Option, traits, and modules. Basic SQL knowledge (CREATE TABLE, INSERT, SELECT, JOIN) is also helpful.

You'll need the Rust toolchain (cargo and rustc) installed. Everything else compiles from source or downloads automatically on first run.

Before We Start

Before We Start...

Create the Project and Install Dependencies

Before you can build a semantic search tool, you need a Rust project with the right dependencies in place.

In this step, you will scaffold a new Rust project and pull in five crates that handle everything from SQLite storage to ML-powered text embeddings.

In this step, get ready to:
  • Create the notevec project with Cargo.
  • Add all required dependencies to Cargo.toml.
  • Verify everything compiles successfully.
Create the Rust project
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project by running this command:
cargo new notevec

What does this command do?

cargo new notevec scaffolds a new Rust binary project inside a folder called notevec.

It creates a Cargo.toml manifest (your project's config) and a src/main.rs file with a default Hello World program.

  • Move into the new project folder by running:
cd notevec
  • Confirm the project files exist by running:
ls

You should see Cargo.toml and a src folder in the output.

Don't see Cargo.toml?

Make sure you ran cargo new notevec from your Desktop. If you see a "could not create directory" error, check that you have write permissions in your current folder.

help me troubleshoot why cargo new isn't creating my project

Add the project dependencies

Your project needs five crates to handle database storage, vector search, text embeddings, CLI parsing, and timestamps.

  • Open Cargo.toml in your code editor.
  • Replace the entire contents of the file with the following:
[package]
name = "notevec"
version = "0.1.0"
edition = "2021"

[dependencies]
rusqlite = { version = "0.39.0", features = ["bundled"] }
sqlite-vec = "0.1.9"
fastembed = "5.13.4"
clap = { version = "4.6.1", features = ["derive"] }
chrono = "0.4"

What do these dependencies do?

  • rusqlite is a Rust binding for SQLite. The bundled feature compiles SQLite from source so you don't need it installed separately.
  • sqlite-vec adds vector search capabilities to SQLite through a loadable extension.
  • fastembed runs the MiniLM-L6-v2 model locally to convert text into 384-dimensional vector embeddings.
  • clap with the derive feature lets you define CLI commands using Rust structs and enums.
  • chrono provides timestamp formatting for your notes.
  • Save Cargo.toml.

Seeing red squiggles in your editor?

Your editor may flag the dependencies as unresolved until you run cargo build in the next substep. This is normal.

help me fix errors in my Cargo.toml file

✔️ Awesome, I've got everything!

Great. Double check you have saved Cargo.toml before moving on.

ⓧ I'd like to double check the full code

[package]
name = "notevec"
version = "0.1.0"
edition = "2021"

[dependencies]
rusqlite = { version = "0.39.0", features = ["bundled"] }
sqlite-vec = "0.1.9"
fastembed = "5.13.4"
clap = { version = "4.6.1", features = ["derive"] }
chrono = "0.4"
Build the project to verify your setup

The first build downloads the MiniLM embedding model (~23MB) and compiles SQLite from C source. Expect this to take 2-5 minutes.

  • Compile the project by running this command in your terminal:
cargo build

Why does the first build take so long?

Cargo is doing three heavy tasks behind the scenes. It downloads all five crates plus their transitive dependencies. It compiles SQLite from C source (because of the bundled feature). It also downloads the ONNX runtime that fastembed uses to run the ML model.

Future builds will be much faster because all of this is cached.

When it finishes, you should see a line ending with Finished and no error messages.

Build failed with an error?

If you see a compilation error mentioning a specific crate version, double check that your Cargo.toml matches the version numbers exactly. A typo in the version string is the most common cause.

If you see a network timeout, try running cargo build again. Cargo will resume where it left off.

help me debug my cargo build failure

Your project is ready to go. Next up, you will learn how vector embeddings and semantic search actually work before writing any application code.

Understand Embeddings and Vector Search

Your project compiles and all five dependencies are locked in. Before writing any Rust code, let's build a mental model of the three concepts that make semantic search work.

Together, these concepts form the "embed-store-search" loop. Text goes into an embedding model, the resulting vector gets stored in a database, and a query vector finds the closest matches. Every RAG system and AI memory layer uses this same pattern.

In this step, get ready to:
  • Understand what vector embeddings are and how they represent meaning as numbers.
  • Learn how cosine distance measures similarity between two vectors.
  • Learn how sqlite-vec's vec0 virtual tables store and query vectors with KNN search.
Understand what vector embeddings are

A vector embedding is a fixed-length array of floating-point numbers that represents the meaning of a piece of text. You feed a sentence into an embedding model, and it outputs an array of numbers.

The key insight is that similar meanings produce similar arrays. "I'm exhausted" and "couldn't sleep last night" end up close together in this number space, even though they share zero keywords.

What does 384 dimensions mean?

Each dimension is one floating-point number. So every sentence your CLI processes becomes an array of 384 floats, like [0.032, -0.118, 0.541, ...].

The fastembed crate runs this model offline using ONNX inference. No API calls, no network needed after the first model download.

Why MiniLM instead of a larger model?

MiniLM-L6-v2 is a small model (22.7M parameters) that runs fast on a CPU. It produces high-quality sentence embeddings without requiring a GPU. For a CLI tool that runs locally, speed and simplicity matter more than squeezing out marginal accuracy gains from a larger model.

Understand cosine distance

Once you have two vectors, you need a way to measure how similar they are. Cosine distance measures the angle between two vectors, ignoring their length.

The scale goes from 0 to 2. A distance of 0 means the vectors point in the exact same direction (identical meaning). A distance of 2 means they point in opposite directions.

How does sqlite-vec use cosine distance?

When you declare a vec0 table with distance_metric=cosine, sqlite-vec automatically uses cosine distance during MATCH queries. You never have to compute the distance yourself.

The query results come back sorted by distance, so the most semantically similar notes appear first.

Understand vec0 virtual tables

SQLite has a feature called virtual tables that lets extensions define custom storage and query behaviour. You may have seen FTS5 for full-text search. The sqlite-vec extension provides vec0 virtual tables that work the same way, but for vectors.

The pattern has three parts: declare the table, insert vectors as binary blobs, and query with MATCH plus a k constraint for KNN search.

What does the vec0 syntax look like?

To create a vec0 table, you declare vector columns with their dimensions and distance metric:

CREATE VIRTUAL TABLE note_embeddings USING vec0(
    note_id   integer primary key,
    embedding float[384] distance_metric=cosine
);

To find the 3 nearest neighbours to a query vector, you use MATCH and k = 3:

SELECT note_id, distance
FROM note_embeddings
WHERE embedding MATCH ?1
    AND k = 3
ORDER BY distance;

The ?1 placeholder holds the raw bytes of your query vector. sqlite-vec handles the distance calculation and returns results sorted from most to least similar.

How does this map to the notevec project?

Your CLI will have two tables: a regular notes table for content and timestamps, and a note_embeddings vec0 table for the 384-dimensional vectors. When you search, the KNN query finds matching vectors, then a JOIN brings back the human-readable note content.

This two-table pattern is the same architecture used in production vector databases and RAG pipelines.

You now have the mental model for everything your CLI will do under the hood. Next up, you'll turn these concepts into real Rust code by building the CLI skeleton with clap.

Build the CLI Skeleton with Clap

You understand how embeddings and vector search work conceptually. Now it's time to build the interface that lets you interact with your notebook.

Every CLI needs a way to parse commands from the terminal. Clap's derive macros let you define your commands as Rust structs and enums. The library generates all the argument parsing, help text, and error handling automatically.

In this step, get ready to:
  • Define a CLI struct and command enum using clap derive macros.
  • Wire up the main function to parse and dispatch commands.
  • Test argument parsing with placeholder output.
Define the CLI struct and Commands enum

Clap's derive API lets you model your CLI as data. You declare a struct for the top-level parser and an enum for the available subcommands. Each enum variant becomes a command the user can run.

  • Open src/main.rs in your editor.
  • Delete all existing content (the hello-world code) and replace it with the following:
use clap::{Parser, Subcommand};

// Top-level CLI struct that clap parses from terminal arguments
#[derive(Parser)]
#[command(name = "notevec", about = "A semantic search notebook")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

// Each variant becomes a subcommand (e.g. `notevec add`, `notevec search`)
#[derive(Subcommand)]
enum Commands {
    /// Add a new note
    Add { text: String },
    /// Search notes by meaning
    Search { query: String },
    /// List all notes
    List,
    /// Wipe all notes
    Forget,
}

What does this code do?

The #[derive(Parser)] macro turns your Cli struct into a full argument parser. The #[command] attribute sets your binary's name and description.

The Commands enum defines four subcommands. Add and Search take string arguments, while List and Forget take none. The /// doc comments become help text when users run notevec --help.

Wire up main() to dispatch commands

The struct and enum define the shape of your CLI. Now you need a main() function that actually parses the terminal input and routes to the right handler.

  • Add this main() function below the Commands enum in src/main.rs:
fn main() {
    // Parse CLI arguments into our Cli struct
    let cli = Cli::parse();

    // Route to the correct handler based on which subcommand was used
    match cli.command {
        Commands::Add { text } => println!("[placeholder] add: {}", text),
        Commands::Search { query } => println!("[placeholder] search: {}", query),
        Commands::List => println!("[placeholder] list"),
        Commands::Forget => println!("[placeholder] forget"),
    }
}

What does this code do?

Cli::parse() reads the arguments from your terminal and populates the struct. If the user provides invalid input, clap automatically prints an error and exits.

The match expression destructures the command enum. Each arm prints a placeholder message for now. You'll replace these with real logic in the next steps.

  • Save src/main.rs.
Test the CLI with cargo run

With the skeleton in place, you can verify that clap correctly parses arguments and routes them to the right match arm.

  • Test the add command by running:
cargo run -- add "test note"
  • You should see:
[placeholder] add: test note
  • Test the search command by running:
cargo run -- search "test query"
  • You should see:
[placeholder] search: test query

The double-dash separator

The -- between cargo run and your arguments tells cargo "everything after this is for my binary, not for cargo itself." Without it, cargo tries to interpret add as a cargo subcommand.

Seeing a compile error?

Make sure you deleted the entire original main() function before adding the new code. Having two main() functions causes a duplicate definition error.

Also check that use clap::{Parser, Subcommand}; is on the very first line of the file. Missing imports produce "cannot find derive macro" errors.

help me fix a compile error in my clap CLI skeleton

✔️ Awesome, I've got everything!

Great. Make sure src/main.rs is saved.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "notevec", about = "A semantic search notebook")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new note
    Add { text: String },
    /// Search notes by meaning
    Search { query: String },
    /// List all notes
    List,
    /// Wipe all notes
    Forget,
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Add { text } => println!("[placeholder] add: {}", text),
        Commands::Search { query } => println!("[placeholder] search: {}", query),
        Commands::List => println!("[placeholder] list"),
        Commands::Forget => println!("[placeholder] forget"),
    }
}

Your CLI skeleton is wired up and parsing arguments. Next up, you'll build the database layer that stores notes and their vector embeddings in SQLite.

Build the Database Layer

Your CLI can parse commands, but it has nowhere to store notes or their embeddings. In this step, you'll build the persistence layer that ties everything together.

You'll use SQLite as the storage engine, extended with sqlite-vec for vector operations. The two-table design (one for note content, one for embeddings) mirrors how production vector databases separate metadata from vector data.

In this step, get ready to:
  • Register the sqlite-vec extension and create the database tables.
  • Write insert and list helper functions for notes.
  • Write a KNN search function using vec0 virtual tables.
Write the database initialization function

Before any notes can be stored, the app needs to open a SQLite database and register the sqlite-vec extension. The init_db() function handles all of this in one place.

  • In src/main.rs, find the existing import line at the top of the file:
use clap::{Parser, Subcommand};
  • Add these three import lines directly below it:
use chrono::Local;
use rusqlite::{ffi::sqlite3_auto_extension, Connection, Result as SqlResult};
use sqlite_vec::sqlite3_vec_init;

What do these imports bring in?

  • chrono::Local gives you the current local timestamp for each note.
  • ffi::sqlite3_auto_extension is the low-level SQLite function that registers extensions before any connection opens.
  • Connection and Result as SqlResult are rusqlite's core types for database access and error handling.
  • sqlite3_vec_init is the C entry point for the sqlite-vec extension.
  • Now scroll down past the Commands enum (after the closing } of the enum) and add the following functions above fn main():
fn db_path() -> String {
    let mut path = dirs_next_or_home();
    path.push_str("/notevec.db");
    path
}

fn dirs_next_or_home() -> String {
    std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
}

fn init_db() -> SqlResult<Connection> {
    // Register sqlite-vec as an auto-loaded extension
    unsafe {
        sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ())));
    }
    // Open (or create) the database file
    let db = Connection::open(db_path())?;
    // Create the notes table and the vec0 virtual table for embeddings
    db.execute_batch(
        "CREATE TABLE IF NOT EXISTS notes (
            id        INTEGER PRIMARY KEY,
            content   TEXT NOT NULL,
            timestamp TEXT NOT NULL
        );
        CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(
            note_id   integer primary key,
            embedding float[384] distance_metric=cosine
        );"
    )?;
    Ok(db)
}

fn vec_to_bytes(v: &[f32]) -> &[u8] {
    // Reinterpret the f32 slice as raw bytes for sqlite-vec
    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 4) }
}

What does this code do?

db_path() and dirs_next_or_home() resolve the database file to $HOME/notevec.db. If the HOME environment variable is missing, it falls back to the current directory.

init_db() does three things: it registers sqlite-vec via the FFI auto-extension mechanism, opens the database file, then creates two tables. The notes table stores human-readable content. The note_embeddings table is a vec0 virtual table that stores 384-dimensional float vectors with cosine distance built in.

vec_to_bytes() converts a &[f32] slice into raw bytes. sqlite-vec expects embedding data as contiguous little-endian f32 bytes, which is exactly what this gives on x86 and ARM.

  • Save src/main.rs.
  • Verify it compiles by running:
cargo build

You should see a successful compilation with no errors. Warnings about unused imports or functions are fine at this stage.

Seeing a compile error?

  • If you see an "unresolved import" error for sqlite_vec, double-check that Cargo.toml has sqlite-vec = "0.1.9" (hyphen in the package name, underscore in Rust imports).
  • If the error mentions ffi::sqlite3_auto_extension, ensure your rusqlite dependency includes features = ["bundled"].

Help me fix this compile error.

Write the insert and list helper functions

With the database initialized, you need functions to write notes in and read them back out. The insert_note() function wraps both table inserts in a single transaction so they either both succeed or both roll back.

  • In src/main.rs, add the following function directly below vec_to_bytes() (and above fn main()):
fn insert_note(db: &Connection, content: &str, embedding: &[f32]) -> SqlResult<()> {
    // Timestamp the note with the current local time
    let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
    // Use a transaction so both inserts succeed or both fail
    let tx = db.unchecked_transaction()?;
    tx.execute(
        "INSERT INTO notes (content, timestamp) VALUES (?1, ?2)",
        rusqlite::params![content, timestamp],
    )?;
    // Get the auto-generated ID to link the embedding
    let note_id = tx.last_insert_rowid();
    tx.execute(
        "INSERT INTO note_embeddings (note_id, embedding) VALUES (?1, ?2)",
        rusqlite::params![note_id, vec_to_bytes(embedding)],
    )?;
    tx.commit()?;
    Ok(())
}

What does this code do?

unchecked_transaction() starts a database transaction on an immutable &Connection reference. If either INSERT fails, the entire transaction rolls back automatically.

last_insert_rowid() grabs the auto-incremented ID from the first insert. That ID becomes the note_id foreign key in the embeddings table, linking the vector back to its note.

  • Now add the list_notes() function directly below insert_note():
fn list_notes(db: &Connection) -> SqlResult<Vec<(i64, String, String)>> {
    // Select all notes, newest first
    let mut stmt = db.prepare(
        "SELECT id, content, timestamp FROM notes ORDER BY timestamp DESC"
    )?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i64>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, String>(2)?,
        ))
    })?;
    rows.collect()
}

What does this code do?

list_notes() prepares a SELECT statement, maps each row into a tuple of (id, content, timestamp), and collects the results into a Vec. The ORDER BY timestamp DESC ensures the newest notes appear first.

  • Add the forget_all() function directly below list_notes():
fn forget_all(db: &Connection) -> SqlResult<()> {
    // Drop and recreate both tables for a clean slate
    db.execute_batch(
        "DROP TABLE IF EXISTS note_embeddings;
         DROP TABLE IF EXISTS notes;
         CREATE TABLE notes (
            id        INTEGER PRIMARY KEY,
            content   TEXT NOT NULL,
            timestamp TEXT NOT NULL
         );
         CREATE VIRTUAL TABLE note_embeddings USING vec0(
            note_id   integer primary key,
            embedding float[384] distance_metric=cosine
         );"
    )?;
    Ok(())
}

Why drop and recreate instead of DELETE?

vec0 virtual tables do not support DELETE FROM without a WHERE clause. Dropping and recreating both tables guarantees a clean slate regardless of the table type. The embeddings table must be dropped first because it references note_id from the notes table.

  • Save src/main.rs and verify it compiles by running:
cargo build

You should see a successful compilation again. The unused-function warnings remain until you wire these up in the next step.

Seeing a type mismatch error?

  • Make sure insert_note returns SqlResult<()> (the type alias you imported), not Result<(), rusqlite::Error>.
  • Check that the vec_to_bytes() function is defined above insert_note() since it's called inside that function.

Help me fix my type error in the database helpers.

Write the KNN search function

The search function is the core of the semantic notebook. It takes an embedding vector, queries the vec0 table using the MATCH keyword with a k constraint, and JOINs back to the notes table to return human-readable results alongside their distance scores.

  • In src/main.rs, add the search_similar() function between insert_note() and list_notes():
fn search_similar(db: &Connection, query_embedding: &[f32], k: usize) -> SqlResult<Vec<(i64, String, String, f64)>> {
    // Query the vec0 table using MATCH for KNN search
    let mut stmt = db.prepare(
        "SELECT
            note_embeddings.note_id,
            notes.content,
            notes.timestamp,
            note_embeddings.distance
        FROM note_embeddings
        JOIN notes ON notes.id = note_embeddings.note_id
        WHERE embedding MATCH ?1
            AND k = ?2
        ORDER BY distance"
    )?;
    // Pass the embedding as raw bytes and k as the result count
    let rows = stmt.query_map(
        rusqlite::params![vec_to_bytes(query_embedding), k as i64],
        |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, f64>(3)?,
            ))
        },
    )?;
    rows.collect()
}

What does this code do?

The WHERE embedding MATCH ?1 AND k = ?2 clause is sqlite-vec's syntax for K-nearest-neighbor search. It finds the k vectors closest to your query vector using the cosine distance metric you set at table creation.

The JOIN pulls in the note's text content and timestamp so you get meaningful results, not just IDs. The distance column is a virtual column that sqlite-vec populates during the MATCH query. Lower distance means higher similarity.

  • Save src/main.rs and run a final compile check:
cargo build

You should see a clean compilation. You now have a complete database layer with five functions: init, insert, search, list, and forget.

Getting a borrow checker error?

  • Make sure search_similar() is placed between insert_note() and list_notes() in the file. Function order does not affect Rust compilation, but it keeps the code organized.
  • Verify the function signature takes query_embedding: &[f32] (a slice reference), not an owned Vec<f32>.

Help me fix this borrow checker error in search_similar.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point:

use clap::{Parser, Subcommand};
use chrono::Local;
use rusqlite::{ffi::sqlite3_auto_extension, Connection, Result as SqlResult};
use sqlite_vec::sqlite3_vec_init;

#[derive(Parser)]
#[command(name = "notevec", about = "A semantic search notebook")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new note
    Add { text: String },
    /// Search notes by meaning
    Search { query: String },
    /// List all notes
    List,
    /// Wipe all notes
    Forget,
}

fn db_path() -> String {
    let mut path = dirs_next_or_home();
    path.push_str("/notevec.db");
    path
}

fn dirs_next_or_home() -> String {
    std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
}

fn init_db() -> SqlResult<Connection> {
    unsafe {
        sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ())));
    }
    let db = Connection::open(db_path())?;
    db.execute_batch(
        "CREATE TABLE IF NOT EXISTS notes (
            id        INTEGER PRIMARY KEY,
            content   TEXT NOT NULL,
            timestamp TEXT NOT NULL
        );
        CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(
            note_id   integer primary key,
            embedding float[384] distance_metric=cosine
        );"
    )?;
    Ok(db)
}

fn vec_to_bytes(v: &[f32]) -> &[u8] {
    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 4) }
}

fn insert_note(db: &Connection, content: &str, embedding: &[f32]) -> SqlResult<()> {
    let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
    let tx = db.unchecked_transaction()?;
    tx.execute(
        "INSERT INTO notes (content, timestamp) VALUES (?1, ?2)",
        rusqlite::params![content, timestamp],
    )?;
    let note_id = tx.last_insert_rowid();
    tx.execute(
        "INSERT INTO note_embeddings (note_id, embedding) VALUES (?1, ?2)",
        rusqlite::params![note_id, vec_to_bytes(embedding)],
    )?;
    tx.commit()?;
    Ok(())
}

fn search_similar(db: &Connection, query_embedding: &[f32], k: usize) -> SqlResult<Vec<(i64, String, String, f64)>> {
    let mut stmt = db.prepare(
        "SELECT
            note_embeddings.note_id,
            notes.content,
            notes.timestamp,
            note_embeddings.distance
        FROM note_embeddings
        JOIN notes ON notes.id = note_embeddings.note_id
        WHERE embedding MATCH ?1
            AND k = ?2
        ORDER BY distance"
    )?;
    let rows = stmt.query_map(
        rusqlite::params![vec_to_bytes(query_embedding), k as i64],
        |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, f64>(3)?,
            ))
        },
    )?;
    rows.collect()
}

fn list_notes(db: &Connection) -> SqlResult<Vec<(i64, String, String)>> {
    let mut stmt = db.prepare(
        "SELECT id, content, timestamp FROM notes ORDER BY timestamp DESC"
    )?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i64>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, String>(2)?,
        ))
    })?;
    rows.collect()
}

fn forget_all(db: &Connection) -> SqlResult<()> {
    db.execute_batch(
        "DROP TABLE IF EXISTS note_embeddings;
         DROP TABLE IF EXISTS notes;
         CREATE TABLE notes (
            id        INTEGER PRIMARY KEY,
            content   TEXT NOT NULL,
            timestamp TEXT NOT NULL
         );
         CREATE VIRTUAL TABLE note_embeddings USING vec0(
            note_id   integer primary key,
            embedding float[384] distance_metric=cosine
         );"
    )?;
    Ok(())
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Add { text } => println!("[placeholder] add: {}", text),
        Commands::Search { query } => println!("[placeholder] search: {}", query),
        Commands::List => println!("[placeholder] list"),
        Commands::Forget => println!("[placeholder] forget"),
    }
}

Your database layer is complete. Next up, you'll build the embedding layer with fastembed and wire everything together so the CLI commands actually store and search real notes.

Build the Embedding Layer and Wire Everything Together

You have a database that can store and query vectors, and a CLI that parses commands. The missing piece is the bridge between human-readable text and those 384-dimensional vectors.

In this step, you'll write the embedding function that converts text into vectors using fastembed, then wire all four commands so the full add-search-list-forget loop works end to end.

In this step, get ready to:
  • Write an embedding function that turns text into a 384-dimensional vector.
  • Wire all four CLI commands to the database and embedding layers.
  • Test semantic search by adding notes and finding them by meaning, not keywords.
Write the embedding function

The fastembed crate wraps the MiniLM-L6-v2 model in an ONNX runtime. You give it a string, it returns a Vec<f32> with 384 floats representing that text's meaning.

  • In src/main.rs, find the import section at the top of the file. Add the fastembed import below the chrono line:
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
  • Now scroll down to the init_db() function. Below its closing brace and above vec_to_bytes(), add the embed_text() function:
fn embed_text(model: &TextEmbedding, text: &str) -> Vec<f32> {
    let embeddings = model.embed(vec![text], None).expect("Embedding failed");
    embeddings.into_iter().next().expect("No embedding returned")
}

What does this code do?

The fastembed import brings in three types: TextEmbedding (the model struct), InitOptions (configuration), and EmbeddingModel (the enum that selects which ML model to load).

The embed_text() function wraps fastembed's batch API. It passes a single string in a vec![], then pulls out the first (and only) embedding from the result.

  • Save src/main.rs.
  • You won't see a change yet. The next chunk wires this function into the commands.
Wire the commands in main()

Right now main() just prints placeholders. You need to initialize the database, load the embedding model, and connect each command to the real functions.

  • In src/main.rs, find the current main() function at the bottom of the file:
fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Add { text } => println!("[placeholder] add: {}", text),
        Commands::Search { query } => println!("[placeholder] search: {}", query),
        Commands::List => println!("[placeholder] list"),
        Commands::Forget => println!("[placeholder] forget"),
    }
}
  • Replace the entire main() function with this version that wires everything together:
fn main() {
    let cli = Cli::parse();

    // Open the database (creates tables if first run)
    let db = init_db().expect("Failed to initialize database");

    // Load the MiniLM-L6-v2 embedding model
    let model = TextEmbedding::try_new(
        InitOptions::new(EmbeddingModel::AllMiniLML6V2)
            .with_show_download_progress(true),
    )
    .expect("Failed to load embedding model");

    match cli.command {
        Commands::Add { text } => {
            // Embed the note text and store both text + vector
            let embedding = embed_text(&model, &text);
            insert_note(&db, &text, &embedding).expect("Failed to insert note");
            println!("Added note: \"{}\"", text);
        }
        Commands::Search { query } => {
            // Embed the query and find the 3 closest notes
            let query_embedding = embed_text(&model, &query);
            let results = search_similar(&db, &query_embedding, 3)
                .expect("Search failed");

            if results.is_empty() {
                println!("No notes found. Add some first!");
            } else {
                println!("Top matches for: \"{}\"", query);
                println!("{}", "-".repeat(50));
                for (id, content, timestamp, distance) in &results {
                    let similarity = (2.0 - distance) / 2.0 * 100.0;
                    println!("[#{}] {} ({:.1}% similar)", id, content, similarity);
                    println!("     {}", timestamp);
                }
            }
        }
        Commands::List => {
            let notes = list_notes(&db).expect("Failed to list notes");
            if notes.is_empty() {
                println!("No notes yet. Add one with: notevec add \"your note\"");
            } else {
                for (id, content, timestamp) in &notes {
                    println!("[#{}] {} ({})", id, content, timestamp);
                }
            }
        }
        Commands::Forget => {
            forget_all(&db).expect("Failed to wipe database");
            println!("All notes forgotten.");
        }
    }
}

What does this code do?

The function starts by initializing two resources: the SQLite database (with sqlite-vec loaded) and the MiniLM embedding model. Both are created once and reused across the command match arms.

  • Add embeds the user's text into a vector, then stores both the text and vector in the database.
  • Search embeds the query, runs a KNN search for the 3 nearest vectors, and displays results with cosine similarity as a percentage.
  • List retrieves all notes ordered by timestamp.
  • Forget drops and recreates both tables, wiping all data.

The similarity formula (2.0 - distance) / 2.0 * 100.0 converts cosine distance (0 = identical, 2 = opposite) into a percentage where 100% means identical meaning.

  • Save src/main.rs.
  • Verify it compiles by running:
cargo build
  • You should see Compiling notevec followed by Finished with no errors.

Seeing a compile error?

  • If you see "cannot find value" errors for TextEmbedding or EmbeddingModel, double check the import line is exactly use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};.
  • If you see "mismatched types" on embed_text, make sure the function signature matches: it takes &TextEmbedding and &str, returns Vec<f32>.
  • Make sure you deleted the old placeholder main() entirely. Rust only allows one main() function.

Help me fix a compile error in my notevec main.rs

Test the full semantic search loop

Time to prove that the search finds notes by meaning, not keywords. You'll add notes about different topics, then search with a phrase that doesn't share any words with the target note.

  • Add a few notes with different themes by running these commands one at a time:
cargo run -- add "I stayed up way too late and feel terrible today"
cargo run -- add "The quarterly revenue report exceeded expectations"
cargo run -- add "My morning jog through the park was refreshing"
cargo run -- add "I keep procrastinating on that big deadline"

What about the first run?

The very first time you run the program, fastembed downloads the MiniLM-L6-v2 model files (~23MB) from HuggingFace. You'll see a progress bar thanks to with_show_download_progress(true). Subsequent runs use the cached model and start instantly.

  • Each command should print Added note: "..." confirming the note was stored.
  • Now test semantic search. Search for a phrase that's related to sleep deprivation but doesn't share keywords with any note:
cargo run -- search "I'm exhausted and burnt out"
  • You should see the "stayed up way too late" note ranked first. The search found it by meaning even though the query and note share zero keywords.
  • Try listing all your notes to confirm they're all stored:
cargo run -- list
  • You should see all 4 notes with their IDs and timestamps, ordered most recent first.

Search returning unexpected results?

  • If the search returns "No notes found", run cargo run -- list to confirm notes exist. If the list is empty, your add commands may not have run successfully.
  • If results seem random, make sure the vec0 table uses distance_metric=cosine in the CREATE VIRTUAL TABLE statement from the previous step.
  • If you want to start fresh, run cargo run -- forget to wipe all notes, then re-add them.

My semantic search isn't finding the right notes

Why does this work without shared keywords?

The MiniLM-L6-v2 model was trained on over 1 billion sentence pairs. It learned that "exhausted and burnt out" occupies a similar region of vector space as "stayed up way too late and feel terrible." The cosine distance between their embeddings is small, so the KNN query ranks them close together.

This is the core principle behind every RAG system: embed the query, find nearby vectors, return the associated text. You just built the exact same loop that powers AI memory layers.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs and that your semantic search returns meaningful results.

ⓧ I'd like to double check the full code

Here's the complete src/main.rs at this point. Compare it against yours:

use clap::{Parser, Subcommand};
use chrono::Local;
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
use rusqlite::{ffi::sqlite3_auto_extension, Connection, Result as SqlResult};
use sqlite_vec::sqlite3_vec_init;

#[derive(Parser)]
#[command(name = "notevec", about = "A semantic search notebook")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new note
    Add { text: String },
    /// Search notes by meaning
    Search { query: String },
    /// List all notes
    List,
    /// Wipe all notes
    Forget,
}

fn db_path() -> String {
    let mut path = dirs_next_or_home();
    path.push_str("/notevec.db");
    path
}

fn dirs_next_or_home() -> String {
    std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
}

fn init_db() -> SqlResult<Connection> {
    unsafe {
        sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ())));
    }
    let db = Connection::open(db_path())?;
    db.execute_batch(
        "CREATE TABLE IF NOT EXISTS notes (
            id        INTEGER PRIMARY KEY,
            content   TEXT NOT NULL,
            timestamp TEXT NOT NULL
        );
        CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(
            note_id   integer primary key,
            embedding float[384] distance_metric=cosine
        );"
    )?;
    Ok(db)
}

fn embed_text(model: &TextEmbedding, text: &str) -> Vec<f32> {
    let embeddings = model.embed(vec![text], None).expect("Embedding failed");
    embeddings.into_iter().next().expect("No embedding returned")
}

fn vec_to_bytes(v: &[f32]) -> &[u8] {
    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, v.len() * 4) }
}

fn insert_note(db: &Connection, content: &str, embedding: &[f32]) -> SqlResult<()> {
    let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
    let tx = db.unchecked_transaction()?;
    tx.execute(
        "INSERT INTO notes (content, timestamp) VALUES (?1, ?2)",
        rusqlite::params![content, timestamp],
    )?;
    let note_id = tx.last_insert_rowid();
    tx.execute(
        "INSERT INTO note_embeddings (note_id, embedding) VALUES (?1, ?2)",
        rusqlite::params![note_id, vec_to_bytes(embedding)],
    )?;
    tx.commit()?;
    Ok(())
}

fn search_similar(db: &Connection, query_embedding: &[f32], k: usize) -> SqlResult<Vec<(i64, String, String, f64)>> {
    let mut stmt = db.prepare(
        "SELECT
            note_embeddings.note_id,
            notes.content,
            notes.timestamp,
            note_embeddings.distance
        FROM note_embeddings
        JOIN notes ON notes.id = note_embeddings.note_id
        WHERE embedding MATCH ?1
            AND k = ?2
        ORDER BY distance"
    )?;
    let rows = stmt.query_map(
        rusqlite::params![vec_to_bytes(query_embedding), k as i64],
        |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, f64>(3)?,
            ))
        },
    )?;
    rows.collect()
}

fn list_notes(db: &Connection) -> SqlResult<Vec<(i64, String, String)>> {
    let mut stmt = db.prepare(
        "SELECT id, content, timestamp FROM notes ORDER BY timestamp DESC"
    )?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i64>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, String>(2)?,
        ))
    })?;
    rows.collect()
}

fn forget_all(db: &Connection) -> SqlResult<()> {
    db.execute_batch(
        "DROP TABLE IF EXISTS note_embeddings;
         DROP TABLE IF EXISTS notes;
         CREATE TABLE notes (
            id        INTEGER PRIMARY KEY,
            content   TEXT NOT NULL,
            timestamp TEXT NOT NULL
         );
         CREATE VIRTUAL TABLE note_embeddings USING vec0(
            note_id   integer primary key,
            embedding float[384] distance_metric=cosine
         );"
    )?;
    Ok(())
}

fn main() {
    let cli = Cli::parse();
    let db = init_db().expect("Failed to initialize database");
    let model = TextEmbedding::try_new(
        InitOptions::new(EmbeddingModel::AllMiniLML6V2)
            .with_show_download_progress(true),
    )
    .expect("Failed to load embedding model");
    match cli.command {
        Commands::Add { text } => {
            let embedding = embed_text(&model, &text);
            insert_note(&db, &text, &embedding).expect("Failed to insert note");
            println!("Added note: \"{}\"", text);
        }
        Commands::Search { query } => {
            let query_embedding = embed_text(&model, &query);
            let results = search_similar(&db, &query_embedding, 3)
                .expect("Search failed");
            if results.is_empty() {
                println!("No notes found. Add some first!");
            } else {
                println!("Top matches for: \"{}\"", query);
                println!("{}", "-".repeat(50));
                for (id, content, timestamp, distance) in &results {
                    let similarity = (2.0 - distance) / 2.0 * 100.0;
                    println!("[#{}] {} ({:.1}% similar)", id, content, similarity);
                    println!("     {}", timestamp);
                }
            }
        }
        Commands::List => {
            let notes = list_notes(&db).expect("Failed to list notes");
            if notes.is_empty() {
                println!("No notes yet. Add one with: notevec add \"your note\"");
            } else {
                for (id, content, timestamp) in &notes {
                    println!("[#{}] {} ({})", id, content, timestamp);
                }
            }
        }
        Commands::Forget => {
            forget_all(&db).expect("Failed to wipe database");
            println!("All notes forgotten.");
        }
    }
}

You now have a fully working semantic search engine. It embeds text, stores vectors, and finds similar notes by meaning. Next up, the Secret Mission adds a similar command that explores relationships between existing notes.

Secret mission

Add Similarity Scores and Related Notes

Your search command finds notes by meaning, but what about exploring connections between notes you've already saved? In this mission, you'll add a similar command that takes a note ID and finds its closest neighbours, complete with similarity percentages.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The notevec project folder on your Desktop.
  • The notevec.db SQLite database file in your home directory.
  • The cached MiniLM-L6-v2 model files downloaded by fastembed.

Keep everything running

No action needed. Choose this if you plan to keep adding notes and searching them.

  • Your notevec binary stays compiled in target/ and is ready to use anytime.
  • Your notevec.db database persists all your notes and embeddings in your home directory.
  • The cached MiniLM model means future runs skip the download step entirely.

Pause - I'll come back to this later

Nothing is actively running, so there is nothing to pause. Your project folder sits on disk until you return.

  • The target/ directory inside notevec holds compiled artifacts and can be large (hundreds of MB). Free disk space by running this from inside your project folder:
cargo clean
  • When you return, run cargo build to recompile. Your notes in notevec.db remain untouched.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your notes and project files.

  • Delete the project folder by running these commands:
cd ~/Desktop
rm -rf notevec
  • Delete the database file from your home directory:
rm ~/notevec.db
  • Delete the cached embedding model files. Fastembed stores them in a .fastembed_cache directory relative to where you ran the binary. Remove it from your project's parent folder:
rm -rf ~/Desktop/.fastembed_cache

Where is my model cache?

Fastembed creates a .fastembed_cache folder in the working directory where you ran the binary. If you always ran cargo run from inside the notevec folder, the cache lives at ~/Desktop/notevec/.fastembed_cache and was already deleted with the project folder above.

On Windows, use these commands instead:

cd ~\Desktop
rmdir /s /q notevec
del %USERPROFILE%\notevec.db
rmdir /s /q %USERPROFILE%\Desktop\.fastembed_cache

Nice Work!

Nice Work!

You just built a fully offline semantic search engine in Rust. Your CLI finds notes by meaning, not keywords, using the same architecture that powers production RAG systems and AI memory layers.

Here's what you accomplished:

  • Built a Rust CLI with clap derive that handles 5 commands for adding, searching, listing, finding similar notes, and wiping data.
  • Stored and queried vector embeddings using sqlite-vec's vec0 virtual tables with KNN search and cosine distance.
  • Generated real 384-dimensional embeddings offline using fastembed and the MiniLM-L6-v2 model, learning the embed-store-search pattern that powers AURA's memory layer.
  • Secret Mission: Added a similar command that explores note relationships and displays cosine similarity as human-readable percentages.

Ready to quiz yourself?