<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Markus Östberg</title>
    <description>Software Engineer fascinated by the web, language processing, machine learning and big data.
</description>
    <link>https://www.ostberg.dev/</link>
    <atom:link href="https://www.ostberg.dev/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Thu, 09 Apr 2026 01:57:40 +0000</pubDate>
    <lastBuildDate>Thu, 09 Apr 2026 01:57:40 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>Content Recommendation on GitHub Pages with TF-IDF</title>
        <description>&lt;p&gt;In a &lt;a href=&quot;/projects/2025/02/16/adding-search-to-github-pages.html&quot;&gt;previous post&lt;/a&gt; I added client-side search to this blog using a pre-built search index and TF-IDF scoring. The same index turns out to be all you need to build a related posts feature too, with no changes to the Jekyll build.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/related-pages.png&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/related-pages.png&quot; alt=&quot;Related Pages&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;what-is-tf-idf&quot;&gt;What is TF-IDF?&lt;/h2&gt;

&lt;p&gt;TF-IDF stands for &lt;strong&gt;Term Frequency–Inverse Document Frequency&lt;/strong&gt;. It weights how important a word is to a document in a collection. A word that appears often in a post but rarely across the blog gets a high weight; a word that appears everywhere (like “the”) gets a weight near zero.&lt;/p&gt;

&lt;p&gt;For a term $t$ in document $d$ across a collection of $N$ documents:&lt;/p&gt;

\[TF(t, d) = \frac{\text{count of } t \text{ in } d}{\text{total terms in } d}\]

\[IDF(t) = \log\left(\frac{N}{df(t)}\right)\]

\[\text{TF-IDF}(t, d) = TF(t, d) \times IDF(t)\]

&lt;p&gt;where $df(t)$ is the number of documents containing term $t$. The resulting weights give you a vector that describes what a post is about in terms the rest of the collection does not share.&lt;/p&gt;

&lt;h2 id=&quot;from-search-to-recommendation&quot;&gt;From Search to Recommendation&lt;/h2&gt;

&lt;p&gt;The search implementation scores a query against each document. Recommendation is the same idea, but instead of a query you use another document. Once each post is a TF-IDF vector, you can compare any two of them using &lt;strong&gt;cosine similarity&lt;/strong&gt;:&lt;/p&gt;

\[\text{sim}(A, B) = \frac{A \centerdot B}{\|A\| \|B\|}\]

&lt;p&gt;Posts that share rare vocabulary score close to 1. Posts with nothing in common score close to 0. Because IDF already down-weights common terms, shared boilerplate does not inflate the score.&lt;/p&gt;

&lt;h2 id=&quot;reusing-the-search-index&quot;&gt;Reusing the Search Index&lt;/h2&gt;

&lt;p&gt;The search index built at Jekyll compile time already has everything we need. Each entry contains raw term counts in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keywords&lt;/code&gt; object:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Build your own Search Engine 101&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;date&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Jan 31, 2015&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;tags&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Dev&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Search&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;keywords&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;search&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;26&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;engine&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;document&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;24&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;frequency&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;7&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;url&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;/projects/2015/01/31/build-your-own-search-engine-101.html&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;excerpt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;For many of us, there is something magical with a search engine...&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;We can compute TF-IDF vectors from those counts entirely client-side.&lt;/p&gt;

&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;

&lt;p&gt;The post layout includes a hidden HTML shell that JavaScript populates after the page loads:&lt;/p&gt;

&lt;h4 id=&quot;_includesrelated-postshtml&quot;&gt;_includes/related-posts.html&lt;/h4&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;{% if page.url %}
&lt;span class=&quot;nt&quot;&gt;&amp;lt;section&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;related-posts&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;related-posts&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;style=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;display:none&quot;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;data-url=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{{ page.url | relative_url }}&quot;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;data-baseurl=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{{ site.baseurl }}&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;h3&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;related-posts-title&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;Related Posts&lt;span class=&quot;nt&quot;&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;ul&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;related-posts-list&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
{% endif %}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The script fetches the search index, computes IDF across all posts, builds TF-IDF vectors, and ranks every other post by cosine similarity against the current one. If there are results, it fills in the list and shows the section.&lt;/p&gt;

&lt;h4 id=&quot;assetsjsrelated-postsjs&quot;&gt;assets/js/related-posts.js&lt;/h4&gt;
&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;section&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;related-posts&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;section&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;currentUrl&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;section&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;baseUrl&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;section&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;baseurl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;MAX_RESULTS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;baseUrl&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;/search_index.json&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;then&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;r&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;then&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;index&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;N&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

      &lt;span class=&quot;c1&quot;&gt;// Document frequency: number of posts each term appears in&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;df&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{};&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;post&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;keys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;keywords&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;df&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;df&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

      &lt;span class=&quot;c1&quot;&gt;// Inverse document frequency: terms in many posts get low weight&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;idf&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{};&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;keys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;df&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;idf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Math&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;N&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;df&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]);&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

      &lt;span class=&quot;c1&quot;&gt;// Build a TF-IDF vector from a post&apos;s keyword counts.&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// Term frequency is normalised by document length so that&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// short and long posts are comparable.&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;toVector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;vec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{};&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;values&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;keywords&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;reduce&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;vec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;entries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;keywords&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;vec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;idf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;term&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;vec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

      &lt;span class=&quot;c1&quot;&gt;// Cosine similarity between two sparse vectors.&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// Only iterates over non-zero entries, so cost is proportional&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// to vocabulary overlap rather than total vocabulary size.&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cosine&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;magA&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;magB&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;entries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;dot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;magA&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;of&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;values&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;magB&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;magA&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;magB&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;dot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;Math&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;sqrt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;magA&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Math&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;sqrt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;magB&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;currentPost&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;find&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;currentUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;currentPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;currentVec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;toVector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;currentPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

      &lt;span class=&quot;c1&quot;&gt;// Score every other post against the current one, then take the&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// top results. Date is used as a tiebreaker: newer posts first.&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;related&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;index&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;currentUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;score&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cosine&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;currentVec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;toVector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)),&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}))&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;c&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;sort&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;score&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;slice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;MAX_RESULTS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

      &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;related&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;length&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

      &lt;span class=&quot;c1&quot;&gt;// Render results as cards and show the section&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;list&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;section&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;querySelector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;.related-posts-list&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// ... DOM rendering omitted for brevity&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;section&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;display&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Related posts error:&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The full file including the DOM rendering code that builds each card is on &lt;a href=&quot;https://github.com/markusos/markusos.github.io/blob/master/assets/js/related-posts.js&quot;&gt;GitHub&lt;/a&gt;. The rendering just creates list items with the same structure as the search results page so they share the same card styling.&lt;/p&gt;

&lt;p&gt;The script is loaded with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;defer&lt;/code&gt; in the site &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;head&amp;gt;&lt;/code&gt; so it does not block page rendering.&lt;/p&gt;

&lt;h2 id=&quot;tradeoffs&quot;&gt;Tradeoffs&lt;/h2&gt;

&lt;p&gt;The algorithm works from post content alone, so it does not need manual categorization or tagging to produce results. If a post has nothing in common with the rest of the blog, its scores will all be low and the section simply will not appear.&lt;/p&gt;

&lt;p&gt;IDF is computed at runtime across the whole collection, so the weights stay calibrated as the blog grows. A term that is rare today might become common after ten more posts on the same topic, and the recommendations will shift the next time the search index is rebuilt.&lt;/p&gt;

&lt;p&gt;The main limitation is the search index itself. It tokenizes content with Liquid at build time, so there is no stemming. “search” and “searching” are treated as different terms. For a personal blog this has not been a problem in practice.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The search index already has enough information per post to power a content-based recommendation system. Adding related posts was mostly a matter of writing a cosine similarity function and wiring it into the post layout.&lt;/p&gt;
</description>
        <pubDate>Tue, 07 Apr 2026 12:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/projects/2026/04/07/related-posts-with-tfidf.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/projects/2026/04/07/related-posts-with-tfidf.html</guid>
        
        <category>Dev</category>
        
        <category>Search</category>
        
        <category>Web</category>
        
        <category>Jekyll</category>
        
        <category>JavaScript</category>
        
        
        <category>projects</category>
        
      </item>
    
      <item>
        <title>Piaule Catskill: A Midweek Escape to the Mountains</title>
        <description>&lt;p&gt;We’d been eyeing &lt;a href=&quot;https://www.piaule.com/&quot;&gt;Piaule Catskill&lt;/a&gt; for a while. Friends had recommended it, the reviews were glowing, and the photos of those minimalist cabins nestled into the hillside had been living rent-free in our heads. When we finally found a window for a midweek getaway, we decided to splurge on two nights, Wednesday to Friday, and it was absolutely worth it.&lt;/p&gt;

&lt;h3 id=&quot;the-cabins&quot;&gt;The Cabins&lt;/h3&gt;

&lt;p&gt;Piaule bills itself as a “landscape hotel,” and once you arrive it’s clear why. The property sits on a west-facing hill overlooking the Catskill Escarpment, with 24 individual cabins scattered along footpaths through the woods. The design draws from Japanese and Scandinavian influences: clean lines, untreated cedar, warm white oak. The whole thing feels like it belongs in the landscape rather than being imposed on it.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/piaule-cabin.jpg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/piaule-cabin.jpg&quot; alt=&quot;Piaule Cabin&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The standout feature of each cabin is the massive sliding glass wall facing west, essentially a 10-by-13-foot window that opens up completely. We had light rain on and off during our stay, and honestly it only made the cabin experience better. Lying in bed listening to the rain tap against the cedar while looking out at the misty mountains was the kind of reset we needed.&lt;/p&gt;

&lt;h3 id=&quot;the-spa&quot;&gt;The Spa&lt;/h3&gt;

&lt;p&gt;A short walk downhill from the cabins takes you to the &lt;a href=&quot;https://www.piaule.com/spa&quot;&gt;spa&lt;/a&gt;, tucked beneath a green roof and almost hidden in the hillside. The facilities are laid out as a progressive circuit: hot pool, mineral plunge, cedar sauna, and a bluestone steam room. We used it multiple times during our stay, and for the most part we were the only ones there. Midweek perks.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/piaule-spa.jpg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/piaule-spa.jpg&quot; alt=&quot;Piaule Spa&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The main jet pool has an uninterrupted view out toward the escarpment. We spent one afternoon just sitting there watching rain clouds drift across the valley, slowly dissolving and reforming over the ridgeline. One of those moments where you stop thinking about everything else. The spa is only open to overnight guests, which keeps it peaceful and unhurried.&lt;/p&gt;

