Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// how-to · 12 min read

Secure RAG for regulated data

Build a retrieval pipeline where the corpus is encrypted at rest under a key Ringside cannot read. You create a bring-your-own-key vector store, hold the passphrase yourself, take a short-lived key lease to ingest and query, then lock the store when idle. This guide walks the full lifecycle with real headers, error codes and the limits that bite.

What this gives you and why it exists

Standard RAG stores your documents in a vector database in the clear. Anyone with a database dump, a backup or host access can read the chunks. For most workloads that is fine. For regulated data (patient records, KYC files, privileged legal text, source you are contractually barred from exposing) it is not, because the threat model includes us, the operator. A bring-your-own-key (byok) vector store removes Ringside from that threat model: the data encryption key (DEK) that seals every chunk and vector is wrapped under a passphrase you hold and we never persist.

Mechanically, each sealed store has a random per-store 32-byte DEK. Chunk text is sealed with AES-256-GCM under that DEK, and with vector_sealing: full the embedding vectors are sealed too, so nothing about the corpus leaks at rest. The difference between custody modes is who can unwrap the DEK. Under managed, the DEK is wrapped with our server key and we can always open it. Under byok, the DEK is wrapped under a key derived (scrypt) from your passphrase, and we store only the wrap, a salt and a verifier. With no active key lease, a byok store is opaque to us. That is the entire point.

Encryption custody modes
encryptionDEK custodyAt restCan Ringside read it?
noneNo DEK; plaintext in WeaviateChunks + vectors plaintextYes
managedDEK wrapped under our server keySealed under the DEKYes (we hold the key)
byokDEK wrapped under your passphraseSealed under the DEKNo, unless you hold a key lease open
No passphrase recovery

We persist the wrap, a scrypt salt and a verifier, never the passphrase and never a server-openable copy of the DEK. Lose the passphrase and the corpus is unrecoverable. There is no reset that gets the data back, only a delete-and-reingest. Treat the passphrase like any other root secret.

The lifecycle

create (encryption=byok, key passphrase)
        |
        v
   [ UNLOCKED ]  <-- first lease opened automatically at create
        |   ^
 ingest |   | POST .../unlock   (or FC-Vector-Store-Key header)
 query  |   |
        v   |
   roll TTL on every cryptographic use (1 h idle window)
        |
        | 1 h with no ingest/query   OR   POST .../lock
        v
   [ LOCKED ]  --> ingest fails the file with vault_locked;
                   retrieval returns no results
A byok store moves between locked and unlocked on a rolling idle timer.
  1. 01Create the store with encryption: "byok", vector_sealing: "full" and a passphrase key. Create opens the first key lease for you, so you can ingest right away.
  2. 02Attach files. Ingest is async; poll each file row until status is completed.
  3. 03Query via the file_search tool inside an Assistants run while a lease is active.
  4. 04Lock the store when you are done, or let the 1-hour idle window expire.
  5. 05Next time, present the passphrase via POST .../unlock (or the FC-Vector-Store-Key header) to take a fresh lease.

Create a sealed byok store

Send a POST to /v1/vector_stores with the custody fields and a passphrase key. The passphrase must be at least 12 characters. It is used once to derive the wrapping key and is never stored.

curl https://api.fightclub.pro/v1/vector_stores \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "kyc-case-files",
    "embedding_model": "text-embedding-3-small",
    "encryption": "byok",
    "vector_sealing": "full",
    "key": { "type": "passphrase", "passphrase": "correct-horse-battery-staple" }
  }'

Create-time parameters that matter for sealing

encryption
none | managed | byok, default none. byok is the only mode where we cannot read the store at rest. A bad value returns 400 invalid_encryption.
vector_sealing
none | source | full, defaults to full when the store is sealed and is ignored when encryption is none. source seals the original chunk text only; full also seals the embedding vectors. Setting it on a plaintext store returns 400 invalid_vector_sealing.
key
Required when encryption is byok. Shape { "type": "passphrase", "passphrase": "<>=12 chars>" }. type: "kms" is reserved for cloud-KMS custody and currently returns 400 byok_kms_unavailable. A missing or malformed key returns 400 byok_key_required; a short passphrase returns 400 byok_passphrase_too_short.
vector_index
auto | flat | ivf, default auto. ivf is for large sealed corpora (see below) and requires a sealed store; requesting it on a plaintext store returns 400 ivf_requires_encryption.
embedding_model
Fixed at create. Change it later only by running a migration (POST .../migrate), which re-embeds and swaps the index atomically.
Passphrase rules

Minimum 12 characters, enforced at create. Beyond the floor, treat it as a high-entropy secret: it is the only thing standing between an attacker with database access and your plaintext. A long passphrase or generated diceware string beats a short complex one, because scrypt makes length the cheap lever.

Take a key lease

