Elasticsearch and OpenSearch#
pip install "wardowl[elasticsearch]" # or "wardowl[opensearch]"
Both backends run an approximate kNN search with the permission filter applied during the search, so the k nearest chunks the subject may read come back however few of the index’s documents that is. Elasticsearch 8.12 or newer and OpenSearch 2.9 or newer are supported; CI runs wardowl’s conformance kit against Elasticsearch 9.5.5 and OpenSearch 3.9.0.
Elasticsearch#
from elasticsearch import Elasticsearch
from wardowl.backends.elasticsearch import ElasticsearchBackend, create_index
client = Elasticsearch("http://localhost:9200")
create_index(client, "docs", dim=768)
backend = ElasticsearchBackend(client, "docs", embed=embed) # embed: str -> list[float]
The filter goes in the knn clause’s filter. Elasticsearch documents that this filter “is
applied during approximate kNN search to ensure that k matching documents are returned”; a filter
placed beside the knn clause would apply afterwards and could return fewer, so wardowl never
does that. num_candidates= (default 100, at least k, at most 10,000) is how many neighbours each
shard considers: higher finds more of the true nearest chunks and takes longer.
Elasticsearch quantizes dense vectors by default (int8_hnsw below 384 dimensions, bbq_hnsw
from 384 in 9.1 and later). That saves memory and costs some recall; pass
create_index(..., index_options={"type": "hnsw"}) to keep full-precision vectors. On a million
384-dimensional chunks, the default with 100 candidates found 0.62 of the true top 10 for a user
who may read 1% of them; int8_hnsw with num_candidates=500 found 0.98 at a similar median
latency (11 ms against 13), in the same 2.2 GiB, and full-precision hnsw found 1.00
(Benchmarks). For a native
aretrieve, pass async_client=AsyncElasticsearch(...) (install elasticsearch[async]).
OpenSearch#
from opensearchpy import OpenSearch
from wardowl.backends.opensearch import OpenSearchBackend, create_index
client = OpenSearch(hosts=["http://localhost:9200"])
create_index(client, "docs", dim=768) # engine="lucene" (default) or "faiss"
backend = OpenSearchBackend(client, "docs", embed=embed)
The filter goes in the knn query’s filter, which the Lucene and Faiss engines apply during the
search (efficient k-NN filtering). create_index makes a knn_vector field with cosine similarity
and HNSW; pass parameters={"m": 16, "ef_construction": 128} to tune the graph. For a native
aretrieve, pass async_client=AsyncOpenSearch(...) (install opensearch-py[async]).
Approximate search and recall#
Both backends always run an approximate kNN search (HNSW, or a quantized form of it), and neither
has a switch for exact search. The engines may search exactly on their own when a filter admits few
documents, but the answer does not say which ran. A result can therefore miss some of the nearest
chunks the user may read and still say complete=True
(Concepts). To find more of them:
Elasticsearch: raise
num_candidates(100 by default), the neighbours each shard considers; the backend sends at least thekthe retriever asks for. Keep full-precision vectors withcreate_index(..., index_options={"type": "hnsw"}), or, on Elasticsearch 8.13 and later, use{"type": "flat"}, which compares the query with every vector the filter admits: exact, and slower as the index grows.OpenSearch: the query takes no candidate count. With the Lucene engine (the default), HNSW examines as many neighbours as the query’s
k, which is the number of chunks the retriever asks for:k, more when post-filtering, or a reranker’s depth. With the Faiss engine, the index settingindex.knn.algo_param.ef_search(100 by default) sets it, as increate_index(..., engine="faiss", settings={"index": {"knn.algo_param.ef_search": 256}}); it can also be changed on a live index.
The benchmarks measure what these settings cost and buy on a million chunks.
How permissions are indexed#
Keyword fields in both engines hold strings, so a terms query for the integer 1 also matches the
string "1", and a field’s type is fixed by the first document that used it. The reference
semantics keep 1 and "1" apart and never match booleans or floats. So both backends index one
keyword field, wardowl_terms, with a type-tagged term for every string and integer in the
chunk’s metadata, scalars and list elements alike:
Metadata |
|
|---|---|
|
|
|
|
|
|
|
none: no filter ever matches booleans or floats |
(␟ is the ASCII unit separator, \x1f; field names may not contain it.) A filter becomes a
terms query on these tags, And a bool filter, Or a bool should, and an In with no
values matches nothing without a call to the engine. filter_to_query raises NothingMatches for
a filter that can match nothing, since a search without a filter matches everything. The metadata
itself is stored with enabled: false: kept in _source and returned exactly as written, so the
retriever’s re-check sees the true permissions, but never indexed, so arbitrary metadata cannot add
mappings to the index. The index uses dynamic: false for the same reason.
Indexes written by other tools#
A document without wardowl_terms is invisible to a pre-filtered search: its metadata may say it
is readable, but no tag matches. Post-filtering still finds it and checks its metadata. To make such
an index fully searchable, add the field to each document in your ingestion pipeline:
from wardowl.backends.elasticsearch import TERMS_FIELD, permission_terms
document[TERMS_FIELD] = permission_terms(document["metadata"])
and map wardowl_terms as keyword. A document without a metadata object carries no
permissions, so the re-check denies it.
Limits#
Both engines return at most 10,000 hits per search, so a search with k above 10,000 raises
ValueError rather than return 10,000 hits as if no more matched. A terms query holds at most
index.max_terms_count values (65,536 by default), which bounds how many groups a single
subject’s filter can name; see Operations.
Partial results#
Every search sends allow_partial_search_results=false, so a shard that fails or times out fails
the search instead of leaving its hits out. An answer that still reports a failed shard
(_shards.failed above 0) or timed_out: true raises RuntimeError naming the engine, rather than
return its hits as complete.