Apache Cassandra#
Apache Cassandra 5.0 and newer search vectors with Storage-Attached
Indexes (SAI). CassandraBackend puts the permission filter in the WHERE clause of each ANN
query, as bound parameters, so Cassandra applies it inside the search.
pip install "wardowl[cassandra]"
from cassandra.cluster import Cluster
from wardowl import MetadataACLAuthorizer, PermissionedRetriever
from wardowl.backends.cassandra import CassandraBackend, create_table
session = Cluster(["cassandra.internal"]).connect()
session.execute(
"CREATE KEYSPACE IF NOT EXISTS rag "
"WITH replication = {'class': 'NetworkTopologyStrategy', 'dc1': 3}" # your data centers
)
create_table(session, "rag", "chunks", dim=768) # the table and its two SAI indexes, if missing
backend = CassandraBackend(session, "rag", "chunks", embed)
backend.add(chunks) # or backend.add(chunks, embeddings) with vectors you computed
retriever = PermissionedRetriever(backend, MetadataACLAuthorizer())
create_table creates the table and its indexes, not the keyspace, so create the keyspace first,
with the replication your cluster needs: NetworkTopologyStrategy with a replica count for each
data center in production, or {'class': 'SimpleStrategy', 'replication_factor': 1} for a single
test node, as CI uses. Keyspace and table names are plain lowercase CQL names.
How filtering works#
Each row stores a tagged term for every string or integer in the chunk’s metadata (the field name,
the value’s type and the value) in the indexed set<text> column wardowl_terms, and the
metadata itself as JSON, returned exactly as written. A filter is rewritten as an OR of ANDs. Each
AND becomes one ANN query whose WHERE requires its terms with wardowl_terms CONTAINS ?; the
queries run concurrently and the best k hits of their union are returned. A subject in the groups
eng and sales costs two ANN queries.
The vector index is approximate, so a search can now and then return fewer than k hits when more
match. The filter is exact: no hit is ever a chunk the filter excludes.
max_queries (256 by default) bounds how many ANN queries one search sends; ANDs that another AND
implies are dropped first.
Filters that need more than max_queries queries#
A filter that still needs more ANDs than max_queries is sent as one ANN query on the terms every
match must have, for 1,000 rows, and the backend applies the filter to those rows itself. With
MetadataACLAuthorizer this happens for a subject in more than 254 groups at the default (one
query for the user, one per group and one for "*"). An And multiplies the counts: two tenants
and 200 groups make 404.
The search never returns a chunk the filter excludes, but it can return fewer than k hits, or
none, though more chunks match, and nothing says so: the retriever reports the result as
complete. That happens when more than 1,000 rows hold the terms every match needs and fewer than
k of the 1,000 nearest pass the filter. An Or of users and groups needs no term in common, so
the query reads the 1,000 nearest rows of the whole table; on a large table where the subject may
read a small part, few of them pass.
To avoid it:
Raise
max_queriesabove the largest number of ANDs your filters need. Each AND is one ANN query, sentconcurrency(32) at a time.Narrow the group lists: give the authorizer only the groups that grant access to chunks, not every group in the directory.
Put a term every match must have in the filter, such as a tenant: in
And(In("tenant", ["acme"]), Or(...))the one query requires the tenant’s term, so it reads the 1,000 nearest rows of that tenant rather than of the whole table.
Production notes#
Consistency: reads and writes use the session’s execution profile.
LOCAL_QUORUMfor both gives read-your-writes within a data center.Permission changes:
set_metadatalooks the ids up and updates the metadata and terms of the rows found, so an id that is not stored creates no row. Its writes are plain writes, like those ofaddanddelete, and use no lightweight transaction: Cassandra orders writes to a row by timestamp, and a lightweight transaction takes its timestamp from Paxos rather than from the client, so mixing the two could apply an older permission over a newer one. A chunk deleted between the lookup and the update is left as a row with metadata and terms but no text and no vector, which an ANN search never returns; a lateraddof that id replaces it.Limits: Cassandra allows 1,000 rows per ANN query, so a search with
kabove 1,000 raisesValueErrorrather than return 1,000 hits as if no more matched.Concurrent writes: Cassandra 5.0’s vector index accepts only a few writes at once per table (about one per core). The backend writes
write_concurrency(4) rows at a time and sends a refused write once more, alone; searches runconcurrency(32) queries at a time.Similarity:
similaritymust match the index (create_table(..., similarity=...)); scores are that function’s values (similarity_cosineby default).No keyword search: Cassandra 5.0 has no BM25, so the backend has vector search only.