<?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: Jasur Yuldoshev</title>
    <description>The latest articles on DEV Community by Jasur Yuldoshev (@jackyu96).</description>
    <link>https://dev.to/jackyu96</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%2F4025043%2F0f7d28f2-ad06-449b-a23b-440819766a29.png</url>
      <title>DEV Community: Jasur Yuldoshev</title>
      <link>https://dev.to/jackyu96</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jackyu96"/>
    <language>en</language>
    <item>
      <title>In-process embeddings for a desktop AI app, or how a missing daemon silently broke my RAG</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Sat, 11 Jul 2026 10:41:08 +0000</pubDate>
      <link>https://dev.to/jackyu96/in-process-embeddings-for-a-desktop-ai-app-or-how-a-missing-daemon-silently-broke-my-rag-4jbh</link>
      <guid>https://dev.to/jackyu96/in-process-embeddings-for-a-desktop-ai-app-or-how-a-missing-daemon-silently-broke-my-rag-4jbh</guid>
      <description>&lt;p&gt;I'm building a desktop AI app for non-technical users — the kind of person who double-clicks an icon and expects search to work, and who will never open a terminal in their life. Under the hood it does local RAG: ingest documents, embed them, retrieve on each question. For a while, the embedding step went through Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that didn't look like a bug
&lt;/h2&gt;

&lt;p&gt;On my machine everything was fine, because on my machine Ollama is always running. Then I ran the flow a real user would hit — fresh login, Ollama not started — and watched the app quietly fall apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/ask&lt;/code&gt; returned an answer with no retrieved context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/search&lt;/code&gt; returned nothing at all.&lt;/li&gt;
&lt;li&gt;Ingestion accepted documents and indexed none of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No stack trace. No red banner. The app looked healthy and did nothing useful.&lt;/p&gt;

&lt;p&gt;To be clear, this wasn't Ollama being sneaky. Ollama fails loudly — you get connection refused the instant you hit &lt;code&gt;localhost:11434&lt;/code&gt; with nothing behind it. The failure was mine: my error handling caught that exception and turned it into an empty result, and every caller downstream treated "empty" as "no matches." A user asking a perfectly good question got a confident, sourceless answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens: the daemon is a dependency you can't see
&lt;/h2&gt;

&lt;p&gt;The swallowed exception is the easy part to fix. The architectural lesson is the one worth keeping: by routing embeddings through Ollama, I had made a separate background process's liveness a hard requirement for my app to function correctly. That's a fine trade when &lt;em&gt;you&lt;/em&gt; are the operator. It's a terrible trade when your user doesn't know a daemon exists, can't tell whether it's running, and certainly won't restart it after a reboot.&lt;/p&gt;

&lt;p&gt;For a desktop app aimed at non-technical people, "keep this background service alive or your search silently breaks" is not a requirement you get to impose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Load the same model in-process. bge-m3, 1024-dim, normalized — identical to what I was pulling from Ollama, just running inside my own Python backend via sentence-transformers.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/embed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bge-m3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeddings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# lazy singleton
&lt;/span&gt;        &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-m3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;encode&lt;/code&gt; is blocking and CPU/GPU-heavy, so in a FastAPI backend you don't call it on the event loop. Push it to a worker thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anyio&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;anyio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And make health deterministic instead of guessing. A tiny status enum beats a boolean, because "loading a 2 GB model" is a real state that lasts several seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ModelStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;NOT_LOADED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NOT_LOADED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LOADING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOADING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;READY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FAILED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/health&lt;/code&gt; can tell the UI exactly where it stands, and the UI can disable search until the answer is &lt;code&gt;READY&lt;/code&gt; instead of returning empty nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a hybrid fallback
&lt;/h2&gt;

&lt;p&gt;The obvious next thought is: keep both. Try Ollama, fall back to in-process if the daemon is down. For embeddings, that is a bug — not a robustness feature.&lt;/p&gt;

&lt;p&gt;Ollama serves a quantized GGUF. sentence-transformers runs fp32. Same model name, different numerics, and therefore a different vector space. The two backends do not produce interchangeable vectors.&lt;/p&gt;

&lt;p&gt;That matters because your index is written once and queried many times. If some vectors were written by the GGUF path and you query with the fp32 path — or you re-ingest under a different backend than you started with — the cosine distances between them are quietly meaningless. Nothing throws. Retrieval just gets subtly, unpredictably worse, and you'll waste days blaming your chunking or your reranker.&lt;/p&gt;

&lt;p&gt;So the rule is one backend per index. If you want to switch backends, you re-embed the whole corpus once, deliberately. You never mix them and hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this is free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PyTorch is heavy.&lt;/strong&gt; sentence-transformers pulls in PyTorch — gigabytes on disk. You're trading a daemon dependency for a large Python dependency. This only makes sense if your app already ships a Python backend or sidecar, which mine does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first run downloads ~2.2 GB.&lt;/strong&gt; The bge-m3 weights come from Hugging Face on first use. For real users you bundle them with the app or self-host them, because anonymous HF downloads get throttled once you're past a few hundred megabytes, and "the app hangs on first launch" is a terrible first impression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fp32 eats RAM.&lt;/strong&gt; In-process fp32 uses more memory than Ollama's quantized GGUF, and it competes with the LLM for that memory on the same machine. On an 8 GB laptop, that competition is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Ollama is still the right call
&lt;/h2&gt;

&lt;p&gt;If your users are developers running their own stack — people who already have Ollama up, who want bring-your-own-model, who treat the daemon as infrastructure they control — then routing through it is the right design. The daemon stops being a hidden liability and becomes a feature. My users aren't developers, so for me it wasn't.&lt;/p&gt;

&lt;p&gt;The code and a longer write-up are here: &lt;a href="https://github.com/JackYU96/embed-without-daemon" rel="noopener noreferrer"&gt;https://github.com/JackYU96/embed-without-daemon&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app itself isn't launched yet — this is a pattern I settled on while building it, not a shipped product. If you've solved the desktop-embeddings problem a different way, I'd genuinely like to hear it.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>python</category>
      <category>fastapi</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
