Skip to content

We rebuilt RAG in a browser tab. Our first bug: every vector was empty.

Our knowledge bases embed, store and search your documents inside the browser. Before rebuilding them, an audit found that no knowledge base had ever had a single searchable chunk. Ten RAG lessons from that rebuild, written so you can check your own pipeline.

Dannick K.
October 15, 2026 · 7 min read
PresentBoard
Share
On this page
  1. Indexing that tells the truth
  2. Search that misses, and why
  3. Explain, protect, and share
  4. What’s still limited, and your checklist

You add your documents to an AI assistant, it says they are indexed, and then it answers as if it had never seen them. Most people conclude the AI is dumb. Often the pipeline behind it is quietly returning less than it should.

That is the story of our own knowledge bases. They live entirely in the browser: documents are chunked and embedded on your machine, stored in a database that runs inside the tab, and searched from chats, agents and workflows. Nothing goes to a server unless you choose a cloud model.

We recently rebuilt the whole thing: 28 issues covering storage, ingestion, search, citations and privacy. The audit before it started with a bad surprise.

The audit

No knowledge base had ever had a single searchable chunk.

And the indexer said success every time.The Indexer node reported success because the writes did not throw.

Kinetic line
Text version
  1. The audit: No knowledge base had ever had a single searchable chunk.

This post is the list of what we changed, one lesson per section. RAG rarely fails loudly. It fails by returning less, and each lesson below is a place where it did.

Indexing that tells the truth

First, what sits under the hood. Each layer runs on your machine.

  1. Knowledge basesSearched from chats, agents and workflows.
  2. MiniLM, 384-dThe embedding model, in a Web Worker.
  3. SurrealDB (Wasm)A real database compiled to WebAssembly, in the tab.
  4. IndexedDBThe browser's own storage, on your disk.

Four layers, all on your device: storage, database, model, search.IndexedDB, SurrealDB 2.x on WebAssembly, all-MiniLM-L6-v2 via transformers.js in a Web Worker, then retrieval.

Layer peel
Text version
  1. Knowledge bases / MiniLM, 384-d / SurrealDB (Wasm) / IndexedDB
  2. Knowledge bases: Searched from chats, agents and workflows. · MiniLM, 384-d: The embedding model, in a Web Worker. · SurrealDB (Wasm): A real database compiled to WebAssembly, in the tab. · IndexedDB: The browser's own storage, on your disk.

If the database-in-a-tab part sounds unlikely, WebAssembly, explained covers how a browser tab became a small computer.

1. Count what you actually wrote

The vector field was declared TYPE array in a SCHEMAFULL table, and SurrealDB saved every vector as []. The Indexer node reported success every time. Users indexed documents, got a green badge, and then searched nothing.

The fix for the empty vectors was one type (array<number>). The real fix was making the writer honest:

  • Every write returns added / updated / failed / removed.
  • A partial failure says “N of M passages could not be saved”.
  • Writing nothing is an error, not a success with zero.
  • The canvas badge reads “24 added · 3 updated”, not “Success”.

Before

Before: the badge

  • Write
  • No error
  • Green badge

Success

Shown even when nothing was saved.

After

After: the counts

  • Write
  • Count real rows
  • Report

24 added

3 updated. Writing nothing is an error.

Drag the line to compare the two badges.Same indexer, honest reporting.

Before / after
Text version
  1. Before: Before: the badge, Success. Shown even when nothing was saved.
  2. After: After: the counts, 24 added. 3 updated. Writing nothing is an error.
  3. Compare before and after

Lesson: an indexer that can’t fail can’t be trusted. Count real rows with real vectors, and treat zero as a failure.

2. Re-adding a document should never duplicate it

Every chunk gets a deterministic id: chunkId(kb, source, index). Writes are upserts. So:

  • Re-adding the same page replaces it in place.
  • If the new version is shorter, the extra chunks (index ≥ new count) are deleted.
  • If the text hash hasn’t changed, the source is skipped.

Each chunk carries its provenance: source id, hash, type, title, URL or file name, chunk index and count, page number for PDFs, and the workflow that indexed it.

Lesson: if your ingestion isn’t idempotent, every re-run makes retrieval worse, because duplicates crowd out everything else in the top-k.

3. Vectors need a birth certificate

Vectors from different embedding models live in different spaces. Comparing them gives confident nonsense. We also had a migration in the past: the old default was TF.js Universal Sentence Encoder (512-d), the new one is MiniLM (384-d).

So each knowledge base records the model that first filled it, and each model has its own table, because a vector index has one fixed dimension. Every reader and writer opens a knowledge base through one function that routes to that model and that table.

The function picks the table from the model on record.The knowledge base's recorded model decides the table; a mismatch cannot happen by accident.

Diagram
Text version
  1. Chat assistant · Agents · Workflow nodes · One open function · MiniLM table (384-d) · USE table (512-d)
  2. Chat assistant → One open function
  3. Agents → One open function
  4. Workflow nodes → One open function
  5. One open function → MiniLM table (384-d): model on record

