Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// concept · 11 min read

Sealed RAG and encryption

A vector store holds your documents in a form an attacker would love: chunk text plus high-dimensional embeddings that can be inverted back toward the source. Ringside lets you seal that store at rest with one of three custody modes (none, managed, byok) and choose how much of it is encrypted, so a database dump, a stolen disk or a curious operator sees ciphertext instead of your corpus.

Why this exists

Retrieval-augmented generation means you upload private text (contracts, support history, internal wikis) and Ringside embeds it, stores the vectors and retrieves the nearest chunks at query time. That store is now a high-value target. Two things leak if it is plaintext: the chunk text itself, and the embedding vectors, which are not one-way. Embedding inversion attacks reconstruct a readable approximation of the source text from the vector alone, so "we only stored the numbers" is not a defence.

Sealing closes that gap. A sealed store encrypts chunk text (and, by default, the vectors too) under a per-store data encryption key (DEK) using AES-256-GCM. The mental model is an envelope: each store gets its own random 32-byte DEK, the DEK is wrapped under a higher key, and the wrap is what sits in Postgres. Who holds the key that unwraps the DEK is the whole question, and it is exactly what encryption selects.

Where sealed stores live

A plaintext store (encryption: none) is materialized as a Weaviate tenant and queried there. A sealed store has no Weaviate tenant. Its chunks live in Postgres as ciphertext and are scored in the web tier after the DEK is unwrapped transiently in memory. This is a different retrieval backend, which is why a few index choices below apply to sealed stores only.

The three custody modes

none
No encryption. Chunks and vectors are stored plaintext in a Weaviate tenant. Fastest, and the right default when the corpus is not sensitive. A DB dump or disk image exposes everything.
managed
The per-store DEK is wrapped under the server key (ENCRYPTION_KEY, a 32-byte AES key) and the wrap is stored on the store row. A database dump is ciphertext, because the wrapped DEK is useless without the server key. We can still unwrap on demand to run retrieval, so this protects against stolen data at rest, not against us.
byok
Bring your own key. The DEK is wrapped under a key derived from a passphrase you present, via scrypt. We persist only the wrap, a scrypt salt and a verifier, never the passphrase and never a server-openable copy of the DEK. When the store is locked, the data is ciphertext even to us, because we hold no key that opens it.

Managed and byok use identical seal and open primitives (AES-256-GCM under the DEK). The only difference is DEK custody: managed wraps under the server key, byok wraps under your passphrase-derived key. That single difference is what moves the trust boundary off our servers.

What each mode protects against

Protection by custody mode. "Locked" byok means no active unlock session.
Threatnonemanagedbyok (locked)
Stolen DB dump or disk imageExposedCiphertextCiphertext
Postgres replica / backup leakExposedCiphertextCiphertext
Embedding inversion from stored vectorsExposedCiphertext with vector_sealing: fullCiphertext with vector_sealing: full
Ringside operator with server-key accessExposedReadable (we hold the key)Ciphertext (no key without your passphrase)
Legal compulsion served on RingsideData producibleData producibleOnly ciphertext producible while locked
Query latency costNone (native Weaviate)Decrypt-on-retrieveDecrypt-on-retrieve + unlock needed
Important

byok shifts a real burden onto you. There is no passphrase recovery. Lose the passphrase and the store is unrecoverable ciphertext, by design. Treat the passphrase like any other root secret: store it in your own secrets manager, not in a config file next to your API key.

What gets sealed: vector_sealing

vector_sealing controls how much of a sealed store is encrypted. It is ignored when encryption is none, and it defaults to full the moment a store is sealed, so the safe choice is the one you get for free.

none
Only valid on an unsealed store (it is the implicit value there). Passing vector_sealing on an encryption: none store returns 400 invalid_vector_sealing.
source
Chunk text is sealed under the DEK; embedding vectors are stored in plaintext. Retrieval scores against plaintext vectors (slightly cheaper, no per-vector decrypt), but you accept that anyone who reads the vectors can attempt embedding inversion. Use this only when the text matters and the vectors do not.
full
Default when sealed. Both chunk text and vectors are sealed. At query time we unwrap the DEK, decrypt the candidate vectors in memory to score them, then decrypt only the top-k chunk texts to build citations. Defends against embedding inversion because the vectors never sit at rest in the clear.
Why full is the default

