navree
Documentation

Embeddings and RAG

Turn your records into vectors with Embeddings, keep them in pgvector, Qdrant or memory with VectorStore, index through the queue, and answer questions grounded in your own data, with sources and the user's permissions.

On this page

Introduction

Featureai A model knows the world up to its training date and nothing about your customers. Retrieval-augmented generation (RAG) closes that gap: when a question comes in, you find the few passages of your own data that answer it, give them to the model with the question, and get an answer that cites them.

Finding those passages is a search by meaning, which is what embeddings are for. An embedding is a vector of numbers that a model computes from a text; texts that mean similar things get vectors that point in similar directions. "The customer is worried about rollout time" and "Dana asked whether onboarding takes more than a month" share no words and sit close together.

navree-ai gives you the pieces:

  • Embeddings computes vectors with the provider you configure, in batches, cached by text.
  • VectorStore keeps them in pgvector (in your PostgreSQL database), Qdrant, or memory, and finds the nearest ones to a query, with metadata filters.
  • Chunker splits long text into passages that embed well.
  • ai.rerank re-orders candidates by how well they answer the question.

The examples build "Ask about this deal" in the CRM: a rep asks "What did they say about the rollout timeline?" and gets an answer from the deal's notes, calls and emails, with links to the activities it came from.

Installation and configuration

Everything is in the ai feature (see AI SDK installation). The pgvector store also needs navree-db's PostgreSQL driver, the postgres feature:

Cargo.toml

Name an embedding model, point ai.embeddings at it, and choose a store:

config/app.toml
KeyDefaultMeaning
ai.embeddings.modelnoneThe model name in ai.models that embeds. Required for Embeddings.
ai.embeddings.batch_size96Texts per provider call in embed_many.
ai.embeddings.cache_ttl_days30How long a text's vector is cached; 0 turns the cache off.
ai.vectors.storememorypgvector, qdrant or memory.
ai.vectors.dimensions1536The vector size. Every collection uses it.
ai.vectors.metriccosinecosine, dot or euclidean.
ai.vectors.url, ai.vectors.keynoneThe Qdrant server's REST address (required with qdrant) and its API key.

From the environment: APP_AI__VECTORS__STORE=qdrant, APP_AI__VECTORS__URL=https://qdrant.internal:6333, APP_AI__VECTORS__KEY=.... A model whose vectors don't have ai.vectors.dimensions entries fails the first embedding with AiError::Config, naming both sizes. The Qdrant host is let through the HTTP client's SSRF guard because you configured it.

Embedding models

Anthropic doesn't offer an embedding model, so an app that writes with Claude embeds with another provider. Any of these work:

ProviderModelDimensions
OpenAItext-embedding-3-small1536 (or fewer, with dimensions)
OpenAItext-embedding-3-large3072 (or fewer)
Google Geminigemini-embedding-0013072 (or fewer)
Mistralmistral-embed1024
Ollamanomic-embed-text, or any embedding model you pull768 for nomic-embed-text
AWS BedrockAmazon Titan (amazon.titan-embed-..., one text per call) and Cohere (cohere.embed-...) models enabled on your accountper model

Changing the embedding model changes every vector: re-index after you switch (see backfilling).

The stores

StoreWhere vectors liveUse it for
pgvectorTables in your app's PostgreSQL database, with an HNSW indexMost apps: no extra service, and vectors back up with your data.
qdrantA Qdrant server over its REST API, one collection per Navree collectionTens of millions of vectors, or a search cluster of its own.
memoryThe process's memory, searched exhaustivelyTests, and development on SQLite. Lost on restart.

Collections declared with m.vector_collection(..) are created at boot; any other collection is created the first time it is used. With pgvector, creating one enables the vector extension (CREATE EXTENSION IF NOT EXISTS vector; on managed PostgreSQL, allow the extension first) and creates its table and indexes; the database must be PostgreSQL. With qdrant, a missing collection is created with the configured size and metric. In both, an existing collection of another size fails the boot (or, for an undeclared one, its first use) with AiError::Config.

