<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Aakriti Dhakal</title>
<link>https://aakritidhakal.vercel.app/notes/</link>
<atom:link href="https://aakritidhakal.vercel.app/notes/index.xml" rel="self" type="application/rss+xml"/>
<description>Writing on AI, ML systems, and software engineering.</description>
<generator>quarto-1.10.18</generator>
<lastBuildDate>Sat, 01 Nov 2025 00:00:00 GMT</lastBuildDate>
<item>
  <title>Metadata Filtering in RAG Pipelines</title>
  <dc:creator>Aakriti Dhakal</dc:creator>
  <link>https://aakritidhakal.vercel.app/notes/rag-metadata-filtering.html</link>
  <description><![CDATA[ 

<style>
/* ─── Search dialog CSS variables ───────────────────────── */
:root {
  --pf-bg: #FAF8F4;
  --pf-border: #e8e8e8;
  --pf-text: #0a0a0a;
  --pf-muted: #888;
  --pf-secondary: #555;
  --pf-hover: #f0ede7;
  --pf-accent: #378ADD;
  --pf-mark-bg: rgba(55, 138, 221, 0.12);
  --pf-mark-fg: #378ADD;
  --pf-overlay: rgba(10, 10, 10, 0.45);
  --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
  --pf-mono: "JetBrains Mono", monospace;
  --pf-sans: "Inter", system-ui, sans-serif;
  --pf-radius: 6px;
}

/* Dark mode: system preference */
@media (prefers-color-scheme: dark) {
  :root {
    --pf-bg: #1e1c1a;
    --pf-border: #2a2520;
    --pf-text: #e8e4df;
    --pf-muted: #8a857f;
    --pf-secondary: #8a857f;
    --pf-hover: #2a2520;
    --pf-overlay: rgba(0, 0, 0, 0.6);
    --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  }
}

/* Dark mode: Quarto / Bootstrap manual toggle */
[data-bs-theme="dark"] {
  --pf-bg: #1e1c1a;
  --pf-border: #2a2520;
  --pf-text: #e8e4df;
  --pf-muted: #8a857f;
  --pf-secondary: #8a857f;
  --pf-hover: #2a2520;
  --pf-overlay: rgba(0, 0, 0, 0.6);
  --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}

/* ─── Overlay ────────────────────────────────────────────── */
#pf-overlay {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 12vh;
  background: var(--pf-overlay);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
}
#pf-overlay[hidden] { display: none; }

/* ─── Panel ──────────────────────────────────────────────── */
#pf-panel {
  width: 100%;
  max-width: 580px;
  margin: 0 1rem;
  background: var(--pf-bg);
  border: 1px solid var(--pf-border);
  border-radius: var(--pf-radius);
  overflow: hidden;
  box-shadow: var(--pf-shadow);
}

/* ─── Input row ──────────────────────────────────────────── */
#pf-input-row {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  padding: 0.75rem 1rem;
  border-bottom: 1px solid var(--pf-border);
}

#pf-input-icon {
  color: var(--pf-muted);
  flex-shrink: 0;
  line-height: 1;
  display: flex;
}

#pf-input {
  flex: 1;
  border: none;
  outline: none;
  background: transparent;
  font-family: var(--pf-sans);
  font-size: 1rem;
  color: var(--pf-text);
  min-width: 0;
}
#pf-input::placeholder { color: var(--pf-muted); }
#pf-input::-webkit-search-cancel-button { display: none; }

#pf-close-key {
  font-family: var(--pf-mono);
  font-size: 0.65rem;
  color: var(--pf-muted);
  border: 1px solid var(--pf-border);
  border-radius: 3px;
  padding: 0.15rem 0.45rem;
  flex-shrink: 0;
  cursor: pointer;
  background: transparent;
  line-height: 1.6;
  transition: color 0.1s, border-color 0.1s;
}
#pf-close-key:hover { color: var(--pf-text); border-color: var(--pf-text); }

/* ─── Results container ──────────────────────────────────── */
#pf-results-wrap {
  max-height: 380px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

#pf-results { padding: 0.375rem 0; }

/* Group label */
.pf-group {
  padding: 0.5rem 1rem 0.2rem;
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--pf-muted);
}

/* Result item */
.pf-result {
  display: block;
  padding: 0.625rem 1rem;
  text-decoration: none !important;
  color: inherit;
  border-left: 2px solid transparent;
  transition: background 0.08s, border-color 0.08s;
  outline: none;
  cursor: pointer;
}
.pf-result:hover,
.pf-result.is-active {
  background: var(--pf-hover);
  border-left-color: var(--pf-accent);
}

.pf-result-title {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--pf-text);
  line-height: 1.3;
  margin-bottom: 0.2rem;
}

.pf-result-excerpt {
  font-size: 0.775rem;
  color: var(--pf-secondary);
  line-height: 1.5;
  margin-bottom: 0.3rem;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.pf-result-excerpt mark {
  background: var(--pf-mark-bg);
  color: var(--pf-mark-fg);
  border-radius: 2px;
  padding: 0 2px;
  font-style: normal;
}

.pf-result-tags { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.pf-result-tag {
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--pf-muted);
  border: 1px solid var(--pf-border);
  border-radius: 2px;
  padding: 0.1rem 0.35rem;
}

/* Empty / error state */
.pf-empty {
  padding: 2rem 1rem;
  text-align: center;
  color: var(--pf-muted);
  font-size: 0.875rem;
  line-height: 1.6;
}
.pf-empty code {
  font-family: var(--pf-mono);
  font-size: 0.82em;
  background: var(--pf-hover);
  border: 1px solid var(--pf-border);
  border-radius: 3px;
  padding: 0.1em 0.35em;
}

/* ─── Footer hint bar ────────────────────────────────────── */
#pf-hint {
  display: flex;
  gap: 1rem;
  padding: 0.45rem 1rem;
  border-top: 1px solid var(--pf-border);
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  color: var(--pf-muted);
  flex-wrap: wrap;
}
#pf-hint kbd {
  display: inline-block;
  border: 1px solid var(--pf-border);
  border-radius: 2px;
  padding: 0 0.3em;
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  line-height: 1.6;
  background: transparent;
}

/* ─── Mobile ─────────────────────────────────────────────── */
@media (max-width: 480px) {
  #pf-panel { margin: 0 0.5rem; }
  #pf-results-wrap { max-height: 55vh; }
  #pf-hint { display: none; }
}
</style>

<!-- Search dialog -->
<div id="pf-overlay" hidden="" aria-modal="true" aria-label="Search">
  <div id="pf-panel">

    <div id="pf-input-row">
      
      <input id="pf-input" type="search" placeholder="Search projects and notes…" autocomplete="off" spellcheck="false" aria-label="Search query" aria-autocomplete="list" aria-controls="pf-results">
      <button id="pf-close-key" type="button" aria-label="Close search">Esc</button>
    </div>

    <div id="pf-results-wrap">
      <div id="pf-results" aria-label="Search results"></div>
    </div>

    

  </div>