Sealing the text but leaving the vectors plaintext is a half-measure that a reviewer will flag, because the vectors alone leak an approximation of the text. If you seal at all, seal full unless you have measured a latency problem and decided the source-only tradeoff is acceptable.

Index strategy: vector_index

Sealed stores are scored in the web tier, so the index choice is about how much of the corpus has to be decrypted to answer a query. vector_index is auto, flat or ivf. auto and flat behave the same today: a warm in-RAM scan that decrypts the store vectors once and ranks them. ivf is the opt-in mode for large corpora.

auto (default) / flat
Warm scan. The DEK decrypts the store vectors into memory and the query vector is scored against all of them. Simple and exact. Holds the decrypted vectors in RAM, which is fine until the corpus is large.
ivf
Inverted-file clustering. The vectors are k-means clustered; plaintext centroids and a plaintext per-chunk cluster id are stored, and a query decrypts only the chunks in the nearest few clusters (roughly 15% of clusters probed per query). This is the mode for a big corpus you do not want held fully decrypted in RAM. It requires a sealed store: vector_index: ivf on an encryption: none store returns 400 ivf_requires_encryption.
ivf leaks coarse topology

The centroids and cluster ids are plaintext, so an attacker who reads them learns which documents cluster together (coarse grouping), but not their content. The vectors and chunk text stay sealed under the DEK exactly as in flat mode; only the cluster assignment is in the clear. Choose ivf when corpus size forces it and you accept that grouping leak; otherwise stay on auto.

flat / auto  (warm scan)              ivf  (cluster probe)

  query vector                          query vector
       |                                     |
  decrypt ALL store vectors            score vs plaintext centroids
       |                                     |
  cosine rank in RAM                   pick nearest ~15% clusters
       |                                     |
  decrypt top-k chunk text             decrypt ONLY those chunks
       |                                     |
  citations                            cosine rank -> decrypt top-k text
flat decrypts the whole corpus per query; ivf decrypts only the probed clusters.

Creating a sealed store

Sealing is set at create time on POST /v1/vector_stores. The relevant body fields are encryption, vector_sealing, vector_index and, for byok, key. The example below creates a managed store with full sealing under customer cus_42.

curl https://api.fightclub.pro/v1/vector_stores \
  -H 'Authorization: Bearer ko_2f9c...' \
  -H 'FC-Customer: cus_42' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "contracts-2026",
    "embedding_model": "text-embedding-3-large",
    "encryption": "managed",
    "vector_sealing": "full",
    "vector_index": "auto"
  }'

byok: presenting your key

A byok store needs a passphrase key at create. The passphrase must be at least 8 characters. We derive a key from it with scrypt (the same hardened KDF the per-user vault uses), wrap the DEK under it and store only the wrap, a salt and a verifier. The create call also opens the first unlock session transiently so you can ingest immediately without a separate unlock.

curl https://api.fightclub.pro/v1/vector_stores \
  -H 'Authorization: Bearer ko_2f9c...' \
  -H 'FC-Customer: cus_42' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "legal-privileged",
    "embedding_model": "text-embedding-3-large",
    "encryption": "byok",
    "vector_sealing": "full",
    "key": { "type": "passphrase", "passphrase": "correct-horse-battery-staple" }
  }'

Unlock, lock and the idle lease

A byok store is useless to ingest or query while locked, because we hold no key. To work with it you take a lease: an unlock session that caches the unwrapped DEK server-side (itself encrypted under the server key, exactly like a vault session) with a 1 hour rolling idle TTL. Every real cryptographic use rolls the window forward; leave it idle past an hour and it locks.

  • ·POST /v1/vector_stores/{id}/unlock with body { "key": { "passphrase": "..." } } (or a bare { "passphrase": "..." }) opens the session and returns the expiry. A wrong passphrase returns 401 invalid_passphrase. Calling unlock on a non-byok store returns 400 not_a_byok_store.
  • ·POST /v1/vector_stores/{id}/lock drops the session immediately. It is idempotent: locking an already-locked store returns 200.
  • ·The FC-Vector-Store-Key request header carries the passphrase on a request so a single call can take the lease inline instead of a separate /unlock round-trip. The passphrase is never persisted either way.
