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.
The short answer
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.
What you'll need
Just the surreal binary:
curl -sSf https://install.surrealdb.com | sh
surreal start --user root --pass root --bind 127.0.0.1:8000 memory| File | Purpose |
|---|---|
schema.surql | Two tenants, one document table with row-level security, the HNSW index |
seed.surql | 1,000 documents, 10 of them owned by the small tenant |
search.surql | The hybrid fn::search |
queries.surql | Every query in this post |
bench.py | Rebuilds both databases, runs every strategy with and without permissions, asserts correctness, prints the tables |
The setup
Two users, and a document table where each row belongs to one of them.
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.
Reproducing the failure
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.
The same thing with no permissions at all
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.
Why raising K cannot fix it
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 / EF | Rows returned | Latency |
|---|---|---|
<\|5,64\|> | 0 | 0.09 ms |
<\|50,64\|> | 1 | 0.14 ms |
<\|100,100\|> | 1 | 0.24 ms |
<\|200,200\|> | 1 | 0.42 ms |
<\|500,500\|> | 5 | 1.02 ms |
<\|1000,1000\|> | 10 | 1.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 rows | Rows returned | Latency |
|---|---|---|
<\|10,64\|> | 0 | 0.09 ms |
<\|100,100\|> | 0 | 0.27 ms |
<\|1000,1000\|> | 0 | 2.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.
Fix A: restate the predicate so it goes into the walk
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.
Fix B: forget the index, score the rows in the slice
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:
| Tenant | Rows in the slice | Fix B latency |
|---|---|---|
| small | 50 | 0.17 ms |
| large | 49,950 | 75 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.
Fix C: index first, exact scan when it comes back short
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);| Tenant | Rows in the slice | Path taken | Latency |
|---|---|---|---|
| small | 50 of 50,000 | index miss → exact scan | 0.34 ms |
| large | 49,950 of 50,000 | index hit | 0.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.
If your filter is a column, not a permission
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.
Why the row count is a sound signal
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.
Two things to tune for your data
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.
The structural answer: give each tenant its own index
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.
Gotchas worth knowing before you benchmark this
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.
Reproduce it
surreal start --user root --pass root --bind 127.0.0.1:8000 memory &
python3 bench.pybench.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.
Further reading
Vector search reference: HNSW parameters, the KNN operator, distance functions
DEFINE INDEX: HNSW tuning knobs (EFC,M,M0)DEFINE TABLE ... PERMISSIONSand record access: how$authis populatedEXPLAIN: reading query plans