Skip to content
NEW

Introducing SurrealDB Studio, the new official app of SurrealDB

Learn more

1/4

Why does my vector search return nothing when I add a filter?

AI
Tutorial

Sep 3, 202617 min read

Martin Schaer

Martin Schaer

Show all posts

Why does my vector search return nothing when I add a filter?

You build semantic search over your documents, it works, everyone is pleased. Then you add the one line that scopes results to the caller, a tenant, an organisation, a workspace, the signed-in user, and the same query starts returning an empty array. The query still succeeds, so nothing in your logs tells you anything is wrong.

The cause sits in how an approximate index works. An HNSW index does not scan your table. It walks a graph of vectors and hands back the K it believes are globally nearest to your query, knowing nothing about who owns them. A filter applied after that walk can only remove rows from the list the index already picked. It can never reach back into the index for the ones the walk passed over.

Picture a table of 1,000 documents where one tenant owns ten of them and another owns the rest. Ask for the ten nearest vectors to a query and, unless the small tenant is remarkably lucky, all ten come back owned by the big one. Apply the filter to that list and nothing survives:

SELECT id, vector::distance::knn() AS dist
FROM doc WHERE embedding <|10,64|> $q;

The query works beautifully in development, where most of the test data is yours, and returns [] in production, where most of it belongs to somebody else. It also makes no difference what is doing the scoping. A PERMISSIONS clause, a tenant_id predicate, an org, a workspace, a language or status column, a filter in your own application code: they all behave the same way. What matters is where in the query the filter lives, because that is what decides whether the index walk knows about it.

From there you get one of two outcomes, and neither is obvious. A filter the query planner cannot see runs after the walk, so it silently returns too few rows and often none at all. A filter the planner can see is pushed into the walk, which returns the right rows but has to keep exploring until it finds K of them that match, and that can be a thousand times slower.

Neither outcome is the one you wanted, and each has a real fix. This post reproduces both failures on 1,000 rows and shows why the instinctive fix (raising K) is the one that cannot work. It then measures the three that do, and ends with the schema change that stops the problem existing at all.

One HNSW index holds rows belonging to different principals, so where you write the filter decides which of the two failures you get. Put it in the same WHERE clause as the KNN operator and the planner pushes it into the walk, so the results are correct. The cost is that the walk keeps exploring until it finds K matches, which at 0.1% selectivity takes 158 ms against 0.09 ms unfiltered. Put it anywhere else, a PERMISSIONS clause, a subquery, a view, or your own application code, and it only sees rows the index has already chosen, so it can only remove them.

Raising K and EF does not solve either failure. Two fixes do, and the best answer is both at once: try the index, then fall back to an exact scan of the caller's own rows when it comes back short. That hybrid is four lines of SurrealQL and stays sub-millisecond for a tenant with 50 documents and for one with 50,000. And if you are multi-tenant and have not shipped yet, there is a schema change that makes the question disappear entirely.

Skip to the hybrid if you just want the code, or carry on to reproduce the failure first.

Our newsletter
Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

Just the surreal binary:

curl -sSf https://install.surrealdb.com | sh
surreal start --user root --pass root --bind 127.0.0.1:8000 memory
FilePurpose
schema.surqlTwo tenants, one document table with row-level security, the HNSW index
seed.surql1,000 documents, 10 of them owned by the small tenant
search.surqlThe hybrid fn::search
queries.surqlEvery query in this post
bench.pyRebuilds both databases, runs every strategy with and without permissions, asserts correctness, prints the tables

Two users, and a document table where each row belongs to one of them.

Schema diagram

schema.surql:

DEFINE TABLE user SCHEMAFULL
    PERMISSIONS FOR select WHERE id = $auth;
DEFINE FIELD email ON user TYPE string;
DEFINE FIELD pass  ON user TYPE string;
DEFINE INDEX user_email ON user FIELDS email UNIQUE;

DEFINE ACCESS user ON DATABASE TYPE RECORD
    SIGNIN ( SELECT * FROM user WHERE email = $email AND crypto::argon2::compare(pass, $pass) )
    DURATION FOR TOKEN 15m, FOR SESSION 2h;

DEFINE TABLE doc SCHEMAFULL
    PERMISSIONS FOR select WHERE owner = $auth;
DEFINE FIELD owner     ON doc TYPE record<user>;
DEFINE FIELD title     ON doc TYPE string;
DEFINE FIELD embedding ON doc TYPE array<float, 8>;