&lt;h3 id=&quot;dinner-at-piaule&quot;&gt;Dinner at Piaule&lt;/h3&gt;

&lt;p&gt;Our first evening, we had dinner at the &lt;a href=&quot;https://www.piaule.com/restaurant&quot;&gt;on-site restaurant&lt;/a&gt;. The dining room is oak and glass, with the entire western wall open to the view. We worked through a three-course prix fixe menu by Chef Ryan Tate, who previously earned a Michelin star at Le Restaurant in Tribeca. The menu changes with the seasons and leans heavily on local farms. Everything we had was thoughtfully prepared, inventive without being fussy.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/piaule-view.jpg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/piaule-view.jpg&quot; alt=&quot;View from Piaule&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What made it feel truly special was the intimacy. There were only one other couple dining with us that night, which gave the whole experience a sense of calm and exclusivity. It almost felt like we had the hotel to ourselves.&lt;/p&gt;

&lt;h3 id=&quot;lil-debs-oasis&quot;&gt;Lil’ Deb’s Oasis&lt;/h3&gt;

&lt;p&gt;The second night we drove down to Hudson for a completely different vibe. &lt;a href=&quot;https://www.lildebsoasis.com/&quot;&gt;Lil’ Deb’s Oasis&lt;/a&gt; is a James Beard-nominated spot on Columbia Street that describes its food as “tropical comfort food,” blending South Asian and Latin American flavors with local Hudson Valley ingredients. The place is vibrant in every sense: coral and turquoise exterior, neon signage, and a buzzing energy that couldn’t be more different from where we’d just come from.&lt;/p&gt;

&lt;p&gt;We can wholeheartedly recommend the whole fried fish, crispy and flaky with a bright citrusy sauce, paired with the Plato Tropical: beans, herbed rice, and garlicky greens. Really, everything we tried was great. After two days of quiet contemplation, the lively restaurant was a welcome jolt of energy and a nice reminder of how much the Hudson Valley has to offer.&lt;/p&gt;

&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;

&lt;p&gt;If you’re looking for a genuinely restorative getaway, Piaule is hard to beat. Go midweek if you can. The quiet makes a huge difference, and a little rain doesn’t hurt either. And leave one evening for dinner in Hudson. The shift from Piaule’s stillness to the lively dining scene there makes the whole trip feel that much richer.&lt;/p&gt;
</description>
        <pubDate>Sat, 04 Apr 2026 19:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/travel/2026/04/04/piaule-catskill.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/travel/2026/04/04/piaule-catskill.html</guid>
        
        <category>Life</category>
        
        <category>Travel</category>
        
        
        <category>travel</category>
        
      </item>
    
      <item>
        <title>Rebuilding the Kublet with Claude Code</title>
        <description>&lt;p&gt;Last year I wrote about &lt;a href=&quot;/projects/2025/02/09/kublet-a-review.html&quot;&gt;my disappointment with the Kublet Kickstarter&lt;/a&gt;, a $150,000 campaign that shipped nice hardware with software that never lived up to its promise. My three Kublets had been sitting in a drawer ever since. This past week, I pulled them out and, with the help of Claude Code and the new Opus 4.6 model, turned them into the devices I originally backed.&lt;/p&gt;

&lt;h2 id=&quot;from-paperweight-to-project&quot;&gt;From paperweight to project&lt;/h2&gt;

&lt;p&gt;The Kublet hardware is fine. It’s a compact ESP32 with a 240x240 LCD, WiFi, and a single button. The software is what let it down. The official app store had almost nothing in it, the companion app didn’t do much, and the developer tools barely worked.&lt;/p&gt;

&lt;p&gt;I forked &lt;a href=&quot;https://github.com/markusos/kublet-apps&quot;&gt;Kublet’s community repo&lt;/a&gt;, stripped out the broken tooling, and started building. The original repo shipped with four bare-bones example apps. Over the course of a week, Claude Code helped me build 20+ new ones on top of that. It was supposed to be a weekend project but I kept going because it was fun.&lt;/p&gt;

&lt;h2 id=&quot;the-tooling-problem-and-fix&quot;&gt;The tooling problem (and fix)&lt;/h2&gt;

&lt;p&gt;The original Kublet developer experience relied on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;krate&lt;/code&gt;, a proprietary CLI that barely worked and is no longer maintained. The official docs point to dead links. Before I could build any apps, I needed a way to actually compile and deploy code to the device.&lt;/p&gt;

&lt;p&gt;Claude Code helped me build a replacement &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dev&lt;/code&gt; tool in Python that handles the full lifecycle. It does initial USB flashing with WiFi credentials baked into NVS, PlatformIO builds with the correct TFT_eSPI pin configuration, and wireless OTA deployment so I don’t have to keep plugging in cables.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;./tools/dev init              &lt;span class=&quot;c&quot;&gt;# One-time USB flash + WiFi setup&lt;/span&gt;
./tools/dev build &amp;lt;app&amp;gt;       &lt;span class=&quot;c&quot;&gt;# Compile an app&lt;/span&gt;
./tools/dev deploy &amp;lt;app&amp;gt;      &lt;span class=&quot;c&quot;&gt;# Build and OTA deploy&lt;/span&gt;
./tools/dev logs              &lt;span class=&quot;c&quot;&gt;# Stream serial logs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Getting the display configuration right was one of those things that would have taken me hours of digging through forums. The Kublet uses an ST7789 display on specific SPI pins with a 40 MHz clock, and none of this was documented. Claude Code helped me trace through the original firmware, figure out the pin mapping, and wire it into the PlatformIO build so it “just works” for every app.&lt;/p&gt;

&lt;h2 id=&quot;the-apps&quot;&gt;The apps&lt;/h2&gt;