A key lease is a short-lived, server-key-encrypted cache of the unwrapped DEK, stored exactly like a vault session. Create opens the first lease automatically. After that, every byok store starts each session locked, and ingest or query needs an active lease. You open one in two ways.

Explicit lease: POST .../unlock

curl https://api.fightclub.pro/v1/vector_stores/vs_8fK2pQ7r/unlock \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": { "passphrase": "correct-horse-battery-staple" } }'

The body accepts either { "key": { "passphrase": "..." } } or a bare { "passphrase": "..." }. A wrong passphrase returns 401 invalid_passphrase (we cannot tell you whether it was close; the verifier either matches or it does not). Calling /unlock on a non-byok store returns 400 not_a_byok_store, and an empty passphrase returns 400 missing_passphrase.

Per-request lease: the FC-Vector-Store-Key header

For a one-shot ingest or query you can skip the separate /unlock round trip and present the passphrase inline with the FC-Vector-Store-Key request header. It takes a lease for the duration of the call. The passphrase is still never persisted. Use the explicit /unlock endpoint when you are about to do a burst of ingest or many queries, so you authenticate the passphrase once and ride the rolling window.

curl https://api.fightclub.pro/v1/vector_stores/vs_8fK2pQ7r/files \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "FC-Vector-Store-Key: correct-horse-battery-staple" \
  -H "Content-Type: application/json" \
  -d '{ "file_id": "file_3Jq9Zt" }'
Lease TTL and the rolling window

A lease has a 1-hour idle TTL. Every real cryptographic use (an ingest or a query that actually unwraps the DEK) rolls the window forward another hour. A store that is actively serving traffic stays unlocked. A store left untouched for an hour idle-locks on its own. That is the behaviour you want for regulated data, because a forgotten lease does not stay open indefinitely.

Ingest under a lease

Upload your documents as files first (purpose does not matter for attach), then attach them to the store. Ingest is asynchronous. Attach returns immediately with the file row in a pending state; the rag-worker fetches a transient lease, parses, chunks, embeds and seals each chunk under the DEK. Poll the file row until status is completed.

File ingest states
statusMeaning
pendingAttached, not yet picked up
in_progressParsing, chunking, embedding and sealing
completedSealed and queryable
failedIngest errored; inspect and retry via POST .../retry
cancelledCancelled via POST .../cancel before completion
STORE=vs_8fK2pQ7r
KEY=correct-horse-battery-staple

# Open a lease for the whole ingest burst
curl -s https://api.fightclub.pro/v1/vector_stores/$STORE/unlock \
  -H "Authorization: Bearer $FC_API_KEY" -H "Content-Type: application/json" \
  -d "{\"key\":{\"passphrase\":\"$KEY\"}}" >/dev/null

# Attach a file (lease already open, no per-call key needed)
FILE=$(curl -s https://api.fightclub.pro/v1/vector_stores/$STORE/files \
  -H "Authorization: Bearer $FC_API_KEY" -H "Content-Type: application/json" \
  -d '{"file_id":"file_3Jq9Zt"}' | jq -r .id)

# Poll until completed
while :; do
  S=$(curl -s https://api.fightclub.pro/v1/vector_stores/$STORE/files/$FILE \
        -H "Authorization: Bearer $FC_API_KEY" | jq -r .status)
  echo "status=$S"; [ "$S" = completed ] || [ "$S" = failed ] && break
  sleep 2
done

For bulk loads, attach up to 500 files in one call with POST .../file_batches. A larger batch returns 400 too_many_files. If the lease expires mid-ingest because the corpus is large and slow, individual files fail closed rather than ingesting in the clear; take a fresh lease and retry the failed rows.

Query under a lease

There is no direct query endpoint. You retrieve from a store through the OpenAI-compatible file_search tool inside an Assistants run, which keeps the same surface for sealed and plaintext stores. Attach the store id under tool_resources.file_search.vector_store_ids and hold a lease open (or present FC-Vector-Store-Key) for the duration of the run. The DEK is unwrapped in the web tier to open chunks and rank vectors, then dropped.

curl https://api.fightclub.pro/v1/assistants \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fc:openai/gpt-4o-mini",
    "instructions": "Answer only from the attached case files. Cite the source.",
    "tools": [{ "type": "file_search" }],
    "tool_resources": { "file_search": { "vector_store_ids": ["vs_8fK2pQ7r"] } }
  }'

If the store has GraphRAG on, file_search also returns a facts array of {subject, predicate, object} relationships next to the chunk citations. On a sealed store the labels and predicates are sealed under the store DEK and resolved in-tier, so the graph stays encrypted at rest while topology stays usable. GraphRAG bills as its own per-GB-day storage line, with the first 1 GB-day per store per day free.

Lock when idle

When a working session ends, drop the lease explicitly rather than waiting for the idle timer. Locking shrinks the window in which a compromised web tier could read the DEK from the unlock-session cache.