DEFINE INDEX doc_embedding ON doc FIELDS embedding
    HNSW DIMENSION 8 DIST COSINE TYPE F64 EFC 150 M 12;

DEFINE INDEX doc_owner ON doc FIELDS owner;

Read owner as any column that narrows a search, not just one that scopes it. tenant and org behave the same way, but so do category = 'footwear', language = 'de' and status = 'published'. What causes the trouble is never ownership, it is that the filter rules out most of the table. This demo hands the filtering to a PERMISSIONS clause because that is the harshest version of the problem. The clause is invisible to the query planner and invisible to root, so it survives every test you run with the credentials in your .env. Every fix here works the same way on a plain column predicate, and the section below measures that with permissions switched off entirely.

Eight-float vectors are a demo convenience. Real embedding models return hundreds or thousands of dimensions: 384 from all-MiniLM-L6-v2, 1,536 from OpenAI's text-embedding-3-small, 3,072 from its large sibling. Eight keeps the seed data on one screen and every distance below reproducible by hand. Set array<float, N> and DIMENSION N to whatever your model emits and nothing else in this post changes, except that each comparison costs more, which makes the slow numbers slower and the case for the fixes stronger.

Two details in there that matter later.

owner = $auth, not owner = $auth.id. This one is permissions-specific; skip it if your filter is an ordinary column. For a record access, $auth is the record link of the signed-in user, so comparing a record<user> field against it directly is free. Writing $auth.id instead makes SurrealDB fetch the user row to read a field off it, which needs a select permission on user. If that permission is missing, $auth.id quietly evaluates to NONE, your permission clause becomes owner = NONE, and every row disappears. This bites hardest on a table you never declared, because an implicitly created table is PERMISSIONS NONE. A schema that failed to apply then hands you a plausible-looking empty result set instead of an error.

DEFINE INDEX doc_owner is the difference between 0.17 ms and 45 ms later on, and it is the one line to copy if you take nothing else from this post.

seed.surql:

-- First let's create the users.
CREATE user:heavy SET email = 'heavy@example.com', pass = crypto::argon2::generate('heavy');
CREATE user:light SET email = 'light@example.com', pass = crypto::argon2::generate('light');

-- Now 1,000 documents, of which the small tenant owns every hundredth: ten rows,
-- 1% of the index:

FOR $i IN 0..1000 {
    CREATE type::record('doc', $i) SET
        owner = IF $i % 100 = 0 { user:light } ELSE { user:heavy },
        title = 'doc-' + <string> $i,
        embedding = [
            math::sin($i * 0.70 + 1), math::cos($i * 1.30 + 2),
            math::sin($i * 2.10 + 3), math::cos($i * 0.37 + 4),
            math::sin($i * 1.90 + 5), math::cos($i * 0.11 + 6),
            math::sin($i * 3.30 + 7), math::cos($i * 0.53 + 8)
        ];
};

The embeddings are trigonometric functions of the record index rather than random, so every distance in this post reproduces exactly on your machine.

Sign in as each user and run the identical query. As root:

LET $q = [0.55, -0.31, 0.72, 0.19, -0.64, 0.28, 0.41, -0.83];

SELECT id, owner, vector::distance::knn() AS dist
FROM doc WHERE embedding <|5,64|> $q;
[
  { dist: 0.0795, id: doc:870, owner: user:heavy },
  { dist: 0.0851, id: doc:179, owner: user:heavy },
  { dist: 0.1469, id: doc:619, owner: user:heavy },
  { dist: 0.1648, id: doc:691, owner: user:heavy },
  { dist: 0.1648, id: doc:334, owner: user:heavy }
]

Five results, all owned by the big tenant, which is exactly what you would expect when one user owns 99% of the rows. The same query as the small tenant:

[]

The query succeeded. The five nearest vectors in the database belong to someone else, the permission clause removed all five, and what is left is nothing.

If you have no row-level security, you are not safe: you are one line of application code away from the same result. Here is the failure as root, which bypasses PERMISSIONS entirely, with the filter written out as an ordinary predicate in a second statement:

LET $hits = (SELECT id, owner FROM doc WHERE embedding <|10,64|> $q);
SELECT * FROM $hits WHERE owner = user:light;
[]

Zero rows again, this time with permissions out of the picture. The subquery form fails the same way, and so does the version everybody actually writes, where the KNN result comes back to the client and gets filtered there:

hits = db.query("SELECT id, owner FROM doc WHERE embedding <|10,64|> $q", {"q": q})
mine = [h for h in hits if h["owner"] == tenant]   # -> []