&lt;p&gt;Each app is a self-contained PlatformIO project that compiles to a firmware image deployed over WiFi. Here are some of them (the full list is in the &lt;a href=&quot;https://github.com/markusos/kublet-apps&quot;&gt;repo&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;app-grid&quot;&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/stock.gif&quot; alt=&quot;Stock Ticker&quot; /&gt;&lt;p&gt;Stock Ticker&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/weather.gif&quot; alt=&quot;Weather&quot; /&gt;&lt;p&gt;Weather&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/hn.gif&quot; alt=&quot;Hacker News&quot; /&gt;&lt;p&gt;Hacker News&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/clock.gif&quot; alt=&quot;Clock&quot; /&gt;&lt;p&gt;Clock&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/music.gif&quot; alt=&quot;Music&quot; /&gt;&lt;p&gt;Music&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/notice.gif&quot; alt=&quot;Notifications&quot; /&gt;&lt;p&gt;Notifications&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/aquarium.gif&quot; alt=&quot;Aquarium&quot; /&gt;&lt;p&gt;Aquarium&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/matrix.gif&quot; alt=&quot;Matrix&quot; /&gt;&lt;p&gt;Matrix&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/dvd.gif&quot; alt=&quot;DVD Screensaver&quot; /&gt;&lt;p&gt;DVD Screensaver&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/snake.gif&quot; alt=&quot;Snake&quot; /&gt;&lt;p&gt;Snake&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/astro.gif&quot; alt=&quot;Astro&quot; /&gt;&lt;p&gt;Astro&lt;/p&gt;&lt;/div&gt;
  &lt;div class=&quot;app-grid-item&quot;&gt;&lt;img src=&quot;/assets/kublet-apps/badgers.gif&quot; alt=&quot;Badgers&quot; /&gt;&lt;p&gt;Badgers&lt;/p&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Stock Ticker&lt;/strong&gt; – Tracks 10 symbols (VOO, AAPL, NVDA, BTC-USD, etc.) with intraday sparkline charts, refreshing every 5 minutes. This is what I originally backed the Kublet for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weather&lt;/strong&gt; – Current conditions plus a 3-day forecast with animated weather icons. Uses the Open-Meteo API so no API key needed. Cycles through three saved locations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hacker News&lt;/strong&gt; – Displays the top 10 stories with scores and comment counts, auto-cycling every 30 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clock&lt;/strong&gt; – Analog clock, NTP-synced, with smooth sweeping hands and proper hour markers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Music&lt;/strong&gt; – Shows what’s currently playing in Apple Music with album artwork rendered as a 240x240 JPEG, track name, artist, and elapsed time. A small Python server on my Mac polls Music.app via AppleScript and serves the data over the local network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notifications&lt;/strong&gt; – Mirrors macOS notifications from iMessage, Slack, Discord, Mail, and Calendar to the Kublet in real time. This one alone justified pulling the devices out of the drawer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aquarium&lt;/strong&gt; – An animated underwater scene with fish, jellyfish, a crab, seahorse, seaweed, and rising bubbles. Three color themes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Matrix&lt;/strong&gt; – Digital rain with 40 falling character columns and glowing trails. Five color themes toggled with the button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DVD Screensaver&lt;/strong&gt; – The bouncing DVD logo. It counts corner hits and flashes white when it finally gets one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snake&lt;/strong&gt; – An “AI”-controlled snake game using A* pathfinding with flood-fill safety checks. It plays itself endlessly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Astro&lt;/strong&gt; – Pulls NASA’s Astronomy Picture of the Day and cycles through a 30-day archive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Badgers&lt;/strong&gt; – My first Kublet app, written a few months ago with much more limited AI help. It plays the Badgers meme. I couldn’t get the GIF rendering smooth until I came back to it with Claude Code.&lt;/p&gt;

&lt;h2 id=&quot;working-with-claude-code&quot;&gt;Working with Claude Code&lt;/h2&gt;

&lt;p&gt;I used Claude Code with Opus 4.6 for the whole project. I expected it to struggle with ESP32 development, given the constrained environment (320 KB of RAM, no filesystem, no standard library networking), but it consistently produced code that compiled and ran on first deploy. It understood the TFT_eSPI API, ArduinoJson patterns, and the quirks of WiFi on ESP32 without much hand-holding.&lt;/p&gt;

&lt;p&gt;The iteration cycle worked well. Describe an app concept, get a working first version, deploy over WiFi, see it on the screen, refine. Most apps went from idea to running on hardware in under an hour. The maze app with its multiple generation and solving algorithms was the most complex and that took maybe two hours.&lt;/p&gt;

&lt;p&gt;The music and notification apps were interesting because they required a Python server running on my Mac to bridge between macOS APIs and the ESP32 over HTTP. Claude Code wrote both sides, the AppleScript integration and the server endpoints on the Mac side, and the embedded C++ client code on the ESP32 side.&lt;/p&gt;

&lt;p&gt;The graphics work impressed me the most. Apps like the aquarium and matrix rain involve real rendering math that all runs at full frame rate on a tiny microcontroller. The first versions worked out of the box, but it took several iterations to get things like the aquarium to actually look alive. Tuning fish movement, bubble timing, color palettes. Claude Code was good at taking vague feedback like “the fish look robotic” and turning it into better animation code.&lt;/p&gt;

&lt;h2 id=&quot;how-far-things-have-moved&quot;&gt;How far things have moved&lt;/h2&gt;

&lt;p&gt;A little over a year ago I wrote about &lt;a href=&quot;/projects/2025/01/20/exploring-ai-coding-tools.html&quot;&gt;exploring AI coding tools&lt;/a&gt;, comparing GitHub Copilot and a local Llama model on a single-file Python problem. My takeaway at the time was that these tools were useful for basic tasks but fell apart when complexity increased, especially across multiple files. I ended that post saying I’d keep writing most of my own code.&lt;/p&gt;

&lt;p&gt;This project was nothing like that. Claude Code handled embedded C++ on an ESP32, Python servers bridging macOS APIs, display driver configuration, OTA deployment, and rendering math for animations, all in the same session. It kept context across files and knew when changes in one place needed updates in another. The gap between what AI coding tools could do in early 2025 and what they can do now is significant.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/markusos/kublet-apps&quot;&gt;repo is public&lt;/a&gt;. If you have a Kublet gathering dust, clone it and try the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dev&lt;/code&gt; tool. Each app is self-contained and deploys over WiFi in about 30 seconds. I have three Kublets on my desk now: one showing stocks, one showing the weather, and one showing my recent notifications. They’re finally doing what I paid for.&lt;/p&gt;

&lt;p&gt;The Kublet is still a cautionary tale about Kickstarter promises, but it’s also proof that good hardware outlasts bad software.&lt;/p&gt;
</description>
        <pubDate>Fri, 27 Mar 2026 22:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/projects/2026/03/27/rebuilding-the-kublet-with-claude-code.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/projects/2026/03/27/rebuilding-the-kublet-with-claude-code.html</guid>
        
        <category>AI</category>
        
        <category>Hardware</category>
        
        <category>Kickstarter</category>
        
        
        <category>projects</category>
        
      </item>
    
      <item>
        <title>MongoDB Community Edition: Vector Search for Everyone</title>
        <description>&lt;p&gt;I’ve been itching to try MongoDB’s vector search ever since I saw it demoed at MongoDB.local NYC back in September. But here’s the thing: until recently, it was Atlas-only. If you wanted to build a RAG application or experiment with semantic search, you either paid for Atlas or jury-rigged something with Pinecone or Weaviate alongside your MongoDB instance. It works, but it always felt like a duct taped solution.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/mongodb_vector_search.png&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/mongodb_vector_search.png&quot; alt=&quot;MongoDB Search Demo&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then &lt;a href=&quot;https://www.mongodb.com/company/blog/product-release-announcements/supercharge-self-managed-apps-search-vector-search-capabilities&quot;&gt;MongoDB 8.2 Community Edition&lt;/a&gt; dropped with vector search in public preview, and I immediately spun up a demo project. This isn’t just a nice-to-have feature. It fundamentally changes what you can build locally. No more “well, I’d love to test this, but I need to sign up for their cloud service first.”&lt;/p&gt;

&lt;h2 id=&quot;what-actually-changed&quot;&gt;What Actually Changed&lt;/h2&gt;

&lt;p&gt;Here’s what MongoDB added to the free, self-managed Community Edition:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$vectorSearch&lt;/code&gt;&lt;/strong&gt; - Semantic similarity search right in your aggregation pipelines&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$search&lt;/code&gt;&lt;/strong&gt; - Full-text keyword search with fuzzy matching&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$searchMeta&lt;/code&gt;&lt;/strong&gt; - Metadata and faceting for search results&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;mongot&lt;/strong&gt; - A separate search binary that handles the indexing (runs alongside mongod)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key part is functional parity with Atlas. Same APIs, same aggregation operators, same everything. You’re not getting a watered-down version. This is the real deal, just running on your own hardware.&lt;/p&gt;

&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;

&lt;p&gt;I built a Wikipedia search demo to test this out properly. The stack is pretty straightforward:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;MongoDB 8.2&lt;/strong&gt; and &lt;strong&gt;mongot&lt;/strong&gt; running in Docker&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;LM Studio&lt;/strong&gt; for generating embeddings locally (using Nomic Embed)&lt;/li&gt;
  &lt;li&gt;A Python app to ingest Wikipedia articles and run searches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting bit is that you need both mongod and mongot running. Mongot is the search server. It handles the indexing and similarity calculations while mongod stores the actual data. They talk to each other behind the scenes.&lt;/p&gt;

&lt;p&gt;One gotcha: MongoDB needs to run as a replica set, even if it’s just a single node. The search features require this. Not a huge deal, but it means your docker-compose setup needs a few extra lines to initialize the replica set.&lt;/p&gt;

&lt;p&gt;Here’s the basic docker-compose structure:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;mongod&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;mongodb/mongodb-community-server:8.2.0-ubi9&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;command&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;mongod --replSet rs0&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;27017:27017&lt;/span&gt;

  &lt;span class=&quot;na&quot;&gt;mongot&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;mongodb/mongodb-community-search:0.53.1&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;27028:27028&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;depends_on&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;mongod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s it. No Atlas credentials, no external services. Everything runs locally.&lt;/p&gt;

&lt;p&gt;For more details, check out the official MongoDB quick start guide: &lt;a href=&quot;https://www.mongodb.com/docs/atlas/atlas-vector-search/tutorials/vector-search-quick-start/?deployment-type=self&quot;&gt;MongoDB Vector Search Quick Start&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;setting-up-the-indexes&quot;&gt;Setting Up the Indexes&lt;/h2&gt;

&lt;p&gt;The first thing you need to do is create search indexes. MongoDB needs these to know how to handle your searches. Kind of like telling it “hey, these vectors should be searchable by similarity” or “these text fields should support keyword matching.”&lt;/p&gt;

&lt;h3 id=&quot;vector-search-index&quot;&gt;Vector Search Index&lt;/h3&gt;

&lt;p&gt;For vector search, you create an index that knows how to handle high-dimensional embeddings. In my case, I’m using 768-dimensional vectors from the Nomic Embed model:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pymongo.operations&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SearchIndexModel&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;fields&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;vector&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;path&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;embedding&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;numDimensions&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;768&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;similarity&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;cosine&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SearchIndexModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;vector_index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;vectorSearch&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;collection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create_search_indexes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;numDimensions&lt;/code&gt; has to match your embedding model. I’m using 768 because that’s what Nomic Embed outputs. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;similarity&lt;/code&gt; metric is usually &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cosine&lt;/code&gt; for text embeddings. It’s what works best for semantic similarity.&lt;/p&gt;

&lt;h3 id=&quot;text-search-index&quot;&gt;Text Search Index&lt;/h3&gt;

&lt;p&gt;For full-text keyword search, it’s even simpler. You can just use dynamic mapping and MongoDB will index all your string fields:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;mappings&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;dynamic&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SearchIndexModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text_search_index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;search&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;collection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create_search_indexes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s it. MongoDB handles the rest.&lt;/p&gt;

&lt;h2 id=&quot;the-data-structure&quot;&gt;The Data Structure&lt;/h2&gt;

&lt;p&gt;I split Wikipedia articles into two collections: one for article metadata and one for searchable chunks. The chunks collection is where the magic happens:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;page_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;12345&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Machine Learning&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;chunk_index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;section&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Introduction&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Machine learning is a method of data analysis...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;embedding&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.123&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.456&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.789&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;...],&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 768-dim vector
&lt;/span&gt;    &lt;span class=&quot;s&quot;&gt;&quot;token_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;256&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Each chunk is about 512 tokens of text (roughly a few paragraphs), and each one gets its own embedding vector. This chunking strategy is important. You can’t just embed entire Wikipedia articles. They’re too long, and you lose granularity. By chunking, you can find the specific section that’s relevant to a query.&lt;/p&gt;

&lt;p&gt;I’m using LM Studio to generate the embeddings locally. The Python code uses the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lmstudio&lt;/code&gt; package to interface with the embedding model:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;lmstudio&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lms&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Load the embedding model
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;embedding_model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text-embedding-nomic-embed-text-v1.5@q8_0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Generate embedding
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;embedding&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;embed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Your text here&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The embeddings go right into MongoDB as arrays. No special format needed, just a list of floats in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;embedding&lt;/code&gt; field.&lt;/p&gt;

&lt;h2 id=&quot;vector-search-semantic-similarity-in-action&quot;&gt;Vector Search: Semantic Similarity in Action&lt;/h2&gt;

&lt;p&gt;This is where things get interesting. Vector search is all about semantic similarity. Finding documents that &lt;em&gt;mean&lt;/em&gt; the same thing, not just documents that contain the same words.&lt;/p&gt;

&lt;p&gt;The query is an aggregation pipeline with the new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$vectorSearch&lt;/code&gt; stage:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;pipeline&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;$vectorSearch&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;vector_index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;path&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;embedding&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;queryVector&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;query_embedding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;numCandidates&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;limit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;$project&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;score&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;$meta&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;vectorSearchScore&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;results&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;collection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aggregate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pipeline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You embed your query text (same way you embedded the documents), then pass that vector to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$vectorSearch&lt;/code&gt;. It finds the closest matches using cosine similarity.&lt;/p&gt;

&lt;p&gt;The results are genuinely impressive. I can ask “Greek hero with weak heel?” and it pulls up relevant chunks about Achilles and the Trojan War, even though I never mentioned his name. That’s the semantic part working. Conveniently, Achilles is one of the first articles loaded from the Wikipedia dataset, which makes it a perfect quick test to validate everything is working correctly.&lt;/p&gt;

&lt;p&gt;One thing to note: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;numCandidates&lt;/code&gt; is how many documents it considers before narrowing down to your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;limit&lt;/code&gt;. Higher numbers give better results but are slower. I experimented a bit and set it to 10x the limit as a starting point.&lt;/p&gt;

&lt;h2 id=&quot;text-search-good-old-keyword-matching&quot;&gt;Text Search: Good Old Keyword Matching&lt;/h2&gt;

&lt;p&gt;Sometimes you just want to find documents that contain specific words. That’s what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$search&lt;/code&gt; is for: traditional full-text search with all the bells and whistles.&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;pipeline&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;$search&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;text_search_index&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;query&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Albert Einstein relativity&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;path&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;fuzzy&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;maxEdits&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;$limit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;$project&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;score&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;$meta&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;searchScore&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;results&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;collection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;aggregate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pipeline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The fuzzy matching is nice. It’ll catch typos and close variations. If someone searches for “Einstien” instead of “Einstein,” it still works.&lt;/p&gt;

