<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arinaymay Bhaskar</title>
    <description>The latest articles on DEV Community by Arinaymay Bhaskar (@arinaymay).</description>
    <link>https://dev.to/arinaymay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4228571%2Fd1ff5132-7e2e-4e4a-9c5c-d700c066d8a6.png</url>
      <title>DEV Community: Arinaymay Bhaskar</title>
      <link>https://dev.to/arinaymay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arinaymay"/>
    <language>en</language>
    <item>
      <title>A benchmark harness that's hard to fool, starting with me</title>
      <dc:creator>Arinaymay Bhaskar</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/arinaymay/a-benchmark-harness-thats-hard-to-fool-starting-with-me-20cm</link>
      <guid>https://dev.to/arinaymay/a-benchmark-harness-thats-hard-to-fool-starting-with-me-20cm</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkxw4hsd186o92mnznv5r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkxw4hsd186o92mnznv5r.png" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before optimising my Electron app, I built a benchmark suite with eleven stages. The best things it did were refusing to let me type numbers into docs, showing regressions first, and catching three of my own wrong conclusions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The other posts in this series quote a lot of numbers: list queries dropping from 246 ms to 0.28 ms, chat taking 14 seconds, search quality of 0.467. Every one of them came from the same harness. This post is about how it's built, and mostly about the ways it stopped me from fooling myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it measures
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm run bench&lt;/code&gt; runs up to eleven stages against MindSage, a Windows journaling app built with Electron, SQLite and local AI models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; every query the screens make, at 150, 5,000 and 50,000 entries, plus reads while a background thread writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI:&lt;/strong&gt; entry enrichment, chat, inline suggestions, cold model load, embedding speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat pipeline:&lt;/strong&gt; each of the four stages separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval quality:&lt;/strong&gt; a hand-labelled test set, so search can't get faster and worse at once.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector search, speech-to-text, media over IPC.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The packaged app:&lt;/strong&gt; cold start, IPC round trips, dashboard render time, dropped frames while scrolling, memory across repeated navigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size:&lt;/strong&gt; installer, packaged output, and JavaScript bundle bytes attributed to each npm package by decoding source maps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full run takes about 40 minutes. A database-only run takes about 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: percentiles, not averages
&lt;/h2&gt;

&lt;p&gt;The stats helper's opening comment explains this better than I can:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A mean hides the stalls that actually get noticed. One 400 ms freeze inside fifty 8 ms calls averages out to 16 ms and looks fine, while p95 shows it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every result reports p50 and p95. Warm-up runs are thrown away, because the first call to a prepared statement pays for query planning once, and that's not what a user feels while scrolling. Each scenario has a 20-second budget, so a 3-second query at 50,000 entries can't stall the suite. That budget has a cost too: one early number rested on only 13 samples, and the docs say so next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: nobody types a number
&lt;/h2&gt;

&lt;p&gt;Every run is saved as JSON under a label like &lt;code&gt;after-wal-and-indexes&lt;/code&gt;. The reports, the comparisons and the status board are all generated from those files. The issue list doesn't store numbers either. It stores where each issue's metric lives in a run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DB-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No indexes on `journal_entries`"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metricLabel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"`list.page1` p95 @ 50k"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metric"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"db"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"volume"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list.page1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p95"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the board can't go stale and can't be "rounded up" by hand. The human part of each entry is the reasoning: what changed, why, and what it means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: regressions come first
&lt;/h2&gt;

&lt;p&gt;The comparison report doesn't open with the wins. If anything got slower, the first thing on the page is a warning box listing it, ending with one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A win elsewhere does not cancel these. Decide deliberately whether each is an acceptable trade, and record the decision in the optimisation log.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That rule made me write down an accepted regression when an index made the main list 800× faster and a gallery query 30% slower. Without the rule, I'd have only seen the 800×.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: know your noise
&lt;/h2&gt;

&lt;p&gt;Early on, a change looked like it made writes 2.1× slower and background contention 2× slower, which fit a neat theory about foreign keys. The same comparison showed contention 2.5× &lt;em&gt;faster&lt;/em&gt; at another size, which fit nothing.&lt;/p&gt;

&lt;p&gt;So I compared the same branch with itself, no code change at all. The report said writes were &lt;strong&gt;13.2× slower&lt;/strong&gt; and listed twelve regressions. On my laptop, a single scenario can move about 2× between identical runs. Every delta in the real comparison was inside that, so it proved nothing in either direction. The log records "no detectable effect" rather than a result.&lt;/p&gt;

&lt;p&gt;Two more ways the harness protects comparisons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every report prints the machine.&lt;/strong&gt; When a Windows update landed mid-project, I re-measured unchanged code across it. The large database scenarios came out flat, so the update wasn't a confound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every run records the AI models.&lt;/strong&gt; Swapping a model moves AI timings more than any code change, so an AI comparison only counts with the models held fixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule 5: a quick check on every commit, that never blocks
&lt;/h2&gt;

&lt;p&gt;The pre-commit hook runs a 25-second benchmark: the database stage at 150 and 5,000 entries, 20 samples each, compared with the previous quick run. It only runs when the commit touches code the app executes, and skips merges and rebases.&lt;/p&gt;

&lt;p&gt;It never fails a commit. With 2× noise and 20 samples, a hard gate would refuse good commits. It prints regressions loudly instead.&lt;/p&gt;