All three are the same mechanism. The walk produced the global top-10, and the filter ran afterwards over those ten rows. EXPLAIN is where the difference shows up. A filter the walk knows about is an attribute of the KnnScan:

{ operator: 'KnnScan',
  attributes: { index: 'doc_embedding', k: '10', ef: '64',
                dimension: '8', predicate: 'owner = (user:light)' } }

A filter that runs afterwards is a separate Filter node sitting above a KnnScan that has no predicate at all:

{ operator: 'Filter',
  attributes: { predicate: 'owner = user:light' },
  children: [ { operator: 'SelectProject', children: [
              { operator: 'KnnScan',
                attributes: { index: 'doc_embedding', k: '10', ef: '64',
                              dimension: '8' } } ] } ] }

No predicate inside the KnnScan means your filter is a post-filter, whatever it is written in. A PERMISSIONS clause is the sneakiest case of all: it shows neither the predicate nor the Filter node, because it is applied outside the plan altogether.

The instinct is to over-fetch: ask for far more than you need and hope enough survives the filter. Here is what that actually buys, as the small tenant, on a table where they own 10 of 1,000 rows:

K / EFRows returnedLatency
<\|5,64\|>00.09 ms
<\|50,64\|>10.14 ms
<\|100,100\|>10.24 ms
<\|200,200\|>10.42 ms
<\|500,500\|>51.02 ms
<\|1000,1000\|>101.93 ms

Two things to take from that table.

First, K alone does nothing. Going from <|50,64|> to <|1000,64|> still returns one row, because EF (the size of the candidate list the walk keeps) caps how much of the graph is ever visited. You have to raise both.

Second, and fatally: the only setting that returns all ten rows is <|1000,1000|>, K and EF equal to the number of rows in the table. At that point the "index lookup" is a full graph traversal that visits every vector, and you have paid for an HNSW index in order to perform a scan. On a table 50× the size, that stops being merely wasteful:

K / EF, 50,000 rowsRows returnedLatency
<\|10,64\|>00.09 ms
<\|100,100\|>00.27 ms
<\|1000,1000\|>02.46 ms

Still zero, now twenty-seven times slower. There is no value of K that makes this correct, because the quantity you need to over-fetch by is your filter's selectivity, and you do not know it at query time. A tenant who owns 0.1% of the table needs K ≈ 1,000× the number of results they asked for; a tenant who owns 0.001% needs a million. Oversampling turns a correctness bug into a correctness bug that also gets slower.

If your filter is a column you can name, put it in the same WHERE clause as the KNN operator and the planner will push it into the traversal:

SELECT id, vector::distance::knn() AS dist
FROM doc WHERE embedding <|10,64|> $q AND owner = $auth;

Many multi-tenant readers are already here, and this is why their vector search is slow rather than empty. EXPLAIN confirms the pushdown; look for predicate inside the KnnScan:

{
  operator: 'KnnScan',
  attributes: { index: 'doc_embedding', k: '10', ef: '64',
                dimension: '8', predicate: 'owner = (user:light)' }
}

This returns all ten correct rows, at K=10, with no oversampling. It is a real fix and worth knowing about.

It is also the slowest option in this post. A filtered graph walk has to keep exploring until it has found K matching neighbours, so its cost scales inversely with the filter's selectivity, and a per-tenant filter is about as selective as filters get. At 50,000 rows with 0.1% visible, this query takes 158 ms, against 0.09 ms for the broken version. Note also that a filtered KNN can legitimately return fewer than K rows when there are fewer than K matches to find.

Fix A suits a filter that is not very selective: a role, a team, a language, a status that most of the table shares. For per-tenant or per-user scoping it is the wrong tool, and the two fixes below do much better.

If the caller can only see 50 rows, the fastest possible correct answer is to compute 50 cosine distances:

SELECT id, title, 1 - vector::similarity::cosine(embedding, $q) AS dist
FROM doc WHERE owner = $auth
ORDER BY dist LIMIT 10;

Notice the LIMIT 10, which is the same as the K in KnnScan. This query computes the distance for every row in the slice (in the example, 50 rows) and sorts the top 10 by key.

With row-level permissions the predicate is owner = $auth; with a plain scoping column it is tenant = $tenant. Nothing else about this query changes.

(SurrealDB exposes cosine as a similarity in [-1, 1], where higher is closer. 1 - similarity converts it to a distance so ORDER BY ... ASC reads the same way as vector::distance::knn().)