&lt;p&gt;Text search is faster than vector search and more predictable. If you know the exact term you’re looking for (like a person’s name or a technical term), text search is usually the better choice. But it doesn’t understand semantics. It’s just matching words.&lt;/p&gt;

&lt;h2 id=&quot;hybrid-search-the-best-of-both-worlds&quot;&gt;Hybrid Search: The Best of Both Worlds&lt;/h2&gt;

&lt;p&gt;The really cool part is combining both approaches. You run a vector search and a text search, then merge the results using something called Reciprocal Rank Fusion (RRF). It’s a way of saying “if a document shows up high in both result sets, it’s probably really relevant.”&lt;/p&gt;

&lt;p&gt;Here’s the basic idea:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# Run both searches
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;vector_results&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vector_search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;text_results&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text_search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Calculate RRF scores
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rrf_scores&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;enumerate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;vector_results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;rrf_scores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;enumerate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text_results&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rrf_scores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;rrf_scores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;rrf_scores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rank&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Sort by combined score
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;final_results&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;sorted&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rrf_scores&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;items&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;lambda&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;reverse&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This gives you the best of both worlds. Documents that match semantically &lt;em&gt;and&lt;/em&gt; contain the right keywords get boosted to the top. It’s surprisingly robust. I’ve found it works well without much tuning.&lt;/p&gt;

&lt;p&gt;You can also do weighted combinations if you want more control (e.g., 70% vector, 30% text), but RRF is usually good enough.&lt;/p&gt;

&lt;h2 id=&quot;what-this-enables&quot;&gt;What This Enables&lt;/h2&gt;

&lt;p&gt;The real win here is RAG (Retrieval-Augmented Generation). You can now build a complete RAG pipeline without leaving MongoDB:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;User asks a question&lt;/li&gt;
  &lt;li&gt;You embed the question and do a vector search&lt;/li&gt;
  &lt;li&gt;You retrieve the top 3-5 relevant chunks&lt;/li&gt;
  &lt;li&gt;You feed those chunks as context to an LLM&lt;/li&gt;
  &lt;li&gt;The LLM generates an answer grounded in your data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the “memory” concept from the MongoDB.local keynote. Giving AI agents access to your data in a way that’s fast, relevant, and doesn’t require syncing between multiple databases.&lt;/p&gt;

&lt;p&gt;And because it’s all in MongoDB, you get:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Transactional consistency&lt;/strong&gt; - No sync lag between your main DB and your vector store&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Simple architecture&lt;/strong&gt; - One database, one query language, one connection string&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Metadata filtering&lt;/strong&gt; - Combine vector search with traditional filters (e.g., “find similar articles, but only in the ‘Science’ category”)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;performance&quot;&gt;Performance&lt;/h2&gt;

&lt;p&gt;The search performance itself is solid. Vector search typically returns results in under 100ms for 10 results. Text search is even faster at around 20-30ms. Hybrid search is just the sum of both plus some merging logic.&lt;/p&gt;

&lt;p&gt;The bottleneck is embedding generation. Processing 10,000 Wikipedia articles for ingestion into MongoDB takes hours on consumer hardware, and parsing and embedding the entire Wikipedia dump would take days. That’s not a MongoDB problem though. That’s just how long it takes to run text through an embedding model locally. Once your data is indexed, queries are fast.&lt;/p&gt;

&lt;h2 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h2&gt;

&lt;p&gt;Vector search in MongoDB Community Edition changes the local development game. Before this, building a RAG application meant juggling multiple databases: MongoDB for your data, Pinecone or Weaviate for vectors, and some sync mechanism to keep them aligned. Now it’s just MongoDB. One connection string, one query language, one place where everything lives.&lt;/p&gt;

&lt;p&gt;The fact that this is in the free Community Edition matters. No monthly bills, no vendor lock-in. You can prototype locally, understand exactly how it works, and decide later whether you want to move to Atlas or keep running it yourself. That’s the kind of flexibility that makes experimenting with new features actually happen instead of staying on the “maybe someday” list.&lt;/p&gt;

&lt;p&gt;I’ve put the complete demo project on GitHub at &lt;a href=&quot;https://github.com/markusos/mongo-search-demo&quot;&gt;markusos/mongo-search-demo&lt;/a&gt;. It includes the Docker Compose setup, Wikipedia ingestion pipeline, LM Studio integration, and an interactive CLI for testing all three search modes. Clone it, run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker compose up&lt;/code&gt;, and a few additional setup steps, and you’ll have a working vector search system running locally in minutes. No cloud accounts required.&lt;/p&gt;
</description>
        <pubDate>Sun, 12 Oct 2025 23:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/work/2025/10/12/mongodb-community-vector-search.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/work/2025/10/12/mongodb-community-vector-search.html</guid>
        
        <category>AI</category>
        
        <category>Data</category>
        
        <category>Search</category>
        
        
        <category>work</category>
        
      </item>
    
      <item>
        <title>Snowflake World Tour NYC: Beyond the Warehouse and into AI</title>
        <description>&lt;p&gt;Is it just me, or is every data conference at the Javits Center this year the exact same conference? A couple of weeks ago it was MongoDB’s turn, and today I just got back from the &lt;a href=&quot;https://www.snowflake.com/en/world-tour/nyc/&quot;&gt;Snowflake World Tour&lt;/a&gt;. It felt like deja vu. The lights are different, the logos are different, but the theme being blasted from the keynote stage is identical: AI is here, and we’re the platform you need for it.&lt;/p&gt;

&lt;p&gt;Snowflake’s version of this story is called &lt;a href=&quot;https://www.snowflake.com/en/engineering-blog/cortex-agents-unified-data-intelligence/&quot;&gt;Cortex Agents&lt;/a&gt;. This was the star of the show, where they pitched a future of building and deploying AI that lives and runs right on top of your data. The vision is compelling, for sure. They even tried a live demo, which promptly ran into some real-world chaos in the form of bright red “Internal server error” messages. It was a good reminder that this stuff is still on the bleeding edge, even for the folks building it.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/snowflake_world_tour_nyc.jpeg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/snowflake_world_tour_nyc.jpeg&quot; alt=&quot;Snowflake World Tour Keynote&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One session that stood out from the main AI theme was on Snowflake Postgres. It was an interesting deviation, and after their acquisition of &lt;a href=&quot;https://www.snowflake.com/en/news/press-releases/snowflake-acquires-crunchy-data-to-bring-enterprise-ready-postgres-offering-to-the-ai-data-cloud/&quot;&gt;Crunchy Data&lt;/a&gt;, it’s a clear indicator of where they’re heading. They’re making a run at developers building transactional apps, trying to pull OLTP workloads onto their platform and stop being just the final destination for data.&lt;/p&gt;

&lt;p&gt;Of course, you can’t unleash AI agents on corporate data without locking them down, which is where the session on the Horizon Catalog provided a necessary dose of reality at the end of the day. They covered the governance and security guardrails, which is the stuff that actually matters if you want to use any of this in a real company.&lt;/p&gt;

&lt;p&gt;So, walking out of the Javits Center again, the deja vu was real. The industry is shouting one thing right now: AI. Every platform wants to be the center of this new world. Last time it was MongoDB pitching itself as the “memory” for agents. Today, Snowflake laid out its grand vision to be the entire “central nervous system”. It feels like the definition of a data platform is changing in real-time. For engineers, this brings our mission into sharp focus. Our job remains what it has always been: to cut through the marketing metaphors and separate the architectural signal from the aspirational noise.&lt;/p&gt;
</description>
        <pubDate>Thu, 02 Oct 2025 23:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/work/2025/10/02/snowflake-world-toure-nyc.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/work/2025/10/02/snowflake-world-toure-nyc.html</guid>
        
        <category>AI</category>
        
        <category>Data</category>
        
        
        <category>work</category>
        
      </item>
    
      <item>
        <title>MongoDB.local NYC: Embracing the AI Era with New Tools</title>
        <description>&lt;p&gt;Just got back from &lt;a href=&quot;https://www.mongodb.com/company/blog/events/local-nyc-2025-defining-ideal-database-for-ai-era&quot;&gt;MongoDB.local NYC&lt;/a&gt;, and my head is still buzzing a bit. It was a long day, but a good one. The theme was pretty much blasted from the stage all day: data infrastructure for the AI era. They kept framing MongoDB as the “memory” for AI agents: the thing that provides context and state so the agents aren’t just stateless parrots.&lt;/p&gt;

&lt;p&gt;For me, the biggest announcement was the public preview of Vector Search for the Community and Enterprise editions. This is a huge deal, honestly. It means I can actually build and test RAG applications locally without being tied to a cloud service just to get vector search working. It really lowers the barrier to just trying things out. Along with that, they announced their new Voyage AI models to improve retrieval accuracy and an Application Modernization Platform, which seems geared more toward large enterprise projects.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/mongodb_local_nyc.jpeg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/mongodb_local_nyc.jpeg&quot; alt=&quot;MongoDB.local main stage&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the highlight for me was a hands-on session on giving AI agents persistent memory. They walked us through the new &lt;a href=&quot;https://www.mongodb.com/company/blog/product-release-announcements/powering-long-term-memory-for-agents-langgraph&quot;&gt;MongoDB Store for LangGraph&lt;/a&gt;, and it finally clicked how this “memory” concept works in practice. We saw how an agent could use MongoDB to store different types of memory (like episodic or semantic) across multiple conversations, using its JSON structure and vector search to pull relevant context. It’s a really practical solution to a tricky problem.&lt;/p&gt;

&lt;p&gt;So, walking away from it, my main takeaway is that MongoDB is making a serious play to be more than just a database in the AI stack. Making vector search accessible to everyone in the Community Edition is the smartest thing they could have done. It feels like they’re shifting from being just a place to store data to being an active part of an AI’s brain. Time to go download the latest version and see what I can build with it.&lt;/p&gt;
</description>
        <pubDate>Wed, 17 Sep 2025 23:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/work/2025/09/17/mongodb-local-nyc.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/work/2025/09/17/mongodb-local-nyc.html</guid>
        
        <category>AI</category>
        
        <category>Data</category>
        
        
        <category>work</category>
        
      </item>
    
      <item>
        <title>MCP Server for Data Access: The Future of Analytics?</title>
        <description>&lt;p&gt;After spending some time with Anthropic’s &lt;a href=&quot;https://modelcontextprotocol.io&quot;&gt;Model Context Protocol (MCP)&lt;/a&gt;, I’m cautiously optimistic about what might be a fundamental shift in how we interact with data. MCP promises to be the “USB-C port for AI applications”: a standardized way for LLMs to access external data and tools. While the protocol itself may seem like yet another abstraction layer, the bigger question is whether we’re witnessing the early stages of LLMs becoming the primary interface for data exploration.&lt;/p&gt;