</div>

<script>
(function () {
  'use strict';

  var overlay    = document.getElementById('pf-overlay');
  var inputEl    = document.getElementById('pf-input');
  var resultsEl  = document.getElementById('pf-results');
  var closeBtn   = document.getElementById('pf-close-key');

  var pf         = null;   // pagefind module, loaded lazily
  var pfPending  = false;
  var selected   = -1;
  var flat       = [];     // flat ordered array of result data (for keyboard nav)
  var debTimer   = null;

  /* ── Pagefind loader ──────────────────────────────────── */
  function loadPf() {
    if (pf) return Promise.resolve(pf);
    if (pfPending) return Promise.resolve(null);
    pfPending = true;
    return import('/pagefind/pagefind.js')
      .then(function (mod) {
        return mod.init().then(function () { pf = mod; pfPending = false; return pf; });
      })
      .catch(function () {
        pfPending = false;
        console.info('[search] Pagefind index not found. Run `quarto render` to build it.');
        return null;
      });
  }

  /* ── Open / close ─────────────────────────────────────── */
  function openDialog() {
    overlay.hidden = false;
    document.body.style.overflow = 'hidden';
    requestAnimationFrame(function () { inputEl.focus(); });
    loadPf(); // warm up the index
  }

  function closeDialog() {
    overlay.hidden = true;
    document.body.style.overflow = '';
    inputEl.value = '';
    resultsEl.innerHTML = '';
    flat = [];
    selected = -1;
  }

  /* ── Search ───────────────────────────────────────────── */
  function runSearch(query) {
    query = query.trim();
    if (!query) { resultsEl.innerHTML = ''; flat = []; selected = -1; return; }

    loadPf().then(function (mod) {
      if (!mod) {
        resultsEl.innerHTML = '<div class="pf-empty">Search index not found.<br>Run <code>quarto render</code> to build it.</div>';
        return;
      }
      mod.search(query).then(function (res) {
        Promise.all(res.results.slice(0, 15).map(function (r) { return r.data(); }))
          .then(function (data) { renderResults(data, query); });
      });
    });
  }

  function renderResults(data, query) {
    if (!data.length) {
      resultsEl.innerHTML = '<div class="pf-empty">No results for <strong>' + esc(query) + '</strong></div>';
      flat = []; selected = -1;
      return;
    }

    var work  = data.filter(function (r) { return /\/work\//.test(r.url); });
    var notes = data.filter(function (r) { return /\/notes\//.test(r.url); });
    var other = data.filter(function (r) { return !/\/(work|notes)\//.test(r.url); });

    flat = work.concat(notes).concat(other);
    selected = -1;

    var html = '';
    if (work.length)  html += makeGroup('Projects', work,  0);
    if (notes.length) html += makeGroup('Notes',    notes, work.length);
    if (other.length) html += makeGroup('Pages',    other, work.length + notes.length);

    resultsEl.innerHTML = html;

    resultsEl.querySelectorAll('.pf-result').forEach(function (el, i) {
      el.addEventListener('pointerover', function () { activate(i); });
    });
  }

  function makeGroup(label, items, offset) {
    return '<div class="pf-group">' + label + '</div>' +
      items.map(function (r, i) { return makeItem(r, offset + i); }).join('');
  }

  function makeItem(r, idx) {
    var title   = r.meta && r.meta.title ? r.meta.title : r.url;
    var excerpt = r.excerpt || '';
    var rawTags = ((r.meta && r.meta.tags) ? r.meta.tags : '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
    var tags    = rawTags.slice(0, 4).map(function (t) { return '<span class="pf-result-tag">' + esc(t) + '</span>'; }).join('');

    return '<a class="pf-result" href="' + esc(r.url) + '" role="option" data-idx="' + idx + '">'
      + '<div class="pf-result-title">' + esc(title) + '</div>'
      + (excerpt ? '<div class="pf-result-excerpt">' + excerpt + '</div>' : '')
      + (tags    ? '<div class="pf-result-tags">'    + tags    + '</div>' : '')
      + '</a>';
  }

  /* ── Keyboard navigation ──────────────────────────────── */
  function activate(idx) {
    var items = resultsEl.querySelectorAll('.pf-result');
    items.forEach(function (el) { el.classList.remove('is-active'); });
    if (idx >= 0 && idx < items.length) {
      items[idx].classList.add('is-active');
      items[idx].scrollIntoView({ block: 'nearest' });
    }
    selected = idx;
  }

  /* ── Utility ──────────────────────────────────────────── */
  function esc(s) {
    return String(s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;')
      .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  /* ── Event listeners ──────────────────────────────────── */

  // Input
  inputEl.addEventListener('input', function () {
    clearTimeout(debTimer);
    var val = this.value;
    debTimer = setTimeout(function () { runSearch(val); }, 200);
  });

  // Close button
  closeBtn.addEventListener('click', closeDialog);

  // Click on backdrop (outside panel)
  overlay.addEventListener('click', function (e) {
    if (e.target === overlay) closeDialog();
  });

  // Keyboard within the open dialog
  overlay.addEventListener('keydown', function (e) {
    if (e.key === 'Escape') { closeDialog(); return; }
    if (!flat.length) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      activate(Math.min(selected + 1, flat.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      activate(Math.max(selected - 1, 0));
    } else if (e.key === 'Enter' && selected >= 0) {
      e.preventDefault();
      window.location.href = flat[selected].url;
    }
  });

  // Global open shortcuts
  document.addEventListener('keydown', function (e) {
    if (!overlay.hidden) return;
    var tag = document.activeElement && document.activeElement.tagName;
    var editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
      || (document.activeElement && document.activeElement.isContentEditable);
    if (e.key === '/' && !editable) { e.preventDefault(); openDialog(); return; }
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openDialog(); }
  });

  // Fix tab title separator: "Page | Site" → "Page - Site"
  document.title = document.title.replace(' | ', ' - ');

  // Wire up the search icon in the Quarto navbar tools
  document.addEventListener('DOMContentLoaded', function () {
    var navBtn = document.querySelector('.quarto-navbar-tools a[aria-label*="Search"]');
    if (navBtn) {
      navBtn.addEventListener('click', function (e) {
        e.preventDefault();
        openDialog();
      });
    }

    // Work detail page: add status dot to the first category that is a status value
    var STATUS_VALUES = ['In Progress', 'Completed', 'Paused'];
    var STATUS_COLORS = { 'In Progress': '#378ADD', 'Completed': '#aaa', 'Paused': '#aaa' };
    document.querySelectorAll('.quarto-categories .quarto-category, .listing-category').forEach(function (el) {
      if (STATUS_VALUES.indexOf(el.textContent.trim()) !== -1) {
        el.style.display = 'none';
      }
    });
  });

})();
</script>




<p>The naive RAG setup retrieves the top-k most semantically similar chunks and passes them to the LLM. This works until your vector store contains documents from multiple contexts — different courses, different users, different domains — and you start getting cross-context contamination in responses.</p>
<p>Metadata filtering fixes this.</p>
<section id="pre-filter-vs.-post-filter" class="level2">
<h2 class="anchored" data-anchor-id="pre-filter-vs.-post-filter">Pre-filter vs.&nbsp;post-filter</h2>
<p><strong>Post-filter</strong>: Retrieve top-k, then discard chunks that don’t match your metadata criteria. Simple but wasteful — if most of your data is in the wrong category, you’ll discard most of your results and end up with low effective k.</p>
<p><strong>Pre-filter</strong>: Apply metadata filters before similarity search, then retrieve top-k from the filtered subset. This is almost always what you want.</p>
<p>With pgvector, pre-filtering is a WHERE clause:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb1-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SELECT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">id</span>, content, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span> (embedding <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&lt;=&gt;</span> $<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AS</span> similarity</span>
<span id="cb1-2"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">FROM</span> document_chunks</span>
<span id="cb1-3"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">WHERE</span> metadata<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-&gt;&gt;</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'course_id'</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> $<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span></span>
<span id="cb1-4">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AND</span> metadata<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-&gt;&gt;</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'content_type'</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'lecture'</span></span>
<span id="cb1-5"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ORDER</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">BY</span> embedding <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&lt;=&gt;</span> $<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span></span>
<span id="cb1-6"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">LIMIT</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">10</span>;</span></code></pre></div></div>
</section>
<section id="the-index-interaction-problem" class="level2">
<h2 class="anchored" data-anchor-id="the-index-interaction-problem">The index interaction problem</h2>
<p>If your filter selects a very small fraction of rows (&lt; 5%), PostgreSQL may fall back to a sequential scan rather than the index. You can check with <code>EXPLAIN</code>:</p>
<p>For highly selective filters, consider a partial index:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb2-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CREATE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">INDEX</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ON</span> document_chunks</span>
<span id="cb2-2"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">USING</span> hnsw (embedding vector_cosine_ops)</span>
<span id="cb2-3"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">WHERE</span> metadata<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-&gt;&gt;</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'content_type'</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'lecture'</span>;</span></code></pre></div></div>
</section>
<section id="metadata-schema-design" class="level2">
<h2 class="anchored" data-anchor-id="metadata-schema-design">Metadata schema design</h2>
<p>Metadata that you’ll filter on should be promoted to top-level jsonb keys (or dedicated columns).</p>
<p>Good:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode json code-with-copy"><code class="sourceCode json"><span id="cb3-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">{</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"course_id"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"CS4320"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"content_type"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"lecture"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"week"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">3</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">}</span></span></code></pre></div></div>
</section>
<section id="multi-tenant-isolation" class="level2">
<h2 class="anchored" data-anchor-id="multi-tenant-isolation">Multi-tenant isolation</h2>
<p>For multi-tenant RAG (e.g., per-user or per-course knowledge bases), metadata filtering is your access control layer. Every query must include a tenant filter, and that filter should be enforced at the application layer:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb4-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> retrieve(query: <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">str</span>, course_id: <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">str</span>, k: <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">int</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">10</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-&gt;</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">list</span>[Chunk]:</span>
<span id="cb4-2">    embedding <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> embed(query)</span>
<span id="cb4-3">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> db.query(</span>
<span id="cb4-4">        <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"SELECT ... WHERE metadata-&gt;&gt;'course_id' = $1 ORDER BY embedding &lt;=&gt; $2 LIMIT $3"</span>,</span>
<span id="cb4-5">        course_id, embedding, k</span>
<span id="cb4-6">    )</span></code></pre></div></div>
<p>Never let <code>course_id</code> come directly from user input without validation against the authenticated user’s allowed courses.</p>


</section>

 ]]></description>
  <category>RAG</category>
  <category>AI</category>
  <category>PostgreSQL</category>
  <category>pgvector</category>
  <guid>https://aakritidhakal.vercel.app/notes/rag-metadata-filtering.html</guid>
  <pubDate>Sat, 01 Nov 2025 00:00:00 GMT</pubDate>
</item>
<item>
  <title>pgvector Indexing: HNSW vs IVFFlat</title>
  <dc:creator>Aakriti Dhakal</dc:creator>
  <link>https://aakritidhakal.vercel.app/notes/pgvector-indexing.html</link>
  <description><![CDATA[ 

<style>
/* ─── Search dialog CSS variables ───────────────────────── */
:root {
  --pf-bg: #FAF8F4;
  --pf-border: #e8e8e8;
  --pf-text: #0a0a0a;
  --pf-muted: #888;
  --pf-secondary: #555;
  --pf-hover: #f0ede7;
  --pf-accent: #378ADD;
  --pf-mark-bg: rgba(55, 138, 221, 0.12);
  --pf-mark-fg: #378ADD;
  --pf-overlay: rgba(10, 10, 10, 0.45);
  --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
  --pf-mono: "JetBrains Mono", monospace;
  --pf-sans: "Inter", system-ui, sans-serif;
  --pf-radius: 6px;
}

/* Dark mode: system preference */
@media (prefers-color-scheme: dark) {
  :root {
    --pf-bg: #1e1c1a;
    --pf-border: #2a2520;
    --pf-text: #e8e4df;
    --pf-muted: #8a857f;
    --pf-secondary: #8a857f;
    --pf-hover: #2a2520;
    --pf-overlay: rgba(0, 0, 0, 0.6);
    --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  }
}

/* Dark mode: Quarto / Bootstrap manual toggle */
[data-bs-theme="dark"] {
  --pf-bg: #1e1c1a;
  --pf-border: #2a2520;
  --pf-text: #e8e4df;
  --pf-muted: #8a857f;
  --pf-secondary: #8a857f;
  --pf-hover: #2a2520;
  --pf-overlay: rgba(0, 0, 0, 0.6);
  --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}

/* ─── Overlay ────────────────────────────────────────────── */
#pf-overlay {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 12vh;
  background: var(--pf-overlay);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
}
#pf-overlay[hidden] { display: none; }

/* ─── Panel ──────────────────────────────────────────────── */
#pf-panel {
  width: 100%;
  max-width: 580px;
  margin: 0 1rem;
  background: var(--pf-bg);
  border: 1px solid var(--pf-border);
  border-radius: var(--pf-radius);
  overflow: hidden;
  box-shadow: var(--pf-shadow);
}

/* ─── Input row ──────────────────────────────────────────── */
#pf-input-row {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  padding: 0.75rem 1rem;
  border-bottom: 1px solid var(--pf-border);
}

#pf-input-icon {
  color: var(--pf-muted);
  flex-shrink: 0;
  line-height: 1;
  display: flex;
}

#pf-input {
  flex: 1;
  border: none;
  outline: none;
  background: transparent;
  font-family: var(--pf-sans);
  font-size: 1rem;
  color: var(--pf-text);
  min-width: 0;
}
#pf-input::placeholder { color: var(--pf-muted); }
#pf-input::-webkit-search-cancel-button { display: none; }

#pf-close-key {
  font-family: var(--pf-mono);
  font-size: 0.65rem;
  color: var(--pf-muted);
  border: 1px solid var(--pf-border);
  border-radius: 3px;
  padding: 0.15rem 0.45rem;
  flex-shrink: 0;
  cursor: pointer;
  background: transparent;
  line-height: 1.6;
  transition: color 0.1s, border-color 0.1s;
}
#pf-close-key:hover { color: var(--pf-text); border-color: var(--pf-text); }

/* ─── Results container ──────────────────────────────────── */
#pf-results-wrap {
  max-height: 380px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

#pf-results { padding: 0.375rem 0; }

/* Group label */
.pf-group {
  padding: 0.5rem 1rem 0.2rem;
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--pf-muted);
}

/* Result item */
.pf-result {
  display: block;
  padding: 0.625rem 1rem;
  text-decoration: none !important;
  color: inherit;
  border-left: 2px solid transparent;
  transition: background 0.08s, border-color 0.08s;
  outline: none;
  cursor: pointer;
}
.pf-result:hover,
.pf-result.is-active {
  background: var(--pf-hover);
  border-left-color: var(--pf-accent);
}

.pf-result-title {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--pf-text);
  line-height: 1.3;
  margin-bottom: 0.2rem;
}

.pf-result-excerpt {
  font-size: 0.775rem;
  color: var(--pf-secondary);
  line-height: 1.5;
  margin-bottom: 0.3rem;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.pf-result-excerpt mark {
  background: var(--pf-mark-bg);
  color: var(--pf-mark-fg);
  border-radius: 2px;
  padding: 0 2px;
  font-style: normal;
}

.pf-result-tags { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.pf-result-tag {
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--pf-muted);
  border: 1px solid var(--pf-border);
  border-radius: 2px;
  padding: 0.1rem 0.35rem;
}

/* Empty / error state */
.pf-empty {
  padding: 2rem 1rem;
  text-align: center;
  color: var(--pf-muted);
  font-size: 0.875rem;
  line-height: 1.6;
}
.pf-empty code {
  font-family: var(--pf-mono);
  font-size: 0.82em;
  background: var(--pf-hover);
  border: 1px solid var(--pf-border);
  border-radius: 3px;
  padding: 0.1em 0.35em;
}

/* ─── Footer hint bar ────────────────────────────────────── */
#pf-hint {
  display: flex;
  gap: 1rem;
  padding: 0.45rem 1rem;
  border-top: 1px solid var(--pf-border);
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  color: var(--pf-muted);
  flex-wrap: wrap;
}
#pf-hint kbd {
  display: inline-block;
  border: 1px solid var(--pf-border);
  border-radius: 2px;
  padding: 0 0.3em;
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  line-height: 1.6;
  background: transparent;
}

/* ─── Mobile ─────────────────────────────────────────────── */
@media (max-width: 480px) {
  #pf-panel { margin: 0 0.5rem; }
  #pf-results-wrap { max-height: 55vh; }
  #pf-hint { display: none; }
}
</style>

<!-- Search dialog -->
<div id="pf-overlay" hidden="" aria-modal="true" aria-label="Search">
  <div id="pf-panel">

    <div id="pf-input-row">
      
      <input id="pf-input" type="search" placeholder="Search projects and notes…" autocomplete="off" spellcheck="false" aria-label="Search query" aria-autocomplete="list" aria-controls="pf-results">
      <button id="pf-close-key" type="button" aria-label="Close search">Esc</button>
    </div>

    <div id="pf-results-wrap">
      <div id="pf-results" aria-label="Search results"></div>
    </div>

    

  </div>
</div>

<script>
(function () {
  'use strict';

  var overlay    = document.getElementById('pf-overlay');
  var inputEl    = document.getElementById('pf-input');
  var resultsEl  = document.getElementById('pf-results');
  var closeBtn   = document.getElementById('pf-close-key');

  var pf         = null;   // pagefind module, loaded lazily
  var pfPending  = false;
  var selected   = -1;
  var flat       = [];     // flat ordered array of result data (for keyboard nav)
  var debTimer   = null;

  /* ── Pagefind loader ──────────────────────────────────── */
  function loadPf() {
    if (pf) return Promise.resolve(pf);
    if (pfPending) return Promise.resolve(null);
    pfPending = true;
    return import('/pagefind/pagefind.js')
      .then(function (mod) {
        return mod.init().then(function () { pf = mod; pfPending = false; return pf; });
      })
      .catch(function () {
        pfPending = false;
        console.info('[search] Pagefind index not found. Run `quarto render` to build it.');
        return null;
      });
  }

  /* ── Open / close ─────────────────────────────────────── */
  function openDialog() {
    overlay.hidden = false;
    document.body.style.overflow = 'hidden';
    requestAnimationFrame(function () { inputEl.focus(); });
    loadPf(); // warm up the index
  }

  function closeDialog() {
    overlay.hidden = true;
    document.body.style.overflow = '';
    inputEl.value = '';
    resultsEl.innerHTML = '';
    flat = [];
    selected = -1;
  }

  /* ── Search ───────────────────────────────────────────── */
  function runSearch(query) {
    query = query.trim();
    if (!query) { resultsEl.innerHTML = ''; flat = []; selected = -1; return; }

    loadPf().then(function (mod) {
      if (!mod) {
        resultsEl.innerHTML = '<div class="pf-empty">Search index not found.<br>Run <code>quarto render</code> to build it.</div>';
        return;
      }
      mod.search(query).then(function (res) {
        Promise.all(res.results.slice(0, 15).map(function (r) { return r.data(); }))
          .then(function (data) { renderResults(data, query); });
      });
    });
  }

  function renderResults(data, query) {
    if (!data.length) {
      resultsEl.innerHTML = '<div class="pf-empty">No results for <strong>' + esc(query) + '</strong></div>';
      flat = []; selected = -1;
      return;
    }

    var work  = data.filter(function (r) { return /\/work\//.test(r.url); });
    var notes = data.filter(function (r) { return /\/notes\//.test(r.url); });
    var other = data.filter(function (r) { return !/\/(work|notes)\//.test(r.url); });

    flat = work.concat(notes).concat(other);
    selected = -1;

    var html = '';
    if (work.length)  html += makeGroup('Projects', work,  0);
    if (notes.length) html += makeGroup('Notes',    notes, work.length);
    if (other.length) html += makeGroup('Pages',    other, work.length + notes.length);

    resultsEl.innerHTML = html;

    resultsEl.querySelectorAll('.pf-result').forEach(function (el, i) {
      el.addEventListener('pointerover', function () { activate(i); });
    });
  }

  function makeGroup(label, items, offset) {
    return '<div class="pf-group">' + label + '</div>' +
      items.map(function (r, i) { return makeItem(r, offset + i); }).join('');
  }

  function makeItem(r, idx) {
    var title   = r.meta && r.meta.title ? r.meta.title : r.url;
    var excerpt = r.excerpt || '';
    var rawTags = ((r.meta && r.meta.tags) ? r.meta.tags : '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
    var tags    = rawTags.slice(0, 4).map(function (t) { return '<span class="pf-result-tag">' + esc(t) + '</span>'; }).join('');

    return '<a class="pf-result" href="' + esc(r.url) + '" role="option" data-idx="' + idx + '">'
      + '<div class="pf-result-title">' + esc(title) + '</div>'
      + (excerpt ? '<div class="pf-result-excerpt">' + excerpt + '</div>' : '')
      + (tags    ? '<div class="pf-result-tags">'    + tags    + '</div>' : '')
      + '</a>';
  }

  /* ── Keyboard navigation ──────────────────────────────── */
  function activate(idx) {
    var items = resultsEl.querySelectorAll('.pf-result');
    items.forEach(function (el) { el.classList.remove('is-active'); });
    if (idx >= 0 && idx < items.length) {
      items[idx].classList.add('is-active');
      items[idx].scrollIntoView({ block: 'nearest' });
    }
    selected = idx;
  }

  /* ── Utility ──────────────────────────────────────────── */
  function esc(s) {
    return String(s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;')
      .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  /* ── Event listeners ──────────────────────────────────── */

  // Input
  inputEl.addEventListener('input', function () {
    clearTimeout(debTimer);
    var val = this.value;
    debTimer = setTimeout(function () { runSearch(val); }, 200);
  });

  // Close button
  closeBtn.addEventListener('click', closeDialog);

  // Click on backdrop (outside panel)
  overlay.addEventListener('click', function (e) {
    if (e.target === overlay) closeDialog();
  });

  // Keyboard within the open dialog
  overlay.addEventListener('keydown', function (e) {
    if (e.key === 'Escape') { closeDialog(); return; }
    if (!flat.length) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      activate(Math.min(selected + 1, flat.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      activate(Math.max(selected - 1, 0));
    } else if (e.key === 'Enter' && selected >= 0) {
      e.preventDefault();
      window.location.href = flat[selected].url;
    }
  });

  // Global open shortcuts
  document.addEventListener('keydown', function (e) {
    if (!overlay.hidden) return;
    var tag = document.activeElement && document.activeElement.tagName;
    var editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
      || (document.activeElement && document.activeElement.isContentEditable);
    if (e.key === '/' && !editable) { e.preventDefault(); openDialog(); return; }
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openDialog(); }
  });

  // Fix tab title separator: "Page | Site" → "Page - Site"
  document.title = document.title.replace(' | ', ' - ');

  // Wire up the search icon in the Quarto navbar tools
  document.addEventListener('DOMContentLoaded', function () {
    var navBtn = document.querySelector('.quarto-navbar-tools a[aria-label*="Search"]');
    if (navBtn) {
      navBtn.addEventListener('click', function (e) {
        e.preventDefault();
        openDialog();
      });
    }

    // Work detail page: add status dot to the first category that is a status value
    var STATUS_VALUES = ['In Progress', 'Completed', 'Paused'];
    var STATUS_COLORS = { 'In Progress': '#378ADD', 'Completed': '#aaa', 'Paused': '#aaa' };
    document.querySelectorAll('.quarto-categories .quarto-category, .listing-category').forEach(function (el) {
      if (STATUS_VALUES.indexOf(el.textContent.trim()) !== -1) {
        el.style.display = 'none';
      }
    });
  });

})();
</script>




<p>pgvector gives you two index types: HNSW and IVFFlat. The docs tell you they exist; they’re less clear on when to choose each. Here’s what the tradeoffs mean in practice.</p>
<section id="the-short-version" class="level2">
<h2 class="anchored" data-anchor-id="the-short-version">The short version</h2>
<ul>
<li><strong>HNSW</strong>: Better recall, faster queries, higher memory cost, slower build time. Default choice for most applications.</li>
<li><strong>IVFFlat</strong>: Lower memory, faster builds, worse recall unless you tune it. Better for very large datasets where you can’t afford HNSW’s memory overhead.</li>
</ul>
</section>
<section id="hnsw" class="level2">
<h2 class="anchored" data-anchor-id="hnsw">HNSW</h2>
<p>Hierarchical Navigable Small World graphs navigate a multi-layer graph structure to find approximate nearest neighbors. Once built, queries are fast and recall is high.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb1-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CREATE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">INDEX</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ON</span> document_chunks</span>
<span id="cb1-2"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">USING</span> hnsw (embedding vector_cosine_ops)</span>
<span id="cb1-3"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">WITH</span> (m <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">16</span>, ef_construction <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">64</span>);</span></code></pre></div></div>
<ul>
<li><code>m</code>: Number of connections per node. Higher = better recall, more memory. Default 16 is fine for most cases.</li>
<li><code>ef_construction</code>: Search width during index build. Higher = better recall, slower build. 64–128 is typical.</li>
</ul>
<p>At query time, set <code>hnsw.ef_search</code> to control the recall/speed tradeoff:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb2-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SET</span> hnsw.ef_search <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">100</span>;</span>
<span id="cb2-2"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SELECT</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">FROM</span> document_chunks</span>
<span id="cb2-3"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ORDER</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">BY</span> embedding <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&lt;=&gt;</span> query_embedding</span>
<span id="cb2-4"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">LIMIT</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">10</span>;</span></code></pre></div></div>
</section>
<section id="ivfflat" class="level2">
<h2 class="anchored" data-anchor-id="ivfflat">IVFFlat</h2>
<p>Inverted file index with flat quantization. Clusters vectors into <code>lists</code> buckets at build time; queries search <code>probes</code> buckets.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb3-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CREATE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">INDEX</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ON</span> document_chunks</span>
<span id="cb3-2"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">USING</span> ivfflat (embedding vector_cosine_ops)</span>
<span id="cb3-3"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">WITH</span> (lists <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">100</span>);</span></code></pre></div></div>
<p>Rule of thumb: <code>lists = rows / 1000</code> for datasets under 1M rows, <code>sqrt(rows)</code> for larger.</p>
<p>At query time:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb4-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SET</span> ivfflat.probes <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">10</span>;</span></code></pre></div></div>
<p>More probes = better recall, slower queries. IVFFlat requires the table to be populated before building — you can’t build it on an empty table and add rows later without degrading recall.</p>
</section>
<section id="recall-testing" class="level2">
<h2 class="anchored" data-anchor-id="recall-testing">Recall testing</h2>
<p>Always test recall for your specific dataset. Generate a set of queries with known ground truth (exact kNN via sequential scan), run your index queries, and measure overlap.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb5" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb5-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-- Exact nearest neighbors (no index)</span></span>
<span id="cb5-2"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SET</span> enable_indexscan <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">off</span>;</span>
<span id="cb5-3"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SELECT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">id</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">FROM</span> document_chunks</span>
<span id="cb5-4"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ORDER</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">BY</span> embedding <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&lt;=&gt;</span> $<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span></span>
<span id="cb5-5"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">LIMIT</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">10</span>;</span></code></pre></div></div>
</section>
<section id="performance-table" class="level2">
<h2 class="anchored" data-anchor-id="performance-table">Performance table</h2>
<table class="caption-top table">
<thead>
<tr class="header">
<th>Threads</th>
<th>Metric</th>
<th>HNSW</th>
<th>IVFFlat</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Build time</td>
<td>—</td>
<td>Slow</td>
<td>Fast</td>
</tr>
<tr class="even">
<td>Query speed</td>
<td>—</td>
<td>Fast</td>
<td>Medium</td>
</tr>
<tr class="odd">
<td>Recall@10</td>
<td>—</td>
<td>~99%</td>
<td>~95%*</td>
</tr>
<tr class="even">
<td>Memory</td>
<td>—</td>
<td>High</td>
<td>Low</td>
</tr>
</tbody>
</table>
<p>*with <code>probes = 10</code> on a well-tuned index</p>


</section>

 ]]></description>
  <category>PostgreSQL</category>
  <category>pgvector</category>
  <category>AI</category>
  <category>Database</category>
  <guid>https://aakritidhakal.vercel.app/notes/pgvector-indexing.html</guid>
  <pubDate>Wed, 01 Oct 2025 00:00:00 GMT</pubDate>
</item>
<item>
  <title>PostgreSQL Schema Design for AI Applications</title>
  <dc:creator>Aakriti Dhakal</dc:creator>
  <link>https://aakritidhakal.vercel.app/notes/postgresql-schemas.html</link>
  <description><![CDATA[ 

<style>
/* ─── Search dialog CSS variables ───────────────────────── */
:root {
  --pf-bg: #FAF8F4;
  --pf-border: #e8e8e8;
  --pf-text: #0a0a0a;
  --pf-muted: #888;
  --pf-secondary: #555;
  --pf-hover: #f0ede7;
  --pf-accent: #378ADD;
  --pf-mark-bg: rgba(55, 138, 221, 0.12);
  --pf-mark-fg: #378ADD;
  --pf-overlay: rgba(10, 10, 10, 0.45);
  --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
  --pf-mono: "JetBrains Mono", monospace;
  --pf-sans: "Inter", system-ui, sans-serif;
  --pf-radius: 6px;
}

/* Dark mode: system preference */
@media (prefers-color-scheme: dark) {
  :root {
    --pf-bg: #1e1c1a;
    --pf-border: #2a2520;
    --pf-text: #e8e4df;
    --pf-muted: #8a857f;
    --pf-secondary: #8a857f;
    --pf-hover: #2a2520;
    --pf-overlay: rgba(0, 0, 0, 0.6);
    --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  }
}

/* Dark mode: Quarto / Bootstrap manual toggle */
[data-bs-theme="dark"] {
  --pf-bg: #1e1c1a;
  --pf-border: #2a2520;
  --pf-text: #e8e4df;
  --pf-muted: #8a857f;
  --pf-secondary: #8a857f;
  --pf-hover: #2a2520;
  --pf-overlay: rgba(0, 0, 0, 0.6);
  --pf-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}

/* ─── Overlay ────────────────────────────────────────────── */
#pf-overlay {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 12vh;
  background: var(--pf-overlay);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
}
#pf-overlay[hidden] { display: none; }

/* ─── Panel ──────────────────────────────────────────────── */
#pf-panel {
  width: 100%;
  max-width: 580px;
  margin: 0 1rem;
  background: var(--pf-bg);
  border: 1px solid var(--pf-border);
  border-radius: var(--pf-radius);
  overflow: hidden;
  box-shadow: var(--pf-shadow);
}

/* ─── Input row ──────────────────────────────────────────── */
#pf-input-row {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  padding: 0.75rem 1rem;
  border-bottom: 1px solid var(--pf-border);
}

#pf-input-icon {
  color: var(--pf-muted);
  flex-shrink: 0;
  line-height: 1;
  display: flex;
}

#pf-input {
  flex: 1;
  border: none;
  outline: none;
  background: transparent;
  font-family: var(--pf-sans);
  font-size: 1rem;
  color: var(--pf-text);
  min-width: 0;
}
#pf-input::placeholder { color: var(--pf-muted); }
#pf-input::-webkit-search-cancel-button { display: none; }

#pf-close-key {
  font-family: var(--pf-mono);
  font-size: 0.65rem;
  color: var(--pf-muted);
  border: 1px solid var(--pf-border);
  border-radius: 3px;
  padding: 0.15rem 0.45rem;
  flex-shrink: 0;
  cursor: pointer;
  background: transparent;
  line-height: 1.6;
  transition: color 0.1s, border-color 0.1s;
}
#pf-close-key:hover { color: var(--pf-text); border-color: var(--pf-text); }

/* ─── Results container ──────────────────────────────────── */
#pf-results-wrap {
  max-height: 380px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

#pf-results { padding: 0.375rem 0; }

/* Group label */
.pf-group {
  padding: 0.5rem 1rem 0.2rem;
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--pf-muted);
}

/* Result item */
.pf-result {
  display: block;
  padding: 0.625rem 1rem;
  text-decoration: none !important;
  color: inherit;
  border-left: 2px solid transparent;
  transition: background 0.08s, border-color 0.08s;
  outline: none;
  cursor: pointer;
}
.pf-result:hover,
.pf-result.is-active {
  background: var(--pf-hover);
  border-left-color: var(--pf-accent);
}

.pf-result-title {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--pf-text);
  line-height: 1.3;
  margin-bottom: 0.2rem;
}

.pf-result-excerpt {
  font-size: 0.775rem;
  color: var(--pf-secondary);
  line-height: 1.5;
  margin-bottom: 0.3rem;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.pf-result-excerpt mark {
  background: var(--pf-mark-bg);
  color: var(--pf-mark-fg);
  border-radius: 2px;
  padding: 0 2px;
  font-style: normal;
}

.pf-result-tags { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.pf-result-tag {
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--pf-muted);
  border: 1px solid var(--pf-border);
  border-radius: 2px;
  padding: 0.1rem 0.35rem;
}

/* Empty / error state */
.pf-empty {
  padding: 2rem 1rem;
  text-align: center;
  color: var(--pf-muted);
  font-size: 0.875rem;
  line-height: 1.6;
}
.pf-empty code {
  font-family: var(--pf-mono);
  font-size: 0.82em;
  background: var(--pf-hover);
  border: 1px solid var(--pf-border);
  border-radius: 3px;
  padding: 0.1em 0.35em;
}

/* ─── Footer hint bar ────────────────────────────────────── */
#pf-hint {
  display: flex;
  gap: 1rem;
  padding: 0.45rem 1rem;
  border-top: 1px solid var(--pf-border);
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  color: var(--pf-muted);
  flex-wrap: wrap;
}
#pf-hint kbd {
  display: inline-block;
  border: 1px solid var(--pf-border);
  border-radius: 2px;
  padding: 0 0.3em;
  font-family: var(--pf-mono);
  font-size: 0.6rem;
  line-height: 1.6;
  background: transparent;
}