The WHERE clause is doing the heavy lifting, and it needs an index on the filter column to exist. With doc_owner in place, EXPLAIN shows the query reading only that slice and doing a bounded top-K sort over it:

SelectProject
  Limit (10)
    SortTopKByKey (dist ASC, limit 10)
      Compute (dist = 1 - vector::similarity::cosine(...))
        IndexScan (index: doc_owner, access: = user:light)

At 50,000 rows with 50 visible, that is 0.17 ms, the same order of magnitude as the broken KNN query, and about nine hundred times faster than the filtered walk in Fix A. Without the doc_owner index it is 7.7 ms, and without the WHERE clause at all (letting PERMISSIONS do the filtering, so every row gets scored) it is 45 ms. The index is the whole trick.

The catch is on the other end. This is a linear scan of the slice, so its cost is the slice's row count:

TenantRows in the sliceFix B latency
small500.17 ms
large49,95075 ms

Roughly 1.5 µs per row scored. Fine for a tenant with thousands of documents, not fine for one with millions, which is precisely the tenant for whom the HNSW index works perfectly.

Neither fix is right for every caller, and the split is not something you can settle when you write the query, because it depends on who is asking. The practical answer is to let the query decide at runtime: try the index first, and fall back to the exact scan when the filter has truncated the result.

search.surql:

DEFINE FUNCTION OVERWRITE fn::search($q: array<float>) {
    -- Fast path. Cheap, but the table's PERMISSIONS clause filters this
    -- *after* the HNSW walk, so the result can be silently truncated.
    LET $fast = (SELECT id, title, vector::distance::knn() AS dist
                 FROM doc WHERE embedding <|10,64|> $q);
    IF array::len($fast) >= 10 { RETURN $fast };

    -- Fallback: exact cosine over the rows in this caller's slice.
    -- `owner = $auth` is served by the doc_owner index, so this touches the
    -- caller's slice of the table, not all of it.
    RETURN (SELECT id, title, 1 - vector::similarity::cosine(embedding, $q) AS dist
            FROM doc WHERE owner = $auth ORDER BY dist LIMIT 10);
} PERMISSIONS FULL;

Both branches run with the caller's permissions, so neither can leak. The PERMISSIONS FULL on the function is about who may call it, not what it may see; without it, record users cannot invoke the function at all.

RETURN fn::search($q);
TenantRows in the slicePath takenLatency
small50 of 50,000index miss → exact scan0.34 ms
large49,950 of 50,000index hit0.16 ms

Sub-millisecond for both, correct for both. The big tenant pays nothing for the fallback because it never fires; the small tenant pays one wasted HNSW walk (0.1 ms) for a correct answer.

In that case the fast path has to apply the filter itself, since nothing else will. Oversample a little and filter the array in place:

LET $fast = (SELECT id, title, vector::distance::knn() AS dist
             FROM doc WHERE embedding <|20,64|> $q)[WHERE tenant = $tenant];
IF array::len($fast) >= 10 { RETURN array::slice($fast, 0, 10) };

That costs one extra pass over K rows instead of nothing, which at K=20 is noise. The fallback branch is unchanged.

The filter removes rows from the KNN result; it never reorders it and never adds to it. That holds whether the filtering is done by a PERMISSIONS clause, a Filter node, or your client code. So any row that comes back is genuinely one of the slice's nearest, and any document in the slice closer than the farthest row returned would have been inside the global top-K too. A truncated result is a correct prefix of the right answer, just too short. That is why count < k is exactly the right trigger, and why a full result needs no second-guessing beyond the approximation error you already accept from any ANN index.

Make the fast path's K a little larger than the number of results you want, so a heavy tenant losing one or two rows to a neighbour's document does not drop into the fallback unnecessarily. And if a single slice grows past a few tens of thousands of rows, the fallback stops being cheap for it, at which point the real answer is the next section.

Everything above is a workaround for one root cause: one HNSW index holding rows that belong to different principals. If the index only ever contains rows the caller may read, there is nothing left to post-filter.

SurrealDB gives you that for free, because a database is cheap and indexes are per-database. Put each tenant in their own database (or namespace) and <|10,64|> is simply correct. You have no predicate to restate, nothing to fall back to, and selectivity stops being something you have to reason about. Every tenant's index also stays small enough to be fast.

That is a bigger decision than a query rewrite, and it costs you cross-tenant queries and a connection that has to route per tenant. It also only helps for filters that partition your data: a filter on status or language still needs Fix A or Fix C. But if you are building multi-tenant vector search and have not shipped yet, this is the choice to make now rather than the one to work around later.