curl https://api.fightclub.pro/v1/vector_stores/vs_8Kp3qR7mWx2nLv4d/unlock \
  -H 'Authorization: Bearer ko_2f9c...' \
  -H 'Content-Type: application/json' \
  -d '{ "key": { "passphrase": "correct-horse-battery-staple" } }'
Fail-closed behaviour when locked

If a byok store has no active lease, ingest fails the file with code vault_locked and retrieval returns no results rather than surfacing ciphertext. A query against a locked store is not an error you see as a 500; it is an empty result set. Build your client to unlock before a retrieval run and to re-prompt for the passphrase if a turn comes back empty after an idle gap.

GraphRAG on a sealed store

Set graphrag_enabled: true and Ringside also builds a knowledge graph, so file_search returns a facts[] array of {subject, predicate, object} relationships next to the chunk citations. On a plaintext store that graph lives in the Neo4j sidecar. On a sealed store it cannot, because the sidecar would need the key, so the graph is built and walked in the web tier against Postgres.

On a sealed store the entity labels and the relationship predicates are sealed under the same store DEK. Node ids are random and the edge topology plus the chunk anchors stay plaintext, so traversal needs no key and only the final fact projection decrypts. Entity resolution is zero-leak: a new mention is matched against the store's existing labels decrypted into memory once per ingest run, never through a persisted blind index.

Honest scope of the sealed graph

The graph structure (which node links to which, and node degree) stays visible at rest. Only the labels, predicates and text are sealed. Closing that structural leak needs a confidential-compute tier, which is the enclave option below. If your threat model includes an attacker who can learn enough from raw graph shape, do not rely on sealed GraphRAG alone.

GraphRAG bills as a per-GB-day line, with the first 1 GB-day per store per day free. It composes with sealing: a byok store can run GraphRAG, the labels and predicates just seal under your key like every other sealed field.

Error reference

Branch on `code`, never on the human-readable `message`.
HTTPcodeWhen
400invalid_encryptionencryption is not one of none, managed, byok.
400invalid_vector_sealingvector_sealing set on an encryption: none store.
400invalid_vector_indexvector_index is not one of auto, flat, ivf.
400ivf_requires_encryptionvector_index: ivf on an unsealed store.
400byok_key_requiredbyok requested without key: {type: "passphrase", passphrase}.
400byok_passphrase_too_shortbyok passphrase is shorter than the minimum.
400byok_kms_unavailablekey.type: "kms" requested; cloud-KMS byok is on request, not self-serve.
400not_a_byok_store/unlock or /lock called on a store that is not byok.
400missing_passphrase/unlock called without a passphrase.
401invalid_passphrase/unlock passphrase did not match the verifier.
404vector_store_not_foundStore id is not owned by the caller. Cross-tenant access is 404, not 403, so ids are not enumerable.

Limits and defaults

  • ·encryption defaults to none. vector_sealing defaults to full once a store is sealed, and is ignored when none. vector_index defaults to auto.
  • ·byok passphrase minimum is 8 characters. There is no passphrase recovery.
  • ·byok unlock lease is a 1 hour rolling idle TTL, extended only by real cryptographic use (ingest or query), not by passive status reads.
  • ·The embedding model is fixed at create; change it via POST /v1/vector_stores/{id}/migrate. Sealing custody (encryption) is also a create-time decision.
  • ·Store metadata cap is 4 KB. A file batch is up to 500 files. Vector-store query lists default to 50, max 200.
  • ·Cloud-KMS byok and a confidential-compute (enclave) BYO-RAG tier that closes the structural leaks above are available on request, not self-serve.
Choosing a mode quickly

Not sensitive: none. Sensitive, and "they cannot read a stolen backup" is enough: managed with vector_sealing: full. Regulated or privileged data where Ringside itself must be outside the trust boundary: byok. Large sealed corpus that should not sit fully decrypted in RAM: add vector_index: ivf and accept the coarse-topology leak.