&lt;p&gt;It earned its place. When I added an index to the journal table, the quick run showed the list query going from 9 ms to 98 ms. The index alone made things worse because of how the query grouped rows. The full story is in &lt;a href="https://mindsageapp.pages.dev/blog/sqlite-50000-entries" rel="noopener noreferrer"&gt;post 01&lt;/a&gt;. Without the hook I would have found that much later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three times it proved me wrong
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"The database will throw busy errors."&lt;/strong&gt; My own performance audit predicted it. The harness recorded zero. The real problem was readers silently waiting 200 ms behind a writer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Sending images over IPC is a major problem."&lt;/strong&gt; The audit rated it as major. Measured, a full image crossed IPC in about 2 ms. I closed it with no work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The renderer is 76% of startup."&lt;/strong&gt; That came from one early run. Three clean runs showed the opposite: the search server I bundled at the time took about two thirds of startup. The single-run number has a correction note next to it in the findings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And once the harness itself was wrong. After I removed an old login argument from the app, the benchmark kept passing it. Every app-level scenario was timing an &lt;code&gt;{"error": "Invalid token"}&lt;/code&gt; response. The dashboard read "0.30 ms, 25 bytes", which should have been the giveaway. Now I check payload sizes next to timings: if a response is suspiciously small, it's probably an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing down what's fine
&lt;/h2&gt;

&lt;p&gt;The optimisation log has a section called "Measured fine, do not optimise": chat time to first token, cold start, Whisper transcription speed (about 9× real time on CPU), insert speed, memory over a session. It's as useful as the problem list, because it tells future me where not to spend a weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Measure your noise floor by comparing code with itself, before trusting any comparison.&lt;/li&gt;
&lt;li&gt;Generate every number in your docs from stored runs.&lt;/li&gt;
&lt;li&gt;Put regressions at the top of every comparison, and make accepting one a written decision.&lt;/li&gt;
&lt;li&gt;Check payload sizes next to timings. A fast response may just be a fast error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mindsageapp.pages.dev/blog/benchmark-harness" rel="noopener noreferrer"&gt;https://mindsageapp.pages.dev/blog/benchmark-harness&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>electron</category>
      <category>performance</category>
      <category>softwaredevelopment</category>
      <category>testing</category>
    </item>
    <item>
      <title>37,500 reads per search: what memory-mapping fixed in sqlite-vec, and what it couldn't</title>
      <dc:creator>Arinaymay Bhaskar</dc:creator>
      <pubDate>Thu, 05 Nov 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/arinaymay/37500-reads-per-search-what-memory-mapping-fixed-in-sqlite-vec-and-what-it-couldnt-256b</link>
      <guid>https://dev.to/arinaymay/37500-reads-per-search-what-memory-mapping-fixed-in-sqlite-vec-and-what-it-couldnt-256b</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyeen57q7rpw8hjobaapf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyeen57q7rpw8hjobaapf.png" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After moving my app's vector search into SQLite, a search over 50,000 vectors missed its target. Profiling showed a quarter of the time was file reads the page cache never saw. The rest was a loop no setting could touch.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://mindsageapp.pages.dev/blog/sqlite-50000-entries" rel="noopener noreferrer"&gt;post 01&lt;/a&gt; I replaced the Qdrant search server in MindSage with &lt;a href="https://github.com/asg017/sqlite-vec" rel="noopener noreferrer"&gt;sqlite-vec&lt;/a&gt;, so the vectors live in a &lt;code&gt;vec0&lt;/code&gt; table in the journal's SQLite file. sqlite-vec does exact, brute-force search: every query compares against every vector. On my Windows laptop that measured 182 ms p50 and 192 ms p95 at 50,000 vectors. The ticket's target was p95 under 100 ms.&lt;/p&gt;

