{"slug": "we-rebuilt-rag-in-a-browser-tab-our-first-bug-every-vector-was-empty", "title": "We rebuilt RAG in a browser tab. Our first bug: every vector was empty.", "summary": "A team that rebuilt its browser-based RAG knowledge bases found in a pre-rebuild audit that no knowledge base had ever stored a single searchable chunk, because SurrealDB saved every vector as an empty array in a SCHEMAFULL table where the vector field was declared TYPE array instead of array<number>. The rebuild, spanning 28 issues across storage, ingestion, search, citations and privacy, fixed the empty-vector bug with that one type change and made every write return added/updated/failed/removed counts, treating zero written rows as a failure rather than success. The team also made ingestion idempotent via deterministic chunkId(kb, source, index) upserts and recorded each knowledge base's embedding model (TF.js Universal Sentence Encoder at 512 dimensions versus MiniLM at 384) alongside its vectors, after the chat assistant's separate routing read a legacy table and returned 0 passages for MiniLM-built knowledge bases.", "body_md": "We rebuilt RAG in a browser tab. Our first bug: every vector was empty.\n\nOur 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.\n\nYou 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.\n\nThat 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.\n\nWe recently rebuilt the whole thing: 28 issues covering storage, ingestion, search, citations and privacy. The audit before it started with a bad surprise.\n\nThis 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.\n\nIndexing that tells the truth\n\nFirst, what sits under the hood. Each layer runs on your machine.\n\nIf the database-in-a-tab part sounds unlikely, WebAssembly, explained covers how a browser tab became a small computer.\n\n1. Count what you actually wrote\n\nThe 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.\n\nThe fix for the empty vectors was one type (array<number>). The real fix was making the writer honest:\n\nEvery write returns added / updated / failed / removed.\n\nA partial failure says “N of M passages could not be saved”.\n\nWriting nothing is an error, not a success with zero.\n\nThe canvas badge reads “24 added · 3 updated”, not “Success”.\n\nLesson: an indexer that can’t fail can’t be trusted. Count real rows with real vectors, and treat zero as a failure.\n\n2. Re-adding a document should never duplicate it\n\nEvery chunk gets a deterministic id: chunkId(kb, source, index). Writes are upserts. So:\n\nRe-adding the same page replaces it in place.\n\nIf the new version is shorter, the extra chunks (index ≥ new count) are deleted.\n\nIf the text hash hasn’t changed, the source is skipped.\n\nEach 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.\n\nLesson: if your ingestion isn’t idempotent, every re-run makes retrieval worse, because duplicates crowd out everything else in the top-k.\n\n3. Vectors need a birth certificate\n\nVectors 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).\n\nSo 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.\n\nBefore, the chat assistant had its own routing and read the legacy table, so knowledge bases built with MiniLM returned 0 passages in chat.\n\nSwitching 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.\n\nLesson: store the model next to the vectors, and make “which model?” impossible to get wrong.\n\nSearch that misses, and why\n\nOnce the vectors were real, the next failures were about what search returns, or doesn’t.\n\n4. Vector search can’t find ERR_4521\n\nEmbeddings 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.\n\nWe 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:\n\nrecall@1\n\nVector\n\nHybrid\n\nHybrid + rerank\n\nAll questions\n\n0.90\n\n0.95\n\n0.95\n\nExact-term questions\n\n0.83\n\n0.92\n\n1.00\n\nParaphrase questions\n\n1.00\n\n1.00\n\n0.88\n\nHybrid 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.\n\nIt’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.\n\n5. Filtered vector search can return nothing\n\nMost 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.\n\nA small knowledge base that shares a table with a big one can get zero hits: its chunks simply never make the global top-k.\n\nThe 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.\n\nLesson: test retrieval on a small collection next to a big one. That’s where filtering bugs hide.\n\n6. Follow-ups retrieve nothing\n\nUser: What does the contract say about termination?\nUser: And the notice period?\n\n“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.\n\nThere’s an 8 s timeout, and on failure it falls back to the original text.\n\nLesson: retrieval sees one message. Conversations don’t work that way.\n\nExplain, protect, and share\n\nThe last four lessons are about what the user sees, what the cloud never sees, and what happens to the data.\n\n7. When retrieval finds nothing, say why\n\n“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:\n\nDocs · 3 passages\n\n· 2 weaker matches\n\n· nothing relevant, with the best score against the cut-off (0.35 by default)\n\n· empty\n\n· skipped · on this device only, naming the cloud model\n\nThe 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.\n\nLesson: show the score and the threshold, and the user can fix it in one click.\n\n8. “Private” has to be a gate, not a setting\n\nEach 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).\n\nRetrieved 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.\n\nLesson: if privacy depends on each feature remembering a flag, one of them will forget. Put the check in the one function every path uses.\n\n9. One single-threaded engine, one lock\n\nThe 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.\n\nThe 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).\n\nLesson: WebAssembly modules are often single-threaded under the hood. If two subsystems share one, they share its limits too.\n\n10. Make the data portable\n\nA 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).\n\nLesson: if the data lives on the user’s device, export is part of the core design, not a nice-to-have.\n\nWhat’s still limited, and your checklist\n\nSome things don’t work yet, and I’d rather you hear it here:\n\nWeb URLs are fetched with plain fetch, so sign-in or CORS-blocked sites fail. Adding the open tab works (extension).\n\nProject-file search is vector-only, not hybrid yet.\n\nNo sync between the web app and the extension (different origins).\n\nKnowledge base text sits in IndexedDB unencrypted, like the rest of local mode.\n\nExtension-only flows (right-click save, a real re-embed on a big knowledge base) are still being tested by hand.\n\nIf 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:\n\nWhat’s the sneakiest RAG bug you’ve shipped? Ours was the empty vectors.\n\nThe knowledge bases described here are part of AWFlow, a browser extension for workflows and AI agents that runs locally.", "url": "https://wpnews.pro/news/we-rebuilt-rag-in-a-browser-tab-our-first-bug-every-vector-was-empty", "canonical_source": "https://awflow.io/blog/rag-in-a-browser-tab", "published_at": "2026-10-15 00:00:00+00:00", "updated_at": "2026-10-21 22:12:50.628312+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-products", "mlops"], "entities": ["SurrealDB", "TF.js Universal Sentence Encoder", "MiniLM", "WebAssembly"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/we-rebuilt-rag-in-a-browser-tab-our-first-bug-every-vector-was-empty", "markdown": "https://wpnews.pro/news/we-rebuilt-rag-in-a-browser-tab-our-first-bug-every-vector-was-empty.md", "text": "https://wpnews.pro/news/we-rebuilt-rag-in-a-browser-tab-our-first-bug-every-vector-was-empty.txt", "jsonld": "https://wpnews.pro/news/we-rebuilt-rag-in-a-browser-tab-our-first-bug-every-vector-was-empty.jsonld"}}