Before, the chat assistant had its own routing and read the legacy table, so knowledge bases built with MiniLM returned 0 passages in chat.

Switching models is a re-embed: copy every chunk into the target model’s table, catch up chunks added in the meantime, then switch with one catalogue update. Any failure rolls back, and searches keep reading the old table until the switch. It’s safe to interrupt.

Lesson: store the model next to the vectors, and make “which model?” impossible to get wrong.

Search that misses, and why

Once the vectors were real, the next failures were about what search returns, or doesn’t.

4. Vector search can’t find ERR_4521

Embeddings are great at meaning and bad at exact strings. ERR_4521 and ERR_4512, v2.3 and v3.2: to a sentence model, these are nearly the same thing.

ERR_4512 troubleshootingERR_4521 troubleshootingERR_4251 troubleshootingv2.3 release notesv3.2 release notesv2.2 release notesBilling FAQOnboarding guide"ERR_4521"

How a sentence model sees codes

  1. ERR_4512 troubleshooting0.96
  2. ERR_4521 troubleshooting0.94
  3. ERR_4251 troubleshooting0.87
Error codesVersionsOther docs

Its neighbours are all lookalikes. The right one is not guaranteed to win.Near-identical strings get near-identical vectors, so cosine ranking cannot separate them reliably.

Map of similarity
Text version
  1. Error codes: ERR_4512 troubleshooting, ERR_4521 troubleshooting, ERR_4251 troubleshooting · Versions: v2.3 release notes, v3.2 release notes, v2.2 release notes · Other docs: Billing FAQ, Onboarding guide
  2. "ERR_4521"
  3. ERR_4512 troubleshooting · ERR_4521 troubleshooting · ERR_4251 troubleshooting

We added BM25 keyword search next to vector search and fused the two ranked lists with reciprocal-rank fusion. On our test set (20 questions over 27 documents full of near-duplicate error codes and versions), with the real MiniLM model:

recall@1 Vector Hybrid Hybrid + rerank
All questions 0.90 0.95 0.95
Exact-term questions 0.83 0.92 1.00
Paraphrase questions 1.00 1.00 0.88

Hybrid never did worse than vector, so it’s the default. The cross-encoder rerank fixed the last exact-term misses but hurt paraphrases, so it’s opt-in.

It’s a small test set, and I won’t pretend otherwise. The point is the method: build the eval from the failures your users hit, and keep it. The full details, including a SurrealDB full-text gotcha, are in part 2: Why vector search misses exact terms, and what hybrid fixed.

5. Filtered vector search can return nothing

Most apps keep many collections in one vector table and filter by collection. We found that in our engine, the plain KNN operator on an HNSW index does a top-k of the whole table first and filters afterwards.

Search a small KBit shares a table with a big one
Top-k of the whole table
Filter to the small KB
none of the 4 match

Then it filters. Nothing from the small one survives.Post-filtering a global top-k leaves 0 rows when the big collection dominates the neighbours.

Pipeline
Text version
  1. Search a small KB: top 4, please
  2. Top-k of the whole table: 4 chunks, all from the big KB
  3. Filter to the small KB: none of the 4 match

A small knowledge base that shares a table with a big one can get zero hits: its chunks simply never make the global top-k.

The fix: a query form that applies the filter during the graph walk, over-fetching (fetchK = min(max(3k, k+8), 200)), plus an exact filter-first fallback when the approximate query returns too few rows. Measured on 3,000 × 384-d vectors: about 14 ms approximate vs 160–556 ms exact, so the fallback is a safety net, not the main path.

Lesson: test retrieval on a small collection next to a big one. That’s where filtering bugs hide.

6. Follow-ups retrieve nothing

User: What does the contract say about termination? User: And the notice period?

“And the notice period?” has almost nothing to search for. Now, when a message is short and anaphoric (≤ 12 words with “it/that/and…”, or ≤ 3 words), it’s rewritten into a standalone query from the last 4 turns.

You
Assistant
Knowledge base
"And the notice period?"
contract notice period
matching passages
an answer with citations

The answer cites what it found.Passages come back with citations you can open.

Conversation
Text version
  1. You → Assistant: "And the notice period?"
  2. Assistant → Knowledge base: contract notice period
  3. Knowledge base → Assistant: matching passages
  4. Assistant → You: an answer with citations

There’s an 8 s timeout, and on failure it falls back to the original text.

Lesson: retrieval sees one message. Conversations don’t work that way.

Explain, protect, and share

The last four lessons are about what the user sees, what the cloud never sees, and what happens to the data.

7. When retrieval finds nothing, say why

“I couldn’t find anything in your documents” is the most frustrating sentence in RAG. Now every knowledge base gets one step in the reply:

  • Docs · 3 passages
  • · 2 weaker matches
  • · nothing relevant, with the best score against the cut-off (0.35 by default)
  • · empty
  • · skipped · on this device only, naming the cloud model