&lt;p&gt;This post is the investigation. The short version: one setting cut about 25 ms, three ideas did nothing, one more would make it much faster at a recall cost, and in the end I changed the target. That last part needs justifying, so I'll get to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine note.&lt;/strong&gt; These experiments ran on a 4-core Linux cloud machine (Xeon 2.8 GHz, 15 GB), with random 768-dimension vectors, top 15, one user. It isn't my laptop, but runs are easy to repeat there. Ranges below are three runs of 100 samples each. I don't compare these figures directly with the laptop's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiments
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change, at 50,000 vectors&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Kept?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Starting point&lt;/td&gt;
&lt;td&gt;104–108 ms&lt;/td&gt;
&lt;td&gt;118–133 ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cache_size&lt;/code&gt; 256 MB (default 16 MB)&lt;/td&gt;
&lt;td&gt;104 ms&lt;/td&gt;
&lt;td&gt;123 ms&lt;/td&gt;
&lt;td&gt;No, no effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mmap_size&lt;/code&gt; 1 GiB&lt;/td&gt;
&lt;td&gt;78–81 ms&lt;/td&gt;
&lt;td&gt;96–117 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top 10 / 50 / 200, file mapped&lt;/td&gt;
&lt;td&gt;83 / 79 / 87 ms&lt;/td&gt;
&lt;td&gt;108 / 101 / 107 ms&lt;/td&gt;
&lt;td&gt;No, no effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;int8 vectors, file mapped&lt;/td&gt;
&lt;td&gt;74 ms&lt;/td&gt;
&lt;td&gt;116 ms&lt;/td&gt;
&lt;td&gt;No (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;float32, fully in memory, no file I/O&lt;/td&gt;
&lt;td&gt;81 ms&lt;/td&gt;
&lt;td&gt;94 ms&lt;/td&gt;
&lt;td&gt;The floor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why a bigger cache did nothing
&lt;/h2&gt;

&lt;p&gt;The usual first move for a slow SQLite read is a bigger page cache. It changed nothing, which was the clue.&lt;/p&gt;

&lt;p&gt;vec0 stores vectors in chunk blobs of about 3 MB each. A blob that big spills across overflow pages, and SQLite reads those straight into the caller's buffer, one &lt;code&gt;read()&lt;/code&gt; call per 4 KB page. That works out to about &lt;strong&gt;37,500 system calls per search&lt;/strong&gt; , and those reads go around SQLite's page cache entirely. Making the cache bigger can't help with reads that never use it.&lt;/p&gt;

&lt;p&gt;Memory-mapping the file changes how those reads happen. With &lt;code&gt;PRAGMA mmap_size&lt;/code&gt; set, each page read becomes a memory copy from a mapped region instead of a system call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One line, applied when the connection opens.&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pragma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`mmap_size = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MMAP_BYTES&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1 GiB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interleaved on the same file, 200 samples each, the mapped version measured 81 / 115 ms against 106 / 140 ms. About 25 ms of every search was I/O overhead. At 5,000 vectors, search went from 11–12 ms to 9–11 ms p50.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 80 ms floor
&lt;/h2&gt;

&lt;p&gt;To find the floor, I loaded all vectors into memory with no file at all. Search still took 81 ms p50. That's the distance loop itself: comparing one vector against 50,000.&lt;/p&gt;

&lt;p&gt;Nothing about the query moved it. Asking for 10, 50 or 200 results cost the same. The app's full search function (joining results back to entries, building payloads) added about 3 ms on top of the bare query, within noise. Float32 cosine, float32 L2 and int8 cosine all measured 81–83 ms in memory. The prebuilt extension reports no SIMD build flags, so the loop costs the same whatever the number format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ideas I didn't ship
&lt;/h2&gt;

&lt;h3&gt;
  
  
  int8 vectors
&lt;/h3&gt;

&lt;p&gt;Storing vectors as 8-bit integers is the standard way to speed up vector search. Here it saved nothing once the file was mapped (74 / 116 ms against 75–77 / 89–90 ms for float32 in the same run). It would also have needed a new table, a full re-index, and rescaling the app's vectors, because &lt;code&gt;vec_quantize_int8(…, 'unit')&lt;/code&gt; clips values outside −1 to 1. Recall@10 against exact search was 0.998, so quality wasn't the problem. It just didn't pay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Binary quantisation with an exact rerank
&lt;/h3&gt;

&lt;p&gt;This one is clearly faster. Reduce each vector to one bit per dimension, scan those bits (2.6 ms at 50,000), then rerank the best candidates with exact float32 distance. Reranking costs about 0.25 ms per candidate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Candidates reranked&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Recall@10, random&lt;/th&gt;
&lt;th&gt;Recall@10, clustered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;28–29 ms&lt;/td&gt;
&lt;td&gt;46–49 ms&lt;/td&gt;
&lt;td&gt;0.940&lt;/td&gt;
&lt;td&gt;0.998&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;64 ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The catch: those recall numbers are on random and synthetic clustered vectors, not real embeddings. Losing even 6% of the right results in a journal search is a real cost. I won't ship it until recall is measured on real nomic vectors, with a migration that keeps the float vectors until the new index proves itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side effects of the mapping
&lt;/h2&gt;

&lt;p&gt;The mapping applies to the whole journal file, so other queries got faster too. At 50,000 entries, one run each, p50: &lt;code&gt;dashboard.stats&lt;/code&gt; 1,002 to 663 ms, the random gallery 531 to 150 ms, &lt;code&gt;dashboard.data&lt;/code&gt; 107 to 37 ms. Nothing got slower.&lt;/p&gt;

&lt;p&gt;Two costs, written down so they don't surprise anyone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory looks higher.&lt;/strong&gt; Resident memory after a 50,000-vector search rose from 185 to 330 MB on Linux, because mapped pages count there. They're file pages the OS can drop, and Windows Task Manager's default column leaves them out. But my own app-level memory benchmark will move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A disk error becomes a crash.&lt;/strong&gt; If the mapped file hits an I/O error, like a failing disk or a profile folder on a network share, the process dies instead of getting an error back. The mapping is only used for reads, so writes and the journal itself aren't put at risk by it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Changing the target
&lt;/h2&gt;

&lt;p&gt;After all this, p95 at 50,000 vectors was 96–117 ms on the test machine, and the floor is the 80 ms loop. My laptop was about 1.7× slower than this machine before the change, so it would land around 165–200 ms p95. I haven't measured that on Windows yet.&lt;/p&gt;

&lt;p&gt;So I revised the target to p95 under 100 ms at 5,000 vectors (about 14 years of daily entries) and under 200 ms at 50,000 on my Windows machine. Here's why that isn't moving the goalposts to declare victory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A search comes right after an embedding call of about 100 ms.&lt;/li&gt;
&lt;li&gt;In chat, it comes right before several seconds of text generation (&lt;a href="https://mindsageapp.pages.dev/blog/local-rag-pipeline" rel="noopener noreferrer"&gt;post 02&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;50,000 entries isn't a real user. It exists to show how the cost grows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If 100 ms at 50,000 ever has to hold, that's its own ticket: binary prefilter plus exact rerank, measured on real embeddings, or a SIMD build of the extension for all three platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When a cache change does nothing, find out why. Here it meant the reads were bypassing the cache.&lt;/li&gt;
&lt;li&gt;Measure the floor first. Running fully in memory told me which costs could move and which couldn't.&lt;/li&gt;
&lt;li&gt;A quantisation trick is only free if you measure recall on your real data.&lt;/li&gt;
&lt;li&gt;Changing a target is fine if you write down why, and what it would take to hit the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mindsageapp.pages.dev/blog/sqlite-vec-mmap" rel="noopener noreferrer"&gt;https://mindsageapp.pages.dev/blog/sqlite-vec-mmap&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Testing embedding models on a hand-labelled journal</title>
      <dc:creator>Arinaymay Bhaskar</dc:creator>
      <pubDate>Thu, 05 Nov 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/arinaymay/testing-embedding-models-on-a-hand-labelled-journal-592h</link>
      <guid>https://dev.to/arinaymay/testing-embedding-models-on-a-hand-labelled-journal-592h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazyokmf9rxt1a9twd8v3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazyokmf9rxt1a9twd8v3.png" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every speed benchmark I had would stay green while search quietly returned the wrong entries. So I built a small labelled test set, and found that my default model ranked the wrong entry first more than half the time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MindSage finds journal entries by meaning, not keywords. Each entry is turned into a vector by an embedding model, and a question is matched against those vectors. The chat feature (&lt;a href="https://mindsageapp.pages.dev/blog/local-rag-pipeline" rel="noopener noreferrer"&gt;post 02&lt;/a&gt;) feeds its top five results to a language model, so if search is wrong, the answer is confidently wrong too.&lt;/p&gt;

&lt;p&gt;Speed benchmarks can't see any of that. You can swap a model, change a threshold or break the indexing, and every latency number looks fine while the feature stops working. I needed a test that measures whether the right entry comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test set
&lt;/h2&gt;

&lt;p&gt;I wrote &lt;strong&gt;18 short journal entries&lt;/strong&gt; , each on a clearly different subject: a running injury, a call with a sister, a failed deployment at work, insomnia, a mum's new pottery class, a dal recipe, a dentist visit, and so on. Then I wrote &lt;strong&gt;15 questions&lt;/strong&gt; and labelled by hand which entries answer each one.&lt;/p&gt;

&lt;p&gt;The subjects are deliberately distinct. With near-duplicate entries a low score would say nothing about the code. With distinct ones, a working system should get the right entry first almost every time. This is not a research benchmark. It's a regression guard that answers "did this change break search?"&lt;/p&gt;

&lt;p&gt;The labels are committed to the repo, so the judgement is made once and every run is scored against the same answers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three numbers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;precision@1:&lt;/strong&gt; how often the top result is a right answer. This is what the user sees first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;recall@5:&lt;/strong&gt; how much of the right material is in the top five. This is what the chat model gets to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MRR&lt;/strong&gt; (mean reciprocal rank): rewards getting the right entry near the top, not just somewhere in the list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The baseline was bad
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;nomic-embed-text:v1.5&lt;/code&gt;, the model the app ships with, recall@5 was 0.767, MRR 0.644 and &lt;strong&gt;precision@1 0.467&lt;/strong&gt;. The right entry was usually somewhere in the top five, but the top hit was wrong in 8 of 15 questions. These weren't judgement calls:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Top result&lt;/th&gt;
&lt;th&gt;Should have been&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"What have I written about my knee?"&lt;/td&gt;
&lt;td&gt;the dentist entry&lt;/td&gt;
&lt;td&gt;the running injury&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What is my mum up to these days?"&lt;/td&gt;
&lt;td&gt;a friend moving away&lt;/td&gt;
&lt;td&gt;the pottery class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Anything about cooking or food I made"&lt;/td&gt;
&lt;td&gt;the pottery class&lt;/td&gt;
&lt;td&gt;the dal recipe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Have I been to any medical appointments?"&lt;/td&gt;
&lt;td&gt;a river walk&lt;/td&gt;
&lt;td&gt;the dentist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Hypothesis 1: missing task prefixes. Wrong.
&lt;/h2&gt;

&lt;p&gt;The nomic model's documentation says to prefix stored text with &lt;code&gt;search_document:&lt;/code&gt; and questions with &lt;code&gt;search_query:&lt;/code&gt;. The app didn't do either. That looked like the obvious bug.&lt;/p&gt;

&lt;p&gt;Adding the prefixes made every number &lt;strong&gt;worse&lt;/strong&gt; : precision@1 dropped from 0.467 to 0.333 and MRR from 0.644 to 0.544. My best guess is that the way the model is packaged already handles this, but I didn't dig further. What matters is that the "obvious fix" from the docs would have shipped a regression without this test. It's written down in the repo so nobody spends an afternoon rediscovering it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hypothesis 2: a different model
&lt;/h2&gt;

&lt;p&gt;Next I ran Google's &lt;code&gt;embeddinggemma&lt;/code&gt; on the same entries, questions and labels. Only the model changed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ramd8907ytjdd8b2dpc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ramd8907ytjdd8b2dpc.png" alt="Chart from the original post" width="800" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;18 entries, 15 questions, top 5. Higher is better. Runs of 27 August 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Wrong top hits went from 8 of 15 to 4 of 15, and the clearly wrong ones were among those fixed. "My knee" now returns the running injury, and "my mum" returns the pottery class.&lt;/p&gt;

&lt;p&gt;It's also nearly a drop-in. Both models produce 768-number vectors, so the vector table doesn't change. Every existing entry would still need re-embedding, because vectors from two models can't be compared.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;nomic&lt;/th&gt;
&lt;th&gt;embeddinggemma&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model on disk&lt;/td&gt;
&lt;td&gt;274 MB&lt;/td&gt;
&lt;td&gt;621 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embeddings per second&lt;/td&gt;
&lt;td&gt;9.44&lt;/td&gt;
&lt;td&gt;7.53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-index 5,000 entries&lt;/td&gt;
&lt;td&gt;8.8 min&lt;/td&gt;
&lt;td&gt;11.1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;precision@1&lt;/td&gt;
&lt;td&gt;0.467&lt;/td&gt;
&lt;td&gt;0.733&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the trade is 347 MB of the user's disk and about 2 extra minutes of background indexing at 5,000 entries, for a top result that's right 73% of the time instead of 47%. I think that's a good trade. It's a product decision, though, not a benchmark result, and I haven't shipped it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much to trust this
&lt;/h2&gt;

&lt;p&gt;Not as much as the percentages suggest. precision@1 going from 0.467 to 0.733 means 7 correct top hits became 11. That's four questions. The direction is consistent across all three metrics, and the misses that disappeared were unambiguous, so I trust the conclusion. I wouldn't quote "+57%" as if it came from a large evaluation.&lt;/p&gt;

&lt;p&gt;One thing I do trust fully: the test is deterministic. Separate runs on different days reproduced the baseline exactly, 0.767 / 0.644 / 0.467 to three decimals. That's what makes it useful as a guard. If those numbers move, something changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embed a title plus summary&lt;/strong&gt; instead of the whole entry. Long entries dilute the vector, and questions are short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search.&lt;/strong&gt; A plain keyword match would get "my knee" right instantly. SQLite's FTS5 can sit next to the vector table in the same file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grow the test set&lt;/strong&gt; with harder, overlapping entries, so it can tell good from very good.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If a feature depends on search, test search quality, not just speed. Speed tests stay green while quality breaks.&lt;/li&gt;
&lt;li&gt;Eighteen hand-written entries were enough to catch a bad default, a wrong fix from the docs, and a better model.&lt;/li&gt;
&lt;li&gt;Say how small your sample is. It makes the conclusion more believable, not less.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mindsageapp.pages.dev/blog/embedding-models-journal" rel="noopener noreferrer"&gt;https://mindsageapp.pages.dev/blog/embedding-models-journal&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>rag</category>
      <category>testing</category>
    </item>
    <item>
      <title>Vector search was 0.03% of my chat pipeline</title>
      <dc:creator>Arinaymay Bhaskar</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/arinaymay/vector-search-was-003-of-my-chat-pipeline-34hh</link>
      <guid>https://dev.to/arinaymay/vector-search-was-003-of-my-chat-pipeline-34hh</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqkg7rujv4e7sj4rcoy1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqkg7rujv4e7sj4rcoy1.png" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;My local "ask your journal" feature took 14 seconds to answer. I assumed retrieval was the hard part. The benchmark said retrieval was basically free, and two language model calls were the whole bill.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MindSage has a feature called Ask. You type a question like "when did my knee start hurting?", and a local model answers from your own journal entries. Everything runs on your PC: a 3B Llama 3.2 model through Ollama for text, &lt;code&gt;nomic-embed-text&lt;/code&gt; for embeddings, and a vector index inside the app's SQLite file.&lt;/p&gt;

&lt;p&gt;It worked, but it was slow. So I timed each stage separately instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;A question goes through four stages, one after another:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Query planning.&lt;/strong&gt; The model reads the question and returns JSON: does it need journal context, does it mention a time range, and what's the core search phrase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding.&lt;/strong&gt; The question becomes a 768-number vector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector search.&lt;/strong&gt; The five closest entries come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer generation.&lt;/strong&gt; The model gets the question plus those five entries and writes the answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stages 2 and 3 look like the clever engineering. That's where I expected the time to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 14 seconds went
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39bj0yo02k4qjqpvj1zq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39bj0yo02k4qjqpvj1zq.png" alt="Chart from the original post" width="800" height="155"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;p50 per stage, baseline run of 25 August 2026, i5-9300H laptop, llama3.2 and nomic-embed-text:v1.5. Stage medians don't add up exactly to the 14.06 s median total.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Query planning (LLM)&lt;/td&gt;
&lt;td&gt;6.16 s&lt;/td&gt;
&lt;td&gt;44%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Embedding&lt;/td&gt;
&lt;td&gt;32 ms&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Vector search&lt;/td&gt;
&lt;td&gt;4.5 ms&lt;/td&gt;
&lt;td&gt;0.03%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Answer generation (LLM)&lt;/td&gt;
&lt;td&gt;8.15 s&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.06 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Retrieval, embedding and search together, was a fifth of one percent. The whole cost was two language model calls in a row, against an Ollama server that handles one request at a time. Nothing I could do to the vector index would ever show up in this number.&lt;/p&gt;

&lt;p&gt;That result later justified a decision I wrote about in &lt;a href="https://mindsageapp.pages.dev/blog/sqlite-50000-entries" rel="noopener noreferrer"&gt;post 01&lt;/a&gt;: replacing the Qdrant search server with sqlite-vec, which is slower at search but removes a whole process. Making a 4.5 ms step 20 ms slower changes nothing a user can feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape showed up twice
&lt;/h2&gt;

&lt;p&gt;Saving a journal entry had the same problem. The app asks the model for metadata (title, mood score, mood tags) and then, separately, for a summary:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Enrichment stage&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metadata (JSON mode)&lt;/td&gt;
&lt;td&gt;2.82 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summary&lt;/td&gt;
&lt;td&gt;1.37 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding&lt;/td&gt;
&lt;td&gt;96 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End to end&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.85 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both features make two generations, and in both the first one only exists to produce structured data for the second. That's a design smell I didn't see until the numbers sat side by side.&lt;/p&gt;

&lt;p&gt;One more number from the same run: the first AI call after launch took &lt;strong&gt;6.40 s&lt;/strong&gt; , of which 6.21 s was loading the model from disk. The same call warm took 224 ms. Whatever feature the user touches first pays that 28× penalty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed first: stream the answer
&lt;/h2&gt;

&lt;p&gt;The quickest win doesn't make the pipeline shorter. It makes the wait feel shorter. On its own, the model's time to first token was about 400 ms. So if the answer streams into the window as it's written, the user waits for planning plus that first token, then reads while the rest arrives.&lt;/p&gt;

&lt;p&gt;There was a catch. The answer step asks the model for JSON, so the response comes back as &lt;code&gt;{"response": "…"}&lt;/code&gt;. You can't show raw JSON to a user, and you can't parse a half-finished JSON object either. So the stream handler pulls the partial &lt;code&gt;response&lt;/code&gt; string out of the incomplete JSON on every token and sends only the new characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_fragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;partialJsonString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partial&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;emitted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;onDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emitted&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;emitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the JSON fails validation, the app retries the call. A failed attempt may already have streamed half an answer, so before each retry the renderer gets a reset event and throws the partial text away. Without that, the second attempt appended itself to the first.&lt;/p&gt;

&lt;p&gt;I haven't benchmarked "time until the first word appears" on the full pipeline yet, so I'm not quoting a number for it. That's the next metric to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;The latest full run, on 20 October, measured planning at 4.13 s, embedding at 20 ms, search at 1.2 ms and the answer at 6.32 s, for &lt;strong&gt;10.48 s&lt;/strong&gt; p50 in total. That's down from 14.06 s, but I can't pin the drop on one change. LLM timings on a laptop move a lot between runs, so I treat it as "somewhat faster", not as a result.&lt;/p&gt;

&lt;p&gt;The real fixes are still ahead, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Skip planning when it can't help.&lt;/strong&gt; Most questions either obviously need the journal or obviously mention a date. A cheap check can handle those and save the model call for unclear ones. That's up to 4 seconds off most questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send smaller context.&lt;/strong&gt; Five whole entries go into the answer prompt. Summaries would cut the prompt reading time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-warm the model&lt;/strong&gt; after launch, so nobody pays the 6-second cold load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix retrieval quality.&lt;/strong&gt; The same benchmark suite showed the top search hit was wrong more than half the time. So those 14 seconds were often spent reasoning over the wrong entries. That's &lt;a href="https://mindsageapp.pages.dev/blog/embedding-models-journal" rel="noopener noreferrer"&gt;post 03&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Time every stage of an AI pipeline separately before optimising any of it. The part that looks hard is rarely the part that's slow.&lt;/li&gt;
&lt;li&gt;On local hardware the model call is the unit of cost. Count model calls before you count milliseconds.&lt;/li&gt;
&lt;li&gt;When a feature can't get faster quickly, streaming changes how long it feels. But measure the time to the first word, or you're only guessing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mindsageapp.pages.dev/blog/local-rag-pipeline" rel="noopener noreferrer"&gt;https://mindsageapp.pages.dev/blog/local-rag-pipeline&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>rag</category>
    </item>
    <item>
      <title>WAL, one index and a query rewrite: making a local journal app's SQLite stay flat at 50,000 entries</title>
      <dc:creator>Arinaymay Bhaskar</dc:creator>
      <pubDate>Thu, 05 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/arinaymay/wal-one-index-and-a-query-rewrite-making-a-local-journal-apps-sqlite-stay-flat-at-50000-entries-32kl</link>
      <guid>https://dev.to/arinaymay/wal-one-index-and-a-query-rewrite-making-a-local-journal-apps-sqlite-stay-flat-at-50000-entries-32kl</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6g0alvr6563bnfqxjb0o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6g0alvr6563bnfqxjb0o.png" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I benchmarked my Electron journal app before touching it. The fix for the slowest list query was three small changes, and one of them made things worse until the other two landed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MindSage is a Windows journaling app I'm building on my own. Everything runs on your PC: entries go into a SQLite file through &lt;code&gt;better-sqlite3&lt;/code&gt; in Electron's main process, a local model writes titles, mood tags and summaries, and a semantic index lets you ask questions about your own writing. Nothing goes to a server, so there is no backend to scale. The only thing that can get slow is the user's own laptop.&lt;/p&gt;

&lt;p&gt;That makes the database the whole performance story. A journal grows by one or two rows a day for years, and every screen in the app reads it. So before changing anything I built a benchmark harness, measured the app as it stood, and only then started fixing. This post covers the three database changes that mattered, the one that backfired, and the regressions I chose to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harness, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm run bench&lt;/code&gt; seeds a fresh database at three sizes and times every query the app's screens make, reporting p50 and p95. The sizes are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;150 entries&lt;/strong&gt; is a few months of use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5,000 entries&lt;/strong&gt; is a daily journal kept for about 14 years. This is the realistic worst case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;50,000 entries&lt;/strong&gt; is not a real user. It exists to show the slope: a query that is fine at 5,000 but grows 300× by 50,000 is scanning, and it will hurt someone eventually.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alongside the timings, the harness runs &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt; on every query and counts the full table scans. Every run is saved as JSON under a label, and the comparison reports and the status board are generated from those files. Nobody types a number into the docs, so no figure can go stale.&lt;/p&gt;

&lt;p&gt;All numbers below come from one laptop: an Intel i5-9300H with 16 GB of RAM on Windows 11, Node 24. "Before" is the run labelled &lt;code&gt;phase-0-2026-10-06&lt;/code&gt; and "after" is &lt;code&gt;phase-1-2026-10-06&lt;/code&gt;, both from the same day on the same OS build.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;The noise floor came first.&amp;lt;/strong&amp;gt; Early on, I measured one branch against itself with no code change. The comparison reported &amp;lt;code&amp;gt;write.create&amp;lt;/code&amp;gt; as 13× slower and flagged twelve "regressions". On this laptop a single scenario can move about 2× between identical runs. Anything smaller than that is not a result, and I stopped reporting it as one.&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Problem 1: reads were waiting behind the background writer
&lt;/h2&gt;

&lt;p&gt;The app has two database connections to the same file: the main process serves the UI, and a background worker writes AI results and index updates. The benchmark reproduces this by reading the journal list while a worker thread writes.&lt;/p&gt;

&lt;p&gt;The list query takes under a millisecond on its own. While the worker wrote, its p95 was &lt;strong&gt;311 ms at 150 entries&lt;/strong&gt; and &lt;strong&gt;436 ms at 50,000&lt;/strong&gt;. That's the one that mattered most, because it hit tiny journals too. SQLite's default rollback journal lets a writer lock out every reader, so the UI simply waited.&lt;/p&gt;

&lt;p&gt;My audit notes had predicted &lt;code&gt;SQLITE_BUSY&lt;/code&gt; errors and recommended &lt;code&gt;busy_timeout = 5000&lt;/code&gt;. The benchmark recorded zero busy errors, and &lt;code&gt;busy_timeout&lt;/code&gt; was already 5000 because better-sqlite3 sets that by default. Half the planned fix was a no-op. The failure was waiting, not failing.&lt;/p&gt;

&lt;p&gt;The real fix is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pragma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"journal_mode = WAL"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pragma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"synchronous = NORMAL"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In WAL mode, readers keep reading the last committed state while a write is in progress. &lt;code&gt;synchronous = NORMAL&lt;/code&gt; drops the fsync on every commit. In WAL mode that is still safe against an app crash, and the worst case is losing the last few commits on a power cut. For a journal that autosaves, I took that trade.&lt;/p&gt;

&lt;p&gt;Reads under worker writes went from &lt;strong&gt;436 ms to 0.37 ms&lt;/strong&gt; p95 at 50,000 entries. Single writes got faster as a side effect: &lt;code&gt;write.create&lt;/code&gt; went from 6.76 ms to 0.41 ms.&lt;/p&gt;

&lt;p&gt;To make sure a speedup really came from WAL, every report prints the pragmas the database actually ran with. If that table had still said &lt;code&gt;delete&lt;/code&gt;, the claim would have been invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the busiest table had no index
&lt;/h2&gt;

&lt;p&gt;Goals, categories, chats and tags all had indexes. &lt;code&gt;journal_entries&lt;/code&gt;, the table every screen reads, had none. The baseline plan reported 41 full table scans per run. Opening the journal list at 50,000 entries cost 246 ms p95.&lt;/p&gt;

&lt;p&gt;Every list query filters by user and hides deleted entries, then orders by date, so the obvious index is a composite one in that order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;idx_je_user_deleted_created&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;journal_entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The index alone made it slower
&lt;/h3&gt;

&lt;p&gt;My quick benchmark run after adding the index showed &lt;code&gt;list.page1&lt;/code&gt; at 5,000 entries going from 9 ms to 98 ms. Ten times worse.&lt;/p&gt;

&lt;p&gt;The list query pulled each entry's tags with a &lt;code&gt;LEFT JOIN&lt;/code&gt; and a &lt;code&gt;GROUP BY j.id&lt;/code&gt;. Grouping forced SQLite to visit and sort every entry before &lt;code&gt;LIMIT 10&lt;/code&gt; could apply. With the new index available, the planner picked a plan that sorted the whole set twice. The index could not help a query that had to touch every row anyway.&lt;/p&gt;

&lt;p&gt;The fix was to stop grouping. Tags now come from a correlated subquery, which runs only for the ten rows that survive the &lt;code&gt;LIMIT&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- before (simplified)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mood_tags&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;journal_entries&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;journal_entry_tags&lt;/span&gt; &lt;span class="n"&gt;jt&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;journal_entry_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- after&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;journal_entry_tags&lt;/span&gt; &lt;span class="n"&gt;jt&lt;/span&gt;
     &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag_id&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;journal_entry_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mood_tags&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;journal_entries&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Functions on a column hide it from the index
&lt;/h3&gt;

&lt;p&gt;The second change in that diff is easy to miss. The date filter wrapped the column in &lt;code&gt;DATE()&lt;/code&gt; and the sort wrapped it in &lt;code&gt;DATETIME()&lt;/code&gt;. Once a column is inside a function, SQLite has to evaluate the function for every row, and no index on &lt;code&gt;created_at&lt;/code&gt; can be used.&lt;/p&gt;

&lt;p&gt;Every writer in the app already stores &lt;code&gt;created_at&lt;/code&gt; as an ISO 8601 UTC string from &lt;code&gt;toISOString()&lt;/code&gt;, and ISO strings sort correctly as text. So the column can be compared bare. The upper bound became &lt;code&gt;created_at &amp;lt; DATE(?, '+1 day')&lt;/code&gt;, which keeps the whole of the last day, exactly what &lt;code&gt;DATE(created_at) &amp;lt;= DATE(?)&lt;/code&gt; used to select.&lt;/p&gt;

&lt;p&gt;The index, the subquery and the bare comparisons only work together. Shipped alone, the index was a regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;Full table scans per run went from &lt;strong&gt;41 to 12&lt;/strong&gt;. At 50,000 entries, p95:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvsg0kib1vkj594yglfbv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvsg0kib1vkj594yglfbv.png" alt="Chart from the original post" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;p95 latency at 50,000 entries, log scale. Before: phase-0-2026-10-06. After: phase-1-2026-10-06. Same laptop, same day.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The list queries are now flat: &lt;code&gt;list.page1&lt;/code&gt; is 0.30 ms at 150 entries, 0.28 ms at 5,000 and 0.30 ms at 50,000. That's what an index serving the &lt;code&gt;ORDER BY&lt;/code&gt; and stopping after one page looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't fix, and the regression I kept
&lt;/h2&gt;

&lt;p&gt;Two rows in that chart are not wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dashboard.stats&lt;/code&gt; went from 1.63 s to 891 ms&lt;/strong&gt; and is still far above its 100 ms target. It runs eight aggregates over every entry for streaks and totals. An index can't make "count everything" cheap. The fix is one pass plus a cache invalidated on write, and that's still open. At 5,000 entries it's about 60 ms, so real users don't feel it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;gallery.random&lt;/code&gt; got slower, 953 ms to 1.25 s.&lt;/strong&gt; It picks random photos with eleven &lt;code&gt;image_key IS NOT NULL ... OFFSET ?&lt;/code&gt; queries. With the new index, each one walks the index and fetches every row just to test &lt;code&gt;image_key&lt;/code&gt;, which turned out slower than the scan it replaced. I logged it as an accepted regression rather than holding back a change that made the main list 800× faster. The query itself needs replacing.&lt;/p&gt;

&lt;p&gt;The comparison tool lists every measurement that got worse at the top of the report, with the line "A win elsewhere does not cancel these." I think that rule did more for the quality of this work than any single optimisation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: a whole search server for 5,000 vectors
&lt;/h2&gt;

&lt;p&gt;Semantic search used to run on Qdrant, shipped as a sidecar process next to the app. Qdrant is very fast: 2.4 ms p95 at 5,000 vectors and 5.4 ms at 50,000, thanks to its HNSW index. But it was a second process to start, supervise and keep in sync with SQLite. It took about 0.5 s of startup, 594 MB of resident memory and 995 MB of disk at 50,000 vectors.&lt;/p&gt;

&lt;p&gt;I replaced it with &lt;a href="https://github.com/asg017/sqlite-vec" rel="noopener noreferrer"&gt;sqlite-vec&lt;/a&gt;, an extension that loads into the same better-sqlite3 connection. The vectors now live in a &lt;code&gt;vec0&lt;/code&gt; virtual table in the journal file itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;VIRTUAL&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vec_entries&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;vec0&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;distance_metric&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cosine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partition key means a search only reads the signed-in user's vectors. The table is created outside the numbered migrations, so a machine that can't load the extension still opens its journal and only search reports itself unavailable.&lt;/p&gt;

&lt;p&gt;sqlite-vec does exact brute-force search, so it is slower than Qdrant, and I want to be upfront about that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;At 50,000 vectors unless noted&lt;/th&gt;
&lt;th&gt;Qdrant&lt;/th&gt;
&lt;th&gt;sqlite-vec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Search p95 at 5,000&lt;/td&gt;
&lt;td&gt;2.4 ms&lt;/td&gt;
&lt;td&gt;23 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search p95 at 50,000&lt;/td&gt;
&lt;td&gt;5.4 ms&lt;/td&gt;
&lt;td&gt;192 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;995 MB&lt;/td&gt;
&lt;td&gt;155 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resident memory&lt;/td&gt;
&lt;td&gt;594 MB&lt;/td&gt;
&lt;td&gt;148 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upsert rate&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;td&gt;about 3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra processes&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Laptop runs from 6 and 20 October 2026. The targets were p95 under 100 ms at 5,000 vectors and under 200 ms at 50,000; both are met.&lt;/p&gt;

&lt;p&gt;The trade made sense because of another benchmark. The chat feature's pipeline took about 14 s per answer, and vector search was &lt;strong&gt;0.03% of it&lt;/strong&gt; (4.5 ms). Two sequential LLM calls took the rest. Making retrieval 20 ms slower changes nothing anyone can feel. Removing a whole process, most of a gigabyte of disk and the work of keeping two stores in sync does.&lt;/p&gt;

&lt;p&gt;It also made deletion simpler. The vector is a row in the same file, so deleting an entry and its vector happens in one transaction. Before, the two stores could disagree.&lt;/p&gt;

&lt;p&gt;On a 4-core cloud sandbox, with the vector file memory-mapped, search came in at 9 to 11 ms at 5,000 vectors. That's a different machine, so I don't compare it with the laptop numbers above.&lt;/p&gt;

&lt;h2&gt;
  
  
  A WAL gotcha that matters for a journal
&lt;/h2&gt;

&lt;p&gt;A journal app has to delete properly. When someone empties the trash, the text should leave the file, not linger in free pages. So the connection also sets &lt;code&gt;secure_delete = ON&lt;/code&gt;, which overwrites deleted content with zeros.&lt;/p&gt;

&lt;p&gt;WAL complicates that. In WAL mode, those zeroed pages are written to &lt;code&gt;mind-sage.db-wal&lt;/code&gt; first. Until a checkpoint copies them back, the main file still holds the old page images with the deleted text, and the WAL may hold older frames too. So everything that deletes for good, like the trash purge or deleting a profile, now runs a &lt;code&gt;wal_checkpoint(TRUNCATE)&lt;/code&gt; afterwards. It uses a short busy timeout, so a reader holding the WAL can't freeze the main process for five seconds.&lt;/p&gt;

&lt;p&gt;It's an easy one to miss: WAL and secure delete each do their job, and only together do they leave deleted text behind for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The installer, while I was there
&lt;/h2&gt;

&lt;p&gt;The same harness tracks disk footprint, so packaging fixes got measured too:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ucfqp4enb0q5xd3aqxu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ucfqp4enb0q5xd3aqxu.png" alt="Chart from the original post" width="800" height="257"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The target is under 180 MB. Most of what's left is the bundled Whisper speech model and Electron itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure the noise before the change.&lt;/strong&gt; Run the same code against itself. If you skip this, you will celebrate noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count full scans, not just milliseconds.&lt;/strong&gt; At 150 entries nearly everything is fast. The query plan shows what will break at 5,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An index is half a fix.&lt;/strong&gt; If the query has to group or sort every row, or wraps the column in a function, the index can't help and may even hurt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile the whole pipeline before optimising the part that looks clever.&lt;/strong&gt; Vector search looked like the hard part of chat. It was 0.03% of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down the regressions you accept.&lt;/strong&gt; A change can be right overall and still make one query slower. Say so in the log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next up is &lt;code&gt;dashboard.stats&lt;/code&gt;. The chat pipeline, where the 14 seconds actually go, is &lt;a href="https://mindsageapp.pages.dev/blog/local-rag-pipeline" rel="noopener noreferrer"&gt;post 02&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mindsageapp.pages.dev/blog/sqlite-50000-entries" rel="noopener noreferrer"&gt;https://mindsageapp.pages.dev/blog/sqlite-50000-entries&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>electron</category>
      <category>performance</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