Check EXPLAIN, not the result shape. Every failure mode here returns status: OK with a plausible-looking array. A predicate attribute inside the KnnScan, or an IndexScan on your filter column, is the only confirmation that the query is doing what you think.

Wait for the HNSW index to finish building. A bulk load returns before the index is done, and querying too early gives you the worst of both worlds. The KNN takes hundreds of milliseconds and returns fewer than K rows, even to root. On 50,000 rows the settling took several seconds after the insert returned. INFO FOR INDEX is how you check:

INFO FOR INDEX doc_embedding ON doc;
{ building: { initial: 50000, pending: 0, status: 'ready', updated: 0 } }

status: 'ready' means the index is complete; anything else ('started' while a CONCURRENTLY build gets going, then a count of rows still to process) means your timings are measuring a half-built graph. bench.py polls until a root KNN is both fast and complete before it times anything; without that step its "plain KNN" numbers came out 90× too slow.

K and EF must be literal integers. <|$k, 64|> is a parse error, including inside a DEFINE FUNCTION body. Fix K in the function, or generate the statement.

The query vector must be a bound parameter. Inlining a record field into the operator (WHERE embedding <|10,64|> doc:123.embedding) silently skips the index: you get null distances and rows in insertion order. Bind it first with LET, then use the parameter.

And two more if your filter is a PERMISSIONS clause:

A root user proves nothing. Root and namespace users bypass PERMISSIONS entirely, which is why this class of bug survives every test you run with the credentials in your .env. Test as a record user or you are not testing it.

Sign in once, reuse the token. HTTP basic auth re-runs the signin query on every request, and crypto::argon2::compare costs about 14 ms. That will swamp anything you are trying to measure. Sign in at /signin and send the bearer token.

surreal start --user root --pass root --bind 127.0.0.1:8000 memory &
python3 bench.py

bench.py has no dependencies. It rebuilds both databases from schema.surql, waits for each HNSW index to settle, computes ground truth from a root session. It then asserts, rather than merely reporting, three things: that every strategy labelled correct returns exactly the ground-truth ranking, that every plain KNN narrower than the whole table is truncated, and that every post-filter comes back empty. If a future SurrealDB release changes any of this, the script fails instead of quietly printing different numbers.

50,000 docs in the HNSW index, 50 in the small tenant's slice (0.1%), asking for the nearest 10

as the tenant, filter in the table's PERMISSIONS clause

| Strategy                     | Rows | Correct | Median |
| ---------------------------- | ---- | ------- | ------ |
| plain KNN, K=10 EF=64        |    0 |      no |  0.26ms |
| plain KNN, K=100 EF=100      |    0 |      no |  0.46ms |
| plain KNN, K=1000 EF=1000    |    0 |      no |  2.69ms |
| filtered KNN, K=10 EF=64     |   10 |     yes | 174.85ms |
| exact scan, whole table      |   10 |     yes | 44.59ms |
| exact scan, owner-scoped     |   10 |     yes |  0.21ms |
| fn::search (hybrid)          |   10 |     yes |  0.49ms |

as root, no permissions anywhere, filter written as a column predicate

| Strategy                     | Rows | Correct | Median |
| ---------------------------- | ---- | ------- | ------ |
| post-filter, two statements  |    0 |      no |  0.60ms |
| post-filter, subquery        |    0 |      no |  0.54ms |
| filter in the KNN's WHERE    |   10 |     yes | 219.01ms |
| exact scan, owner-scoped     |   10 |     yes |  0.34ms |

Numbers are medians of nine runs against an in-memory 3.2.4 instance on an Apple Silicon laptop, measured 2026-08-21; treat the ratios as the finding and the absolutes as indicative.

Related posts

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

SurrealDB

The unified data layer for AI

Graph, vector, document, and relational in one engine.
Agent Memory that connects and retrieves context wherever your data lives.

Explore with AI

Stay in the loop

Tutorials, AI agent recipes, and product updates, every two weeks.

Independently verified

SOC 2 Type 2

GDPR

Cyber Essentials Plus

ISO 27001

Trust Centre

Copyright © 2026 SurrealDB Ltd. Registered in England and Wales. Company no. 13615201

Registered address: 3rd Floor 1 Ashley Road, Altrincham, Cheshire, WA14 2DT, United Kingdom

Trading address: Huckletree Oxford Circus, 213 Oxford Street, London, W1D 2LG, United Kingdom