Collections

A collection is a named set of vectors with their text and metadata, usually one per kind of content. Its name uses lower-case letters, digits and _. Declare it in the module that owns the content:

crates/crm-activities/src/lib.rs

With pgvector, this collection is a table ai_vectors_activity_notes:

Column
idText, your key for the record (here <activity id>:<chunk>)
embeddingvector(1536), with an HNSW index for the configured metric
textThe passage the vector was computed from
metadatajsonb, with a GIN index, for filters
updated_atWhen it was last written

Inject Arc<VectorStore> and open a collection by name:

Rust

search takes a text, which it embeds with Embeddings (and the cache), or an Embedding you computed. It returns Vec<VectorMatch>, best first; each has id, score (higher is closer, whatever the metric: the cosine similarity, the dot product, or 1 / (1 + distance) for euclidean), text and metadata. With Qdrant, which keys points by UUID, each record is stored under a UUID derived from its id, and the id comes back unchanged.

Method
upsert(record), upsert_many(records)Insert or replace by id. A record whose vector isn't ai.vectors.dimensions long, or whose metadata isn't a JSON object, fails with AiError::Config.
delete(id)Remove one.
delete_where(filter)Remove every record matching a filter, e.g. all chunks of one activity.
search(query)A query builder: .filter(f), .limit(n) (default 10), .min_score(s), then await.
count()The number of records.

Filters work on metadata fields: Filter::eq("kind", "call"), Filter::any_of("subject_id", ids), Filter::gte("created_at", "2026-10-01"), combined with .and(..) and .or(..), negated with Filter::not(..). They are applied inside the vector search, so limit(8) returns eight matching records, not eight records of which some match. A dotted name (owner.id) reaches a nested field; names are letters, digits and _. gt, gte, lt and lte compare numbers with numbers and strings with strings (RFC 3339 dates compare correctly as strings). Calling .filter(..) twice combines both with and.

Embeddings

Most code never calls Embeddings directly, because search embeds the query for you. You call it when you index:

Rust
  • embed_many takes any iterator of strings, sends ai.embeddings.batch_size texts per provider call, in parallel, and returns the vectors in input order.
  • Vectors are cached by model and text (a SHA-256 of both) in the app's cache for ai.embeddings.cache_ttl_days, so re-indexing unchanged text costs no tokens.
  • Embedding calls carry no user, so they don't count against budgets; they do appear in the metrics.
  • Calls go through the model's failover chain. Fallbacks of an embedding model must be embedding models of the same size, or the boot fails, since mixing models in one collection breaks its distances.
  • Embedding derefs to [f32] and has cosine(&other) for comparing two texts in memory.

Chunking

An embedding summarizes its whole text in one vector. A two-page meeting transcript embedded whole is "about" everything in it and close to nothing in particular, so long text is split into passages first. Chunker splits at paragraph and sentence boundaries and overlaps neighbors, so a fact that straddles a boundary is in both:

Rust