&lt;p&gt;To test this in practice, I built an NYC 311 Data MCP Server that exposes 3.5 million New York City service requests from 2024, enabling LLMs to interact with the data. The server lets AI assistants run read-only SQL queries against the full dataset: complaint types, geographic patterns, temporal trends. It’s built on DuckDB for performance and includes some security guardrails to prevent dangerous operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bottom line&lt;/strong&gt;: It works surprisingly well for basic exploratory data analysis, and raises interesting questions about whether traditional dashboards and BI tools might eventually give way to conversational analytics.&lt;/p&gt;

&lt;h2 id=&quot;mcp-architecture&quot;&gt;MCP Architecture&lt;/h2&gt;

&lt;p&gt;MCP uses a client-server architecture where the host (Claude Desktop, LM Studio, VS Code, etc) coordinates everything and enforces security, while clients maintain isolated sessions with servers that expose data through standardized primitives.&lt;/p&gt;

&lt;p&gt;The good news is that servers are isolated from each other, ensuring they cannot access the full conversation history. Capability negotiation occurs upfront, allowing you to understand what features are supported before proceeding. While this adds complexity compared to a simple REST endpoint, MCP offers a lightweight and practical standard for tool interoperability. However, as MCP is still in its early stages and evolving rapidly, its long-term adoption remains uncertain.&lt;/p&gt;

&lt;h2 id=&quot;nyc-311-data-mcp-server&quot;&gt;NYC 311 Data MCP Server&lt;/h2&gt;

&lt;p&gt;To test this concept, I built an MCP server using the FastMCP framework with a DuckDB backend optimized for analytics requests. The server provides access to the full 2024 NYC 311 service requests dataset, comprising 3.5 million records with detailed location data (borough, ZIP, latitude/longitude), request specifics, and temporal information.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/mcp.png&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/mcp.png&quot; alt=&quot;MCP Architecture&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core functionality centers around a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;query_data&lt;/code&gt; tool that executes read-only SELECT queries, plus a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;schema&lt;/code&gt; resource that provides table structure information. Security guardrails block SQL operations altering data (DROP, DELETE, UPDATE, INSERT, ALTER, CREATE, TRUNCATE, EXEC) and enforce LIMIT clauses for SELECT * queries. This allows the LLM to safely explore the dataset without risking accidental data corruption or excessive resource usage.&lt;/p&gt;

&lt;h3 id=&quot;testing-with-real-data-natural-language-to-sql&quot;&gt;Testing with Real Data: Natural Language to SQL&lt;/h3&gt;

&lt;p&gt;The complete code to test this out is available in the &lt;a href=&quot;https://github.com/markusos/llm_duck&quot;&gt;markusos/llm_duck&lt;/a&gt; repository.&lt;/p&gt;

&lt;p&gt;Setting this up requires Python 3.9+, the uv package manager, and an MCP-compatible client. &lt;a href=&quot;https://lmstudio.ai/blog/lmstudio-v0.3.17&quot;&gt;LM Studio 0.3.17+&lt;/a&gt; supports MCP by editing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.lmstudio/mcp.json&lt;/code&gt;, and tool calls show confirmation dialogs for security by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mcp.json&lt;/strong&gt;:&lt;/p&gt;
&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;mcpServers&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;311_data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;command&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;uv&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;args&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;--directory&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;/ABSOLUTE/PATH/TO/llm_duck&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;run&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;run_mcp_server.py&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After setting up the MCP configuration and loading Google’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemma-3-27b&lt;/code&gt; LLM model into LM Studio, the model automatically discovered my MCP server. When prompting, the LLM began exploring the NYC 311 dataset. Without the need for manual SQL writing, it generated queries to uncover patterns across the 3.5 million records. DuckDB’s columnar storage handled the analytical workload efficiently, while the MCP layer added minimal overhead.&lt;/p&gt;

&lt;p&gt;The assistant successfully analyzed complaint patterns, handled missing data gracefully, and even generated simple aggregations on demand. While these capabilities—basic text-to-SQL conversion and query execution—are impressive, MCP provides a crucial advantage: it establishes a standardized framework for LLMs to interact seamlessly with multiple tools, diverse data sources, and complex workflows.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/mcp_use_lm_studio.png&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/mcp_use_lm_studio.png&quot; alt=&quot;MCP Usage in LM Studio&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During testing of the MCP server with LM Studio and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;google/gemma-3-27b&lt;/code&gt; model, the AI assistant successfully generated SQL to aggregate data, such as identifying the Zip codes with the most service requests in Brooklyn. When encountering invalid column names, the LLM utilized the schema resource to understand the correct structure and re-queried the data. This highlights how LLMs can leverage structured metadata and iterative refinement to enhance query accuracy, representing a significant advancement beyond basic text-to-SQL conversion.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; 
    &lt;span class=&quot;n&quot;&gt;incident_zip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; 
    &lt;span class=&quot;k&quot;&gt;COUNT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;count&lt;/span&gt; 
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;service_requests&lt;/span&gt; 
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;borough&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Brooklyn&apos;&lt;/span&gt; 
&lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;incident_zip&lt;/span&gt; 
&lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt; 
&lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;What’s particularly impressive is how the LLM can enrich raw query results with contextual information not stored in the database. For example, it translates ZIP codes into recognizable neighborhood names, making the data more meaningful to users.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;ZIP Code&lt;/th&gt;
      &lt;th&gt;Neighborhood Name&lt;/th&gt;
      &lt;th&gt;Service Request Count&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;11213&lt;/td&gt;
      &lt;td&gt;Sunset Park&lt;/td&gt;
      &lt;td&gt;849&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11203&lt;/td&gt;
      &lt;td&gt;Williamsburg&lt;/td&gt;
      &lt;td&gt;765&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11216&lt;/td&gt;
      &lt;td&gt;Bushwick&lt;/td&gt;
      &lt;td&gt;746&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11206&lt;/td&gt;
      &lt;td&gt;Bed-Stuy (Bedford–Stuyvesant)&lt;/td&gt;
      &lt;td&gt;738&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11211&lt;/td&gt;
      &lt;td&gt;Greenpoint&lt;/td&gt;
      &lt;td&gt;735&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11209&lt;/td&gt;
      &lt;td&gt;Crown Heights&lt;/td&gt;
      &lt;td&gt;724&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11217&lt;/td&gt;
      &lt;td&gt;Kensington&lt;/td&gt;
      &lt;td&gt;687&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11221&lt;/td&gt;
      &lt;td&gt;Fort Greene/Clinton Hill&lt;/td&gt;
      &lt;td&gt;653&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11205&lt;/td&gt;
      &lt;td&gt;Park Slope&lt;/td&gt;
      &lt;td&gt;649&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11233&lt;/td&gt;
      &lt;td&gt;Flatbush&lt;/td&gt;
      &lt;td&gt;648&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The system works well for natural language to insights without manual SQL writing. While someone fluent in SQL could write these queries more accurately and efficiently, LLMs with MCP enables individuals without SQL expertise to access and interact with data in ways that were previously inaccessible, opening up new possibilities for data exploration.&lt;/p&gt;

&lt;h2 id=&quot;llms-as-the-new-dashboard&quot;&gt;LLMs as the New Dashboard?&lt;/h2&gt;

&lt;p&gt;This MCP server builds on my &lt;a href=&quot;/projects/2025/06/14/decoding-the-citys-pulse.html&quot;&gt;previous work&lt;/a&gt; categorizing NYC 311 requests using local LLMs, but represents a fundamental shift toward conversational analytics. Where traditional BI tools require pre-built charts and fixed queries, LLM-powered analytics enables dynamic exploration through natural language.&lt;/p&gt;

&lt;p&gt;The implications are profound. Instead of building dozens of dashboard widgets to answer specific questions, we might soon have AI assistants that augment dashboard capabilities to instantly generate insights from natural language queries. Want to see “noise complaints by neighborhood during summer weekends”? No need to pre-define that visualization. The LLM constructs the query, executes it, and explains the results.&lt;/p&gt;

&lt;p&gt;Rather than clicking through dashboard filters and pivot tables, analysts could simply describe what they want to understand and let the AI handle the technical translation. This represents a fundamental shift from static, pre-built visualizations to dynamic, conversation-driven data exploration.&lt;/p&gt;

&lt;p&gt;What’s particularly interesting is how this complements my earlier batch processing work. Where I previously used LLMs to transform and categorize data at scale, this MCP server demonstrates LLMs as interactive query engines. We’re seeing the emergence of a complete LLM-powered analytics stack: batch processing for data preparation and real-time conversations for data consumption.&lt;/p&gt;

&lt;h3 id=&quot;challenges-and-opportunities-ahead&quot;&gt;Challenges and Opportunities Ahead&lt;/h3&gt;

&lt;p&gt;While this experiment demonstrates the potential for LLM-powered analytics, several challenges remain before conversational data analysis becomes mainstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM reliability issues&lt;/strong&gt; surfaced during testing that highlight the technology’s current limitations. The AI assistant sometimes failed to generate correct tool calls, occasionally made up tool responses instead of using the actual MCP server, and would ignore parts of the tool output while generating responses. These aren’t just minor bugs. They represent fundamental challenges with LLM reliability that could undermine user trust in production environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical standardization&lt;/strong&gt; is still evolving. MCP provides one approach, but the ecosystem is fragmented. Today you might build an MCP server, tomorrow it could be a different protocol entirely. The good news is that the underlying capability (LLMs generating and executing SQL) works regardless of the transport mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security and governance&lt;/strong&gt; require careful consideration. Unlike traditional dashboards with fixed queries, LLM analytics can generate arbitrary SQL. This flexibility is powerful but demands robust guardrails, especially with sensitive enterprise data. My implementation blocks some dangerous operations, but determined users could still extract large datasets through their prompts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data complexity&lt;/strong&gt; scales quickly beyond simple schemas. My single-table NYC 311 dataset works relatively well, but enterprise scenarios with hundreds of tables, complex joins, and business logic require sophisticated semantic layers. The LLM needs to understand not just what data exists, but what it means and how metrics should be computed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User expectations&lt;/strong&gt; will need to evolve too. Traditional dashboards set clear expectations about what questions can be answered. Conversational analytics promises infinite flexibility, but users will need to learn how to ask good questions and interpret probabilistic responses from LLMs that might misunderstand context or hallucinate results.&lt;/p&gt;

