revector

Declarative, versioned schema & config migrations for Qdrant — Alembic for vector collections.

revector brings ordered, reversible, database-tracked migrations to Qdrant — the piece that, unlike for relational databases, didn't exist yet. You write declarative YAML migrations, commit them next to your code, and apply or roll them back with a single static binary. No Python venv, no external state store.

Schema, not data. revector manages collection schema and config — collections, payload indexes, named vectors, aliases, and all tunable knobs. Moving points between instances is a solved problem (see qdrant/migration); that's explicitly out of scope. The one data operation revector does help with — re-embedding — is handled through an exec-hook.

Why

Qdrant collections drift. You tune hnsw_config, add a payload index, introduce a second named vector for a new model, flip quantization on. Today those changes live in ad-hoc scripts or a teammate's shell history. revector makes them:

  • Versioned — each change is a file with a revision and down_revision, forming an ordered chain (Alembic's model).
  • Tracked — applied revisions are recorded inside Qdrant itself, in a dedicated _revector_migrations collection. No external database.
  • Reversible (honestly) — downgrades are auto-derived where safe and refused loudly where they'd lose data, instead of pretending.
  • Idempotent & resumable — Qdrant has no transactional DDL and builds indexes asynchronously, so every step is safe to re-run after a failure.

Where to next

Install

Once a release is cut, prebuilt binaries for Linux, macOS, and Windows are attached to each GitHub Release by cargo-dist, with installers:

# Shell (Linux/macOS) — downloads the right prebuilt binary
curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.sh | sh

# Windows (PowerShell)
powershell -c "irm https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.ps1 | iex"

Or build from source (requires Rust 1.82+):

cargo install --path .          # from a checkout
cargo build --release           # ./target/release/revector

Homebrew and crates.io are planned — see ROADMAP.md.

Quick start

# 1. Scaffold a config + migrations/ directory
revector init

# 2. Create your first migration
revector new "create products collection"
#  → migrations/1718480000_create_products_collection.yaml

# 3. Edit the file (see Migration files), then apply
export REVECTOR_URL=http://localhost:6334
revector up

# 4. Inspect state
revector status

# 5. Roll back the last migration
revector down

Want the full, hands-on version — starting a local Qdrant in Docker and building a collection from scratch? See the Tutorial.

See the Commands reference for every subcommand and flag, and Migration files for the YAML format.

Tutorial: from zero to a versioned collection

This is the hands-on walkthrough: start a local Qdrant in Docker, create a collection from scratch with revector, evolve it across a few migrations, roll a change back, and tear it all down. By the end you'll have a migrations/ directory you could commit next to your code.

Everything here runs locally and disposably — no cloud account, no external state store.

Prerequisites

  • Docker (to run Qdrant locally).
  • revector on your PATH — see Install. The quickest route from a checkout is cargo install --path .; verify with revector --help.

1. Start Qdrant locally (latest Docker image)

Qdrant publishes an official image. Pull the latest and run it, exposing both the REST (6333) and gRPC (6334) ports — revector talks gRPC on 6334:

docker run -p 6333:6333 -p 6334:6334 \
  --name qdrant-revector-tutorial \
  qdrant/qdrant:latest

Leave that running in its own terminal. To confirm it's up, the REST API and a web dashboard are on 6333:

curl http://localhost:6333/healthz       # → healthz check passed
# or open the dashboard:                 http://localhost:6333/dashboard

Tip — persist data across restarts. The container above is ephemeral; add -v "$(pwd)/qdrant_storage:/qdrant/storage" to keep the data on disk. For a throwaway tutorial you don't need it.

2. Initialize a revector project

In a fresh working directory:

revector init

This creates two things:

  • migrations/ — where your migration files live.
  • revector.toml — project config, pre-pointed at http://localhost:6334, which is exactly where our Docker container is listening:
# revector.toml
url = "http://localhost:6334"
migrations_dir = "migrations"
# api_key = "..."            # or set REVECTOR_API_KEY
# tracking_collection = "_revector_migrations"

Because the default already matches local Docker, you don't have to set anything else. (If you'd run Qdrant elsewhere, you'd set url here or export REVECTOR_URL. See Configuration.)

3. Write your first migration — create a collection

Scaffold a migration. revector new creates a timestamped file chained onto the current head (here, nothing yet, so it becomes the base of the chain):

revector new "create products collection"
#  → migrations/1718480000_create_products_collection.yaml

Open that file. It's a commented template; replace the up: section so it creates a products collection with a 768-dim cosine text vector:

revision: "1718480000_create_products_collection"
down_revision: null            # null marks the base of the chain
description: create products collection

up:
  - op: create_collection
    name: products
    spec:
      vectors:
        "":                    # "" is the unnamed/default vector
          size: 768
          distance: Cosine
      hnsw_config:
        m: 16
        ef_construct: 128

# No explicit `down`: revector auto-inverts create_collection to
# delete_collection on a rollback.

(Your revision and filename will use a real timestamp — leave them as scaffolded; the 0001_… ids elsewhere in the docs are just hand-authored examples.)

4. Validate, then apply

First check the chain parses and resolves — offline, no Qdrant needed. This is the same check you'd run in CI:

revector validate

Now apply it. revector connects to Qdrant, creates the collection, and records the revision as applied inside Qdrant itself (in a _revector_migrations collection):

revector up

Preview first without touching Qdrant by adding --dry-run to print the plan.

5. Inspect what happened

Check revector's own view of the world:

revector status

You'll see 1718480000_create_products_collection marked applied. And the collection really exists — ask Qdrant directly:

curl http://localhost:6333/collections/products

or browse it in the dashboard at http://localhost:6333/dashboard. Notice there's also a _revector_migrations collection — that's revector's tracking store, living inside the same Qdrant instance (no external database). See How state is tracked.

6. Evolve the schema — a second migration

Real projects change. Let's index a payload field and turn on scalar quantization. Scaffold another migration — it chains automatically onto the previous head:

revector new "index category and quantize"

Edit it so up adds an index and patches the config, with an explicit down (config patches aren't auto-reversible because Qdrant doesn't hand back the prior values):

revision: "1718480100_index_category_and_quantize"
down_revision: "1718480000_create_products_collection"
description: index the category field and enable scalar quantization

up:
  - op: create_payload_index
    collection: products
    field_name: category
    schema: keyword

  - op: update_collection
    collection: products
    quantization_config:
      scalar:
        type: int8
        quantile: 0.99
        always_ram: true

down:
  - op: update_collection
    collection: products
    quantization_config: disabled

  - op: delete_payload_index
    collection: products
    field_name: category
    schema: keyword

Apply just the new one:

revector up
revector status      # both revisions now applied

revector up is idempotent and resumable — if a step fails partway (Qdrant has no transactional DDL), just run it again and it picks up where it left off.

7. Roll a change back

Made a mistake, or just want to see reversibility work? Roll back the last migration:

revector down        # undoes the quantize/index migration
revector status      # the second migration is pending again; the first still applied

down rolls back one step by default; pass --steps N or --to <rev> to go further, and --yes to skip the confirmation prompt (required in non-interactive shells like CI). revector refuses rollbacks that would silently lose data instead of pretending — see the operations reference for each op's reversibility.

Re-apply when you're ready:

revector up

8. (Optional) Check for drift

If someone changes the collection by hand outside revector, you can catch it. Declare the expected shape in a spec file and diff it against the live collection:

revector diff products --spec expected.yaml

See Drift detection (diff) for the spec format and how it avoids false positives from Qdrant's normalized defaults.

9. Tear down

When you're done experimenting:

docker rm -f qdrant-revector-tutorial

Your migrations/ directory and revector.toml remain — that's the artifact you'd commit to version control. Pointed at a staging or prod Qdrant (via REVECTOR_URL), the exact same files reproduce this schema there. revector up is safe to run in CI: it takes an advisory lock so parallel jobs don't race.

Where to next

Migration files

A migration is a YAML file with a revision id, a link to its parent, and up / optional down operation lists. Each operation names itself with an op: key.

revision: "0001_products"
down_revision: null            # null marks the base of the chain
description: create products collection

up:
  - op: create_collection
    name: products
    spec:
      vectors:
        "":                    # "" is the unnamed/default vector
          size: 768
          distance: Cosine
      hnsw_config:
        m: 16
        ef_construct: 128

  - op: create_payload_index
    collection: products
    field_name: category
    schema: keyword

# Optional. If omitted, revector auto-inverts the `up` ops in reverse order
# and refuses the downgrade if any step is irreversible.
down:
  - op: delete_collection
    name: products

Required fields

FieldTypeNotes
revisionstringUnique id. Auto-scaffolded by revector new as <timestamp>_<slug>.
down_revisionstring | nullParent revision. null marks the base of the chain.
descriptionstringFree-text label for revector status output.
uplist of operationsSteps applied on revector up.
downlist of operationsOptional. If omitted, revector auto-inverts up in reverse order.

What each op: does

See the Operations reference for a full description, example, spec fields, and reversibility behavior of every operation.

Safety

  • Confirmation. Rollbacks (down, and a to that moves backwards) prompt before proceeding. Pass -y / --yes to skip the prompt; in a non-interactive shell (CI) revector refuses rather than guessing, so --yes is required there.
  • Advisory lock. up / down / to / stamp take a lock record in the tracking collection for the duration of the run, so two concurrent runs (e.g. parallel CI jobs) don't stomp on each other. If a previous run died and left a stale lock, --force overrides it. (Best-effort — Qdrant has no compare-and-set — but it reliably catches the common case.)
  • Checksums. revector records the SHA-256 of each migration file when applied; editing an already-applied file fails loudly on the next run. See How state is tracked.

Commands

CommandDescription
revector initCreate migrations/ and a starter revector.toml.
revector new <name>Scaffold a new migration chained onto the current head.
revector statusShow applied vs pending revisions, checksums, and reversibility.
revector up [--to <rev>] [--dry-run]Apply pending migrations.
revector down [--to <rev>] [--steps N] [--dry-run]Roll back migrations (default: 1 step).
revector to <rev> [--dry-run]Migrate to an exact revision (up or down).
revector validateParse all migrations and resolve the chain offline — no Qdrant connection. Good as a CI / pre-commit check.
revector stamp <rev|head|base> [--dry-run]Mark the DB as being at a revision without running any ops — for adopting an existing collection (Alembic's stamp).
revector diff <collection> --spec <file.yaml>Compare a declared collection spec against the live collection.

--dry-run prints the plan without touching Qdrant.

Global flags

These flags work on every subcommand:

FlagEnvDescription
--config <FILE>Path to a revector.toml (default: ./revector.toml).
--url <URL>REVECTOR_URLQdrant gRPC URL.
--api-key <KEY>REVECTOR_API_KEYQdrant API key.
--migrations-dir <DIR>Migrations directory.
-v, -vvIncrease log verbosity (debug, trace).
-y, --yesSkip confirmation prompts (required in non-interactive shells for rollbacks).
--forceOverride a held or stale migration lock.

Set REVECTOR_LOG=revector=debug for verbose logging (equivalent to -v).

Configuration

Settings are layered (highest precedence first): CLI flags → REVECTOR_* environment variables → revector.toml → defaults.

# revector.toml
url = "http://localhost:6334"
migrations_dir = "migrations"
# api_key = "..."                      # or REVECTOR_API_KEY
# tracking_collection = "_revector_migrations"
SettingEnvDefault
urlREVECTOR_URLhttp://localhost:6334
api_keyREVECTOR_API_KEYnone
migrations_dirREVECTOR_MIGRATIONS_DIRmigrations
tracking_collectionREVECTOR_TRACKING_COLLECTION_revector_migrations

Set REVECTOR_LOG=revector=debug for verbose logging (or pass -v / -vv).

Operations

Each step inside up: / down: is identified by an op: key. The table summarizes effect and auto-reversibility; click through for a detailed description, runnable example, and the full list of spec fields.

op:EffectAuto-reversible?
create_collectionCreate a collection from a full spec✔ → delete_collection
delete_collectionDrop a collection✘ (data loss)
update_collectionPatch hnsw_config, quantization_config, optimizers_config, or per-vector params in place✘ (prior state unknown)
create_vectorAdd a named dense vector (Qdrant v1.18+)✔ → delete_vector
create_sparse_vectorAdd a named sparse vector✔ → delete_vector
delete_vectorDrop a named vector✘ (data loss)
create_payload_indexIndex a payload field✔ → delete_payload_index
delete_payload_indexRemove a payload index✔ iff schema: is given
create_aliasPoint an alias at a collection✔ → delete_alias
delete_aliasRemove an alias✘ (target unknown)
switch_aliasAtomically repoint an alias (zero-downtime swap)✘ (prior target unknown)
execRun a shell command (the re-embedding escape hatch)✘ unless down provided

When an operation isn't auto-reversible, supply an explicit down: block — then revector down uses it verbatim.

The shapes referenced by these operations (CollectionSpec, VectorSpec, HnswConfigSpec, …) are documented on the Specs page.

create_collection

Create a new collection from a full CollectionSpec.

Example

up:
  - op: create_collection
    name: products
    spec:
      vectors:
        "":                          # "" is the unnamed/default vector
          size: 768
          distance: Cosine
        image:                       # multiple named vectors
          size: 512
          distance: Dot
          on_disk: true
      sparse_vectors:
        keywords:
          on_disk: false
      hnsw_config:
        m: 16
        ef_construct: 128
      optimizers_config:
        default_segment_number: 2
      shard_number: 2
      replication_factor: 2
      on_disk_payload: true

Fields

FieldTypeRequiredDescription
namestringyesCollection name.
specCollectionSpecyesFull collection specification.

spec fields

FieldTypeDescription
vectorsmap<name, VectorSpec>Named dense vectors. Use "" as the key for the unnamed/default vector.
sparse_vectorsmap<name, SparseVectorSpec>Named sparse vectors.
hnsw_configHnswConfigSpecCollection-level HNSW defaults.
quantization_configQuantizationSpecCollection-level quantization defaults.
optimizers_configOptimizersConfigSpecOptimizer thresholds.
shard_numberuintNumber of shards (immutable on single-node).
replication_factoruintReplication factor.
write_consistency_factoruintWrite consistency factor.
on_disk_payloadboolStore the whole collection payload on disk.

Reversibility

Auto-reversible → delete_collection. Dropping the new collection destroys whatever was written into it in the interim, so the downgrade is destructive but unambiguous.

delete_collection

Drop a collection entirely.

Example

up:
  - op: delete_collection
    name: legacy_products

# delete_collection is irreversible — declare an explicit `down`
# (typically a full create_collection) if you need to be able to roll back.
down:
  - op: create_collection
    name: legacy_products
    spec:
      vectors:
        "":
          size: 768
          distance: Cosine

Fields

FieldTypeRequiredDescription
namestringyesCollection to drop.

Reversibility

Irreversible. Dropping a collection destroys all its points; revector cannot reconstruct them. revector down refuses unless you supply an explicit down: block — typically a create_collection op matching the original spec.

update_collection

Patch tunable collection-level configuration in place. Only the fields you set are sent to Qdrant — unset fields are left untouched.

Example

up:
  - op: update_collection
    collection: products
    hnsw_config:
      ef_construct: 256
    quantization_config:
      scalar:
        type: int8
        quantile: 0.99
        always_ram: true
    optimizers_config:
      indexing_threshold: 20000
    vectors:                       # patch existing named-vector params
      image:
        on_disk: true

# update_collection is not auto-reversible (previous values aren't recorded).
# Spell out the inverse explicitly:
down:
  - op: update_collection
    collection: products
    quantization_config: disabled

Fields

FieldTypeRequiredDescription
collectionstringyesCollection to update.
hnsw_configHnswConfigSpecnoOverride collection-level HNSW params.
quantization_configQuantizationSpecnoSet or replace quantization (scalar / product / binary / disabled).
optimizers_configOptimizersConfigSpecnoTune optimizer thresholds.
vectorsmap<name, VectorParamsDiff>noPatch params of existing named vectors (on_disk, hnsw_config, quantization_config).

At least one of those four fields must be set, otherwise revector refuses the op as a no-op.

Note. size and distance of a named vector are immutable. Per-vector hnsw_config / quantization_config cannot be set at create_vector time either (Qdrant's add-vector API doesn't accept them); apply them with a follow-up update_collection step as shown above.

Reversibility

Not auto-reversible. The previous values aren't stored, so revector cannot synthesise an inverse. Provide an explicit down: block:

  • To restore prior numeric values, repeat update_collection with the original numbers.
  • To turn quantization back off, use quantization_config: disabled.

create_vector

Add a new named dense vector to an existing collection. Requires Qdrant v1.18+.

Example

up:
  - op: create_vector
    collection: products
    name: image
    spec:
      size: 512
      distance: Dot
      on_disk: true
      datatype: float16

To tune the new vector's hnsw_config or quantization_config, follow it with an update_collection step — Qdrant's add-vector API doesn't accept those at create time.

Fields

FieldTypeRequiredDescription
collectionstringyesCollection to add the vector to.
namestringyesVector name (must be unique within the collection).
specVectorSpecyesVector configuration.

spec fields

FieldTypeDescription
sizeuintDimensionality. Immutable once created.
distanceCosine | Euclid | Dot | ManhattanDistance metric. Immutable.
on_diskboolStore vectors on disk rather than in RAM.
datatypefloat32 | uint8 | float16Element storage type.
hnsw_configHnswConfigSpecIgnored at create time — apply via update_collection.
quantization_configQuantizationSpecIgnored at create time — apply via update_collection.

Reversibility

Auto-reversible → delete_vector. Note the downgrade discards any embeddings written to the vector in the meantime — a deliberate, declared choice.

create_sparse_vector

Add a named sparse vector to an existing collection.

Example

up:
  - op: create_sparse_vector
    collection: products
    name: keywords
    spec:
      on_disk: true
      full_scan_threshold: 5000

Fields

FieldTypeRequiredDescription
collectionstringyesCollection to add the vector to.
namestringyesSparse vector name (must be unique within the collection).
specSparseVectorSpecyesSparse-vector configuration.

spec fields

FieldTypeDescription
on_diskboolStore the sparse index on disk.
full_scan_thresholduintPostings-list size below which Qdrant performs a full scan instead of using the index.

Reversibility

Auto-reversible → delete_vector. The downgrade drops any sparse vectors stored under this name.

delete_vector

Drop a named vector (dense or sparse) from a collection.

Example

up:
  - op: delete_vector
    collection: products
    name: text_v1

# delete_vector is irreversible — declare an explicit `down` if rollback matters.
down:
  - op: create_vector
    collection: products
    name: text_v1
    spec:
      size: 768
      distance: Cosine

Fields

FieldTypeRequiredDescription
collectionstringyesCollection that holds the vector.
namestringyesVector name to drop.

Reversibility

Irreversible. Dropping a vector destroys its embeddings; revector cannot restore them. Supply an explicit down: to recreate the vector definition (though the embeddings themselves will need to be regenerated — typically via an exec step).

create_payload_index

Create an index on a payload field so it can be used in filters efficiently.

Example

up:
  - op: create_payload_index
    collection: products
    field_name: category
    schema: keyword

  - op: create_payload_index
    collection: products
    field_name: price
    schema: float

  - op: create_payload_index
    collection: products
    field_name: location
    schema: geo

Fields

FieldTypeRequiredDescription
collectionstringyesCollection that holds the field.
field_namestringyesPayload field to index.
schemaPayloadSchemaTypeyesField type: keyword, integer, float, geo, text, bool, datetime, uuid.

Reversibility

Auto-reversible → delete_payload_index with the same schema: carried over, so the inverse can recreate the index if needed.

delete_payload_index

Drop an index from a payload field. The field stays in the payload — only the index goes away.

Example

up:
  - op: delete_payload_index
    collection: products
    field_name: category
    schema: keyword          # optional, but needed for auto-rollback

Pass schema: when you want the downgrade to be auto-reversible — revector uses it to reconstruct the matching create_payload_index.

Fields

FieldTypeRequiredDescription
collectionstringyesCollection that holds the index.
field_namestringyesPayload field whose index to drop.
schemaPayloadSchemaTypenoOriginal field schema. Required for auto-reversibility.

Reversibility

  • Auto-reversible iff schema: is supplied → recreates the index via create_payload_index.
  • Without schema:, revector has no way to know the original field type and refuses the auto-downgrade; supply an explicit down:.

create_alias

Point an alias at a collection. Aliases are how you keep callers stable while rebuilding collections underneath.

Example

up:
  - op: create_alias
    collection: products_v2
    alias: products

Fields

FieldTypeRequiredDescription
collectionstringyesCollection the alias should resolve to.
aliasstringyesAlias name.

Reversibility

Auto-reversible → delete_alias.

delete_alias

Remove an alias. The target collection stays in place.

Example

up:
  - op: delete_alias
    alias: products_v1_legacy

# Spell out the inverse — revector does not record the previous target.
down:
  - op: create_alias
    alias: products_v1_legacy
    collection: products

Fields

FieldTypeRequiredDescription
aliasstringyesAlias name to remove.

Reversibility

Not auto-reversible — the previous target isn't recorded anywhere, so revector can't know which collection to re-point at on downgrade. Supply an explicit down: that calls create_alias.

switch_alias

Atomically repoint an alias to a different collection — the zero-downtime swap used to roll out a rebuilt collection.

Example

up:
  - op: switch_alias
    alias: products
    to_collection: products_v2

# Spell out the inverse — revector does not record the previous target.
down:
  - op: switch_alias
    alias: products
    to_collection: products_v1

Fields

FieldTypeRequiredDescription
aliasstringyesAlias to repoint.
to_collectionstringyesNew target collection.

Reversibility

Not auto-reversible — the previous target isn't stored. Supply an explicit down: that switches the alias back to the prior collection.

exec

Run a shell command as a migration step — the escape hatch for steps a generic binary can't own (most often re-embedding). See the Re-embedding guide for the canonical pattern, and the Model migration recipe for the full end-to-end flow.

Example

up:
  - op: exec
    name: re-embed with the new model
    command: "python scripts/reembed.py --collection products --target text_v2"
    workdir: ./scripts

# `exec` has no automatic inverse. Spell out a compensating command if one
# exists, or omit `down` and revector will refuse the downgrade.
down:
  - op: exec
    name: restore embeddings from snapshot
    command: "python scripts/restore_text_v1.py"

Fields

FieldTypeRequiredDescription
commandstringyesCommand line, executed via sh -c. Inherits environment and stdio.
namestringnoHuman-readable label for status / log output.
workdirstringnoWorking directory. Defaults to the project root.

A non-zero exit aborts the migration.

Reversibility

Not auto-reversible. Provide an explicit down: block if a compensating command exists; otherwise revector refuses the downgrade.

Specs

The shapes referenced by operations. These types are the on-disk vocabulary — they are deliberately decoupled from qdrant-client, so a client-crate upgrade can't silently change the meaning of a committed migration.

CollectionSpec

The full specification of a collection. Used by create_collection and as the desired state passed to revector diff.

FieldTypeDescription
vectorsmap<name, VectorSpec>Named dense vectors. Use "" as the key for the unnamed/default vector.
sparse_vectorsmap<name, SparseVectorSpec>Named sparse vectors.
hnsw_configHnswConfigSpecCollection-level HNSW defaults.
quantization_configQuantizationSpecCollection-level quantization defaults.
optimizers_configOptimizersConfigSpecOptimizer thresholds.
shard_numberuintNumber of shards (immutable after create on single-node).
replication_factoruintReplication factor.
write_consistency_factoruintWrite consistency factor.
on_disk_payloadboolStore the whole collection payload on disk.

VectorSpec

Configuration of a single (dense) named vector.

FieldTypeDescription
sizeuintDimensionality. Immutable once created.
distanceDistanceDistance metric. Immutable in place.
on_diskboolStore vectors on disk rather than in RAM.
hnsw_configHnswConfigSpecPer-vector HNSW overrides. Ignored at create_vector time — apply via update_collection.
quantization_configQuantizationSpecPer-vector quantization overrides. Ignored at create_vector time — apply via update_collection.
datatypeDatatypeElement storage type.

SparseVectorSpec

Configuration of a single named sparse vector.

FieldTypeDescription
on_diskboolStore the sparse index on disk.
full_scan_thresholduintPostings-list size below which Qdrant performs a full scan instead of using the index.

VectorParamsDiff

Used inside update_collection.vectors to patch the in-place tunables of an existing named vector. size and distance are deliberately excluded — they are immutable.

FieldTypeDescription
on_diskboolMove the vector on / off disk.
hnsw_configHnswConfigSpecPer-vector HNSW params.
quantization_configQuantizationSpecPer-vector quantization.

HnswConfigSpec

HNSW index parameters. Only fields you set are sent — unset means "leave alone".

FieldTypeDescription
muintNumber of edges per node in the index graph.
ef_constructuintSize of the dynamic candidate list during construction.
full_scan_thresholduintVector count below which Qdrant uses a full scan instead of the index.
max_indexing_threadsuintMaximum threads to use when building the index.
on_diskboolStore the HNSW graph on disk.
payload_muintm value for the dedicated payload-filtered graph.

QuantizationSpec

Tagged union — set exactly one variant. Use disabled inside update_collection to turn quantization off.

# scalar
quantization_config:
  scalar:
    type: int8
    quantile: 0.99
    always_ram: true

# product
quantization_config:
  product:
    compression: x8
    always_ram: true

# binary
quantization_config:
  binary:
    always_ram: true

# disable (update_collection only)
quantization_config: disabled

ScalarQuantizationSpec

FieldTypeDescription
typestringQuantization type. Only int8 exists today. Default: int8.
quantilefloatQuantile used to clip outliers when computing the scale.
always_ramboolKeep quantized vectors in RAM.

ProductQuantizationSpec

FieldTypeDescription
compressionstringCompression ratio — one of x4, x8, x16, x32, x64.
always_ramboolKeep quantized vectors in RAM.

BinaryQuantizationSpec

FieldTypeDescription
always_ramboolKeep quantized vectors in RAM.

OptimizersConfigSpec

Optimizer thresholds and behavior. All fields optional.

FieldTypeDescription
deleted_thresholdfloatFraction of deleted points that triggers segment vacuum.
vacuum_min_vector_numberuintMinimum vectors per segment before vacuum is considered.
default_segment_numberuintTarget number of segments.
max_segment_sizeuintMaximum segment size in KB.
memmap_thresholduintSegment size in KB above which Qdrant memory-maps it.
indexing_thresholduintVector count above which a segment becomes indexed.
flush_interval_secuintInterval (seconds) between automatic flushes.

Distance

Cosine · Euclid · Dot · Manhattan

Datatype

float32 · uint8 · float16

PayloadSchemaType

keyword · integer · float · geo · text · bool · datetime · uuid

Drift detection (diff)

revector diff compares a declared collection spec against the live collection. It is declaration-driven: only fields you actually wrote in the spec are compared. A field you leave unset means "don't care", never "must be unset" — this avoids the classic Alembic-autogenerate false positives caused by Qdrant normalizing and defaulting config on read.

revector diff products --spec products.spec.yaml
# collection `products` has 1 difference(s):
#   vectors.<default>.size : declared 1024 | live 768

The spec file uses the same CollectionSpec shape as a create_collection op's spec: block — without the surrounding op: / name: keys, since diff already knows the collection from its CLI argument.

# products.spec.yaml
vectors:
  "":
    size: 1024
    distance: Cosine
hnsw_config:
  m: 16
  ef_construct: 128

diff exits non-zero when differences are found, so it slots into CI as a drift guard. Folding the full migration chain into a desired-state spec is future work — today you write the spec file by hand.

Model migration (end-to-end recipe)

You're switching embedding models — a new model, a new dimensionality, or a new distance metric. In Qdrant a vector's size and distance are immutable: the engine will not alter them in place, and you must not recreate the whole collection under a live service. The supported path is a three-beat dance:

add a new vector → re-embed your points into it → drop the old vector

revector owns the first and third beats as ordinary, reversible schema operations. The middle beat — running your model over your points — is the one thing a generic binary can't own, so revector shells out to your command via the exec op. This page walks the whole thing end-to-end, including the checkpoints where you stop and verify before anything destructive happens.

If you only want the mechanics of the exec-hook itself, see Re-embedding (the exec-hook). This guide is the full recipe built on top of it.

Pick a strategy

There are two shapes, and the right one depends on which vector you're changing:

StrategyUse whenCutoverRollback
Named-vector swap (in place)The collection uses named vectors and you can add a second one alongside the old.Switch your query code from text_v1 to text_v2.Roll back to "both vectors exist".
Collection rebuild + aliasYou're changing the default/unnamed vector, or you want a clean blue/green collection.switch_alias flips reads atomically.switch_alias back to the old collection.

The named-vector swap is the default — it's cheaper (no full copy of the collection) and keeps point ids stable. Reach for the alias rebuild when the vector you need to change is the unnamed default (which can't sit alongside a second default) or when you want a fully isolated new collection you can smoke- test before any traffic touches it.


Strategy 1 — Named-vector swap

Scenario: products has a named text_v1 vector (768-dim, an old model) and you're moving to a 1024-dim model under the name text_v2.

Split the work across three migrations so each destructive boundary is its own deliberate, reversible step:

0005_add_text_v2_vector      add the new (empty) vector       reversible
0006_reembed_text_v2         exec-hook: fill it with the new model
0007_drop_text_v1_vector     remove the old vector            one-way

Migration 1 — add the new vector

revision: "0005_add_text_v2_vector"
down_revision: "0004_..."
description: add text_v2 vector for the new embedding model

up:
  - op: create_vector
    collection: products
    name: text_v2
    spec:
      size: 1024
      distance: Cosine
# down: auto-inverts to delete_vector (the vector is still empty here).

create_vector auto-reverses to delete_vector, so this migration is freely reversible — at this point the new vector holds no embeddings worth keeping.

Migration 2 — re-embed via the exec-hook

revision: "0006_reembed_text_v2"
down_revision: "0005_add_text_v2_vector"
description: re-embed products into text_v2 with the new model

up:
  - op: exec
    name: re-embed products → text_v2
    command: "python scripts/reembed.py --collection products --target text_v2"

# `exec` has no automatic inverse. Re-running migration 1's down would already
# drop text_v2 and its data; only spell out a `down` here if you need a
# distinct compensating action.
down:
  - op: exec
    name: clear text_v2 (no-op placeholder)
    command: "true"

The command runs via sh -c, inherits the environment and stdio, and a non-zero exit aborts the migration — so make your script fail loudly. A minimal, resumable re-embed script:

# scripts/reembed.py
import argparse
from qdrant_client import QdrantClient, models
from my_embeddings import embed  # your new model

def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--collection", required=True)
    ap.add_argument("--target", required=True)       # the new vector name
    ap.add_argument("--source-field", default="text")  # payload field to embed
    ap.add_argument("--batch", type=int, default=256)
    args = ap.parse_args()

    client = QdrantClient(url="http://localhost:6334")
    offset = None
    while True:
        points, offset = client.scroll(
            collection_name=args.collection,
            with_payload=True,
            with_vectors=False,
            limit=args.batch,
            offset=offset,
        )
        if not points:
            break

        texts = [p.payload[args.source_field] for p in points]
        vectors = embed(texts)  # batch-encode with the NEW model

        client.update_vectors(
            collection_name=args.collection,
            points=[
                models.PointVectors(id=p.id, vector={args.target: v})
                for p, v in zip(points, vectors)
            ],
        )
        if offset is None:        # last page
            break

if __name__ == "__main__":
    main()

Notes that make this safe in production:

  • Idempotent. update_vectors only writes the named target vector, so re-running after a crash just overwrites the same points — exactly what you want, since Qdrant has no transactional DDL and revector's exec is resumable.
  • Only the target vector is touched. The old text_v1 and all payloads are left intact, which is what keeps migration 1's rollback lossless.
  • Fail loudly. Let exceptions propagate (non-zero exit) so a partial run aborts the migration instead of silently leaving half-embedded points.

Apply and verify

export REVECTOR_URL=http://localhost:6334
revector up            # applies 0005 then 0006
revector status        # confirm both are recorded as applied

Now verify before you drop anything. Point a few real queries at text_v2, compare recall against text_v1, and confirm every point actually has the new vector (a point missing from your --source-field would be skipped). This is the checkpoint the three-migration split exists to give you.

If something's wrong, you can still roll all the way back — nothing destructive has happened yet:

revector down          # undo 0006 (exec down)
revector down          # undo 0005 → delete_vector text_v2

Migration 3 — drop the old vector (one-way)

Once you've cut your query code over to text_v2 and you're happy, retire the old vector in its own migration:

revision: "0007_drop_text_v1_vector"
down_revision: "0006_reembed_text_v2"
description: drop the retired text_v1 vector

up:
  - op: delete_vector
    collection: products
    name: text_v1
# No down: dropping a vector destroys its data. revector refuses the downgrade
# rather than pretend it's reversible.
revector up            # applies 0007 — deletes text_v1

delete_vector is irreversible: revector will refuse a downgrade past this point instead of pretending the old embeddings can come back. Keeping it in a separate revision means everything before it stays rollback-able, and the destructive step is an explicit, reviewable boundary.


Strategy 2 — Collection rebuild + alias

Use this when you're changing the default/unnamed vector, or you want a fresh collection you can smoke-test in isolation before any read touches it. Callers talk to a stable alias the whole time; you build products_v2 alongside products_v1 and flip the alias atomically.

Assume your app already reads through the products alias (if it reads the raw collection name, add a create_alias first and switch your client to the alias).

Migration 1 — build the new collection

revision: "0005_build_products_v2"
down_revision: "0004_..."
description: build products_v2 with the new model dimensions

up:
  - op: create_collection
    name: products_v2
    spec:
      vectors:
        "":
          size: 1024          # new model's dimensionality
          distance: Cosine
      hnsw_config: { m: 16, ef_construct: 128 }
# down: auto-inverts to delete_collection products_v2 (still empty).

Migration 2 — re-embed into the new collection

revision: "0006_reembed_products_v2"
down_revision: "0005_build_products_v2"
description: embed all points into products_v2 with the new model

up:
  - op: exec
    name: re-embed products_v1 → products_v2
    command: "python scripts/reembed_into.py --source products_v1 --target products_v2"

Here the script reads points (and payloads) from products_v1, embeds with the new model, and upserts full points into products_v2. Same idempotency and fail-loud rules as Strategy 1; the difference is you're writing whole points into a separate collection rather than one named vector in place.

Migration 3 — flip the alias (the zero-downtime cutover)

revision: "0007_switch_products_alias"
down_revision: "0006_reembed_products_v2"
description: point the products alias at products_v2

up:
  - op: switch_alias
    alias: products
    to_collection: products_v2

# switch_alias does NOT record the previous target — spell out the inverse.
down:
  - op: switch_alias
    alias: products
    to_collection: products_v1

switch_alias is atomic, so reads move from v1 to v2 with no gap. Because you supplied a down, rolling this migration back instantly repoints the alias to the old collection — your blue/green safety net. Keep products_v1 around until you're confident, then retire it in a final, one-way migration:

revision: "0008_drop_products_v1"
down_revision: "0007_switch_products_alias"
description: drop the retired products_v1 collection

up:
  - op: delete_collection
    name: products_v1
# No down — delete_collection destroys data; revector refuses the downgrade.

Checklist & gotchas

  • Split destructive steps into their own migration. delete_vector / delete_collection are one-way; isolating them keeps every earlier step reversible and gives reviewers an explicit checkpoint.
  • Verify between re-embed and drop. Check recall and coverage on the new vector/collection before the destructive migration runs — that gap is the whole point of the split.
  • Make the embed script resumable. exec re-runs cleanly after a failure; use update_vectors (named-vector swap) or idempotent upserts (rebuild) so a retry overwrites rather than duplicates.
  • Let the script fail loudly. A non-zero exit aborts the migration; swallow errors and you'll record a "successful" half-embedded state.
  • Coordinate the model side with the qdrant-model-migration skill — revector orchestrates the schema and the cutover; that skill drives the embedding work itself.
  • Don't move points between clusters with revector. That's data movement — use qdrant/migration. revector only re-embeds in place via the exec-hook.

Re-embedding (the exec-hook)

Changing a vector's size or distance is structural — Qdrant can't mutate it in place. The path is: add a new named vector → re-embed points with your model → drop the old vector. The re-embedding step is the one thing a generic binary can't own, so revector shells out to your command via the exec op:

up:
  - op: create_vector
    collection: products
    name: text_v2
    spec: { size: 1024, distance: Cosine }

  - op: exec
    name: re-embed with the new model
    command: "python scripts/reembed.py --collection products --target text_v2"

  - op: delete_vector          # irreversible — make this a separate, deliberate migration
    collection: products
    name: text_v1

The command runs via sh -c, inherits the environment and stdio, and a non-zero exit aborts the migration.

Put the destructive step (delete_vector) in a separate migration applied after you've verified the new vector is healthy. Two migrations instead of one gives you:

  • A safe rollback point — you can roll back to "both vectors exist" without losing data.
  • An explicit checkpoint where someone has to look at the new state before the old one is destroyed.

Full recipe

This page covers the exec-hook mechanics. For the complete, copy-pasteable walkthrough — three migrations, a resumable re-embed script, the verify checkpoint, rollback at each stage, and the alias-based collection-rebuild variant — see Model migration (end-to-end recipe).

Adopting an existing collection

If you already have a collection that matches an early migration, stamp lets revector take over without re-creating it:

# Tell revector the DB is already at 0002 (no operations run)
revector stamp 0002_index_and_quantize
# Now apply everything after it normally
revector up

stamp accepts a revision id, the literal head (latest revision in the chain), or base (clear all tracking, mark nothing applied). It records every revision up to and including the target as applied, and removes any recorded revisions above it.

Use --dry-run to preview the change to the tracking collection without writing.

  1. Write the migrations that would have produced your current schema, in order. Don't apply them.
  2. Run revector validate to make sure the chain parses and resolves.
  3. Run revector stamp head --dry-run and confirm what would be written.
  4. Run revector stamp head for real.
  5. From now on, new schema changes go through revector new + revector up.

CI/CD integration

revector is a single static binary that reads its config from flags or REVECTOR_* environment variables and runs fully non-interactively — which is exactly what a pipeline needs. The natural split is:

  • On every pull request — run revector validate (offline, no Qdrant) so a broken or unorderable chain never merges. Optionally add revector diff as a drift guard.
  • On deploy / merge to main — run revector up against the target Qdrant so the schema moves forward in lockstep with the code that depends on it.

The two safety nets that make this trustworthy in automation are built in: the SHA-256 checksum guard refuses to proceed if an already-applied migration file was edited, and the advisory lock (held as a point in the tracking collection) stops two concurrent pipeline runs from applying at once. Neither needs configuration.

Non-interactive usage

Three things matter when running outside a TTY:

  1. Pass connection details as env vars, not flags, so secrets stay out of process listings and logs:

    export REVECTOR_URL="$QDRANT_URL"
    export REVECTOR_API_KEY="$QDRANT_API_KEY"   # from your CI secret store
    
  2. Use -y / --yes for anything that prompts. up is non-destructive and runs unattended, but down and to (when rolling back) require confirmation in an interactive shell — -y is mandatory for them in CI.

  3. Rely on exit codes. Every command exits non-zero on failure, so no extra grep-the-output logic is needed. revector diff exits non-zero when it finds drift; validate exits non-zero on a malformed or unresolvable chain.

Installing the binary in a runner

Use the prebuilt installer — no Rust toolchain required:

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.sh | sh

Pin to a specific release in CI rather than tracking latest, so a new release can't change behavior under you — swap latest/download for download/vX.Y.Z. (See Security & supply chain for verifying release artifacts.)

GitHub Actions

Validate on every pull request

This job needs no Qdrant and no secrets — it just parses the migration files and resolves the chain offline. Keep it fast and required.

# .github/workflows/migrations.yml
name: migrations
on:
  pull_request:
    paths:
      - "migrations/**"
      - "revector.toml"

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install revector
        run: |
          curl --proto '=https' --tlsv1.2 -LsSf \
            https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.sh | sh
      - name: Validate migration chain
        run: revector validate

Apply on deploy

Gate this on your deploy event (a push to main, a tag, or an environment deployment) and pull the Qdrant URL and API key from repository or environment secrets. The GitHub environment: key lets you require a manual approval before the migration runs.

  apply:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production       # optional: gate behind a required reviewer
    env:
      REVECTOR_URL: ${{ secrets.QDRANT_URL }}
      REVECTOR_API_KEY: ${{ secrets.QDRANT_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - name: Install revector
        run: |
          curl --proto '=https' --tlsv1.2 -LsSf \
            https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.sh | sh
      - name: Show plan
        run: revector status
      - name: Apply pending migrations
        run: revector up

revector up is idempotent: every operation checks existence before create/delete, so a re-run after a partial failure (or a duplicate pipeline trigger) is safe. A run with nothing pending is a no-op that exits zero.

Preview the plan on the PR

To see what a merge would change before it merges, run up --dry-run against a staging instance — it prints the ordered plan without touching anything:

      - name: Plan against staging
        env:
          REVECTOR_URL: ${{ secrets.STAGING_QDRANT_URL }}
          REVECTOR_API_KEY: ${{ secrets.STAGING_QDRANT_API_KEY }}
        run: revector up --dry-run

Drift detection as a guard

If you want CI to catch a live collection that has drifted from its declared spec (someone hand-edited config in the Qdrant dashboard, say), add a diff step. It exits non-zero on any difference, so a failing job flags the drift:

      - name: Check for drift
        run: revector diff products --spec specs/products.spec.yaml

This is declaration-driven — only fields you actually wrote in the spec are compared — so it won't false-positive on Qdrant's read-time defaulting. See the drift detection guide for the spec-file shape.

GitLab CI

The same shape maps onto any runner. Store QDRANT_URL and QDRANT_API_KEY as masked/protected CI/CD variables.

stages: [validate, deploy]

validate-migrations:
  stage: validate
  image: debian:stable-slim
  before_script:
    - apt-get update && apt-get install -y curl
    - curl --proto '=https' --tlsv1.2 -LsSf
        https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.sh | sh
    - export PATH="$HOME/.cargo/bin:$PATH"
  script:
    - revector validate

apply-migrations:
  stage: deploy
  image: debian:stable-slim
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  variables:
    REVECTOR_URL: $QDRANT_URL
    REVECTOR_API_KEY: $QDRANT_API_KEY
  before_script:
    - apt-get update && apt-get install -y curl
    - curl --proto '=https' --tlsv1.2 -LsSf
        https://github.com/diegoglozano/revector/releases/latest/download/revector-installer.sh | sh
    - export PATH="$HOME/.cargo/bin:$PATH"
  script:
    - revector status
    - revector up

Rollbacks in automation

Rollbacks are deliberately less automatic, because a down can be destructive or irreversible (revector refuses to auto-invert a step that needs unrecorded prior state — see Migration files). If you do wire a rollback step — for example a manually-triggered job — it must pass -y and should target an explicit revision rather than relying on step counts:

      - name: Roll back to a known-good revision
        run: revector to 0002_index_and_quantize -y

Prefer revector to <rev> -y over down --steps N in pipelines: it's explicit about the end state and idempotent if re-run.

Checklist

  • revector validate runs on every PR that touches migrations/.
  • Secrets come from the CI secret store as REVECTOR_API_KEY / REVECTOR_URL, never committed to revector.toml.
  • The apply job is gated on a deploy event and, ideally, a required reviewer / protected environment.
  • revector status (or up --dry-run) runs before up so the plan is in the logs.
  • The installer is pinned to a release tag, not latest.
  • Any rollback job passes -y and targets an explicit revision.

How state is tracked

Applied revisions are stored as points in the _revector_migrations collection (a dummy 1-d vector plus a payload of revision id, parent, checksum, and timestamp). Because the checksum of each migration file is recorded, revector refuses to proceed if a migration was edited after being applied — catching silent divergence between your files and the database.

You can rename the tracking collection via the tracking_collection config option (or the REVECTOR_TRACKING_COLLECTION env var), if _revector_migrations clashes with naming conventions in your cluster.

Advisory lock

While up / down / to / stamp is running, revector writes a lock record into the tracking collection so a second concurrent process refuses to start. If a previous run died mid-flight and left a stale lock, pass --force to override it.

This is best-effort — Qdrant has no compare-and-set primitive, so two processes that both check the lock at the exact same moment can both proceed. In practice the window is small and the common case (parallel CI jobs racing for the same DB) is caught reliably.

Inspecting the tracking collection

The tracking collection is a normal Qdrant collection — you can query it with the Qdrant API or dashboard if you need to debug. Each point's payload looks roughly like:

{
  "revision":      "0002_index_and_quantize",
  "down_revision": "0001_create_products",
  "checksum":      "sha256:…",
  "applied_at":    "2025-01-15T12:34:56Z"
}

Never edit these payloads by hand — use revector stamp instead.

Scope & limitations

  • Schema, not data. revector manages collection schema and config — collections, payload indexes, named vectors, aliases, and tunable knobs. Moving points between instances is out of scope; use qdrant/migration for that. The one data operation revector helps with — re-embedding — goes through the exec-hook.
  • Linear chains only (single base, single head) in v1 — branching/merging is rejected with a clear error.
  • Per-vector hnsw_config / quantization_config can't be set at create_vector time (Qdrant's add-vector API doesn't accept them); apply them with a follow-up update_collection step.
  • diff reads a standalone spec file; folding the full migration chain into a desired-state spec is future work.

Security & supply chain

  • Dependency scanning. cargo-deny runs in CI (and weekly, to catch newly-published advisories) checking vulnerabilities, licenses, banned/duplicate crates, and that every dependency comes from crates.io. Config: deny.toml.
  • Automated updates. Dependabot opens weekly PRs for Rust deps and GitHub Actions, so security patches land promptly.
  • Build provenance. Release binaries carry SLSA build-provenance attestations generated by cargo-dist, so you can verify an artifact was built by this repo's CI:
    gh attestation verify revector-x86_64-unknown-linux-gnu.tar.xz \
        --repo diegoglozano/revector
    
  • Reproducibility. Cargo.lock is committed and cargo publish --locked is used, so published builds resolve to pinned versions.
  • Minimal runtime surface. The known advisories in the tree are confined to dev-dependencies (the test harness) and don't ship in the binary; see deny.toml for the tracked exceptions.

Development

cargo test                       # unit + logic tests
cargo clippy --all-targets       # lints
cargo fmt                        # format

Integration tests spin up a real Qdrant via testcontainers (Docker required); they skip automatically when Docker is unavailable. To run them against an already-running Qdrant instead:

REVECTOR_TEST_URL=http://localhost:6334 cargo test --test integration

Building the docs site

The site you're reading is built with mdBook:

cargo install mdbook       # one-time
mdbook serve docs --open   # live preview on http://localhost:3000
mdbook build docs          # static site → docs/book/

docs/book/ is gitignored — CI builds and publishes the site to GitHub Pages on every push to main. The source lives under docs/src/.