{"slug": "why-i-kept-search-scope-inside-a-single-supabase-rpc", "title": "Why I Kept Search Scope Inside a Single Supabase RPC", "summary": "A developer fixed a dangerous failure mode in vector search by making a single Supabase RPC the sole source of truth for search scope. The bug produced plausible-but-wrong neighbors from the wrong repository because the embedding, filter, and candidate count were assembled in separate places. The fix bundles query embedding, match count, and JSONB filter into one request object, ensuring the database enforces the exact search intent.", "body_md": "A retrieval system returned the right embedding, the right similarity score, and a completely wrong answer. The chunk it surfaced shared vocabulary, naming conventions, even architectural patterns with the query — but it came from the wrong repository. Nothing crashed. Nothing timed out. The system just lied with perfect confidence, and I did not catch it for two days.\n\nThat is the most dangerous failure mode in vector search: plausible neighbors from the wrong scope. The embedding math was fine. The rows were valid. The search logic was simply assembling too many decisions in too many places, and by the time the request reached PostgreSQL, the database had to guess which parts belonged together.\n\nI fixed the problem by making the RPC the single source of truth. The caller sends the query embedding, the candidate count, and the JSONB filter together. The SQL function receives those same values together. The index is built for the same embedding column the function reads. Once those pieces move as one unit, the path stops drifting.\n\n``` php\nflowchart TD\n  query[Query embedding] --> payload[RPC payload]\n  count[match_count] --> payload\n  filter[JSONB filter] --> payload\n  payload --> rpc[Supabase RPC]\n  rpc --> sql[search_embeddings]\n  sql --> rows[Ranked rows]\n```\n\nThe first mistake was treating search as a handful of knobs instead of a single request object. If the embedding is built in one place, the metadata filter is built in another, and the candidate depth is decided a layer above that, the call boundary becomes mushy. The function still runs, but nobody can point to one object and say: this is the exact search intent.\n\nThat matters most when retrieval is scoped. In this codebase, the filter is not an afterthought. It can describe a repo, a file path, a language, a type, or any combination of metadata fields that belong in the same search slice. If I am looking for TypeScript files in a specific repository, I want that to be expressed as part of the same request that carries the vector. I do not want the application to infer scope from session state, hidden defaults, or a previous call.\n\nThe bug showed up as plausible-but-wrong neighbors because vector similarity is happy to rank related text from the wrong place. That is what makes retrieval bugs so slippery. The results do not look random. They look close enough to distract you. A chunk from the wrong repo can still share concepts, terminology, or naming conventions with the query. If the filter is applied too late, the wrong row can look like a good answer until you inspect the metadata closely.\n\nThe fix was to make the request shape boring and explicit. The caller decides the search scope. The database enforces that scope. The index serves that same scope. There is no second pass that tries to patch over a weaker request after the fact.\n\nThe wrapper I use is intentionally small. It does not hide the request shape, and it does not smuggle in extra search behavior. It passes the vector, the count, and the filter straight into `search_embeddings`\n\n.\n\n``` js\nimport { SupabaseClient } from '@supabase/supabase-js';\n\ntype SearchFilter = Record<string, unknown>;\n\ninterface SearchResult {\n  id: string;\n  document_id: string;\n  chunk_text: string;\n  similarity: number;\n  metadata: Record<string, unknown>;\n}\n\nexport async function runSearch(\n  client: SupabaseClient,\n  queryEmbedding: number[],\n  matchCount = 5,\n  filter: SearchFilter = {}\n): Promise<SearchResult[]> {\n  const { data, error } = await client.rpc<SearchResult>('search_embeddings', {\n    query_embedding: queryEmbedding,\n    match_count: matchCount,\n    filter,\n  });\n\n  if (error) {\n    throw error;\n  }\n\n  return data ?? [];\n}\n```\n\nI like this shape because it is hard to misunderstand. The only inputs that matter at call time are the embedding, the number of rows to return, and the structured scope filter. If I need to search within one repo, I pass a repo filter. If I need to narrow by language, I add a language key. If I need to restrict by file type or path, I add that too. The caller is not building a query plan; it is declaring intent.\n\nThe same function can be called with a narrow filter or an empty one. An empty filter means search the whole corpus. A populated filter means search the subset that matches the metadata predicate. That is a very different outcome, and I want that difference visible right where the request is created.\n\nA typical call site stays just as clear:\n\n``` js\nconst results = await runSearch(supabase, embedding, 8, {\n  repo: 'the author/portfolio',\n  language: 'typescript',\n});\n```\n\nThat is the point. The search boundary should be obvious at a glance. If I am debugging a bad answer later, I should be able to inspect one payload and know exactly what the database was asked to do.\n\nOn the PostgreSQL side, the `search_embeddings`\n\nfunction accepts the same three inputs the caller sends. The metadata filter stays inside SQL, where it belongs. The rows are filtered first, then ranked by vector distance.\n\n```\nCREATE EXTENSION IF NOT EXISTS vector;\n\nCREATE OR REPLACE FUNCTION search_embeddings(\n  query_embedding vector,\n  match_count integer DEFAULT 5,\n  filter jsonb DEFAULT '{}'::jsonb\n)\nRETURNS TABLE (\n  id uuid,\n  document_id uuid,\n  chunk_text text,\n  similarity double precision,\n  metadata jsonb\n)\nLANGUAGE sql\nSTABLE\nAS $$\n  SELECT\n    e.id,\n    e.document_id,\n    e.chunk_text,\n    1 - (e.embedding <=> query_embedding) AS similarity,\n    e.metadata\n  FROM embeddings e\n  WHERE filter = '{}'::jsonb OR e.metadata @> filter\n  ORDER BY e.embedding <=> query_embedding\n  LIMIT match_count;\n$$;\n\nCREATE INDEX IF NOT EXISTS idx_embeddings_embedding\n  ON embeddings\n  USING hnsw (embedding vector_cosine_ops);\n```\n\nThe important line is the metadata predicate: `e.metadata @> filter`\n\n. That is not a clean-up step after ranking. It is part of the search itself. Rows that do not belong to the requested scope never enter the ranked candidate set.\n\nThat design matters because the database is the only place that can apply the filter consistently at the same moment it applies similarity. If the application filters after ranking, the query can still surface neighbors from the wrong scope first. If the filter is inside SQL, then ranking only happens across rows that already belong to the same metadata neighborhood as the request.\n\nThe `similarity`\n\nfield is there for the caller's benefit. Internally, cosine distance still drives the ordering. Externally, I want a score where larger reads as better. Returning both the score and the raw chunk text gives the next stage enough context to render, inspect, or rerank without another round trip.\n\nThe `STABLE`\n\nmarker also fits the way I use the function. For a fixed snapshot and a fixed input payload, this is a deterministic retrieval step. It is not a side-effect machine. It is a search function.\n\nThe filter is JSONB because the scope is structured, not free-form. A metadata filter can say more than one thing at once, and it needs to do that without collapsing into a pile of ad hoc parameters. The same object can describe a repository slice, a file path constraint, a language constraint, or a file-type constraint.\n\nThat gives me a single place to express the search boundary. If I need to retrieve only TypeScript chunks from a particular repository, I do not want to assemble a special query for that case. I want to build a JSONB object like `{ repo: 'the author/portfolio', language: 'typescript' }`\n\nand pass it straight through. The SQL predicate then enforces exactly that constraint.\n\nThis is also the reason I keep the filter visible at the RPC boundary instead of burying it in a helper that silently rewrites inputs. Hidden rewrite logic is how search calls become hard to reason about. The JSONB object is simple enough to inspect, easy to log, and unambiguous in SQL.\n\nThere is a second benefit that shows up during debugging. When a query returns too much, I can loosen the filter and see the effect immediately. When it returns too little, I can inspect the metadata keys that are actually present in the table. Because the filter is part of the request, there is no mystery about which layer decided the corpus was too broad or too narrow.\n\nThe same applies when I expand the metadata model. If I start attaching more structure to a chunk, such as file type or path segments, I do not need to change the RPC signature. I update the JSONB shape, then let the same `search_embeddings`\n\nfunction enforce the new predicate. The request boundary stays stable while the metadata vocabulary evolves.\n\nI do not think about the index as a separate optimization pass. It is part of the same guarantee that the RPC makes. If the function says it will search the `embeddings`\n\ntable with cosine distance, the index should be built for that exact access path.\n\nThat is why the HNSW index sits beside the function in my mental model. The function defines the agreement. The index makes that agreement fast enough to use all the time. `vector_cosine_ops`\n\nmatches the ranking strategy, so the storage layer is not fighting the retrieval layer.\n\nThe nice thing about HNSW here is that it matches the shape of the workload I care about: lots of dense vector searches, with a metadata filter that keeps the working set scoped before ranking. I am not trying to make the index do the job of the filter. I am making both pieces do the job they are good at. The metadata predicate narrows the rows. The vector index ranks the rows that remain.\n\nThat separation is what keeps the system predictable. If I ever need to inspect performance, I know where to look. If the wrong neighbors are showing up, I inspect the filter. If the right neighbors are slow, I inspect the index and the shape of the vector column. The responsibilities do not blur together.\n\nThe first broken version of this path made the request feel more flexible than it really was. The caller knew one thing, the search function inferred another, and the database had to reconcile them later. That is exactly the sort of accidental complexity that makes retrieval bugs hard to pin down.\n\nThe visible symptom was a result set that looked sane at a glance. The hidden problem was that the request did not fully describe the scope of the search. That is why the bug survived long enough to matter. Nothing crashed. Nothing timed out. The system just answered the wrong question with confidence.\n\nOnce I stopped spreading that decision across layers, the failure mode disappeared. The request object became the single place where I could answer three questions at once:\n\nThat is a much better debugging surface than a trail of local variables and implicit defaults. If I have to reason about a bad answer, I want to reason about one request object and one SQL function. That is enough.\n\nThe version I trust is the one where the search request is explicit enough that the database never has to guess. The caller passes the vector, the candidate count, and the JSONB filter in one place. The SQL function applies the filter inside the ranking query. The HNSW index is built on the same embedding column the function reads. Every step agrees on the same shape.\n\nThat is the whole reason I keep search scope inside a single Supabase RPC. Not because it is fashionable, and not because it makes the code shorter, but because it keeps the search intent attached to the request that asked for it. The RPC boundary becomes the line where scope is declared and enforced.\n\nOnce I made that change, retrieval stopped feeling like a chain of guesses and started feeling like a reliable interface again. That matters in a system where a correct answer is only useful if it comes from the right slice of data. In the next pass, I am going to push that same discipline further into the ingestion side, because retrieval only stays honest when the embeddings and metadata that feed it are just as deliberate.\n\n🎧 **Listen to the audiobook** — [Spotify](https://open.spotify.com/show/4ABVd5yDVfbX9HlV5JjT7D) · [Google Play](https://play.google.com/store/audiobooks/details/How_to_Architect_an_Enterprise_AI_System_And_Why_t?id=AQAAAECafz8_tM&hl=en) · [All platforms](https://www.craftedbydaniel.com/audiobook)\n\n🎬 [Watch the visual overviews on YouTube](https://youtube.com/playlist?list=PLRteDbGJPYDb9XNjecvHplGlgW7tIv_q6)\n\n📖 [Read the full 13-part series](https://www.craftedbydaniel.com/blog/series/how-to-architect-an-enterprise-ai-system-and-why-the-engineer-still-matters)", "url": "https://wpnews.pro/news/why-i-kept-search-scope-inside-a-single-supabase-rpc", "canonical_source": "https://dev.to/daniel_romitelli_44e77dc6/why-i-kept-search-scope-inside-a-single-supabase-rpc-212a", "published_at": "2026-07-27 16:03:04+00:00", "updated_at": "2026-07-27 16:31:34.557046+00:00", "lang": "en", "topics": ["artificial-intelligence", "machine-learning", "developer-tools"], "entities": ["Supabase", "PostgreSQL"], "alternates": {"html": "https://wpnews.pro/news/why-i-kept-search-scope-inside-a-single-supabase-rpc", "markdown": "https://wpnews.pro/news/why-i-kept-search-scope-inside-a-single-supabase-rpc.md", "text": "https://wpnews.pro/news/why-i-kept-search-scope-inside-a-single-supabase-rpc.txt", "jsonld": "https://wpnews.pro/news/why-i-kept-search-scope-inside-a-single-supabase-rpc.jsonld"}}