&lt;p&gt;Despite these challenges, the trend toward LLM-powered analytics feels inevitable. As models become more capable and data infrastructure adapts, the friction of asking questions will continue to decrease. The question isn’t whether this will happen, but how quickly organizations can adapt their data practices to support it and how fast foundational LLM models keep improving.&lt;/p&gt;

&lt;h2 id=&quot;observations-and-future-potential&quot;&gt;Observations and Future Potential&lt;/h2&gt;

&lt;p&gt;My trial with the NYC 311 Data MCP Server demonstrates both the promise and practical challenges of LLM-powered analytics. Similar to my initial impressions of AI coding assistants, there’s an immediate sense that we’re witnessing the early stages of a fundamental shift in how humans interact with data.&lt;/p&gt;

&lt;p&gt;The system excels at translating natural language questions into SQL queries and providing immediate insights without manual coding. The standardized interface could theoretically work across different AI assistants, creating a unified way to access organizational data. Most importantly, it hints at a future where business users can explore data directly through conversation rather than relying on pre-built dashboards or technical teams.&lt;/p&gt;

&lt;p&gt;However, significant challenges remain. The infrastructure feels heavyweight for simple use cases, and real-world adoption patterns are still unclear. More critically, scaling beyond simple schemas requires sophisticated semantic layers that most organizations haven’t built yet.&lt;/p&gt;

&lt;p&gt;The honest assessment is that we’re still in the early experimental phase, but the direction seems clear. As LLMs become more capable and data infrastructure evolves to support them, conversational analytics will likely become the norm rather than the exception. Traditional dashboards may not disappear entirely, but they’ll increasingly serve as fallbacks rather than primary interfaces.&lt;/p&gt;

&lt;p&gt;For now, MCP represents one approach to standardizing this future, but the more important trend is the underlying shift toward AI-powered data exploration. Whether through MCP, enhanced REST APIs, or entirely different protocols, the age of conversational analytics is beginning.&lt;/p&gt;
</description>
        <pubDate>Sun, 29 Jun 2025 23:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/projects/2025/06/29/mcp-server-for-data-access.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/projects/2025/06/29/mcp-server-for-data-access.html</guid>
        
        <category>AI</category>
        
        <category>Data</category>
        
        <category>Python</category>
        
        
        <category>projects</category>
        
      </item>
    
      <item>
        <title>Decoding the City&apos;s Pulse: Analyzing NYC 311 Data with LLMs</title>
        <description>&lt;p&gt;NYC’s 311 system generated ~3.5 million service requests in 2024, a perfect dataset for testing whether local LLMs can handle large-scale text analysis without API costs or rate limits. In this post, I’ll walk through how I used a local LLM to categorize and analyze these requests, revealing insights into the city’s pulse.&lt;/p&gt;

&lt;p&gt;Instead of processing each request individually, I reduced the dataset to ~1,200 unique combinations of agency, complaint type, and descriptor fields, then used a local LLM to categorize these patterns. This approach works well for pre-computing categories to later use for analysis, such as identifying hotspots or trends.&lt;/p&gt;

&lt;h2 id=&quot;visualizing-the-baseline-data&quot;&gt;Visualizing the Baseline Data&lt;/h2&gt;

&lt;p&gt;Before diving into LLM categorization, let’s examine the spatial and temporal patterns in the raw data:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/2024_nyc_service_requests_heatmap_by_hour.gif&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/2024_nyc_service_requests_heatmap_by_hour.gif&quot; alt=&quot;2024 311 Requests Heatmap&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The heatmap shows expected patterns, high density correlates with population density. More interesting is the per-capita analysis:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/2024_nyc_service_requests_choropleth_map_by_month.gif&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/2024_nyc_service_requests_choropleth_map_by_month.gif&quot; alt=&quot;2024 311 Requests Choropleth&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some hotspots with anomalously high request rates (highest month per zipcode):&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Zip Code&lt;/th&gt;
      &lt;th&gt;Neighborhood&lt;/th&gt;
      &lt;th&gt;Month&lt;/th&gt;
      &lt;th&gt;Requests per 1000 Residents&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;10466&lt;/td&gt;
      &lt;td&gt;Wakefield&lt;/td&gt;
      &lt;td&gt;Dec 2024&lt;/td&gt;
      &lt;td&gt;275&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10004&lt;/td&gt;
      &lt;td&gt;Financial District&lt;/td&gt;
      &lt;td&gt;Oct 2024&lt;/td&gt;
      &lt;td&gt;241&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11239&lt;/td&gt;
      &lt;td&gt;East New York&lt;/td&gt;
      &lt;td&gt;Dec 2024&lt;/td&gt;
      &lt;td&gt;166&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11366&lt;/td&gt;
      &lt;td&gt;Fresh Meadows&lt;/td&gt;
      &lt;td&gt;Jun 2024&lt;/td&gt;
      &lt;td&gt;158&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10006&lt;/td&gt;
      &lt;td&gt;Financial District&lt;/td&gt;
      &lt;td&gt;Jul 2024&lt;/td&gt;
      &lt;td&gt;109&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11101&lt;/td&gt;
      &lt;td&gt;Long Island City&lt;/td&gt;
      &lt;td&gt;Sep 2024&lt;/td&gt;
      &lt;td&gt;101&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10018&lt;/td&gt;
      &lt;td&gt;Garment District&lt;/td&gt;
      &lt;td&gt;Oct 2024&lt;/td&gt;
      &lt;td&gt;95&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10007&lt;/td&gt;
      &lt;td&gt;Tribeca&lt;/td&gt;
      &lt;td&gt;Sep 2024&lt;/td&gt;
      &lt;td&gt;92&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10464&lt;/td&gt;
      &lt;td&gt;City Island&lt;/td&gt;
      &lt;td&gt;Jun 2024&lt;/td&gt;
      &lt;td&gt;86&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10036&lt;/td&gt;
      &lt;td&gt;Hell’s Kitchen&lt;/td&gt;
      &lt;td&gt;Oct 2024&lt;/td&gt;
      &lt;td&gt;81&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The Financial District’s high rates likely reflect commercial density rather than residential issues, while areas like Wakefield and East New York suggest genuine challenges.&lt;/p&gt;

&lt;h2 id=&quot;llm-powered-categorization&quot;&gt;LLM-Powered Categorization&lt;/h2&gt;

&lt;p&gt;To extract insights from this massive dataset, I integrated a local LLM directly into DuckDB using a custom User Defined Function (UDF). This approach combines SQL’s analytical power with modern language models, processing and categorizing data at scale within a single query.&lt;/p&gt;

&lt;h3 id=&quot;duckdb--llm-integration&quot;&gt;DuckDB + LLM Integration&lt;/h3&gt;

&lt;p&gt;I created a Python UDF that connects DuckDB to a local install of &lt;a href=&quot;https://lmstudio.ai/&quot;&gt;LM Studio&lt;/a&gt;, serving the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gemma-3-27b-it&lt;/code&gt; model via OpenAI-compatible API.&lt;/p&gt;

&lt;p&gt;Here’s the core implementation:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# This file contains the UDF for calling the local LLM API.
# Register the Python function as a scalar UDF
# con.create_function(&quot;prompt&quot;, prompt, [str, str, str, float], str)
&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;json&lt;/span&gt;

&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;requests&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;MODEL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gemma-3-27b-it&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;MODEL_TEMP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.4&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;MODEL_MAX_TOKENS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Define the UDF
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;prompt_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;system_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;json_schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
    Calls the local LLM API and returns the response.
    &quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;http://localhost:1234/v1/chat/completions&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MODEL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;messages&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;role&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;content&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;temperature&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MODEL_TEMP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;max_tokens&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MODEL_MAX_TOKENS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;stream&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;# Set system message if provided
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;system_message&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;messages&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;insert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;role&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;system&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;content&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;system_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;# Set temperature if provided
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;temperature&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json_schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;response_format&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;json_schema&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;json_schema&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;loads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json_schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requests&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# Raise an error for HTTP codes 4xx/5xx
&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;choices&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[{}])[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;message&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{})&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;content&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;No response&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requests&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exceptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RequestException&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# Raise an exception to ensure the query fails
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;RuntimeError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;API request failed: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# Handle any other exceptions
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;RuntimeError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;An error occurred: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This UDF handles the API communication, error handling, and JSON schema enforcement. The &lt;a href=&quot;https://duckdb.org/community_extensions/extensions/open_prompt.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open_prompt&lt;/code&gt;&lt;/a&gt; DuckDB extension provides similar functionality, but I opted for a custom UDF to ensure I had full control over the model usage and response formatting.&lt;/p&gt;

&lt;h3 id=&quot;sql-powered-categorization&quot;&gt;SQL-Powered Categorization&lt;/h3&gt;

&lt;p&gt;The following python code snippet demonstrates how to use the UDF within a DuckDB query to categorize service requests based on agency, complaint type, and description. The LLM processes each unique combination, returning a JSON object with the assigned category and subcategory.&lt;/p&gt;

&lt;p&gt;Load the UDF and other necessary libraries, then define the system prompt and JSON schema for the LLM:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;json&lt;/span&gt;

&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;duckdb&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;jinja2&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Template&lt;/span&gt;