/* ─── Mobile ─────────────────────────────────────────────── */
@media (max-width: 480px) {
  #pf-panel { margin: 0 0.5rem; }
  #pf-results-wrap { max-height: 55vh; }
  #pf-hint { display: none; }
}
</style>

<!-- Search dialog -->
<div id="pf-overlay" hidden="" aria-modal="true" aria-label="Search">
  <div id="pf-panel">

    <div id="pf-input-row">
      
      <input id="pf-input" type="search" placeholder="Search projects and notes…" autocomplete="off" spellcheck="false" aria-label="Search query" aria-autocomplete="list" aria-controls="pf-results">
      <button id="pf-close-key" type="button" aria-label="Close search">Esc</button>
    </div>

    <div id="pf-results-wrap">
      <div id="pf-results" aria-label="Search results"></div>
    </div>

    

  </div>
</div>

<script>
(function () {
  'use strict';

  var overlay    = document.getElementById('pf-overlay');
  var inputEl    = document.getElementById('pf-input');
  var resultsEl  = document.getElementById('pf-results');
  var closeBtn   = document.getElementById('pf-close-key');

  var pf         = null;   // pagefind module, loaded lazily
  var pfPending  = false;
  var selected   = -1;
  var flat       = [];     // flat ordered array of result data (for keyboard nav)
  var debTimer   = null;

  /* ── Pagefind loader ──────────────────────────────────── */
  function loadPf() {
    if (pf) return Promise.resolve(pf);
    if (pfPending) return Promise.resolve(null);
    pfPending = true;
    return import('/pagefind/pagefind.js')
      .then(function (mod) {
        return mod.init().then(function () { pf = mod; pfPending = false; return pf; });
      })
      .catch(function () {
        pfPending = false;
        console.info('[search] Pagefind index not found. Run `quarto render` to build it.');
        return null;
      });
  }

  /* ── Open / close ─────────────────────────────────────── */
  function openDialog() {
    overlay.hidden = false;
    document.body.style.overflow = 'hidden';
    requestAnimationFrame(function () { inputEl.focus(); });
    loadPf(); // warm up the index
  }

  function closeDialog() {
    overlay.hidden = true;
    document.body.style.overflow = '';
    inputEl.value = '';
    resultsEl.innerHTML = '';
    flat = [];
    selected = -1;
  }

  /* ── Search ───────────────────────────────────────────── */
  function runSearch(query) {
    query = query.trim();
    if (!query) { resultsEl.innerHTML = ''; flat = []; selected = -1; return; }

    loadPf().then(function (mod) {
      if (!mod) {
        resultsEl.innerHTML = '<div class="pf-empty">Search index not found.<br>Run <code>quarto render</code> to build it.</div>';
        return;
      }
      mod.search(query).then(function (res) {
        Promise.all(res.results.slice(0, 15).map(function (r) { return r.data(); }))
          .then(function (data) { renderResults(data, query); });
      });
    });
  }

  function renderResults(data, query) {
    if (!data.length) {
      resultsEl.innerHTML = '<div class="pf-empty">No results for <strong>' + esc(query) + '</strong></div>';
      flat = []; selected = -1;
      return;
    }

    var work  = data.filter(function (r) { return /\/work\//.test(r.url); });
    var notes = data.filter(function (r) { return /\/notes\//.test(r.url); });
    var other = data.filter(function (r) { return !/\/(work|notes)\//.test(r.url); });

    flat = work.concat(notes).concat(other);
    selected = -1;

    var html = '';
    if (work.length)  html += makeGroup('Projects', work,  0);
    if (notes.length) html += makeGroup('Notes',    notes, work.length);
    if (other.length) html += makeGroup('Pages',    other, work.length + notes.length);

    resultsEl.innerHTML = html;

    resultsEl.querySelectorAll('.pf-result').forEach(function (el, i) {
      el.addEventListener('pointerover', function () { activate(i); });
    });
  }

  function makeGroup(label, items, offset) {
    return '<div class="pf-group">' + label + '</div>' +
      items.map(function (r, i) { return makeItem(r, offset + i); }).join('');
  }

  function makeItem(r, idx) {
    var title   = r.meta && r.meta.title ? r.meta.title : r.url;
    var excerpt = r.excerpt || '';
    var rawTags = ((r.meta && r.meta.tags) ? r.meta.tags : '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
    var tags    = rawTags.slice(0, 4).map(function (t) { return '<span class="pf-result-tag">' + esc(t) + '</span>'; }).join('');

    return '<a class="pf-result" href="' + esc(r.url) + '" role="option" data-idx="' + idx + '">'
      + '<div class="pf-result-title">' + esc(title) + '</div>'
      + (excerpt ? '<div class="pf-result-excerpt">' + excerpt + '</div>' : '')
      + (tags    ? '<div class="pf-result-tags">'    + tags    + '</div>' : '')
      + '</a>';
  }

  /* ── Keyboard navigation ──────────────────────────────── */
  function activate(idx) {
    var items = resultsEl.querySelectorAll('.pf-result');
    items.forEach(function (el) { el.classList.remove('is-active'); });
    if (idx >= 0 && idx < items.length) {
      items[idx].classList.add('is-active');
      items[idx].scrollIntoView({ block: 'nearest' });
    }
    selected = idx;
  }

  /* ── Utility ──────────────────────────────────────────── */
  function esc(s) {
    return String(s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;')
      .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  /* ── Event listeners ──────────────────────────────────── */

  // Input
  inputEl.addEventListener('input', function () {
    clearTimeout(debTimer);
    var val = this.value;
    debTimer = setTimeout(function () { runSearch(val); }, 200);
  });

  // Close button
  closeBtn.addEventListener('click', closeDialog);

  // Click on backdrop (outside panel)
  overlay.addEventListener('click', function (e) {
    if (e.target === overlay) closeDialog();
  });

  // Keyboard within the open dialog
  overlay.addEventListener('keydown', function (e) {
    if (e.key === 'Escape') { closeDialog(); return; }
    if (!flat.length) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      activate(Math.min(selected + 1, flat.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      activate(Math.max(selected - 1, 0));
    } else if (e.key === 'Enter' && selected >= 0) {
      e.preventDefault();
      window.location.href = flat[selected].url;
    }
  });

  // Global open shortcuts
  document.addEventListener('keydown', function (e) {
    if (!overlay.hidden) return;
    var tag = document.activeElement && document.activeElement.tagName;
    var editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
      || (document.activeElement && document.activeElement.isContentEditable);
    if (e.key === '/' && !editable) { e.preventDefault(); openDialog(); return; }
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openDialog(); }
  });

  // Fix tab title separator: "Page | Site" → "Page - Site"
  document.title = document.title.replace(' | ', ' - ');

  // Wire up the search icon in the Quarto navbar tools
  document.addEventListener('DOMContentLoaded', function () {
    var navBtn = document.querySelector('.quarto-navbar-tools a[aria-label*="Search"]');
    if (navBtn) {
      navBtn.addEventListener('click', function (e) {
        e.preventDefault();
        openDialog();
      });
    }

    // Work detail page: add status dot to the first category that is a status value
    var STATUS_VALUES = ['In Progress', 'Completed', 'Paused'];
    var STATUS_COLORS = { 'In Progress': '#378ADD', 'Completed': '#aaa', 'Paused': '#aaa' };
    document.querySelectorAll('.quarto-categories .quarto-category, .listing-category').forEach(function (el) {
      if (STATUS_VALUES.indexOf(el.textContent.trim()) !== -1) {
        el.style.display = 'none';
      }
    });
  });

})();
</script>




<p>AI applications have a different data access pattern than typical CRUD apps. You’re often storing large blobs of text, embedding vectors, and audit trails of LLM interactions. Here’s what I’ve learned about structuring PostgreSQL for this.</p>
<section id="separate-concerns-source-vs.-derived-data" class="level2">
<h2 class="anchored" data-anchor-id="separate-concerns-source-vs.-derived-data">Separate concerns: source vs.&nbsp;derived data</h2>
<p>Keep source documents in one table and their embeddings in another. This lets you re-embed without touching the source, and query each independently.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb1-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CREATE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">TABLE</span> documents (</span>
<span id="cb1-2">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">id</span>          uuid <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">PRIMARY</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">KEY</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DEFAULT</span> gen_random_uuid(),</span>
<span id="cb1-3">  source_url  text,</span>
<span id="cb1-4">  content     text <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NOT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NULL</span>,</span>
<span id="cb1-5">  metadata    jsonb,</span>
<span id="cb1-6">  created_at  timestamptz <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DEFAULT</span> now()</span>
<span id="cb1-7">);</span>
<span id="cb1-8"></span>
<span id="cb1-9"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CREATE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">TABLE</span> document_chunks (</span>
<span id="cb1-10">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">id</span>          uuid <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">PRIMARY</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">KEY</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DEFAULT</span> gen_random_uuid(),</span>
<span id="cb1-11">  document_id uuid <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">REFERENCES</span> documents(<span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">id</span>) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">ON</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DELETE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CASCADE</span>,</span>
<span id="cb1-12">  chunk_index <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">int</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NOT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NULL</span>,</span>
<span id="cb1-13">  content     text <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NOT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NULL</span>,</span>
<span id="cb1-14">  embedding   vector(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1536</span>),</span>
<span id="cb1-15">  metadata    jsonb,</span>
<span id="cb1-16">  created_at  timestamptz <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DEFAULT</span> now()</span>
<span id="cb1-17">);</span></code></pre></div></div>
<p>The <code>metadata</code> jsonb column on chunks is important — it’s what enables filtering before or after similarity search.</p>
</section>
<section id="audit-tables-for-llm-interactions" class="level2">
<h2 class="anchored" data-anchor-id="audit-tables-for-llm-interactions">Audit tables for LLM interactions</h2>
<p>Every query sent to an LLM and every response received should be logged. This is essential for debugging, evaluation, and compliance.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb2-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">CREATE</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">TABLE</span> llm_interactions (</span>
<span id="cb2-2">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">id</span>           uuid <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">PRIMARY</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">KEY</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DEFAULT</span> gen_random_uuid(),</span>
<span id="cb2-3">  session_id   uuid,</span>
<span id="cb2-4">  model        text <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NOT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NULL</span>,</span>
<span id="cb2-5">  prompt       text <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NOT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">NULL</span>,</span>
<span id="cb2-6">  response     text,</span>
<span id="cb2-7">  latency_ms   <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">int</span>,</span>
<span id="cb2-8">  tokens_in    <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">int</span>,</span>
<span id="cb2-9">  tokens_out   <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">int</span>,</span>
<span id="cb2-10">  created_at   timestamptz <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">DEFAULT</span> now()</span>
<span id="cb2-11">);</span></code></pre></div></div>
</section>
<section id="indexing-strategy" class="level2">
<h2 class="anchored" data-anchor-id="indexing-strategy">Indexing strategy</h2>
<p>For vector columns, an HNSW index on <code>document_chunks.embedding</code> is almost always the right choice. See <a href="../notes/pgvector-indexing.html">pgvector indexing strategies</a> for details on when to use IVFFlat instead.</p>
</section>
<section id="jsonb-vs.-dedicated-columns" class="level2">
<h2 class="anchored" data-anchor-id="jsonb-vs.-dedicated-columns">jsonb vs.&nbsp;dedicated columns</h2>
<p>Use <code>jsonb</code> for metadata that varies across records (tags, categories, source-specific fields). Use dedicated columns for fields you’ll frequently filter or sort on — PostgreSQL can’t use standard indexes on jsonb internals efficiently without expression indexes.</p>


</section>

 ]]></description>
  <category>PostgreSQL</category>
  <category>AI</category>
  <category>Database</category>
  <guid>https://aakritidhakal.vercel.app/notes/postgresql-schemas.html</guid>
  <pubDate>Mon, 01 Sep 2025 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
