The embedding was right. The similarity score was right. The answer was completely wrong.
The chunk that came back shared vocabulary with the query, shared naming conventions, shared architectural patterns. It also came from the wrong repository. Nothing crashed, nothing timed out, and I went two days without catching it.
Plausible neighbors from the wrong scope is the failure mode I'd rank as the most dangerous one in vector search. The embedding math was fine. The rows were valid. The trouble was that search decisions were getting assembled in too many places, so by the time the request reached PostgreSQL, the database had to guess which parts belonged together.
So I made 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 wandering.
The bug was scope, not similarity math
My first mistake was treating search as a handful of knobs instead of one request object. Build the embedding in one place, the metadata filter in another, decide candidate depth a layer above that, and the call boundary turns to mush. The function still runs. But nobody can point at one object and say: this is the exact search intent.
That matters most when retrieval is scoped, and in this codebase the filter is no 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'm looking for TypeScript files in a specific repository, I want that expressed as part of the same request that carries the vector. I don't want the application inferring scope from session state, hidden defaults, or whatever the previous call happened to do.
The symptom was plausible-but-wrong neighbors, because vector similarity is happy to rank related text from the wrong place. Retrieval bugs are slippery for exactly that reason. The results don't 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, and if the filter is applied too late, that wrong row can look like a good answer right up until you inspect the metadata closely.
The fix was to make the request shape dull and explicit. The caller decides the search scope. The database enforces that scope. The index serves that same scope. No second pass trying to patch over a weaker request after the fact.
The caller sends one object
The wrapper I use is intentionally small. It doesn't hide the request shape, and it doesn't smuggle in extra search behavior. Vector, count, filter, passed straight into search_embeddings.
import { SupabaseClient } from '@supabase/supabase-js';
type SearchFilter = Record<string, unknown>;
interface SearchResult {
id: string;
document_id: string;
chunk_text: string;
similarity: number;
metadata: Record<string, unknown>;
}
export async function runSearch(
client: SupabaseClient,
queryEmbedding: number[],
matchCount = 5,
filter: SearchFilter = {}
): Promise<SearchResult[]> {
const { data, error } = await client.rpc<SearchResult>('search_embeddings', {
query_embedding: queryEmbedding,
match_count: matchCount,
filter,
});
if (error) {
throw error;
}
return data ?? [];
}
I like this shape because it's hard to misunderstand. At call time only three inputs matter: the embedding, the number of rows to return, and the structured scope filter. Searching inside one repo means passing a repo filter. Narrowing by language means adding a language key. Restricting by file type or path, same deal. The caller isn't building a query plan. It's declaring intent.
The same function can be called with a narrow filter or an empty one. An empty filter means search the whole corpus. A populated one means search the subset that matches the metadata predicate. Very different outcomes, and I want that difference visible right where the request gets created.
A typical call site stays just as clear:
const results = await runSearch(supabase, embedding, 8, {
repo: 'the author/portfolio',
language: 'typescript',
});
That's the point. The search boundary should be obvious at a glance. When I'm debugging a bad answer later, I want to inspect one payload and know exactly what the database was asked to do.
The database function matches the same interface
On the PostgreSQL side, search_embeddings accepts the same three inputs the caller sends. The metadata filter stays inside SQL, where it belongs. Rows get filtered first, then ranked by vector distance.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE OR REPLACE FUNCTION search_embeddings(
query_embedding vector,
match_count integer DEFAULT 5,
filter jsonb DEFAULT '{}'::jsonb
)
RETURNS TABLE (
id uuid,
document_id uuid,
chunk_text text,
similarity double precision,
metadata jsonb
)
LANGUAGE sql
STABLE
AS $$
SELECT
e.id,
e.document_id,
e.chunk_text,
1 - (e.embedding <=> query_embedding) AS similarity,
e.metadata
FROM embeddings e
WHERE filter = '{}'::jsonb OR e.metadata @> filter
ORDER BY e.embedding <=> query_embedding
LIMIT match_count;
$$;
CREATE INDEX IF NOT EXISTS idx_embeddings_embedding
ON embeddings
USING hnsw (embedding vector_cosine_ops);
The line carrying the weight is the metadata predicate: e.metadata @> filter. That isn't a cleanup step after ranking. It's part of the search itself. Rows outside the requested scope never enter the ranked candidate set.
The design matters because the database is the only place that can apply the filter consistently at the same moment it applies similarity. Filter in the application after ranking, and the query can still surface neighbors from the wrong scope first. Put the filter inside SQL, and ranking only happens across rows that already belong to the same metadata neighborhood as the request.
The similarity field is there for the caller's benefit. Cosine distance still drives the ordering internally. Externally, I want a score where larger reads as better. Returning both that score and the raw chunk text gives the next stage enough context to render, inspect, or rerank without another round trip.
The STABLE marker fits the way I use the function too. For a fixed snapshot and a fixed input payload, this is a deterministic retrieval step. It isn't a side-effect machine. It's a search function.
Why JSONB belongs in the request, not outside it
The filter is JSONB because the scope is structured rather than 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. One object can describe a repository slice, a file path constraint, a language constraint, or a file-type constraint.
So there's a single place to express the search boundary. To retrieve only TypeScript chunks from a particular repository, I don't want to assemble a special query for that case. I want to build a JSONB object like { repo: 'the author/portfolio', language: 'typescript' } and pass it straight through. The SQL predicate then enforces exactly that constraint.
It's also why I keep the filter visible at the RPC boundary rather than burying it in a helper that rewrites inputs behind my back. Hidden rewrite logic is how search calls become hard to reason about. A JSONB object is simple enough to inspect, easy to log, and unambiguous in SQL.
Debugging gets a second benefit out of this. When a query returns too much, I can loosen the filter and watch the effect immediately. When it returns too little, I can inspect which metadata keys are actually present in the table. Because the filter is part of the request, there's no mystery about which layer decided the corpus was too broad or too narrow.
The same holds when I expand the metadata model. If I start attaching more structure to a chunk, file type or path segments say, the RPC signature doesn't need to change. I update the JSONB shape, then let the same search_embeddings function enforce the new predicate. The request boundary holds still while the metadata vocabulary grows.
Why the index is part of the same story
I don't think about the index as a separate optimization pass. It's part of the same guarantee the RPC makes. If the function says it'll search the embeddings table with cosine distance, the index should be built for that exact access path.
Which is why the HNSW index sits right beside the function in my mental model. The function defines the agreement. The index makes that agreement fast enough to lean on all the time. vector_cosine_ops matches the ranking strategy, so the storage layer isn't fighting the retrieval layer.
What's nice 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 anything gets ranked. I'm not asking the index to do the filter's job. Each piece does the job it's good at. The metadata predicate narrows the rows, and the vector index ranks whatever remains.
That separation is what keeps the system predictable. If I ever need to inspect performance, I know where to look. Wrong neighbors showing up, I inspect the filter. Right neighbors arriving slowly, I inspect the index and the shape of the vector column. The responsibilities don't blur together.
What went wrong the first time
The 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 was left to reconcile them later. That's accidental complexity of exactly the sort that makes retrieval bugs hard to pin down.
The visible symptom was a result set that looked sane at a glance. The hidden problem was a request that didn't fully describe the scope of the search. Which is why the bug survived long enough to matter. Nothing crashed. Nothing timed out. The system answered the wrong question with confidence.
Once I stopped spreading that decision across layers, the failure mode went away. The request object became the single place where I could answer three questions at once:
- What text or embedding is this search about?
- How deep should the candidate set be?
- Which metadata fields are allowed to participate?
Much better debugging surface than a trail of local variables and implicit defaults. When I have to reason about a bad answer, I want to reason about one request object and one SQL function. That's enough.
Why I trust the boundary now
The 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.
That's the whole reason search scope lives inside a single Supabase RPC. Not because it's 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.
Once 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'm pushing that same discipline further into the ingestion side, because retrieval only stays trustworthy when the embeddings and metadata that feed it are just as deliberate.