&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;src.duckdb_prompt_udf&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Connect to DuckDB
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;con&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;duckdb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;database&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;:memory:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;read_only&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Register the Python function as a scalar UDF
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;con&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create_function&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;prompt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Define the system prompt and JSON schema for the LLM. The system prompt instructs the model to categorize service requests based on predefined categories and subcategories, while the JSON schema ensures valid responses:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;system_prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;You are a government auditor reviewing New York&apos;s 311 service request system.&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Your task:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;1. Review the given service request details (agency, complaint type, and description)&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;2. Choose the most appropriate category and subcategory from the provided CATEGORIES json structure&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;3. The sample data in CATEGORIES json serves as guidance for classification&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Important rules:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;- Only use CATEGORIES and SUBCATEGORIES that exist in the provided CATEGORIES json block&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;- Do NOT use the sample labels as categories or subcategories&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;- Do NOT create new categories&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Response format:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;```json&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;{&quot;category&quot;: &quot;STRING&quot;, &quot;subcategory&quot;: &quot;STRING&quot;}&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;```&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Note: Your response must contain ONLY the JSON object, nothing else.&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;#  Define the JSON schema for the response
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json_schema&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;category_response&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;object&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;strict&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;true&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;schema&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;object&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;properties&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;category&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;subcategory&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;string&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;required&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;category&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;subcategory&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Finally, we define the SQL query that uses the UDF to categorize service requests. The query processes the dataset, calling the LLM for each unique combination of agency, complaint type, and descriptor. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prompt&lt;/code&gt; function formats the request, while the JSON schema ensures valid responses.&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# Define the SQL query template
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query_template&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
COPY (
    WITH llm_categorization AS (
        SELECT
            regexp_replace(
                prompt(
                    &apos;# CATEGORIES:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    || &apos;```json&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    ||categories::VARCHAR || &apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    || &apos;```&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    ||&apos;# SERVICE REQUEST:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    ||&apos;AGENCY: &apos; || IFNULL(agency, &apos;N/A&apos;) || &apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    ||&apos;COMPLAINT TYPE: &apos; || IFNULL(complaint_type, &apos;N/A&apos;)  || &apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;
                    ||&apos;DESCRIPTION: &apos; || IFNULL(descriptor, &apos;N/A&apos;) || &apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;,
                    &apos;&apos;::VARCHAR,
                    &apos;&apos;::VARCHAR,
                    ::FLOAT
                ),
                &apos;```json|```&apos;,
                &apos;&apos;,
                &apos;g&apos;
            )::VARCHAR AS raw_llm_response,
            json_extract_string(raw_llm_response, &apos;$.category&apos;)::VARCHAR AS category,
            json_extract_string(raw_llm_response, &apos;$.subcategory&apos;)::VARCHAR AS subcategory,
            agency,
            complaint_type,
            descriptor,
            request_count,
        FROM (
            SELECT 
                agency,
                complaint_type,
                descriptor,
                count(*) AS request_count
            FROM &quot;&quot; 
                group by 1,2,3
                order by 4 desc
                limit 
        ) CROSS JOIN read_json(&apos;./data/categories.json&apos;)
    )

    SELECT
        agency,
        complaint_type,
        descriptor,
        category,
        subcategory,
        raw_llm_response,
        request_count
    FROM llm_categorization 
) TO &apos;&apos;;
&quot;&quot;&quot;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# # Render the template with variables
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;template&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Template&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query_template&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;render&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;system_prompt&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;system_prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;json_schema&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json_schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;data_file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;./data/cityofnewyork/service_requests_2024.parquet&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;output_file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;./output/llm_categorize_output_2024.csv&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Execute the query
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Executing query...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;con&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fetchall&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The input dataset is a Parquet file containing NYC 311 service requests for 2024, which is read into DuckDB. The query deduplicates the dataset to focus on unique combinations of agency, complaint type, and descriptor, significantly reducing the number of LLM calls required.&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;categories.json&lt;/code&gt; file contains the predefined categories and subcategories, which the LLM uses to classify requests. The query deduplicates the dataset, reducing it from 3.5 million records to just ~1,200 unique combinations.&lt;/p&gt;

&lt;p&gt;The resulting categorized data is saved to a CSV file, which can be used for further analysis, by loading it back into DuckDB or any other data analysis tool.&lt;/p&gt;

&lt;h3 id=&quot;processing-results&quot;&gt;Processing Results&lt;/h3&gt;

&lt;p&gt;Processing this SQL query with the LLM integration took about 60 minutes on a MacBook Pro. Each LLM call took 2-4 seconds, with the bottleneck being model inference rather than data processing.&lt;/p&gt;

&lt;p&gt;The model achieved high categorization accuracy. I manually reviewed a random sample of 200 requests and found ~196 correctly assigned. This 98% figure should be taken as indicative rather than rigorous.&lt;/p&gt;

&lt;h2 id=&quot;breaking-down-the-data&quot;&gt;Breaking down the data&lt;/h2&gt;

&lt;p&gt;Now that we have the data categorized, we can start to break it down by category and subcategory to identify trends.&lt;/p&gt;

&lt;p&gt;The top 10 request categories/subcategories for 2024 are:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Category&lt;/th&gt;
      &lt;th&gt;Subcategory&lt;/th&gt;
      &lt;th&gt;Request Count&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Public Safety &amp;amp; Order&lt;/td&gt;
      &lt;td&gt;Parking&lt;/td&gt;
      &lt;td&gt;796,805&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Public Safety &amp;amp; Order&lt;/td&gt;
      &lt;td&gt;Noise &amp;amp; Disturbances&lt;/td&gt;
      &lt;td&gt;752,910&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Housing &amp;amp; Infrastructure&lt;/td&gt;
      &lt;td&gt;Building &amp;amp; Utilities&lt;/td&gt;
      &lt;td&gt;715,222&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Environmental Health &amp;amp; Sanitation&lt;/td&gt;
      &lt;td&gt;Waste Management &amp;amp; Sanitation&lt;/td&gt;
      &lt;td&gt;247,084&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Housing &amp;amp; Infrastructure&lt;/td&gt;
      &lt;td&gt;Street &amp;amp; Sidewalk Conditions&lt;/td&gt;
      &lt;td&gt;234,890&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Environmental Health &amp;amp; Sanitation&lt;/td&gt;
      &lt;td&gt;Animals &amp;amp; Pests&lt;/td&gt;
      &lt;td&gt;129,931&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Hazardous Conditions&lt;/td&gt;
      &lt;td&gt;Water Quality &amp;amp; Leaks&lt;/td&gt;
      &lt;td&gt;113,746&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Government &amp;amp; Community Services&lt;/td&gt;
      &lt;td&gt;Parks &amp;amp; Community&lt;/td&gt;
      &lt;td&gt;104,311&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Public Safety &amp;amp; Order&lt;/td&gt;
      &lt;td&gt;Non-Emergency Police Matters&lt;/td&gt;
      &lt;td&gt;95,088&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Consumer &amp;amp; Business Services&lt;/td&gt;
      &lt;td&gt;Consumer Complaints&lt;/td&gt;
      &lt;td&gt;64,080&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;These results align well with the primary functions of NYC’s 311 system, showing that our LLM-powered categorization has successfully identified the most common types of citizen requests across the city.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;This analysis demonstrates how LLMs can automatically categorize large-scale government datasets at zero marginal cost. By integrating a local LLM into DuckDB through a custom UDF, we processed 3.5 million NYC 311 requests with 98% accuracy in 60 minutes on consumer hardware.&lt;/p&gt;

&lt;p&gt;The key technical insight—deduplication before processing—reduced computational requirements by 99.97% while maintaining analytical value. This approach proves that sophisticated text analysis can be performed cost-effectively without relying on expensive cloud APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance characteristics:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Input: 3.5M records → 1,200 unique combinations&lt;/li&gt;
  &lt;li&gt;Processing: 60 minutes on M4 Max MacBook Pro with 36GB RAM&lt;/li&gt;
  &lt;li&gt;Accuracy: 98% on manual validation sample&lt;/li&gt;
  &lt;li&gt;Cost: $0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The complete methodology could be applied to any large-scale text classification task where deduplication is possible, or sufficiently unique combinations exist. This opens up new possibilities for real-time categorization of citizen requests, customer feedback, and other large text datasets.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s Next?&lt;/h2&gt;

&lt;p&gt;Having established a working methodology for LLM-powered categorization of NYC 311 data, several analytical directions could provide deeper insights:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seasonal Patterns&lt;/strong&gt;: Analyze how complaint categories vary throughout the year—do heating complaints surge in winter while noise complaints peak in summer?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neighborhood Clustering&lt;/strong&gt;: Combine our categorized data with the geographic hotspots to identify which neighborhoods consistently report specific types of issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response Time Analysis&lt;/strong&gt;: Use LLMs to extract urgency indicators from complaint text and correlate with actual resolution times across different categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Predictive Modeling&lt;/strong&gt;: Train machine learning models on the categorized data to predict future complaint trends based on historical patterns.&lt;/p&gt;

&lt;p&gt;The complete code is available in the &lt;a href=&quot;https://github.com/markusos/llm_duck&quot;&gt;markusos/llm_duck&lt;/a&gt; repository.&lt;/p&gt;

</description>
        <pubDate>Sat, 14 Jun 2025 21:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/projects/2025/06/14/decoding-the-citys-pulse.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/projects/2025/06/14/decoding-the-citys-pulse.html</guid>
        
        <category>AI</category>
        
        <category>Data</category>
        
        <category>Python</category>
        
        
        <category>projects</category>
        
      </item>
    
      <item>
        <title>5 days with Jules: Evaluating Google&apos;s AI coding assistant</title>
        <description>&lt;p&gt;For many in software engineering, the idea of an AI coding assistant feels a bit like magic. You give it a task, and it dives into the codebase to help out. Pretty exciting, right? So, when Google recently opened up access to &lt;a href=&quot;http://jules.google&quot;&gt;Jules&lt;/a&gt;, their new Gemini-powered AI coding agent, I was keen to see how it worked in practice. After trying it out for five days, here’s a look at what I found.&lt;/p&gt;

&lt;h3 id=&quot;getting-started-the-initial-wow&quot;&gt;Getting Started: The Initial “Wow”&lt;/h3&gt;

&lt;p&gt;First off, let’s be clear: the concept is super cool. Imagine an AI that can clone your repository, understand your instructions, and then try to write code, fix bugs, or even update dependencies. When I first set it up and gave Jules its initial tasks, it definitely felt like I was looking at a new way of working.&lt;/p&gt;

&lt;p&gt;During this trial period, Google offers five free tasks per day, which provided ample opportunity to experiment without cost concerns.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/jules.png&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/jules.png&quot; alt=&quot;Jules&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3 id=&quot;what-happens-when-jules-gets-to-work&quot;&gt;What Happens When Jules Gets to Work?&lt;/h3&gt;

&lt;p&gt;So, what was it like once I got past the initial setup? It quickly became clear that Jules is still very much a work in progress, which is expected for a new tool in a trial phase.&lt;/p&gt;

&lt;p&gt;One of the first things I noticed was that the service seemed to be under heavy load quite often. This meant that sometimes starting a task or getting consistent results was a bit tricky.&lt;/p&gt;

