Umer359 commited on
Commit
5da83ea
Β·
1 Parent(s): 259f9f0

Add fully offline RAG for local mode

Browse files

Extends RAG mode to work with zero network calls when combined with
local (in-browser WebLLM) mode β€” documents are chunked, embedded, and
retrieved entirely on-device, so a visitor can do private document
Q&A with no internet connection and nothing ever leaving the browser.

- Embeddings: WebLLM (already used for local chat) doesn't do
embeddings, so this pulls in a second, much smaller model via
Transformers.js (Xenova/all-MiniLM-L6-v2, ~23MB quantized) β€” lazy-
loaded the same way the local chat model already is.
- Storage: IndexedDB instead of the server's KV β€” persists
indefinitely (unlike the server's 24h TTL, since this is the
visitor's own device with no server cost to bound), cleaned up only
when a saved conversation is deleted outright.
- Retrieval: the exact same chunking/cosine-similarity logic as the
server path, ported as plain dependency-free JS, injected into the
local model's prompt for that turn only (never persisted into
history).
- Local mode's existing document decline now only applies to Chat
mode's one-shot analysis (still genuinely unsupported by the tiny
local model) β€” RAG-mode documents are fully supported offline.

Files changed (1) hide show
  1. index.html +202 -14
index.html CHANGED
@@ -346,7 +346,7 @@
346
  <div class="cap-title">πŸ–₯️ Local AI mode β€” <span class="info-term" id="localLimitedTerm" tabindex="0" role="button" aria-expanded="false" aria-describedby="localLimitedTooltip">limited<div class="tooltip-bubble" id="localLimitedTooltip" role="tooltip">
347
  <ol>
348
  <li><strong>Smaller model</strong> β€” it's a 1B-parameter model, chosen specifically to run smoothly on phones and slower devices (WebGPU memory is often tight on mobile). A real capability gap versus the server's best ("Smart", 8B), but a much smaller, faster download than a bigger model would be.</li>
349
- <li><strong>No images</strong> β€” it's text-only. Attaching a photo/document in local mode gets declined with a message telling you to switch back to the server.</li>
350
  <li><strong>No live data</strong> β€” the server injects real-time weather, news, currency rates, holidays, and general web facts into its answers. Local mode has none of that β€” it's just the raw model guessing from its own training, so it'll be wrong or evasive on anything time-sensitive ("what's the weather," "who won yesterday," etc.).</li>
351
  <li><strong>No date awareness</strong> β€” the server also tells the model today's actual date; local mode doesn't get that either, so it may not know what day it is.</li>
352
  <li><strong>Speed/quality depend on your device</strong> β€” no server-grade GPU behind it, just whatever's in your laptop/phone.</li>
