← Blog

Engineering · · 8 min read

How We Give Every Docs Branch Its Own Search Index

Our documentation agent on a branch has to search that branch, not production. turbopuffer's copy-on-write namespace branching gives every docs branch its own search index in about a second, with no re-chunking, no re-embedding, and no data copying.

Dhanunjaya Varma
Engineering

Two linked search index panels, one for the main namespace and one for a branch namespace created from a feature branch
On this page

Documentation.AI is an AI-native documentation platform. Teams write their docs in MDX, deploy them to their own domain, and every site comes with three things built in: a search bar, an Ask AI assistant that answers questions from the docs, and an MCP endpoint that tools like Claude and Cursor connect to.

All of that search runs on turbopuffer. Each organization has a namespace holding its published documentation as chunked records with vector embeddings, BM25 full-text indexes, and filterable attributes. The same namespace also grounds our documentation agent, the AI that helps writers draft and edit content inside the editor. When the agent answers a question about your docs, it is searching turbopuffer.

Recently we shipped branching. Writers can now work on documentation branches the way developers work on git branches: open a branch, edit with the agent alongside you, merge when ready. Teams wanted this for the same reasons engineers branch code. A big restructure can be drafted without touching the live site, several writers can work in parallel without stepping on each other, and changes get reviewed before they publish. Branching also created a search problem, because the agent on a branch needs to search that branch, not production. Our solution: we use turbopuffer's namespace branching to give every documentation branch its own search index, without replicating the underlying index storage.

TL;DR

  • Every active docs branch gets its own turbopuffer namespace, created with namespace branching: a constant-time, copy-on-write clone that is ready in about a second regardless of site size, for a flat fee of about three cents. No re-chunking, no re-embedding, no data copying. Before this, creating a branch index meant re-indexing the whole site.
  • The documentation agent retrieves from the branch namespace with hybrid search (under 300ms per round trip, including the embedding call) and gets unsaved edits injected straight from our database. Unsaved changes are never indexed.
  • Idle branch namespaces cost object storage and nothing else, so running thousands of short-lived branch indexes a month is not a cost concern.

Branching in Documentation.AI

A branch in Documentation.AI works like a branch in git. A writer opens one, edits pages, and merges it when the work is done. The merge publishes the changes to the live site. While the branch is open, the documentation agent helps with the work: it drafts sections, answers questions about existing content, and keeps structure consistent.

The agent is only useful if it sees the branch's version of the docs. A writer who just rewrote the OAuth guide on their branch needs the agent to know about the new OAuth guide, not the published one. If the agent searches a stale index, it gives wrong answers with citations attached, which is worse than no agent at all.

So every branch needs its own search index, and the index has to track the branch as it changes.

That requirement was the blocker for a long time. Our search stack used to be Meilisearch for the keyword search bar and Milvus for the AI features. Both reserve capacity per index. Memory is allocated, compute is provisioned, and the bill arrives whether anyone queries the index or not. Branch indexes are idle almost all the time, because writers edit in bursts and then go do something else. Paying reserved capacity for thousands of mostly idle indexes made no sense.

Creating those indexes was the other half of the problem. Neither system could share storage between indexes, so a branch index meant copying the corpus: chunk everything again, embed everything again, write everything again. The cost of opening a branch would scale with the size of the documentation instead of the size of the change. On a large docs site that is minutes of pipeline work and real embedding spend for a branch that might only ever touch two pages.

So branching stayed on the shelf.

Why We Migrated to turbopuffer

We moved our search stack to turbopuffer before branching existed as a feature, and the original reason was the cost model. turbopuffer keeps namespaces on object storage and pulls them into cache only when queried. That's the core of its architecture. [1] An idle namespace bills as stored bytes, with no reserved memory and no per-index compute. For a multi-tenant product where most indexes are cold most of the time, this changed what we could afford to build. It also collapsed two systems into one, since ANN and BM25 run against the same records in a single query. [2]

Branching became practical when turbopuffer shipped namespace branching: branch_from creates a copy-on-write clone of an existing namespace. The clone is constant-time no matter how many records the parent holds, there's no limit on how many branches a namespace can have, and creation is a flat fee of $0.032 per branch. [3] That is exactly the shape a documentation branch needs. Creating a branch index no longer copies or re-embeds anything, deleting one is instant, and a 4,000-page site costs the same three cents to branch as a 40-page one.

turbopuffer reads are also strongly consistent by default, which the editing loop depends on. [4] A query issued right after a save sees that save. There is no refresh interval to wait out and no eventual-consistency window to explain to a writer whose agent just answered from old content.

How Indexing and Retrieval Work

Nothing happens in turbopuffer when a writer opens a branch. On the first save, we call branch_from on the organization's production namespace, which gives the branch its own namespace in about a second, then upsert the sections that changed. Each subsequent save is the same small operation: the changed sections are chunked, embedded with Voyage voyage-4-lite at 1024 dimensions, and upserted in column format. Because reads are strongly consistent, the agent's next search sees the save immediately.

Branch namespace lifecycle: on the first save the production namespace is cloned copy-on-write with branch_from, each later save chunks, embeds, and upserts the changed sections while the agent retrieves from the branch namespace, and on merge the changed files are re-indexed into production and the branch namespace is deleted

The read path is one multi-query round trip carrying two legs: an ANN leg over the embeddings and a BM25 leg over the full text, both scoped with attribute filters for documentation project, branch, visibility, and access roles. [5] We over-fetch 20 results per leg and fuse client-side with Reciprocal Rank Fusion at k=60, weighted 0.75 toward semantic for the agent. The round trip lands under 300ms including the embedding call. turbopuffer's own share of that is small: their published warm vector query latency is 14ms p50 at one million documents, so most of our budget goes to the embedding API. [1] That budget is what makes the agent's retrieval loop workable. The agent searches, reads, refines, and searches again, up to 12 steps per interaction while streaming its answer. At 300ms per retrieval a deep loop costs under four seconds of tool time. At two seconds per retrieval it would cost 24, and we would not have shipped it.