&lt;p&gt;And how long did tasks take? Well, agent tasks could often take hours to complete. I also saw that the tool usage for Jules would frequently time out. This made for an experience that wasn’t always smooth and made it hard to depend on for anything urgent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment Awareness Gaps:&lt;/strong&gt; One particularly interesting observation was Jules’ interaction with its work environment. The agent didn’t always seem aware of the status of the Virtual Machines (VMs) it was using. This led to frustrating loops where Jules would repeatedly attempt failing commands without recognizing that the underlying VM might be the issue.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/jules_timeout.png&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/jules_timeout.png&quot; alt=&quot;Jules timeout&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At one point, I was trying to convince Jules to validate the VM status by running a simple command instead of repeatedly trying to run the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pytest&lt;/code&gt; that was timing out. After some back and forth, it finally agreed to check the VM status using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ls&lt;/code&gt;. This kind of interaction highlighted how much more efficient the process could be if Jules had better environmental awareness and could automatically provision a new VM when the existing one failed.&lt;/p&gt;

&lt;h3 id=&quot;communicating-with-jules-interface-and-code-quality&quot;&gt;Communicating with Jules: Interface and Code Quality&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Feedback Mechanisms:&lt;/strong&gt; Currently, communication with Jules happens primarily through a chat interface. While this works for general feedback, it lacks the ability to comment on specific lines of code that Jules suggests. Adding inline code commenting would significantly improve the feedback refinement process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Context:&lt;/strong&gt; I experimented with providing Jules repository-specific context. While we all have project-specific guidelines, there’s currently no dedicated mechanism to share this information with Jules (unlike GitHub’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;copilot-instructions.md&lt;/code&gt; approach). I tried adding context to the main &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.md&lt;/code&gt; file, but Jules’ adoption of this information seemed inconsistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code Quality Assessment:&lt;/strong&gt; Now, for the big question: what about the code quality? Jules could generate code snippets and suggestions pretty quickly. But the usefulness varied a lot. Some suggestions were genuinely spot on and helpful. Other times, however, I found Jules struggling with fixing even basic Python linter errors, like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E501 Line too long&lt;/code&gt;. Sometimes, its attempts to fix these small things would actually break something else, and it would get stuck trying to fix the new problems it created. So, some of the code was great, while other parts weren’t quite usable without a lot of changes.&lt;/p&gt;

&lt;h3 id=&quot;verdict-on-jules-for-now&quot;&gt;Verdict on Jules for Now&lt;/h3&gt;

&lt;p&gt;After spending a week with Jules, my overall impression is that it’s a genuinely powerful tool with a lot of exciting potential. The thought of handing off certain coding tasks to an AI is very appealing.&lt;/p&gt;

&lt;p&gt;However, it’s also clear that Jules, in its current state, isn’t about to replace human developers. Think of it more as a helpful assistant that works alongside us, rather than a replacement. It might be great for taking a first pass at a new feature, helping with boilerplate, or suggesting solutions for simpler bugs. But the complex problem-solving, the architectural decisions, and the deep understanding of a project? That’s still our job.&lt;/p&gt;

&lt;p&gt;The world of AI in software development is evolving rapidly. My time with Jules showed me both the incredible promise and the current learning curve. As the technology gets better, deals with performance hiccups, and improves how it understands our projects and code, tools like Jules are set to become even more valuable. I’ll definitely be watching to see how it grows!&lt;/p&gt;
</description>
        <pubDate>Sat, 24 May 2025 12:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/projects/2025/05/24/five-days-with-jules.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/projects/2025/05/24/five-days-with-jules.html</guid>
        
        <category>AI</category>
        
        <category>Dev</category>
        
        <category>Python</category>
        
        
        <category>projects</category>
        
      </item>
    
      <item>
        <title>Escape the City Buzz: A Cozy Catskills Getaway</title>
        <description>&lt;p&gt;Feeling the relentless pace of city life? That craving for fresh air, quiet woods, and a sky full of stars, perhaps without sacrificing a comfy bed and modern conveniences, is something we know well. We recently chased that feeling with a weekend escape to &lt;a href=&quot;https://postcardcabins.com/eastern-catskills/&quot;&gt;Postcard Cabins Eastern Catskills&lt;/a&gt;, and it turned out to be the perfect antidote to the urban grind.&lt;/p&gt;

&lt;h3 id=&quot;day-one-trading-concrete-jungles-for-woodland-whispers&quot;&gt;Day One: Trading Concrete Jungles for Woodland Whispers&lt;/h3&gt;

&lt;p&gt;Leaving the familiar chaos of New York City behind, we pointed our car north towards the Catskills. The roughly 2.5-hour drive to Catskill felt like shedding layers of city stress with every mile. Its proximity makes it an incredibly accessible retreat for a quick nature fix. Before fully settling into our woodland haven, a quick 7-minute zip to the nearby Hannaford had our groceries sorted for the weekend.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/cabin.jpeg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/cabin.jpeg&quot; alt=&quot;Postcard Cabins&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With supplies gathered, our first evening unfolded exactly as hoped: simple, peaceful, and outdoors. We fired up the grill over the cabin’s fire pit, the scent of woodsmoke replacing traffic fumes, enjoying dinner under the vast, darkening sky. It was an instant decompression.&lt;/p&gt;

&lt;h3 id=&quot;day-two-cabin-calm-and-cascading-falls&quot;&gt;Day Two: Cabin Calm and Cascading Falls&lt;/h3&gt;

&lt;p&gt;Day two was all about embracing the stillness we came for. We let the morning unfurl slowly within our cozy cabin, a space genuinely designed for recharging. Sunlight streamed through the large window, offering front-row seats to the local wildlife theatre. We watched birds flit between branches and squirrels scamper playfully, a simple, grounding connection to nature enjoyed over morning coffee, right from our bed.&lt;/p&gt;

&lt;p&gt;Later, adventure beckoned. A scenic 28-minute drive brought us to the legendary &lt;a href=&quot;https://www.alltrails.com/trail/us/new-york/katterskill-falls-from-laurel-house-road&quot;&gt;Kaaterskill Falls&lt;/a&gt;. Setting off from Laurel House Road, we tackled the 1.7-mile out-and-back trail. It’s generally considered moderately challenging; while well-maintained steps have been added recently, expect some steep and rocky sections (good footwear is recommended!). The payoff? Absolutely breathtaking views of the 260-foot Kaaterskill Falls, New York’s highest cascading waterfall. The sheer scale and the roar of the water were invigorating.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://www.ostberg.dev/assets/kaaterskill_falls.jpeg&quot;&gt;&lt;img src=&quot;https://www.ostberg.dev/assets/kaaterskill_falls.jpeg&quot; alt=&quot;Kaaterskill Falls&quot; class=&quot;center-image&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For dinner, we ventured to &lt;a href=&quot;https://camptowncatskills.com/casa-susanna&quot;&gt;Casa Susanna&lt;/a&gt; in nearby Leeds (part of Camptown lodging), drawn by its reputation for modern Mexican cuisine inspired by family history and the incredible bounty of the Hudson Valley. Their commitment to local farmers, ranchers, and fishermen felt perfectly aligned with the region’s natural spirit, and the meal was fantastic. Back at the cabin, the day wound down with a quintessential campfire activity: melting marshmallows for gooey, delicious s’mores by the fire pit.&lt;/p&gt;

&lt;h3 id=&quot;departure-day-hudson-charm-and-lingering-calm&quot;&gt;Departure Day: Hudson Charm and Lingering Calm&lt;/h3&gt;

&lt;p&gt;Our final morning involved packing up, a straightforward process thanks to the cabin’s minimalist, “everything you need, nothing you don’t” approach. We weren’t quite ready to dive back into city life, so we made a detour to the delightful town of Hudson, NY.&lt;/p&gt;

&lt;p&gt;For brunch, &lt;a href=&quot;https://www.leperchehudson.com/&quot;&gt;Le Perche&lt;/a&gt; on bustling Warren Street proved to be an excellent choice. We can personally vouch for the incredible Croque Madame and the perfectly zesty Spicy Chicken Sandwich. While they might be known for baked goods, their savory options are definitely worth the stop! Afterwards, a leisurely stroll down Warren Street, popping into unique boutiques and galleries, offered a final, perfect taste of Hudson Valley charm before the drive back to Brooklyn.&lt;/p&gt;

&lt;h3 id=&quot;overall-reflections-the-glamping-experience&quot;&gt;Overall Reflections: The Glamping Experience&lt;/h3&gt;

&lt;p&gt;Our stay at Postcard Cabins Eastern Catskills delivered a solid dose of “modern glamping.” It strikes a great balance, offering comforts like an en-suite bathroom (a huge plus!) and reliable AC/Heat, alongside outdoor essentials like the cozy fire pit. It’s ideal if you want a nature immersion without fully “roughing it.” The cabins are smartly designed, cozy, and make for a comfortable base camp.&lt;/p&gt;

&lt;h3 id=&quot;a-few-things-to-keep-in-mind&quot;&gt;A Few Things to Keep in Mind:&lt;/h3&gt;

&lt;p&gt;While we thoroughly enjoyed our stay, here are a few practical points based on our experience and general feedback:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Community Vibe:&lt;/strong&gt; While nestled in woodlands, the cabins are situated relatively near each other. Expect to see and possibly hear your neighbors – it’s more of a shared nature retreat than total seclusion.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Hot Water Schedule:&lt;/strong&gt; The hot water system provides about five minutes of shower time before needing roughly 30 minutes to regenerate. Just something to plan around, especially if multiple people are staying.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Connectivity:&lt;/strong&gt; As expected in a more remote spot, cell service can be patchy (perhaps a welcome nudge to disconnect!). However, the Wi-Fi worked reliably when we needed it.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Priced Provisions:&lt;/strong&gt; While convenient, some items stocked in the cabin (like tea bags, coffee and firewood) come at an extra cost, operating like a hotel minibar. To give you an idea, a single tea bag was priced at $2.50, so factor that in if you plan to use these extras.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;

&lt;p&gt;Ultimately, our weekend at Postcard Cabins was precisely the recharge we craved. It offered a seamless blend of quiet nature, comfortable lodging, and easy access to stunning local attractions like Kaaterskill Falls and the vibrant town of Hudson. It was a welcome reminder that a truly refreshing escape doesn’t have to be far-flung or complicated. We left with lungs full of fresh Catskills air, feeling reconnected and ready to face the city again, until the next escape calls!&lt;/p&gt;
</description>
        <pubDate>Sat, 05 Apr 2025 19:00:00 +0000</pubDate>
        <link>https://www.ostberg.dev/travel/2025/04/05/escape-the-city-buzz.html</link>
        <guid isPermaLink="true">https://www.ostberg.dev/travel/2025/04/05/escape-the-city-buzz.html</guid>
        
        <category>Life</category>
        
        <category>Travel</category>
        
        
        <category>travel</category>
        
      </item>
    
  </channel>
</rss>