Chunker::markdown() splits at headings first (ignoring # inside code fences) and keeps each chunk's heading path at its start ("Pricing > Discounts"), which helps both the embedding and the model reading it. Tokens are counted as four characters each, which is close for English text with every common embedding tokenizer. The defaults are max_tokens(512) and overlap(64); a word longer than a chunk is cut. A text shorter than max_tokens is one chunk, so most CRM notes are a single passage, and blank text gives none.

Indexing through the queue

Embedding calls a provider, so it belongs in a queued job, not in the request that saved the note. The CRM indexes an activity whenever it is created or changed:

crates/crm-activities/src/index.rs

The service dispatches it after each write. In a request, the job joins the request's unit of work, so it is queued only if the activity is saved, and the worker never reads a note that was rolled back:

crates/crm-activities/src/service.rs

Do the same in update and delete. The job is safe to run twice, as jobs should be: it replaces the activity's chunks every time.

Backfilling existing records

Records that existed before you added the index, or every record after you change the embedding model, need a pass of their own. A command that queues one job per record does it, and the queue spreads the work over your workers:

crates/crm-activities/src/commands/reindex.rs
Shell

Embeddings are cached by text, so a second backfill with the same model costs nothing for unchanged records.

Retrieval and permissions

A vector index doesn't know your access rules. Two patterns keep retrieval inside them.

Scope by a record the route already authorized. "Ask about this deal" runs behind #[can(Read, Deal, from = "id")], so filtering by subject_id keeps the search inside one readable deal.

Re-check the hits. For questions across records, take the candidate ids from the index and load them through your access filter. Anything the user can't read drops out before the model sees it:

Rust

Store the fields your rules use (owner_id, workspace_id) in metadata as well, and filter on them in the search, so the top results aren't spent on records the check will discard. In a workspace app, always filter by workspace_id.

Reranking

Vector search is fast and approximate: it finds passages near the question, not necessarily the ones that answer it. Reranking reads the question and each candidate together and orders them by how well they answer it. Retrieve generously, rerank, keep the best few:

Rust

ai.rerank scores every candidate in one structured-output call to the model you name (default ai.default), reading at most 2,000 characters of each, and returns the top ones with score replaced by the reranker's (0 to 1). .user(id) counts the call against that user's budget. It costs one model call, so skip it when the collection is small and the vector scores already separate well.

A RAG endpoint

Put together: retrieve, rerank, then ask for a structured answer that cites its sources by number, so the response can link to them.

crates/crm-deals/src/ask.rs
crates/crm-deals/src/controller.rs
HTTP
JSON

What keeps the answers honest:

  • Only sources go in. The system prompt confines the model to the passages, and answered: false gives it a way out that isn't a guess.
  • Citations are checked. Source numbers that don't exist are dropped by passages.get, so a source the model invents never reaches the page.
  • Sources are visible. The response carries the passages, and the page links each to its activity, so the rep can read the original.

For a chat that keeps context across questions, give an agent a search_notes tool that wraps the same retrieval; the model then decides when to look things up.

Testing

With ai.fake on, Embeddings is deterministic too: it hashes words into vectors, so texts that share words are close and texts that don't are far. That is enough to test that indexing, filters and permissions put the right passages in front of the model. Use the memory store and run the queue:

apps/crm-api/tests/deal_questions.rs
  • crm.run_queue() runs the index jobs, so the test covers the dispatch in the service as well as the job.
  • With the fake, ai.rerank keeps the search order and makes no model call, so the only scripted reply is the answer.
  • vectors.collection("activity_notes").count() and search work against the memory store for direct assertions.
  • To test the job alone, dispatch it with app.get::<Queue>().await.dispatch(IndexActivity { activity_id }) and run it with run_pending().

Reference

Type
Embeddingsembed(text), embed_many(texts), model(), dimensions(). Inject Arc<Embeddings>, or build one with Embeddings::new(ai).
EmbeddingA vector: derefs to [f32]; dimensions(), cosine(&other), into_vec(); Embedding::new(values) or From<Vec<f32>>.
VectorStorecollection(name), kind(), ensure_collection(name). Inject Arc<VectorStore>; VectorStore::memory(embeddings) builds a memory store.
Collectionupsert, upsert_many, delete, delete_where, search, count, name().
SearchRequest, SearchQueryWhat search returns (.filter, .limit, .min_score, then await), and what it takes: a text or an Embedding.
VectorCollectionA collection declaration, for m.vector_collection(..).
VectorRecordVectorRecord::new(id, embedding).text(..).metadata(..).
VectorMatchA search result: id, score, text, metadata.
Filtereq, any_of, gt, gte, lt, lte, not, .and, .or; matches(&metadata) checks one in memory.
Chunker, ChunkChunker::new(), Chunker::markdown(), .max_tokens(n), .overlap(n), .split(text); a Chunk has index and text.
ai.rerank(query, matches)A RerankRequest: .model(name), .top(n), .user(id), then await.
VectorStoreKind, Metricai.vectors.store and ai.vectors.metric.