@@ -826,7 +826,10 @@ fileInput.addEventListener('change', async () => {
826
  } else if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name)) {
827
  const text = await extractPdfText(file);
828
  if (attachmentMode === 'rag') {
829
- await ingestDocumentForRag(text, file.name);
 
 
 
830
  } else {
831
  pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
832
  showAttachmentPreview(file.name);
@@ -834,7 +837,10 @@ fileInput.addEventListener('change', async () => {
834
  } else if (file.type.startsWith('text/') || /\.(txt|md|csv|json)$/i.test(file.name)) {
835
  const text = await fileToText(file);
836
  if (attachmentMode === 'rag') {
837
- await ingestDocumentForRag(text, file.name);
 
 
 
838
  } else {
839
  pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
840
  showAttachmentPreview(file.name);
@@ -1250,6 +1256,7 @@ function deleteConversation(id) {
1250
  const convos = loadConversations();
1251
  delete convos[id];
1252
  saveConversations(convos);
 
1253
  if (id === activeConvoId) {
1254
  startNewChat();
1255
  } else {
@@ -1826,6 +1833,178 @@ async function loadLocalEngine() {
1826
  return localEngineLoadPromise;
1827
  }
1828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1829
  // Which of the banner's three mutually-exclusive views is showing β€”
1830
  // the outer .cap-banner container itself just handles positioning/padding.
1831
  function showBannerView(view) {
@@ -2280,7 +2459,16 @@ async function requestLocalReply(loadingBubble, userMessageText) {
2280
  }
2281
  try {
2282
  const engine = await loadLocalEngine();
2283
- const chunks = await engine.chat.completions.create({ messages: history, stream: true });
 
 
 
 
 
 
 
 
 
2284
  let full = '';
2285
  let started = false;
2286
  for await (const chunk of chunks) {
@@ -2321,17 +2509,17 @@ async function requestReply(imageBase64) {
2321
  const userMessageText = [...history].reverse().find((m) => m.role === 'user')?.content || '';
2322
 
2323
  if (localModeActive) {
2324
- // Local mode runs entirely in the browser via WebLLM, with zero calls
2325
- // to the server β€” it has no way to reach the RAG index at all, even
2326
- // if it wanted to. And a chat-mode one-shot document is folded into
2327
- // the message content as a "[Attached file: ...]" block with the full
2328
- // extracted text, which the tiny local model isn't remotely equipped
2329
- // to process either (real report: it produced total gibberish when
2330
- // handed a document this way). Both must be declined exactly like
2331
- // images are, not silently attempted with the document ignored.
2332
- if (imageBase64 || ragDocumentNames.length > 0 || /\[Attached file:/.test(userMessageText)) {
2333
  loadingBubble.parentElement.classList.remove('loading');
2334
- loadingBubble.textContent = "Local AI mode can't read images or documents β€” remove the attachment, or switch back to the server to send it.";
2335
  scrollToBottom();
2336
  return;
2337
  }
 
346
  <div class="cap-title">πŸ–₯️ Local AI mode β€” <span class="info-term" id="localLimitedTerm" tabindex="0" role="button" aria-expanded="false" aria-describedby="localLimitedTooltip">limited<div class="tooltip-bubble" id="localLimitedTooltip" role="tooltip">
347
  <ol>
348
  <li><strong>Smaller model</strong> β€” it's a 1B-parameter model, chosen specifically to run smoothly on phones and slower devices (WebGPU memory is often tight on mobile). A real capability gap versus the server's best ("Smart", 8B), but a much smaller, faster download than a bigger model would be.</li>
349
+ <li><strong>No images, no one-off documents</strong> β€” it's text-only, and can't analyze a document in a single pass. Switch to RAG mode to work with a document offline instead β€” that's fully supported, indexed and searched entirely on your device.</li>
350
  <li><strong>No live data</strong> β€” the server injects real-time weather, news, currency rates, holidays, and general web facts into its answers. Local mode has none of that β€” it's just the raw model guessing from its own training, so it'll be wrong or evasive on anything time-sensitive ("what's the weather," "who won yesterday," etc.).</li>
351
  <li><strong>No date awareness</strong> β€” the server also tells the model today's actual date; local mode doesn't get that either, so it may not know what day it is.</li>
352
  <li><strong>Speed/quality depend on your device</strong> β€” no server-grade GPU behind it, just whatever's in your laptop/phone.</li>
 
826
  } else if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name)) {
827
  const text = await extractPdfText(file);
828
  if (attachmentMode === 'rag') {
829
+ await (localModeActive ? ingestDocumentForOfflineRag(text, file.name) : ingestDocumentForRag(text, file.name));
830
+ } else if (localModeActive) {
831
+ alert("Local AI mode can't analyze a one-off document β€” switch to RAG mode to index it offline, or switch back to the server.");
832
+ clearAttachment();
833
  } else {
834
  pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
835
  showAttachmentPreview(file.name);
 
837
  } else if (file.type.startsWith('text/') || /\.(txt|md|csv|json)$/i.test(file.name)) {
838
  const text = await fileToText(file);
839
  if (attachmentMode === 'rag') {
840
+ await (localModeActive ? ingestDocumentForOfflineRag(text, file.name) : ingestDocumentForRag(text, file.name));
841
+ } else if (localModeActive) {
842
+ alert("Local AI mode can't analyze a one-off document β€” switch to RAG mode to index it offline, or switch back to the server.");
843
+ clearAttachment();
844
  } else {
845
  pendingOneShotDoc = { text: text.slice(0, MAX_ONE_SHOT_DOC_CHARS), name: file.name };
846
  showAttachmentPreview(file.name);
 
1256
  const convos = loadConversations();
1257
  delete convos[id];
1258
  saveConversations(convos);
1259
+ deleteOfflineRagChunks(id);
1260
  if (id === activeConvoId) {
1261
  startNewChat();
1262
  } else {
 
1833
  return localEngineLoadPromise;
1834
  }
1835
 
1836
+ // --- Offline RAG (local mode + RAG attachment mode together) -----------
1837
+ // Mirrors the server-side RAG feature (chunk -> embed -> store -> cosine-
1838
+ // similarity retrieve -> inject as context) entirely client-side, for
1839
+ // visitors who want document Q&A with zero network calls at all. WebLLM
1840
+ // (already used for local chat) doesn't do embeddings, so this pulls in
1841
+ // a second, much smaller model via Transformers.js β€” the same lazy,
1842
+ // load-once-then-cached pattern as loadLocalEngine() above. Storage uses
1843
+ // IndexedDB (not the server's KV) since this needs to work fully
1844
+ // offline and never leave the device.
1845
+ let localEmbedder = null;
1846
+ let localEmbedderLoadPromise = null;
1847
+ async function loadLocalEmbedder() {
1848
+ if (localEmbedder) return localEmbedder;
1849
+ if (localEmbedderLoadPromise) return localEmbedderLoadPromise;
1850
+ localEmbedderLoadPromise = (async () => {
1851
+ const { pipeline } = await import('https://esm.run/@huggingface/transformers');
1852
+ // ~23MB quantized β€” a much smaller one-time download than the chat
1853
+ // model, and WebGPU is already confirmed available by this point
1854
+ // (local mode can't be entered at all without it, see HAS_WEBGPU).
1855
+ const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { device: 'webgpu' });
1856
+ localEmbedder = extractor;
1857
+ return extractor;
1858
+ })();
1859
+ return localEmbedderLoadPromise;
1860
+ }
1861
+ async function embedLocalTexts(texts) {
1862
+ const extractor = await loadLocalEmbedder();
1863
+ const output = await extractor(texts, { pooling: 'mean', normalize: true });
1864
+ return output.tolist();
1865
+ }
1866
+
1867
+ // Same chunking/similarity logic as the server's chunkText/
1868
+ // cosineSimilarity β€” kept as plain, dependency-free JS specifically so
1869
+ // it's trivially portable between the two, not because of any DOM/
1870
+ // browser-specific need.
1871
+ const OFFLINE_RAG_CHUNK_SIZE = 800;
1872
+ const OFFLINE_RAG_CHUNK_OVERLAP = 100;
1873
+ const OFFLINE_RAG_TOP_K = 4;
1874
+ function chunkTextOffline(text, chunkSize, overlap) {
1875
+ const chunks = [];
1876
+ let start = 0;
1877
+ while (start < text.length) {
1878
+ let end = Math.min(start + chunkSize, text.length);
1879
+ if (end < text.length) {
1880
+ const lastSpace = text.lastIndexOf(' ', end);
1881
+ if (lastSpace > start + chunkSize * 0.5) end = lastSpace;
1882
+ }
1883
+ const piece = text.slice(start, end).trim();
1884
+ if (piece) chunks.push(piece);
1885
+ if (end >= text.length) break;
1886
+ start = Math.max(end - overlap, start + 1);
1887
+ }
1888
+ return chunks;
1889
+ }
1890
+ function cosineSimilarityOffline(a, b) {
1891
+ let dot = 0, magA = 0, magB = 0;
1892
+ for (let i = 0; i < a.length; i++) {
1893
+ dot += a[i] * b[i];
1894
+ magA += a[i] * a[i];
1895
+ magB += b[i] * b[i];
1896
+ }
1897
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB) || 1);
1898
+ }
1899
+
1900
+ const OFFLINE_RAG_DB_NAME = 'noxOfflineRag';
1901
+ const OFFLINE_RAG_STORE = 'chunks';
1902
+ function openOfflineRagDb() {
1903
+ return new Promise((resolve, reject) => {
1904
+ const req = indexedDB.open(OFFLINE_RAG_DB_NAME, 1);
1905
+ req.onupgradeneeded = () => {
1906
+ const db = req.result;
1907
+ if (!db.objectStoreNames.contains(OFFLINE_RAG_STORE)) {
1908
+ const store = db.createObjectStore(OFFLINE_RAG_STORE, { keyPath: 'id', autoIncrement: true });
1909
+ store.createIndex('convoId', 'convoId', { unique: false });
1910
+ }
1911
+ };
1912
+ req.onsuccess = () => resolve(req.result);
1913
+ req.onerror = () => reject(req.error);
1914
+ });
1915
+ }
1916
+ async function addOfflineRagChunks(convoId, entries) {
1917
+ const db = await openOfflineRagDb();
1918
+ return new Promise((resolve, reject) => {
1919
+ const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
1920
+ const store = tx.objectStore(OFFLINE_RAG_STORE);
1921
+ for (const entry of entries) store.add({ convoId, ...entry });
1922
+ tx.oncomplete = () => resolve();
1923
+ tx.onerror = () => reject(tx.error);
1924
+ });
1925
+ }
1926
+ async function getOfflineRagChunks(convoId) {
1927
+ const db = await openOfflineRagDb();
1928
+ return new Promise((resolve, reject) => {
1929
+ const tx = db.transaction(OFFLINE_RAG_STORE, 'readonly');
1930
+ const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
1931
+ const req = index.getAll(convoId);
1932
+ req.onsuccess = () => resolve(req.result);
1933
+ req.onerror = () => reject(req.error);
1934
+ });
1935
+ }
1936
+ // Unlike the server's KV (24h TTL, a shared/costed resource), IndexedDB
1937
+ // is the visitor's own device with no server cost β€” so offline chunks
1938
+ // deliberately persist indefinitely, same as saved conversations
1939
+ // themselves do, rather than expiring. The one real cleanup needed is
1940
+ // when a saved conversation is deleted outright (see deleteConversation),
1941
+ // so its documents don't sit there orphaned forever with nothing that
1942
+ // can ever reference them again.
1943
+ async function deleteOfflineRagChunks(convoId) {
1944
+ try {
1945
+ const db = await openOfflineRagDb();
1946
+ await new Promise((resolve, reject) => {
1947
+ const tx = db.transaction(OFFLINE_RAG_STORE, 'readwrite');
1948
+ const index = tx.objectStore(OFFLINE_RAG_STORE).index('convoId');
1949
+ const req = index.openCursor(convoId);
1950
+ req.onsuccess = () => {
1951
+ const cursor = req.result;
1952
+ if (cursor) { cursor.delete(); cursor.continue(); }
1953
+ };
1954
+ tx.oncomplete = () => resolve();
1955
+ tx.onerror = () => reject(tx.error);
1956
+ });
1957
+ } catch {}
1958
+ }
1959
+
1960
+ // Mirrors ingestDocumentForRag (the server path) exactly in shape and UX,
1961
+ // just routed to IndexedDB + the local embedder instead of a fetch() β€”
1962
+ // this is what the fileInput handler below calls when RAG mode AND
1963
+ // local mode are both active at once.
1964
+ async function ingestDocumentForOfflineRag(text, name) {
1965
+ if (!activeConvoId) activeConvoId = makeConvoId();
1966
+ attachPreview.style.display = 'flex';
1967
+ attachPreview.innerHTML = `<span>πŸ“„ Indexing ${name} (offline)…</span>`;
1968
+ try {
1969
+ const rawChunks = chunkTextOffline(text, OFFLINE_RAG_CHUNK_SIZE, OFFLINE_RAG_CHUNK_OVERLAP);
1970
+ if (!rawChunks.length) throw new Error('empty document');
1971
+ const vectors = await embedLocalTexts(rawChunks);
1972
+ const entries = rawChunks.map((chunkText, i) => ({ text: chunkText, name, vector: vectors[i] }));
1973
+ await addOfflineRagChunks(activeConvoId, entries);
1974
+ ragDocumentNames.push(name);
1975
+ attachPreview.innerHTML = `<span>πŸ“„ ${name} (indexed offline)</span><button type="button" id="clearAttach" aria-label="Dismiss">Γ—</button>`;
1976
+ document.getElementById('clearAttach').addEventListener('click', clearAttachment);
1977
+ } catch (err) {
1978
+ console.error(err);
1979
+ alert("Couldn't index that document right now β€” please try again in a moment.");
1980
+ clearAttachment();
1981
+ }
1982
+ }
1983
+
1984
+ // Called from requestLocalReply below β€” same idea as the server's own
1985
+ // ragNote injection, just entirely local: nothing here ever touches the
1986
+ // network.
1987
+ async function retrieveOfflineRagContext(convoId, question) {
1988
+ if (!convoId) return '';
1989
+ let stored;
1990
+ try {
1991
+ stored = await getOfflineRagChunks(convoId);
1992
+ } catch {
1993
+ return '';
1994
+ }
1995
+ if (!stored || !stored.length) return '';
1996
+ try {
1997
+ const [queryVector] = await embedLocalTexts([question]);
1998
+ const scored = stored.map((entry) => ({ ...entry, score: cosineSimilarityOffline(queryVector, entry.vector) }));
1999
+ scored.sort((a, b) => b.score - a.score);
2000
+ const top = scored.slice(0, OFFLINE_RAG_TOP_K);
2001
+ const excerptText = top.map((c, i) => `[Excerpt ${i + 1}, from "${c.name}"]\n${c.text}`).join('\n\n');
2002
+ return `\n\nRELEVANT EXCERPTS FROM THE VISITOR'S UPLOADED DOCUMENT(S) (retrieved just now based on their question β€” use these to answer if they're actually relevant, and cite the document name; if these excerpts don't actually answer the question, say so honestly rather than guessing or falling back to unrelated training knowledge):\n${excerptText}`;
2003
+ } catch {
2004
+ return '';
2005
+ }
2006
+ }
2007
+
2008
  // Which of the banner's three mutually-exclusive views is showing β€”
2009
  // the outer .cap-banner container itself just handles positioning/padding.
2010
  function showBannerView(view) {
 
2459
  }
2460
  try {
2461
  const engine = await loadLocalEngine();
2462
+ // Injected only into THIS call's messages, never persisted into
2463
+ // `history` itself β€” same reasoning as the server's own ragNote:
2464
+ // the retrieved excerpts are only ever relevant to the turn that
2465
+ // triggered retrieval, not something to permanently bake into every
2466
+ // future message.
2467
+ const ragContext = await retrieveOfflineRagContext(activeConvoId, userMessageText);
2468
+ const messagesForEngine = ragContext
2469
+ ? [{ ...history[0], content: history[0].content + ragContext }, ...history.slice(1)]
2470
+ : history;
2471
+ const chunks = await engine.chat.completions.create({ messages: messagesForEngine, stream: true });
2472
  let full = '';
2473
  let started = false;
2474
  for await (const chunk of chunks) {
 
2509
  const userMessageText = [...history].reverse().find((m) => m.role === 'user')?.content || '';
2510
 
2511
  if (localModeActive) {
2512
+ // Local mode still can't handle images or a one-shot document dump
2513
+ // (a chat-mode attachment folded into the message as a "[Attached
2514
+ // file: ...]" block with the full extracted text) β€” the tiny local
2515
+ // model isn't remotely equipped for that (real report: it produced
2516
+ // total gibberish when handed a document this way). RAG-mode
2517
+ // documents are fine now, though: those get retrieved from the
2518
+ // offline (IndexedDB) index inside requestLocalReply itself, not
2519
+ // stuffed into the message here at all.
2520
+ if (imageBase64 || /\[Attached file:/.test(userMessageText)) {
2521
  loadingBubble.parentElement.classList.remove('loading');
2522
+ loadingBubble.textContent = "Local AI mode can't read images or one-off documents β€” remove the attachment, switch to RAG mode to index it offline, or switch back to the server.";
2523
  scrollToBottom();
2524
  return;
2525
  }