curl https://api.fightclub.pro/v1/vector_stores/vs_8fK2pQ7r/lock \
  -H "Authorization: Bearer $FC_API_KEY"

After locking, the store fails closed. A new ingest under no lease marks the file failed with last_error.code set to vault_locked. A retrieval run against a locked store returns no chunks rather than surfacing ciphertext, so a run that comes back empty after an idle gap is your signal to re-/unlock (or pass FC-Vector-Store-Key) and retry. Branch on the code, never the human message.

Large sealed corpora: the ivf index

Sealing has a cost. With vector_sealing: full, the vectors are ciphertext at rest, so the default warm path (vector_index: auto, which resolves to flat on small stores) must decrypt vectors to rank them and holds the working set in RAM. That is fast and leaks nothing, but it does not scale to very large stores you would rather not hold decrypted in memory. For those, set vector_index: "ivf".

Index strategies on a sealed store
vector_indexSearch pathLeakageUse when
auto / flatWarm in-RAM scan of decrypted vectorsNone at restSmall to medium stores
ivfCold inverted-file probe over clustersCoarse topology via plaintext centroidsLarge corpora you do not want held decrypted in RAM
ivf is a deliberate tradeoff

ivf clusters the store's vectors and keeps the cluster centroids in plaintext so it can probe without decrypting the whole index. That leaks coarse geometry of your embedding space. It does not reveal chunk text or exact vectors, but it is strictly more than zero. Choose ivf only when the corpus is large enough that warm in-RAM scanning is the bigger problem. ivf requires a sealed store; on a plaintext store you get 400 ivf_requires_encryption.

Errors and edge cases

Errors specific to sealed and byok stores
StatuscodeWhen
400invalid_encryptionencryption is not none, managed or byok
400invalid_vector_sealingvector_sealing set on a plaintext store, or not one of none/source/full
400byok_key_requiredencryption: byok with no (or malformed) key object
400byok_passphrase_too_shortpassphrase under 12 characters
400byok_kms_unavailablekey.type: "kms" requested; cloud-KMS custody is not yet available
400ivf_requires_encryptionvector_index: ivf on a plaintext store
400not_a_byok_store/unlock or /lock called on a managed or plaintext store
400missing_passphrase/unlock with an empty passphrase
401invalid_passphrasewrong passphrase at /unlock
404vector_store_not_foundno such store, or cross-tenant access (404, not 403, so ids are not enumerable)
409vector_store_unavailableattach attempted while the store is deleting or deleted
402vector_store_suspendedattach attempted while the store is suspended for non-payment

A locked byok store does not reject ingest or query synchronously. There is no 409 here. Attach still returns 201; the async ingest then fails the file row, setting status to failed and last_error.code to vault_locked, because the worker could not get the DEK without a lease. A retrieval run against a locked store returns no chunks. So a lock surfaces as a failed file or an empty run, never as ciphertext.

Every error body follows the standard Ringside shape: { "error": { "type", "code", "message", "param"? } }. The request id is in the X-Request-Id response header, never the body. Always branch on code. A wrong-passphrase invalid_passphrase at /unlock and a vault_locked file failure call for different recovery (fix the secret versus take a fresh lease and retry the file), so do not pattern-match on the message string.

Lease state is separate from store status

The status field on a store reflects its lifecycle (active, deleting, suspended), not whether a key lease is open. Lease state lives in the unlock session. Before a batch job, take a fresh lease with /unlock and let it succeed, rather than relying on a status field to tell you the store is open. A successful /unlock returns unlocked: true and the lease expires_at.

Limits and defaults to plan around

  • ·Passphrase: minimum 12 characters, derived once, never stored.
  • ·Lease TTL: 1 hour idle, rolled forward on every cryptographic use.
  • ·File batch: up to 500 files per file_batches call (too_many_files past that).
  • ·Store metadata: 4 KiB serialized, enforced on create and update (metadata_too_large past that). Keep to the platform metadata convention of at most 16 keys, key <=64 chars and value <=512 chars.
  • ·File upload size: 512 MB per file.
  • ·Query log pagination: GET .../queries defaults to 50, max 200; other lists default 20, max 100.
  • ·Embedding model is fixed at create; change it only via POST .../migrate.

A safe operating pattern

  1. 01Store the passphrase in your own secrets manager (Vault, a KMS-backed secret, an HSM-fronted store). Never in code, env files you commit or logs.
  2. 02Open one lease per work session with /unlock, do all ingest and queries inside the rolling window, then /lock explicitly when done.
  3. 03Prefer FC-Vector-Store-Key for genuinely one-shot calls so no lease lingers; prefer /unlock plus /lock for bursts.
  4. 04Default to vector_sealing: full and vector_index: auto. Only reach for ivf once the corpus is large enough that warm scanning hurts, and accept the coarse-topology leak when you do.
  5. 05Treat a vault_locked file failure or an empty retrieval run as a re-lease-and-retry signal, not an error to surface to end users.