Six knowledge types, one query
Architecture
Distributed Systems
RAG

Saral Hemnani
Founding Engineer
20 minutes
read
Last Edited:

Our platform runs AI agents for hundreds of organizations. Every one of those agents needs to know things — a return policy, a product catalog, an uploaded spreadsheet of store locations, a set of web pages to search. We call each of these a knowledge base (KB), and an agent reaches for them mid-conversation to ground its answers.
The catch is that “a thing an agent can know” turns out to be wildly different things:
a CSV the customer uploaded,
a set of documents (PDFs, docs) to chunk and embed,
a media catalogue of images and video,
a list of web links to search live,
a blob of raw JSON (config, policies),
and — the interesting one — a live mirror of data that already exists in our own database, like a Shopify product catalog that changes every hour.
Physically, these have nothing in common. A 450-row CSV and a live Postgres queryset over 50,000 products don’t even live in the same database. But the consumer is always the same: an LLM that wants to call query(what_im_looking_for, filters) and get ranked, filtered results back quickly.
That mismatch is the whole design problem. Here’s how we ended up resolving it.
The starting point: The class you would have written
That many kinds behind one interface makes for a textbook polymorphic type, and the first design answered it by the book: the Strategy pattern. One class per KB type behind a lookup map, all of it in knowledge/strategies.py, 1,008 lines. That part was the right call, and the rewrite keeps it.
But the types also share a capability: most of them can store vectors. And inheritance is how you share code, right? So we wrote a mixin, PineconableKnowledgeBaseStrategy, named it after the only vector store we had, and handed it down through multiple inheritance. You would have merged that PR too.
knowledge/strategies.py — before
Fig 0 — the actual pre-rewrite hierarchy. There was even a comment: “Using MRO to resolve the order of inheritance.”
Pineconable rhymes with Serializable and Hashable, the -able family nobody audits in review. The difference took us embarrassingly long to see: Hashable names what a class is. Pineconable names what it rents.
The rent came due when we wanted a second backend and there was no seam to put it in. Redis never got a class of its own. It got a boolean, redis_rag_enabled, and an if branch inside the class named after its competitor. At that point the name was lying, and the lie went all the way down to the schema: pinecone_namespace was a literal column on the Knowledge table. (It’s gone now.)
Inheritance answers “what am I?”; composition answers “what do I use?” A Table KB is a table strategy, and always will be. A vector store is just something it uses, and the things you use get swapped eventually. Spell a has-a as an is-a and you’ve promised it will never change.
The rewrite started from one observation: the differences between KB types aren’t where we thought they were. Storing bytes and running similarity searches look nearly identical across every type. What actually differs is four separate decisions.
The core idea: Four orthogonal layers
Every KB is now assembled from four independent choices. The point is that they compose: adding a new KB type doesn’t mean new infrastructure, just a new combination of existing pieces.
Layer | Owns |
|---|---|
Service | What does this type of knowledge do? — varies by KB type |
Strategy | How do I validate input / run a query? — a pluggable algorithm |
Store | Where do the bytes physically live? — varies by backend |
Transformer | How do I map a record ⇆ the store’s native format? — 1:1 vs chunked |
Request flow — write down the left, read down the right
Fig 1 — one interface at the top, six services in the middle, swappable stores at the bottom.
The module tree enforces this: dependencies point strictly downward (views → services → coordinator → stores → transformers), never sideways or up. If a change ripples upward, that’s usually the first review comment it gets.
The Store contract is where the leverage is
Every place bytes can live — DynamoDB, Pinecone, Redis Stack, a Postgres queryset — implements the same small interface:
store.py
A service doesn’t hold a Pinecone client. It holds one primary store (the source of truth, always retrievable by ID) and a list of secondary stores (derived indexes it can rebuild whenever it wants), and it never knows which backend is behind either one. Almost everything else in this article falls out of that decision: swapping backends, rebuilding indexes, and multi-store consistency all happen against the same small contract.
PROXY: the type that proves the design
One type deserves a closer look, because the old design couldn’t have expressed it at all. A merchant’s product catalog already lives in our own Postgres tables, and it changes constantly: prices move, stock runs out. Agents need to search it anyway. The obvious move is to copy every product into the KB like any other upload — and now you have two sources of truth and a sync job that’s wrong exactly when it matters.
PROXY skips the copy. The KB owns no data at all. Its primary store is a QuerysetStore: a thin wrapper that speaks the normal Store contract but answers straight from the live Postgres rows. The only thing the KB actually owns is the derived piece — the vector index over those rows.
Follow a query and you’ll see why that split matters. The vector search finds matching IDs; the IDs then hydrate from the primary, which is the live table. So even if the index lags the catalog, every fact in the answer — the price, the stock count — is read from the live row at query time. The index can go stale; the answers can’t. And “sync” just means re-embedding from the live rows, because there’s no copy to reconcile. The rest of the machinery never notices that its primary is someone else’s table.
The write path: Streams all the way down
Follow one upload through the system: a merchant hands us a 50,000-row product CSV. The obvious implementation parses it into a list, validates the list, transforms the list, writes the list. That’s four copies of the dataset in memory, and a worker that falls over the first time someone uploads something genuinely large.
So when the file arrives, almost nothing happens. Parsing, validation, and transformation are each a generator wrapped around the one before, and none of them do any work until someone asks. The one asking sits at the very bottom of the stack: the store’s native writer, the code that actually makes API calls. Every record it pulls ripples up the chain (the transformer asks the validator, the validator asks the parser, the parser reads one more line of the file). The dataset never exists anywhere in full. It flows.
A CSV ingest, top to bottom — every arrow is a lazy generator
Fig 2 — the store’s native writer is the sole pull driver; upstream batch boundaries are invisible to it.
This upside-down arrangement pays twice. Memory stays flat no matter how big the file is. And nobody upstream has to know how each backend wants its writes shaped — the store hands _write_native a generator it never iterates itself, and the writer sets its own pace. Dynamo takes 25 records at a time, the vector stores take 100, the embedding API takes 2,048. Each layer batches for its own reasons and stays out of the others’ way:
Layer | Bounded by | Concern |
|---|---|---|
Coordinator chunk |
| Constant peak memory, any dataset size |
Store native batch |
| Wire / API-call compliance |
Embedding batch |
| OpenAI token limits |
Batch sizing has a catch, though. By the time a record reaches the bottom, it isn’t necessarily one thing anymore: a single document chunk can fan out into a dozen vectors. Count inputs and you’ll blow a store’s batch limit without ever seeing it coming. So each transformer keeps score — it watches how many native records its last ten inputs actually produced, and sizes batches by predicted output, with a 20% margin for surprises. A record that would push a batch over the limit just gets bumped to open the next one. A fresh pipeline that hasn’t measured anything yet starts from conservative defaults.
transformers/base.py — the pipeline learns its own fan-out
Fig 3 — batches are sized from the running median of what the last ten conversions actually produced.
Chunking answers a similar question with the same trick. How many records fit inside the 100 MB memory budget? That depends on how big a record is, so peek() lifts the first one off the stream, measures it, and splices it back on as if nothing happened. Its size divides the budget, the result rounds down to a clean multiple of the largest native batch, and empty uploads get to exit early for free. Measure where you can; assume the worst where you can’t.
The last question is who does the writing. One worker thread per secondary store, not per record, each pushing the same chunk to its own backend at once. Threads are enough here, because the work is all waiting on I/O anyway. If one backend fails, a shared cancel event stops the others from picking up new work, so the caller hears about it immediately instead of after the slowest thread finishes. Deletes, by contrast, are best-effort. Timeouts come from the same learned fan-out.
The primary is not in the pool
Secondaries fan out across threads. The primary is then written on the calling thread, sequentially, last. That one decision means the coordinator’s shared bookkeeping (the committed and unconfirmed ID sets) only ever gets touched by a single thread, so there are no locks to take and no contention to reason about.
The hard part: Staying consistent across two stores
Every ingest writes to two systems that don’t share a transaction: the record store (DynamoDB) and the vector index (Pinecone or Redis). Now imagine the worker dies at 2 a.m., forty thousand rows into those fifty thousand. Some vectors are written, some records aren’t, and no transaction is coming to save you. This is the classic dual-write problem, and it’s the part of the design we sweated over most.
We didn’t reach for a distributed transaction. Instead, the StoreCoordinator makes that 2 a.m. crash boring, with two rules.
Rule 1 — write secondaries first, primary last
The coordinator’s own docstring calls this ordering “rollback safety,” and it earns the name twice. The first half is the crash story. Die after the vectors land but before the record does, and the vector is an orphan the next rebuild overwrites. Harmless. Crash in the other order and you’d have a queryable record with no vector: silently unretrievable, with nothing to tell you it’s broken.
The second half is what happens when a write fails outright. The primary write is the commit point: until it confirms a batch, everything already sitting in the secondaries is provisional, tracked by ID and safe to delete. So when a primary batch fails, the coordinator purges that batch from the secondaries and the chunk simply never happened. Flip the order, or run the two in parallel, and rollback would mean deleting records the primary had already committed.
Rule 2 — track every write, compensate on failure
A
WriteTrackerrecords the IDs written to each store. If any chunk fails, the coordinator purges the uncommitted tail (everything written since the last confirmed commit), skipping any IDs the primary has already committed. Committed data never gets rolled back; only the in-flight remainder is unwound.
Under the hood, the tracking is event-driven. The coordinator injects one shared emitter into every store and watches the writes land:
Event | From | Coordinator’s response |
|---|---|---|
| primary | Add IDs to unconfirmed |
| primary | Move IDs unconfirmed → committed; untrack from every secondary tracker |
| secondary | Record IDs in that store’s WriteTracker (rollback candidates) |
| primary | Move IDs to committed (confirmed gone) |
The row that matters is the second one: the moment the primary confirms a batch, its secondary writes stop being rollback candidates. That’s Rule 1’s commit point, seen from the inside. The whole model boils down to this:
Write the derived index first and the source of truth last; delete the source of truth first and the derived index last. Either way, the only reachable failure state is orphaned vectors — which the next rebuild erases.
Ingestion itself runs off a Django post_save signal onto a dedicated Celery queue with a long soft time limit. A _should_process gate makes sure renaming a KB doesn’t kick off a multi-minute re-embed; only changes that actually affect the index do.
The records API skips that flow entirely. Writing to an already-indexed KB doesn’t queue a re-process; each batch goes through the same coordinated write, secondaries then primary, so everything that committed is already synchronized across both stores. If a call fails partway, only its uncommitted tail was rolled back: the caller replays the remainder, not the dataset. A full replay from the primary happens for exactly one reason — rebuilding secondaries.
Re-indexing rides the same machinery. Change a KB’s schema (say, which columns to embed versus filter on) and the vectors rebuild from the primary via sync_to_secondaries(), without re-ingesting the source CSV. purge_before_write closes the gap where a delete-then-insert would briefly return nothing.
The pattern: Is this a saga?
The dual-write problem we just walked through is the defining data problem of microservice architecture, and it comes with a standard menu of answers. Here’s which one we took, and why the famous ones don’t fit.
Pattern | Its promise | Verdict here |
|---|---|---|
2PC / XA | True atomicity: participants vote in a prepare phase, then commit together | Participants must hold locks and park prepared state — Pinecone, Redis, and Dynamo batch writes offer nothing of the sort (TCC: nothing to “reserve”). And 2PC fails into an in-doubt participant — we’d rather have a known-harmless state than an unknowable one. |
Transactional outbox + CDC | Commit truth and intent in one local transaction; a relay delivers to the index eventually | Inverts our ordering: truth commits first, so a record exists before it’s searchable — the exact silent-miss window we designed against. It also adds a relay to operate and blurs the “done” signal: |
Event sourcing | Secondaries become projections, rebuilt by replaying the log | Kept the moral, not the paradigm: the vector index is a projection and |
Choreographed saga | No central brain; each step reacts to the previous step’s event | Earns its keep across team and service boundaries; we have neither, so we’d pay its price — implicit control flow — for nothing. We do use events, but demoted to bookkeeping: they feed the write-trackers, never the control flow. |
Orchestrated saga | A coordinator runs ordered local transactions, compensating on failure | The one we kept — shrunk from a fleet of services to a single process, minus the durable saga log. |
Calling it “a saga, shrunk” isn’t hand-waving. The saga literature gives transactions an anatomy (compensatable steps, then a pivot, then retriable steps), and the coordinator’s ordering rules map onto it exactly:
The saga anatomy, mapped
Compensatable: the secondary writes — every ID tracked, purgeable on failure. Pivot: the primary write — the moment
POST_SAVEfires, those records are committed, and no rollback path will ever touch them. Retriable: the post-pivot delete replays to secondaries — best-effort, continue-on-error, always converging toward the primary’s state. The PUT/DELETE mirror-ordering isn’t a quirk; it’s the compensatable/retriable split wearing operational clothes.
We should be honest about where we deviate. A real orchestrated saga persists its progress so a crashed orchestrator can resume compensation where it died. Ours can’t: the write-trackers live in memory, and a hard kill mid-ingest compensates nothing. Our durable saga state is one enum column, INDEXING_FAILED, which the next processing pass reads as “run everything again.” Where a saga keeps a log so it can finish the story, we made the story re-tellable from the top. The log is replaced by idempotence, and that trade is only safe because every secondary is 100% derived. Steal the pattern, not the machinery: if your failure states heal on rebuild, you don’t need the machinery that remembers them.
The read path: A reference is a guardrail, not just a pointer
The LLM writes the filters here. What stops it from asking for another tenant’s rows? Agents never query a KB directly; they query a KnowledgeReference, a named, scoped view over one. (This logic used to live on the Django model as KnowledgeReference.query(); the rewrite pulled it into a service and deleted the model method.) The indirection is where the safety lives. A reference carries:
scope — which slice of the KB is visible (which columns are filterable, which document IDs, which JSON paths).
base_filters — a filter expression that is always applied. This is the guardrail: tenant scoping and access rules live here, not in anything the LLM controls.
projection — which fields come back (include / exclude).
allow_fallback — a graceful-degradation switch.
When a query comes in:
The reference’s
base_filtersare AND-merged with whatever filters the LLM asked for. The model can narrow results; it can never escape the base filters.Placeholders resolve at query time —
session_id,user_idand similar are injected from request context, so a single stored reference safely serves every user.The
LinkedSearchStrategysearches one store and answers from another: a similarity search with filters pushed down into the index (filtering in the index beats filtering after), then the matching IDs hydrate full records from the primary.Fallback (opt-in): if the merged filters return nothing, retry with just the base filters — degrade to “here’s the closest relevant thing” instead of an empty hand — but only when the reference explicitly allows it.
Even the parameters police the layering. A query travels as a chain of frozen dataclasses: Query (which carries context) narrows to ServiceQuery (context resolved, then dropped), which splits at the store boundary into StoreQuery and FormatParams. Everything’s immutable and transformed only via replace(), so each layer sees exactly the fields it’s entitled to and nothing else.
And because the whole thing hides behind one interface, LINKS quietly does something else entirely: no vector search, just an external web-search engine scoped to the reference’s URIs. Same query() call, different machinery underneath. The agent never finds out.
The payoffs: What fell out of the abstraction
Because vector access sits behind the Store contract, the backend is just a per-KB config value resolved by a VectorStoreFactory. Most KBs run on Pinecone, with a namespace per KB so a tenant’s blast radius is exactly one KB; some run on Redis Stack. Moving one is a config change, not a code change.
A second payoff came basically for free: a records API. Every KB’s primary store honors the same RetrievableStore contract, so one generic set of endpoints (from CRUD through streaming to cursor pagination) serves table rows in DynamoDB and products in Postgres alike. The per-type endpoint code the old design would have needed simply has nowhere to exist.
Retrieval tuning is usually vibes. Someone nudges top_k or rewords a base query, and the first real feedback is a customer conversation going sideways. Search sessions make the comparison a first-class object: pin a set of query variants to a reference, run them as a batch, and every result gets persisted next to the configuration that produced it. The part we care about most is where the eval runs — through the exact query path the agent uses, guardrail filters, projection, fallback and all. A notebook benchmark starts drifting from production the day it’s written. A session can’t, because it is production. It isn’t free (persisted executions, an async lifecycle to operate), but it buys the one thing retrieval tuning never has: receipts.
The scorecard: What the abstraction bought us
A new KB type is a composition, not a project. Pick a store, a transformer, a query strategy. No changes to the vector layer, the coordinator, or the API.
Backends are swappable per KB. Pinecone or Redis is a config value.
Partial-failure correctness is handled once, in the coordinator, for every type — not reimplemented (and forgotten) per path.
Live data doesn’t get copied.
PROXYmirrors it in place.Security is structural. Tenant filters live in
base_filters, resolved server-side, permanently out of the LLM’s reach.
One trade-off the rest of the article doesn’t cover: bulk records live in DynamoDB, and only low-cardinality config lives in Postgres. Record access is key-based and unbounded; config is relational and small.
None of these layers is clever on its own. The Store interface is boring. The transformer is boring. The win is that they’re orthogonal — and boring, orthogonal pieces are exactly what you want holding up something that has to answer six completely different questions with one method call.

Saral Hemnani
Founding Engineer
Subscribe to our mailing list
We won't spam, just notifications when we are putting out something important.