Merging is mostly cleanup. The changed files are re-indexed into the production namespace: we stamp new chunks with an indexed_at timestamp, write them, and only then delete the chunks with older timestamps, so live search never has a moment where a page is missing. Then the branch namespace is deleted. From that point the production namespace serves the merged content to all four of its consumers: the typeahead search bar (pure BM25 with fuzzy and prefix matching, no embedding call), the Ask AI assistant (hybrid at 0.60 semantic), the MCP endpoint at docs.{customer}.com/mcp (hybrid at 0.75), and the editor agent for whoever branches next.

One production namespace read four ways: the typeahead search bar on pure BM25 with no embedding call, the Ask AI assistant on hybrid search at 0.60 semantic, the MCP endpoint on hybrid at 0.75, and the branch-scoped editor agent

Why We Don't Index Unsaved Changes

There is a third source of truth on every branch: the writer's unsaved edits. They never touch turbopuffer.

We don't autosave and we don't index on a debounce. A writer's in-progress edits sit in our database, and nothing is written to the search index until they click Save. When the agent runs, we load the uncommitted diff from the database and put it directly into the agent's context, alongside whatever retrieval returns from the branch namespace.

We went back and forth on this. Indexing every pause in typing would make search the single source of truth, which is easier to reason about. But it means an embedding call and a background write for a paragraph that will be rewritten four more times before anyone saves it, and it puts an async pipeline between a writer's keystroke and the agent's answer being correct. Batching writes to make that affordable would add the very staleness window we were trying to avoid.

The deciding argument is simpler than any of that: an uncommitted diff is small. Small enough to hand the model directly, every time, with no staleness and no indexing cost. So we hand it over directly and let retrieval cover the rest of the branch.

Saves are chunked, embedded, and upserted into the branch namespace while keystrokes land in the database as unsaved edits; the agent's context combines hybrid retrieval from the branch namespace with the unsaved diff injected directly, never indexed

What's Next

The external embedding call is the largest single component of save-to-searchable latency. turbopuffer recently shipped native embedding generation (currently in beta), which would remove that hop from the save path entirely. We haven't migrated yet because our retrieval evaluation harness is pinned to a specific model version, and we'd rather re-run the eval than guess.

Concurrent writers are the other open question. Two writers on the same branch don't see each other's unsaved work, because uncommitted edits live in one editing session and get injected into one agent's context. The second writer's agent sees the branch as of the last save. Whether that needs fixing depends on how often two people actually edit the same branch at the same time, which we are measuring before we build anything.

Conclusion

The problem was straightforward to state: a documentation agent on a branch has to be grounded in that branch, which means a search index per branch, and our old stack priced that out twice over, once in reserved capacity for idle indexes and once in the cost of duplicating a corpus every time someone opened a branch.

turbopuffer removed both. Object storage economics make idle branch indexes cost cents, and namespace branching makes creating one a constant-time copy-on-write operation instead of a re-index. On top of that we made one design choice we'd defend anywhere: index saved content, keep unsaved diffs out of the index, and put them straight into the model's context instead.

BeforeAfter
Branch index creationfull re-index, scales with site sizebranch_from, about a second, any size
Cost to create a branch indexembedding the whole corpus againflat $0.032, no re-embedding
Cost of an idle branch indexreserved compute per indexobject storage only
Agent retrieval round tripn/aunder 300ms p90
Branch namespaces churned0thousands per month

Branch-aware documentation editing is live for every customer today.

The Documentation.AI editor on a branch, with navigation and pages on the left, the page body in the middle, and the documentation agent panel on the right reading and editing content alongside the writer

FAQ

Why give each branch its own namespace instead of filtering one big index by branch?

Because a branch is an overlay: the parent's content, except where the branch changed it. A filter can select records tagged with a branch, but it can't express "prefer the branch's version of this page, fall back to production for everything else" in one query without duplicating the corpus per branch inside the namespace. A namespace per branch keeps queries simple, makes access boundaries physical, and makes cleanup a single delete on merge. With copy-on-write branching, creating one no longer even involves copying data.

What happens to a branch namespace after merge?

The changed files are re-indexed into production under the indexed_at scheme, then the branch namespace is deleted. Its whole lifetime might be an afternoon.

Do end users ever see branch content?

No. The public search bar, AI assistant, and MCP endpoint only query the production namespace. Branch namespaces are only reachable from the editor, behind the same access controls as the branch itself.

Which embedding model do you use?

Voyage voyage-4-lite at 1024 dimensions. In our retrieval evaluation it matched larger models on documentation content with a third less storage than 1536-dimensional alternatives, which adds up across thousands of namespaces.

Sources

  1. turbopuffer, "Architecture," turbopuffer Docs, accessed August 28, 2026, https://turbopuffer.com/docs/architecture
  2. turbopuffer, "Hybrid Search," turbopuffer Docs, accessed August 28, 2026, https://turbopuffer.com/docs/hybrid
  3. turbopuffer, "Namespace Branching," turbopuffer Docs, accessed August 28, 2026, https://turbopuffer.com/docs/branching
  4. turbopuffer, "Guarantees," turbopuffer Docs, accessed August 28, 2026, https://turbopuffer.com/docs/guarantees
  5. turbopuffer, "Query Documents," turbopuffer Docs, accessed August 28, 2026, https://turbopuffer.com/docs/query

Try branching

Branch your docs like code

Open a branch, edit with the agent alongside you, and merge when ready. Live for every customer today.

Start free

No credit card required