Lesson 7

An empty retrieval is a UX state, not an edge case.

Show the score and the cut-off, and they can fix it.Best score vs the 0.35 cut-off, plus one-click actions to use weaker matches or test the search.

Kinetic line
Text version
  1. Lesson 7: An empty retrieval is a UX state, not an edge case.

The steps come with two actions: Use weaker matches for this reply and Test this search, which opens a search panel with scores and the cut-off line. Citations open a passage card showing the knowledge base, chunk, page and score, with a link to the source.

Lesson: show the score and the threshold, and the user can fix it in one click.

8. “Private” has to be a gate, not a setting

Each knowledge base is Device only or Cloud OK. A device-only one never goes into a prompt for a cloud model, and that’s enforced on every path: chats, agents, team threads, routines and workflow nodes. “Auto” or unknown models count as cloud. The assistant skips the knowledge base and says so. Workflow nodes fail by default (or skip, if you choose).

Myth

A privacy setting keeps private documents private.

Fact

Only a check in the one function every path uses does. Unknown models count as cloud.

The fix: one gate that every path goes through.Device-only knowledge never reaches a cloud-model prompt on chats, agents, teams, routines or nodes; auto or unknown models count as cloud.

Myth vs fact
Text version
  1. A privacy setting keeps private documents private.
  2. Only a check in the one function every path uses does. Unknown models count as cloud.

Retrieved passages are always fenced as “reference data, not instructions”. The only model-driven write (add_to_knowledge_base) asks before every call, and teams don’t get it.

Lesson: if privacy depends on each feature remembering a flag, one of them will forget. Put the check in the one function every path uses.

9. One single-threaded engine, one lock

The knowledge base database and the app’s local database are two IndexedDB databases on one SurrealDB WebAssembly module, which isn’t re-entrant. When two calls interleaved, it crashed with errors like “FnOnce called more than once” and “memory access out of bounds”. The knowledge base page went blank.

Knowledge bases
App's local database
One locka single promise chain
One SurrealDB Wasm modulenot re-entrant
a search
a save
One call at a time

The engine sees one call at a time, and stops crashing.No interleaving, so no 'FnOnce called more than once' or out-of-bounds errors.

Funnel
Text version
  1. Knowledge bases: a search · App's local database: a save
  2. a search + a save → One lock
  3. One lock → One SurrealDB Wasm module: One call at a time

The fix: every engine call, from both databases, goes through one promise-chain lock. And each JS context now shares one connection, instead of one engine per knowledge base (about +10 MB each).

Lesson: WebAssembly modules are often single-threaded under the hood. If two subsystems share one, they share its limits too.

10. Make the data portable

A knowledge base exports to a .awkb file: gzip JSON with the model reference and every chunk with its vector as float32 base64, about 4× smaller than JSON numbers. Importing never re-embeds and always creates a new knowledge base. Every file is validated as untrusted input (size caps, schema, version).

Lesson: if the data lives on the user’s device, export is part of the core design, not a nice-to-have.

What’s still limited, and your checklist

Some things don’t work yet, and I’d rather you hear it here:

  • Web URLs are fetched with plain fetch, so sign-in or CORS-blocked sites fail. Adding the open tab works (extension).
  • Project-file search is vector-only, not hybrid yet.
  • No sync between the web app and the extension (different origins).
  • Knowledge base text sits in IndexedDB unencrypted, like the rest of local mode.
  • Extension-only flows (right-click save, a real re-embed on a big knowledge base) are still being tested by hand.

If you run the embedding model on device as we do, running an LLM in the browser explains the runtimes behind it. And whatever your stack, these are the questions I’d ask of any RAG pipeline:

Check your own pipeline

Eight questions for any RAG pipeline

  • Does zero written count as an error? (done)automated
  • Is re-adding a document idempotent? (done)automated
  • Is the model stored with the vectors? (done)automated
  • Can you find an exact error code? (done)automated
  • Does a small collection still get hits? (done)automated
  • What happens to "and the price?" (done)automated
  • When nothing is found, is the reason shown? (done)automated
  • Is privacy enforced in one place? (done)automated

One gate for privacy.Check device-only data in the one function every path uses.

Checklist
Text version
  1. Eight questions for any RAG pipeline
  2. Does zero written count as an error?
  3. Is re-adding a document idempotent?
  4. Is the model stored with the vectors?
  5. Can you find an exact error code?
  6. Does a small collection still get hits?
  7. What happens to "and the price?"
  8. When nothing is found, is the reason shown?
  9. Is privacy enforced in one place?

What’s the sneakiest RAG bug you’ve shipped? Ours was the empty vectors.

The knowledge bases described here are part of AWFlow, a browser extension for workflows and AI agents that runs locally.

Automate the web you're on. Free, in your browser.