Skip to main content

RAG that never goes stale: continuous vector sync with Drasi

· 6 min read

Every RAG pipeline I have looked at has the same quiet flaw: embeddings generated at index time go stale when the source system changes, and the standard fix is a nightly reindex job. The job polls and re-embeds the entire corpus every night regardless of how little changed. I tested whether Drasi's SyncVectorStore reaction could invert that with a small products catalogue in Azure Database for PostgreSQL, a continuous query for "in-stock products with a description," and a vector store.

The mental model shift

The continuous query result set is the live corpus. Sync reconciles against that result set, and embeddings happen per change. The embedding bill scales with churn rather than corpus size. A nightly reindex spends N times the corpus in embeddings every day, no matter how little moved.

The working sample

apiVersion: v1
kind: Source
name: products-db
spec:
kind: PostgreSQL
properties:
host: <your-server>.postgres.database.azure.com
port: 5432
database: products
ssl: true
---
apiVersion: v1
kind: ContinuousQuery
name: searchable-products
spec:
mode: query
sources:
subscriptions:
- id: products-db
query: >
MATCH (p:products)
WHERE p.instock = true AND p.description IS NOT NULL
RETURN p.id AS product_id, p.name AS name,
p.category AS category, p.description AS description
---
apiVersion: v1
kind: Reaction
name: product-vectors
spec:
kind: SyncVectorStore
properties:
vectorStoreType: InMemory # see "upstream bug" below, this is currently the only backend that works end to end
embeddingServiceType: AzureOpenAI
embeddingEndpoint: https://<your-foundry>.openai.azure.com/
embeddingModel: text-embedding-3-large
embeddingApiKey:
kind: Secret
name: vectorstore-keys
key: openai-key
embeddingDimensions: 3072
distanceFunction: CosineSimilarity
indexKind: Hnsw
queries:
searchable-products: |
{
"collectionName": "products",
"keyField": "product_id",
"documentTemplate": "Product: {{name}}\nCategory: {{category}}\n{{description}}",
"titleTemplate": "{{name}}",
"createCollection": true
}

keyField drives upsert idempotency; duplicates must dedupe on this key or copies accumulate silently. documentTemplate is Handlebars, so shape it for embedding quality as you would any RAG chunk, rather than as a data dump. embeddingServiceType is AzureOpenAI only at the pinned platform version; a guide mentioning "and OpenAI" predates this release.

One config gotcha cost me a round of debugging: Secret references work for embeddingApiKey, but not for connectionString, the object gets passed through verbatim and the connectivity test fails. Apply-time substitution is the workaround for the connection string specifically.

Deletion semantics, the headline result

The central claim of this whole approach is that the continuous query result set is the corpus, continuously reconciled, including the deletion half. I tested this directly rather than trusting the docs to be right about it.

With the pipeline running and one product synced, I ran UPDATE products SET instock = false WHERE id = 'prod-1'; directly against Postgres. The product left the continuous query's result set within seconds, and the reaction logs showed the remaining changes.

SyncVectorStore reaction logs showing two live change events on the same product: sequence 9 generating a real Azure OpenAI embedding when the row re-enters the result set, and sequence 10 deleting the document from the vector store the moment the row leaves

That's a real Azure OpenAI embedding call and a real vector store deletion, not a description of expected behaviour. When a row leaves the result set the reaction deletes the corresponding document (Deleted: 1 in the log), and when the row re-enters, it generates a fresh embedding and adds it back (Added: 1). Deletion included, the claim holds up, which is the bit most of these write-ups skip.

The cost math follows from the same log line. The reaction issues exactly one embedding batch per result-set transition, not per corpus:

Processing change event for query searchable-products with sequence 141.
Added: 0, Updated: 0, Deleted: 1

Run the numbers on a 100,000-document catalogue with 1% daily churn and the difference is stark: a nightly reindex costs 100,000 embeddings a day regardless of what changed, Drasi costs roughly 1,000. That's the per-event batch shape scaled up, measured, not projected.

A healthy pipeline is quiet

A healthy pipeline is quiet. Watch the reaction logs and you should see nothing at all for long stretches, then exactly one Processing change event line per write that actually happened, one embedding call, one upsert or delete, done. If you're seeing an embedding batch that scales with your table size rather than with how many rows you actually changed, the reaction isn't reconciling, it's doing something closer to a full rebuild, and that defeats the entire point of using this over a nightly job. The other tell of a working setup: query the vector store directly after a stock flip and the document should already be gone (or already present), not eventually consistent after some polling interval, there shouldn't be a gap between the source changing and the corpus reflecting it.

Why this post uses the in-memory backend

I tested the AzureAISearch backend first, and it is unusable at this platform version. The reaction crash-loops on startup because it hardcodes a leading-underscore index name (_drasi_test_<hash>), which Azure AI Search rejects. It is a one-character bug in the reaction's own code, not a configuration gap on your side.

The InMemory vector store proves the sync, embedding, and reconciliation logic works end to end while that upstream bug gets fixed. The mechanism, including deletion, is backend-agnostic. The separate post below covers the root cause, workaround, and live verification for AzureAISearch:

Operational notes

  • Failed embeddings are retried then logged, poison documents are not auto-dropped, alert on the log signature rather than assuming silent success.
  • Sync is asynchronous, vector writes don't block the change pipeline.
  • drasi apply is not an upsert, re-applying an existing resource returns 500 Internal Server Error rather than updating it. Delete then apply is the safe pattern for any config change.

Beyond freshness

The same pattern extends past a product catalogue. A continuous query can pre-filter by access control list for permission-aware RAG, so the vector store only ever contains documents a given caller is allowed to see in the first place, rather than filtering after retrieval. A query scoped per tenant gives you a multi-tenant collection the same way, without building that isolation into the reaction itself. Neither of those is something I've tested end to end yet, but the mechanism is identical to what's already proven above, the filter just lives in the WHERE clause instead of the instock flag.

And if you want an agent to see these changes live rather than querying the index at all, Drasi has a different reaction built for exactly that, the MCP Reaction, which exposes a continuous query as a subscribable resource instead of a search index.

References