API reference#
The core is importable from wardowl and has no dependencies. Adapters live in their own
modules and need their extra.
Retrieval#
- class wardowl.PermissionedRetriever(backend, authorizer, *, strategy='auto', action='read', overfetch=4, max_candidates=1000, recheck=True, observers=(), audit_key=None, reranker=None, rerank_depth=50, redactor=None)[source]#
Searches a backend and returns only the hits the subject may read.
strategychooses where permissions are applied:"prefilter"inside the search, with a filter from the authorizer;"postfilter"after it, checking each candidate;"auto"(the default) pre-filters when the backend supports filters and the authorizer returns one.actionis passed to the authorizer on every call.Post-filtering fetches
k * overfetchcandidates and multiplies the fetch byoverfetchuntil k permitted hits are found, the backend has no more, ormax_candidatesis reached; it never checks more candidates than it asked for, whatever the backend returns.max_candidatesbounds every retrieval: akabove it raisesValueError.recheckchecks pre-filtered hits again with the authorizer, so a backend that ignores the filter cannot leak; withrecheck=Falsethey are matched against the filter locally instead, which needs the backend to return each chunk’s metadata as stored (the conformance kit checks that).observersreceive aRetrievalEventfor every call, andaudit_key(at least 16 random bytes) adds an HMAC of the query to those events.rerankerreorders the result after the permission check: the retriever finds the toprerank_depthhits the subject may read (or k, if larger, and at mostmax_candidates), the reranker orders them, and the first k are returned. The reranker never sees a chunk the subject may not read.redactor(seewardowl.redaction) hides parts of the permitted hits, after the check and before the reranker: it may change a hit’s text and metadata but must return the same hits in the same order, and if it fails the retrieval raisesAuthorizationError.aretrieveruns it in a thread, off the event loop.- Parameters:
backend (SearchBackend)
authorizer (Authorizer)
strategy (Strategy)
action (str)
overfetch (int)
max_candidates (int)
recheck (bool)
observers (Sequence[Observer])
audit_key (bytes | None)
reranker (Reranker | RerankFunction | None)
rerank_depth (int)
redactor (Redactor | RedactFunction | None)
- retrieve(query, subject, k=5)[source]#
Return at most
khits the subject may read, best first.queryis text, or aQuerycarrying a vector you computed. RaisesAuthorizationErrorwhen the authorizer fails or gives a malformed answer, andValueErrororTypeErrorfor invalid arguments:InvalidQuery, aValueError, when the query orkis at fault rather than the configuration. Nothing is returned in any of these cases.- Parameters:
- Return type:
- async aretrieve(query, subject, k=5)[source]#
The async form of
retrieve, with the same arguments and guarantees.It uses the backend’s
asearchand the authorizer’safilter_forandacheckwhen they exist, and runs the sync methods in a thread otherwise. The redactor runs in a thread.- Parameters:
- Return type:
- class wardowl.RetrievalResult(hits, strategy, examined, denied, violations, complete)[source]#
What one retrieval returned, with the counts needed to monitor it.
- Parameters:
hits (list[Hit])
strategy (Literal['prefilter', 'postfilter'])
examined (int)
denied (int)
violations (int)
complete (bool)
- strategy: Literal['prefilter', 'postfilter']#
The strategy that was actually used.
- examined: int#
Candidates the authorizer checked.
- denied: int#
Post-filtered candidates the subject may not read.
- violations: int#
Pre-filtered hits that failed the re-check. Should always be 0; anything else means the backend did not apply the filter and deserves an alert.
- complete: bool#
False when wardowl knows results may be missing: post-filtering stopped at
max_candidates, or the re-check removed pre-filtered hits. It trusts the backend’s ranking, so it cannot see results an approximate index failed to return.
- class wardowl.SearchBackend(*args, **kwargs)[source]#
A vector store or search index. Return hits ordered best first.
supports_filterssays whethersearchhonours thefilterargument. Backends may also provideasync def asearch(...)with the same signature; otherwisesearchruns in a thread.queryis what the caller passed toretrieve, unchanged: a string, or aQuerywhen the caller supplies a vector.Query.of(query)gives aQueryeither way. A backend that takes only strings still works with every string query.
- class wardowl.Redactor(*args, **kwargs)[source]#
Changes the text or metadata of hits the subject may read, keeping the same hits in order.
- class wardowl.SpanRedactor(field='redactions', replacement='[REDACTED]', *, required=True)[source]#
Hides spans of a chunk’s text that the subject may not read.
Each chunk carries, under
fieldin its metadata, a list of spans:{"start": 120, "end": 340, "allowed_groups": ["hr"], "allowed_users": []}, character offsets into the text. A span the subject may not read (not a listed user, not in a listed group, and"*"not listed) is replaced byreplacement; overlapping spans are replaced together. The field itself is removed from the metadata returned, since it names who may read what. A malformed list of spans raisesValueError, which the retriever turns intoAuthorizationError: a field that is not a list, a span that is not a mapping, offsets that are not two integers with0 <= start < end, asha256that is not a string, or readers that are not lists of strings. Those are wrong whatever the text.A chunk without the field is hidden whole, its text replaced by
replacement: a list lost when the metadata was replaced cannot be told from one never written. Store[]for a chunk with nothing to hide, or passrequired=Falseto show chunks without the field in full.A chunk whose spans no longer fit its text is hidden whole too: a span that ends past the text, because the text was shortened after the spans were written, or a span whose
sha256(thespan_digestof the text it covers) no longer matches the text at its offsets. The offsets may no longer cover what they were written to protect.- Parameters:
field (str)
replacement (str)
required (bool)
- wardowl.redaction.span_digest(text)[source]#
The
sha256a span may carry: the hex SHA-256 of the UTF-8 text it covers,text[start:end].- Parameters:
text (str)
- Return type:
str
- class wardowl.PatternRedactor(patterns, *, exempt_groups=None, replacement='[REDACTED {name}]')[source]#
Hides text matching patterns, unless the subject belongs to a group exempt from a pattern.
patternsmaps a name to a regular expression.exempt_groupsmaps a name to the groups whose members see that pattern’s matches. Matches are replaced byreplacement, in which{name}is the pattern’s name.- Parameters:
patterns (Mapping[str, str | re.Pattern[str]])
exempt_groups (Mapping[str, Collection[str]] | None)
replacement (str)
- class wardowl.Reranker(*args, **kwargs)[source]#
Reorders the hits a subject may read: a cross-encoder, a hosted rerank API, any model.
rerankreceives the query as it was given toretrieveand the permitted hits, best first, and returns some or all of those hits in its order, with its scores. It may also provideasync def arerank(...)with the same signature. A plain function with the same signature can be passed instead.
- class wardowl.RankFusion(*backends, weights=None, rank_constant=60.0, depth=None)[source]#
A backend that searches several backends and fuses their rankings (reciprocal rank fusion).
weights(one per backend, default 1 each) scale each backend’s contribution.rank_constant(default 60, as in the original RRF paper) damps the advantage of the very top ranks.depthis how many results each backend is asked for (default: thekrequested). Filters are supported when every backend supports them. Every backend gets the same query: aQuerywith bothtextandvectorgives keyword backends the text and vector backends the vector, so the query is embedded once, by you, however many vector backends there are.- Parameters:
backends (SearchBackend)
weights (Sequence[float] | None)
rank_constant (float)
depth (int | None)
Strategy is Literal["auto", "prefilter", "postfilter"].
Subjects, documents, hits and queries#
- class wardowl.Subject(id, groups=(), attributes=None)[source]#
Who is asking: a user id, the groups they belong to, and any attributes your policy needs.
The id and every group are strings.
attributesis copied when the subject is made, and the copy cannot be changed: mappings in it are read-only, lists become tuples and sets frozensets. Decisions are cached per subject, so a subject changed afterwards could otherwise be given an answer cached for what it was before.- Parameters:
id (str)
groups (frozenset[str])
attributes (Mapping[str, Any])
- id: str#
- groups: frozenset[str] = frozenset({})#
- attributes: Mapping[str, Any]#
- class wardowl.Document(id, text, metadata=<factory>)[source]#
A chunk as it is stored: an id, its text, and metadata that may carry permissions.
- Parameters:
id (str)
text (str)
metadata (Mapping[str, Any])
- id: str#
- text: str#
- metadata: Mapping[str, Any]#
- class wardowl.Hit(id, text, score, metadata=<factory>)[source]#
A search result: the chunk plus the backend’s relevance score (higher is better).
- Parameters:
id (str)
text (str)
score (float)
metadata (Mapping[str, Any])
- id: str#
- text: str#
- score: float#
- metadata: Mapping[str, Any]#
- class wardowl.Query(text=None, vector=None)[source]#
What to search for: text, a vector you computed, or both.
A plain string is a text query. Pass a
Querywith avectorto search with an embedding you made yourself: an image’s, one your embedding service returned, or one computed once and shared by several backends. Vector search usesvectorwhen it is given and embedstextotherwise; keyword search usestext. Give both to fuse vector and keyword results withRankFusion.- Parameters:
text (str | None)
vector (tuple[float, ...] | None)
- class wardowl.InvalidQuery[source]#
A query that cannot be searched as asked, through a fault of the query, not the system.
Raised for a
koutside what the retriever allows, a query with only a vector for keyword search, only text for a backend with no embed function, or a vector whose length the backend knows is wrong. It is aValueError, so code catching that keeps working; the HTTP service answers it with 422. A backend of your own can raise it for queries it cannot search.
Filters#
Filter is In | And | Or.
- class wardowl.In(field, values)[source]#
True when the metadata field equals one of
values.For list-valued fields (for example
allowed_groups: ["sales", "hr"]) it is true when the field andvaluesshare at least one element. A missing field never matches, and neither do metadata values that are not strings or integers.- Parameters:
field (str)
values (frozenset[str | int])
Permission sync#
See Google Drive.
- class wardowl.sync.Access(users=(), groups=(), everyone=False, expires_at=None)[source]#
Who may read a document: these users, the members of these groups, or everyone.
Access()grants nobody: the safe answer when a source says nothing.expires_at, a timezone-aware time, is when the earliest grant in the answer ends: from then on the answer may grant more than the source does, so the document must be synced again before it.- Parameters:
users (frozenset[str])
groups (frozenset[str])
everyone (bool)
expires_at (datetime | None)
- metadata(*, users_field='allowed_users', groups_field='allowed_groups', everyone='*', expires_field='access_expires_at')[source]#
The permission fields for
MetadataACLAuthorizer, with its default field names and marker.Both fields are always present, so writing them replaces every earlier grant. A group named like the
everyonemarker raisesValueError: stored, it would let everyone read. Whenexpires_atis set,expires_fieldholds it too, in UTC to the second, rounded down ("2026-10-17T12:00:00Z"), so stored expiries sort as text.- Parameters:
users_field (str)
groups_field (str)
everyone (str)
expires_field (str)
- Return type:
dict[str, Any]
- wardowl.sync.with_access(metadata, access, *, users_field='allowed_users', groups_field='allowed_groups', everyone='*', expires_field='access_expires_at')[source]#
A chunk’s metadata with its permission fields replaced by
access, and every other key kept.An expiry written by an earlier sync is removed when
accesshas none.- Parameters:
metadata (Mapping[str, Any])
access (Access)
users_field (str)
groups_field (str)
everyone (str)
expires_field (str)
- Return type:
dict[str, Any]
- class wardowl.sync.google_drive.GoogleDriveACL(service, *, link_sharing=False, domain_prefix='domain:', domains=None, expiring_grants=False)[source]#
Reads who may read Drive files, through a Drive v3 service.
serviceisgoogleapiclient.discovery.build("drive", "v3", credentials=...). Its credentials need thedrive.readonlyscope (shared drives’ settings need more thandrive.metadata.readonly) and must see each file’s sharing and each shared drive’s settings.domainslists your organization’s domains. When given, grants outside them count for nothing. A file in a shared drive that is open only to its domain’s users needs them: reading its access without them raisesValueError.expiring_grants=Truecounts grants with an expiry time until they expire (seedrive_access).A file that is gone, or that the credentials can no longer see (HTTP 404, or 403 with the reason
notFoundorinsufficientFilePermissions), is readable by nobody:accessreturnsAccess(), so deleting a file or unsharing it from the indexer revokes access. Other errors from the API, such as rate limits and server errors, are raised: a document whose permissions could not be read should keep the permissions it has, not lose or gain any.- Parameters:
service (Any)
link_sharing (bool)
domain_prefix (str)
domains (Iterable[str] | None)
expiring_grants (bool)
- wardowl.sync.google_drive.drive_access(permissions, *, link_sharing=False, domain_prefix='domain:', members_only=False, domains=None, expiring_grants=False, now=None)[source]#
Who may read a file, from the permission resources
permissions.listreturns for it.Ask for the fields in
FIELDS.link_sharing=Truecounts sharing by link as access for the whole domain or for everyone. A domain grant gives the groupdomain_prefix+ the domain.members_only=Truecounts only grants to a shared drive’s members. Whendomainsis given, only grants inside those domains count, and an “anyone” grant gives each of their domain groups instead of everyone. A grant with an expiry time counts only withexpiring_grants=True, until it expires, and the answer’sexpires_atis then the earliest expiry among the grants that count. Expiry times are compared withnow, the current time by default.- Parameters:
permissions (Iterable[Mapping[str, Any]])
link_sharing (bool)
domain_prefix (str)
members_only (bool)
domains (Iterable[str] | None)
expiring_grants (bool)
now (datetime | None)
- Return type:
- wardowl.sync.google_drive.FIELDS = 'nextPageToken,permissions(id,type,role,emailAddress,domain,allowFileDiscovery,deleted,expirationTime,view,permissionDetails,inheritedPermissionsDisabled)'#
The fields
GoogleDriveACLasks Drive for: everythingdrive_accessreads.
- wardowl.sync.google_drive.READ_ROLES = frozenset({'commenter', 'fileOrganizer', 'organizer', 'owner', 'reader', 'writer'})#
Every Drive role that can read a file’s content.
Events, audit and tracing#
- class wardowl.RetrievalEvent(subject_id, action, k, strategy, returned_ids=(), examined=0, denied=0, violations=0, complete=False, duration_ms=0.0, error=None, query_hmac=None, started_at=0.0, search_ms=0.0, authorize_ms=0.0, searches=0, rerank_ms=0.0, reranked=0, cause=None)[source]#
One retrieval, for audit logs and monitoring. Never contains query or chunk text.
- Parameters:
subject_id (str)
action (str)
k (int)
strategy (str | None)
returned_ids (tuple[str, ...])
examined (int)
denied (int)
violations (int)
complete (bool)
duration_ms (float)
error (str | None)
query_hmac (str | None)
started_at (float)
search_ms (float)
authorize_ms (float)
searches (int)
rerank_ms (float)
reranked (int)
cause (str | None)
- subject_id: str#
- action: str#
- k: int#
- strategy: str | None#
The strategy that ran, or None if the call failed before choosing one.
- returned_ids: tuple[str, ...] = ()#
- examined: int = 0#
- denied: int = 0#
- violations: int = 0#
- complete: bool = False#
- duration_ms: float = 0.0#
- error: str | None = None#
The exception type when the call failed, for example “AuthorizationError”.
- query_hmac: str | None = None#
- started_at: float = 0.0#
Unix time when the call started.
- search_ms: float = 0.0#
Time spent in the backend, over every search of this call.
- authorize_ms: float = 0.0#
Time spent in the authorizer: filters, scopes and checks.
- searches: int = 0#
Backend searches made: one when pre-filtering, more when post-filtering fetched again.
- rerank_ms: float = 0.0#
Time spent in the reranker.
- reranked: int = 0#
Permitted chunks given to the reranker; 0 when there is none.
- cause: str | None = None#
The type of the exception behind
error, when there is one: for anAuthorizationError, what the call to the permission service raised, such as “TimeoutError” or “ConnectionRefusedError”, or “CircuitOpenError” when an openCircuitBreakerrefused the call. None when the error has no cause, as for a malformed answer.
- class wardowl.JsonLinesAudit(stream)[source]#
Writes one JSON object per retrieval to a text stream (a file, stdout, a socket wrapper).
It records who asked, what came back and what was denied, never the query or chunk text.
- Parameters:
stream (IO[str])
- wardowl.events.query_fingerprint(query, key)[source]#
HMAC-SHA256 of the query under
key, in hex. Equal queries give equal fingerprints.A
Querywith text is fingerprinted by its text, so the same question matches whether or not a vector came with it. A vector-only query is fingerprinted by its vector.- Parameters:
query (str | Query)
key (bytes)
- Return type:
str
- class wardowl.otel.OpenTelemetryObserver(tracer=None, *, record_subject=False)[source]#
Records each
RetrievalEventas a span namedwardowl.retrieve.tracerdefaults to thewardowltracer of the global tracer provider. Spans carry counts, outcomes and the time spent in the backend and the authorizer, never query or chunk text; the subject id only withrecord_subject=True.- Parameters:
tracer (Any)
record_subject (bool)
- class wardowl.otel.OpenTelemetryMetrics(meter=None)[source]#
Records each
RetrievalEventas OpenTelemetry metrics.meterdefaults to thewardowlmeter of the global meter provider. Every instrument is broken down bywardowl.actionandwardowl.strategyonly, plus the outcome anderror.typewhere they apply. These have a handful of values each, so the metrics stay cheap to store; subjects, queries and chunks are never recorded.gen_ai.client.operation.duration(histogram, s): each retrieval, withgen_ai.operation.name = "retrieval".wardowl.retrievals(counter): retrievals bywardowl.outcome, which iscomplete,incompleteorerror.wardowl.search.duration,wardowl.authorize.duration(histograms, s): time spent in the backend and in the authorizer during each retrieval.wardowl.rerank.duration(histogram, s): time spent in the reranker, for retrievals that reranked.wardowl.chunks.returned,wardowl.chunks.examined,wardowl.chunks.denied(counters): chunks returned, checked by the authorizer, and withheld.wardowl.violations(counter): pre-filtered chunks the re-check rejected. Alert on any increase.
- Parameters:
meter (Any)
An observer is any callable that takes a RetrievalEvent.
Backends#
- class wardowl.InMemoryBackend(documents=())[source]#
Keeps documents in memory and ranks them by keyword overlap.
For tests, examples and small corpora. Its filtering follows
matchesexactly, which makes it the reference the other backends are tested against.- Parameters:
documents (Iterable[Document])
- add(documents)[source]#
Store chunks, replacing any with the same id.
Unlike the vector backends, it takes no
embeddings: it ranks by the words a chunk shares with the query and never uses a vector, so it has nowhere to keep one.- Parameters:
documents (Iterable[Document])
- Return type:
None
- exception wardowl.backends.NothingMatches(message='the filter matches nothing, so there is no query to send')[source]#
The filter can match no chunk (an
Inwith no values, or anAndholding one), so there is no query to send.The public filter translators (
filter_to_where,filter_to_query,filter_to_qdrant, …) raise it rather than returnNoneor{}, which store clients read as “no filter”, that is, as “match everything”. The backends catch it and return no hits without asking the store; code that sends a translated filter itself should do the same.- Parameters:
message (str)
- Return type:
None
- class wardowl.backends.pgvector.PgVectorBackend(pool, embed=None, *, table='wardowl_chunks', iterative_scan='relaxed_order', ef_search=None, mode='vector', text_search='english', async_pool=None)[source]#
Cosine-similarity search over a pgvector table, with the filter in the WHERE clause.
poolis a psycopgConnectionPool;embedturns text into a vector (your embedding model). Withoutembed, search withQuery(vector=...)and passembeddingstoadd. Scores are cosine similarity,1 - cosine distance. Passasync_pool(anAsyncConnectionPool) for a nativeasearch; otherwise async calls runsearchin a thread.ef_searchsets pgvector’shnsw.ef_search(1 to 1000) for each search: higher finds more of the true nearest neighbours and takes longer.Noneleaves the server’s setting (40 by default).mode="keyword"searches the text with PostgreSQL full-text search instead (websearch_to_tsqueryin thetext_searchconfiguration, ranked byts_rank_cd), under the same permission condition, for hybrid search withRankFusion;embedmay then be None. Create the matching index withcreate_schema(..., text_search=...).- Parameters:
pool (Any)
embed (Embed | None)
table (str)
iterative_scan (str | None)
ef_search (int | None)
mode (str)
text_search (str)
async_pool (Any)
- wardowl.backends.pgvector.create_schema(pool, dim, *, table='wardowl_chunks', hnsw=True, text_search=None)[source]#
Create the extension, table and indexes if they do not exist.
poolis a psycopg ConnectionPool.text_search(a text search configuration, such as"english") also creates the full-text GIN index thatPgVectorBackend(mode="keyword", text_search=...)uses; pass the same name to both.- Parameters:
pool (Any)
dim (int)
table (str)
hnsw (bool)
text_search (str | None)
- Return type:
None
- wardowl.backends.pgvector.filter_to_sql(f)[source]#
Translate a filter into a condition on the
metadatajsonb column, with bound parameters.Each value becomes two containment tests: scalar equality (
{"field": v}) and array membership ({"field": [v]}), which the GIN index onmetadataserves. AboveMANY_VALUESvalues of one type (a subject in thousands of groups) the test takes two array parameters instead of two per value, so the query stays small and far below PostgreSQL’s limit of 65,535 parameters. jsonb compares strings, and keeps strings, numbers and booleans apart, as the reference semantics inwardowl.filters.matchesdo; but it compares numbers by value, so1.0would contain1. Integer values therefore also require the stored number to read as that integer (_EXACT_INTEGERS), so a float in the metadata never matches, as in the reference.
- class wardowl.backends.chroma.ChromaBackend(collection, embed=None, *, batch_size=None)[source]#
Search a Chroma collection with the permission filter applied by Chroma.
If
embedis given, queries and added documents are embedded with it; otherwise the collection’s own embedding function is used. AQuerywith a vector is searched with that vector. Scores are1 - distance, so create the collection with cosine distance for scores in [-1, 1].add,deleteandset_metadatasend at mostbatch_sizerecords per call, since Chroma refuses larger batches. By default it is the client’s own maximum (get_max_batch_size(), about 5,400 for a local client), or 1,000 when the collection does not report one. Each write is checked whole before the first batch is sent; a store that fails part way leaves the batches before it written.- Parameters:
collection (Any)
embed (Embed | None)
batch_size (int | None)
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
An update, which Chroma applies to stored records only: a chunk deleted meanwhile is not recreated, and its text and vector are never read and written back. Keys of the stored metadata that the new one lacks are removed (set to None), so no old list key survives. The read and the update are two calls, so an
addbetween them can leave list keys the update does not know of;searchchecks each hit’s JSON copy against the filter, so such a key never makes a chunk match.- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.chroma.filter_to_where(f)[source]#
Translate a filter into a Chroma
wheredocument.Inbecomes$inon the scalar field (one clause per value type, since Chroma’s$inlists are single-typed) plus an equality test on each value’s list key. RaisesNothingMatcheswhen nothing can match, as Chroma reads an emptywhereas “match everything”, andValueErrorfor a field name containing::or equal towardowl_metadata, which would address the stored list keys or the JSON copy rather than a metadata field.
- class wardowl.backends.elasticsearch.ElasticsearchBackend(client, index, embed=None, *, vector_field='embedding', content_field='content', metadata_field='metadata', num_candidates=100, refresh='wait_for', mode='vector', async_client=None)[source]#
Search an Elasticsearch index with the permission filter applied during the kNN search.
embedturns text into a vector; without it, search withQuery(vector=...)and passembeddingstoadd.num_candidatesis how many nearest neighbours each shard considers (at least k, at most 10,000): higher finds more of the true nearest chunks and takes longer. Scores are Elasticsearch’s:(1 + cosine) / 2for a cosine field.refreshis passed to bulk writes ("wait_for"by default, so written chunks are searchable whenaddreturns).mode="keyword"searches the text with BM25 instead, under the same permission filter, for hybrid search withRankFusion;embedmay then be None.- Parameters:
client (Any)
index (str)
embed (Embed | None)
vector_field (str)
content_field (str)
metadata_field (str)
num_candidates (int)
refresh (bool | str)
mode (str)
async_client (Any)
- add(documents, embeddings=None)#
Insert or replace chunks. Embeddings are computed with
embedunless given.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- set_metadata(metadata)#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.elasticsearch.create_index(client, index, dim, *, index_options=None, settings=None, vector_field='embedding', content_field='content', metadata_field='metadata')[source]#
Create the index if it does not exist, with a cosine
dense_vectorfield.Elasticsearch quantizes dense vectors by default (
int8_hnswbelow 384 dimensions,bbq_hnswfrom 384 in 9.1 and later), which saves memory and costs some recall. Passindex_options={"type": "hnsw"}to keep full-precision vectors.- Parameters:
client (Any)
index (str)
dim (int)
index_options (Mapping[str, Any] | None)
settings (Mapping[str, Any] | None)
vector_field (str)
content_field (str)
metadata_field (str)
- Return type:
None
- wardowl.backends.elasticsearch.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class wardowl.backends.opensearch.OpenSearchBackend(client, index, embed=None, *, vector_field='embedding', content_field='content', metadata_field='metadata', num_candidates=100, refresh='wait_for', mode='vector', async_client=None)[source]#
Search an OpenSearch index with the permission filter applied during the k-NN search.
embedturns text into a vector; without it, search withQuery(vector=...)and passembeddingstoadd. Scores are OpenSearch’s:(1 + cosine) / 2for acosinesimilfield.refreshis passed to bulk writes ("wait_for"by default, so written chunks are searchable whenaddreturns).num_candidatesis not used by OpenSearch’sknnquery. With the Lucene engine (the default), the query’sksets how widely the graph is searched; the index settingindex.knn.algo_param.ef_searchapplies to the Faiss engine only.mode="keyword"searches the text with BM25 instead, under the same permission filter, for hybrid search withRankFusion;embedmay then be None.- Parameters:
client (Any)
index (str)
embed (Embed | None)
vector_field (str)
content_field (str)
metadata_field (str)
num_candidates (int)
refresh (bool | str)
mode (str)
async_client (Any)
- add(documents, embeddings=None)#
Insert or replace chunks. Embeddings are computed with
embedunless given.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- set_metadata(metadata)#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.opensearch.create_index(client, index, dim, *, engine='lucene', parameters=None, settings=None, vector_field='embedding', content_field='content', metadata_field='metadata')[source]#
Create the index if it does not exist, with a cosine
knn_vectorfield (HNSW).engineis"lucene"(the default) or"faiss"; both apply filters during the search.parametersare the HNSW method parameters, for example{"m": 16, "ef_construction": 128}.- Parameters:
client (Any)
index (str)
dim (int)
engine (str)
parameters (Mapping[str, Any] | None)
settings (Mapping[str, Any] | None)
vector_field (str)
content_field (str)
metadata_field (str)
- Return type:
None
- wardowl.backends.opensearch.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class wardowl.backends.qdrant.QdrantBackend(client, collection, embed=None, *, mode='vector', text_search=False, content_key='page_content', metadata_key='metadata', vector_name=None, search_params=None, async_client=None)[source]#
Search a Qdrant collection with the permission filter applied by Qdrant.
embedturns text into a vector (your embedding model).vector_nameselects a named vector,search_params(aqdrant_client.models.SearchParams) tunes the search, for exampleSearchParams(hnsw_ef=128)for higher recall orSearchParams(exact=True)for brute force. Scores are Qdrant’s: cosine similarity for a cosine collection. Passasync_client(anAsyncQdrantClient) for a nativeasearch; otherwise async calls runsearchin a thread. Withoutembed, search withQuery(vector=...)and passembeddingstoadd.mode="keyword"ranks by BM25 instead, on the sparse vectorwardowl_bm25of a collection created withtext_search=True, under the samequery_filter. Scores are BM25, higher for better matches. A vector backend writes that sparse vector too when giventext_search=True, so one collection serves both halves of hybrid search. A keyword backend withoutembedwrites chunks with only the sparse vector, which keyword search finds and vector search does not.- Parameters:
client (Any)
collection (str)
embed (Embed | None)
mode (str)
text_search (bool)
content_key (str)
metadata_key (str)
vector_name (str | None)
search_params (Any)
async_client (Any)
- add(documents, embeddings=None)[source]#
Insert or replace chunks. Embeddings are computed with
embedunless given.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
Each change sets only the metadata key of the payload (Qdrant replaces a top-level key’s whole value, so no key of the old metadata survives), on the points that hold that id. It never writes the text back, and a point deleted meanwhile is not recreated.
- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.qdrant.create_collection(client, name, dim, *, keyword_fields=('allowed_users', 'allowed_groups'), metadata_key='metadata', vector_name=None, text_search=False)[source]#
Create a cosine-distance collection if it does not exist, and keyword indexes on the permission fields (
metadata.allowed_usersandmetadata.allowed_groupsby default) and onwardowl_id, whichset_metadataanddeletefilter on (a collection in strict mode refuses filtered updates on fields without an index).text_search=Truealso creates the sparse vectorwardowl_bm25with Qdrant’s IDF modifier, formode="keyword". Qdrant can add it to an existing collection only by recreating it.- Parameters:
client (Any)
name (str)
dim (int)
keyword_fields (Sequence[str])
metadata_key (str)
vector_name (str | None)
text_search (bool)
- Return type:
None
- wardowl.backends.qdrant.filter_to_qdrant(f, metadata_key='metadata')[source]#
Translate a filter into a Qdrant
Filter. RaisesNothingMatcheswhen nothing can match.InbecomesMatchAnyonmetadata.<field>, one condition per value type (strings and integers are never mixed in one list), joined withshould. AnInwith no values matches nothing; that is decided here instead of being left to Qdrant’s handling of empty lists, and a filter that can match nothing raises rather than return something Qdrant reads as no filter.- Parameters:
f (Filter)
metadata_key (str)
- Return type:
models.Filter
- wardowl.backends.qdrant.point_id(document_id)[source]#
The Qdrant point id
addwrites a document id to: a UUID derived from its SHA-256.Every id is hashed, one that is already a UUID too, so no two document ids share a point.
- Parameters:
document_id (str)
- Return type:
str
- wardowl.backends.qdrant.bm25_terms(text)[source]#
A chunk’s BM25 term weights, as the indices and values of the sparse vector
wardowl_bm25.Words are runs of letters, digits and underscores, lowercased; every word is kept. Each word’s index is its CRC-32, and its value BM25’s term-frequency part,
tf * (k1 + 1) / (tf + k1 * (1 - b + b * length / 256))with k1 = 1.2 and b = 0.75. Qdrant multiplies in the inverse document frequency at search time. Use it to fill the field for points written by other tools.- Parameters:
text (str)
- Return type:
tuple[list[int], list[float]]
- class wardowl.backends.milvus.MilvusBackend(client, collection, embed=None, *, mode='vector', vector_field='embedding', content_field='content', metadata_field='metadata', ef=100, search_params=None, consistency_level='Strong', async_client=None)[source]#
Vector search on Milvus, with the permission filter applied inside the search.
embedturns text into a vector (your embedding model).efis HNSW’s search breadth: higher finds more of the true nearest neighbours, more slowly; it is raised tokwhenkis larger, as Milvus requires.search_paramsreplaces the search parameters entirely, for another index type.consistency_levelis passed to every search (“Strong” by default, so writes are visible at once; “Bounded” is faster when a short delay is acceptable). Passasync_client(anAsyncMilvusClient) for a nativeasearch; otherwise async calls runsearchin a thread. Withoutembed, search withQuery(vector=...)and passembeddingstoadd.mode="keyword"searches the BM25 sparse vector of a collection created withtext_search=Trueinstead, under the same permission filter, with the query’s text. Scores are Milvus’s BM25, higher for better matches;search_paramsis passed only when given. Milvus requires every chunk’s vector, soaddstill needsembedorembeddingsin this mode.- Parameters:
client (Any)
collection (str)
embed (Embed | None)
mode (str)
vector_field (str)
content_field (str)
metadata_field (str)
ef (int)
search_params (dict[str, Any] | None)
consistency_level (str)
async_client (Any)
- add(documents, embeddings=None)[source]#
Insert or replace chunks. Embeddings are computed with
embedunless given.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
The rows found are changed with a partial update (Milvus 2.6.2 or newer) of the metadata and terms alone, which replaces the value of each field it names: the text and vector are never written back. A row deleted between the lookup and the update is not recreated, since Milvus refuses to insert a row without its vector; the call then raises, and can be repeated.
- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.milvus.create_collection(client, name, dim, *, metric='COSINE', vector_field='embedding', content_field='content', metadata_field='metadata', max_id_length=512, max_content_length=65535, max_terms=4096, max_term_length=1024, hnsw=None, text_search=False)[source]#
Create a collection
MilvusBackendcan use, with an HNSW index on the vector and an inverted index on the permission terms.max_termscaps the tagged terms per chunk: one per string or integer in its metadata, so a chunk shared with many groups needs more.max_term_lengthcaps one term, which is the field name, two characters and the value.text_search=Trueanalyzes the text with Milvus’s standard analyzer, which keeps every word, and adds a BM25 function that fills a sparse vector,wardowl_sparse, with an inverted index, formode="keyword". Milvus can only add it when the collection is created.- Parameters:
client (Any)
name (str)
dim (int)
metric (str)
vector_field (str)
content_field (str)
metadata_field (str)
max_id_length (int)
max_content_length (int)
max_terms (int)
max_term_length (int)
hnsw (dict[str, int] | None)
text_search (bool)
- Return type:
None
- wardowl.backends.milvus.filter_to_expression(f)[source]#
Translate a filter into a Milvus filter template and its parameters.
Returns
(expression, params)forsearch(filter=expression, filter_params=params). EachInbecomesARRAY_CONTAINS_ANY(wardowl_terms, {pN})with the tagged terms inparams["pN"]. RaisesNothingMatcheswhen nothing can match, as Milvus reads an empty filter as “match everything”.
- wardowl.backends.milvus.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata, for rows written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class wardowl.backends.mongodb.MongoDBBackend(collection, embed=None, *, mode='vector', index='wardowl', text_index=None, vector_field='embedding', content_field='content', metadata_field='metadata', num_candidates=100, exact=False, wait_timeout=60.0, async_collection=None)[source]#
Vector search on MongoDB Atlas Vector Search, with the permission filter applied inside the search.
embedturns text into a vector (your embedding model).indexnames the vector search index.num_candidatesis how many neighbours the approximate search considers (raised tok, at most 10,000): higher finds more of the true nearest chunks and takes longer.exact=Truesearches every chunk instead.wait_timeoutbounds how longaddanddeletewait for the index. Passasync_collection(the same collection from anAsyncMongoClient) for a nativeasearch; otherwise async calls runsearchin a thread. Withoutembed, search withQuery(vector=...)and passembeddingstoadd.mode="keyword"searches the text with Atlas Search (BM25) instead, in the index namedtext_index("wardowl_text"by default; seecreate_text_index), under the same permission filter. Scores are Atlas Search’s, higher for better matches. Such a backend needs noembed: without one,addwrites chunks with no vector, which keyword search still finds. A vector backend giventext_indexalso waits for that index after each write, so writes through it are searchable by both halves of hybrid search when they return.- Parameters:
collection (Any)
embed (Embed | None)
mode (str)
index (str)
text_index (str | None)
vector_field (str)
content_field (str)
metadata_field (str)
num_candidates (int)
exact (bool)
wait_timeout (float)
async_collection (Any)
- add(documents, embeddings=None, *, wait=True)[source]#
Insert or replace chunks. Embeddings are computed with
embedunless given.With
wait(the default), returns once the search index shows every chunk as written, or raisesTimeoutErrorafterwait_timeoutseconds.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
wait (bool)
- Return type:
None
- set_metadata(metadata, *, wait=True)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
With
wait(the default), returns once the search index shows the new metadata, or raisesTimeoutErrorafterwait_timeoutseconds.- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
wait (bool)
- Return type:
None
- wardowl.backends.mongodb.create_search_index(collection, dim, *, name='wardowl', vector_field='embedding', similarity='cosine', wait=True, timeout=120.0)[source]#
Create the vector search index
MongoDBBackenduses, creating the collection if needed.The index holds the vector and, as filter fields, the permission terms, the write marker and
_id. Withwait, it returns once the index is queryable, or raisesTimeoutError.- Parameters:
collection (Any)
dim (int)
name (str)
vector_field (str)
similarity (str)
wait (bool)
timeout (float)
- Return type:
None
- wardowl.backends.mongodb.create_text_index(collection, *, name='wardowl_text', content_field='content', wait=True, timeout=120.0)[source]#
Create the Atlas Search index that
mode="keyword"searches, creating the collection if needed.The index holds the text, with the
lucene.standardanalyzer, which keeps every word, and as tokens the permission terms, the write marker and_id. Withwait, it returns once the index is queryable, or raisesTimeoutError.- Parameters:
collection (Any)
name (str)
content_field (str)
wait (bool)
timeout (float)
- Return type:
None
- wardowl.backends.mongodb.filter_to_mongo(f)[source]#
Translate a filter into a
$vectorSearchfilter on the tagged terms.Raises
NothingMatcheswhen nothing can match, as$vectorSearchreads a missing filter as “match everything”.
- wardowl.backends.mongodb.filter_to_search(f)[source]#
Translate a filter into an Atlas Search operator on the tagged terms, for
$search:infor eachIn, andcompoundforAndandOr. RaisesNothingMatcheswhen nothing can match.
- wardowl.backends.mongodb.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata, for documents written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class wardowl.backends.weaviate.WeaviateBackend(client, collection, embed=None, *, vector_name='embedding', content_property='content', metadata_property='metadata_json', mode='vector', async_client=None)[source]#
Vector search on Weaviate, with the permission filter applied inside the search.
embedturns text into a vector (your embedding model).vector_nameis the collection’s named vector. Scores are cosine similarity, 1 minus Weaviate’s cosine distance. Passasync_client(a connectedWeaviateAsyncClient) for a nativeasearch; otherwise async calls runsearchin a thread. Withoutembed, search withQuery(vector=...)and passembeddingstoadd.mode="keyword"searches the text with Weaviate’s BM25 instead, under the same permission filter, for hybrid search withRankFusion;embedmay then be None, and scores are BM25’s.- Parameters:
client (Any)
collection (str)
embed (Embed | None)
vector_name (str)
content_property (str)
metadata_property (str)
mode (str)
async_client (Any)
- add(documents, embeddings=None)[source]#
Insert or replace chunks. Embeddings are computed with
embedunless given.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
An object is changed only when its
wardowl_idis the id given, or, for an object written by another tool, when it has nowardowl_idand the id is its UUID.- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.weaviate.create_collection(client, name, *, vector_name='embedding', content_property='content', metadata_property='metadata_json')[source]#
Create a collection
WeaviateBackendcan use: a self-provided cosine HNSW vector, the text, the metadata as unindexed JSON, and the id and permission terms withfieldtokenization.- Parameters:
client (Any)
name (str)
vector_name (str)
content_property (str)
metadata_property (str)
- Return type:
None
- wardowl.backends.weaviate.filter_to_weaviate(f)[source]#
Translate a filter into a Weaviate filter on the tagged terms. Raises
NothingMatcheswhen nothing can match.
- wardowl.backends.weaviate.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata, for objects written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- wardowl.backends.weaviate.point_id(document_id)[source]#
The Weaviate object id
addwrites a document id to: a UUID derived from its SHA-256.Every id is hashed, one that is already a UUID too, so no two document ids share an object.
- Parameters:
document_id (str)
- Return type:
str
- class wardowl.backends.redis.RedisBackend(client, index, embed=None, *, mode='vector', prefix='wardowl:', vector_field='embedding', content_field='content', metadata_field='metadata', ef_runtime=None, async_client=None)[source]#
Vector search on the Redis Query Engine, with the permission filter applied inside the search.
embedturns text into a vector (your embedding model). Chunks are hashes underprefix(the document id appended).ef_runtimeis HNSW’s search breadth (Redis’s default when None). Scores are cosine similarity, 1 minus Redis’s cosine distance. Passasync_client(aredis.asyncio.Redis) for a nativeasearch; otherwise async calls runsearchin a thread. Withoutembed, search withQuery(vector=...)and passembeddingstoadd.mode="keyword"searches the text with BM25 instead, under the same permission filter, in an index created withcontent_field=. Each word of the query (letters, digits and underscores) becomes a parameter, so none can act as query syntax, and up to 64 distinct words are kept. Scores are Redis’s BM25, higher for better matches. Such a backend needs noembed: without one,addwrites chunks with no vector, which keyword search still finds.- Parameters:
client (Any)
index (str)
embed (Embed | None)
mode (str)
prefix (str)
vector_field (str)
content_field (str)
metadata_field (str)
ef_runtime (int | None)
async_client (Any)
- wardowl.backends.redis.create_index(client, index, dim, *, prefix='wardowl:', vector_field='embedding', content_field=None, algorithm='HNSW', m=16, ef_construction=200)[source]#
Create the index if it does not exist: the permission terms as a TAG field and a cosine vector index, over the hashes whose keys start with
prefix.algorithm="HNSW"(the default, tuned bymandef_construction) is approximate. Redis indexes a hash again whenever any of its fields changes, soset_metadataremoves and re-adds the chunk’s vector in the graph, and a small, often-changed graph can then miss a chunk.algorithm="FLAT"compares the query with every vector: exact, and fast enough for indexes of up to some tens of thousands of chunks.With
content_field(the backend’s,"content"by default) the chunks’ text is indexed as well, formode="keyword", keeping every word: the index has no stop words. An index created without it can add it withFT.ALTER <index> SCHEMA ADD content TEXT.- Parameters:
client (Any)
index (str)
dim (int)
prefix (str)
vector_field (str)
content_field (str | None)
algorithm (str)
m (int)
ef_construction (int)
- Return type:
None
- wardowl.backends.redis.filter_to_query(f)[source]#
Translate a filter into a Query Engine expression on the hex-encoded terms.
Raises
NothingMatcheswhen nothing can match, as a query without the expression matches everything.
- wardowl.backends.redis.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata, for hashes written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
str
- class wardowl.backends.lancedb.LanceDBBackend(table, embed=None, *, mode='vector', vector_field='vector', content_field='content', metadata_field='metadata', nprobes=None, refine_factor=None)[source]#
Vector search on a LanceDB table, with the permission filter applied before ranking.
embedturns text into a vector (your embedding model); without it, search withQuery(vector=...)and passembeddingstoadd. Scores are cosine similarity, 1 minus LanceDB’s cosine distance.nprobesandrefine_factortune an IVF index if the table has one. Async calls runsearchin a thread.mode="keyword"searches the text with the table’s full-text index instead (build it withcreate_indexes(table, content_field="content")), under the same permission filter. Scores are LanceDB’s BM25, higher for better matches. Such a backend needs noembed: without one,addwrites chunks with no vector, which keyword search still finds.- Parameters:
table (Any)
embed (Embed | None)
mode (str)
vector_field (str)
content_field (str)
metadata_field (str)
nprobes (int | None)
refine_factor (int | None)
- add(documents, embeddings=None)[source]#
Insert or replace chunks. Embeddings are computed with
embedunless given.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
An update-only merge of the metadata and terms columns: rows whose id is not stored are not inserted, so a chunk deleted meanwhile is not recreated, and the text and vector are never read and written back. No metadata value is ever part of the SQL.
- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.lancedb.create_table(db, name, dim, *, vector_field='vector', content_field='content', metadata_field='metadata')[source]#
Create the table if it does not exist, and return it.
dbis alancedb.connect(...)connection.- Parameters:
db (Any)
name (str)
dim (int)
vector_field (str)
content_field (str)
metadata_field (str)
- Return type:
Any
- wardowl.backends.lancedb.create_indexes(table, *, content_field=None)[source]#
Index the permission terms (LABEL_LIST) and the ids, which filters and upserts use. Run it once the table holds data; rows added later are searched correctly before they are indexed.
With
content_field(the backend’s,"content"by default) it also builds the full-text index thatmode="keyword"searches, keeping every word: no stop words are removed. Rows added later are found by a scan untiltable.optimize()adds them to the index.- Parameters:
table (Any)
content_field (str | None)
- Return type:
None
- wardowl.backends.lancedb.filter_to_sql(f)[source]#
Translate a filter into a
wherepredicate on the hex-encoded terms.Raises
NothingMatcheswhen nothing can match, as LanceDB reads a missing predicate as “match everything”.
- wardowl.backends.lancedb.permission_terms(metadata)[source]#
The value of
wardowl_termsfor a chunk with this metadata, for rows written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class wardowl.backends.faiss.FaissBackend(embed, dim, *, index=None)[source]#
Chunks in a FAISS index, searched with the permission filter as an ID selector.
embedturns text into a vector ofdimnumbers (it may be None when every chunk and query carries its own vector). Thread-safe: a lock serializes changes and searches.- Parameters:
embed (Embed | None)
dim (int)
index (Any)
- add(documents, embeddings=None)[source]#
Store chunks, replacing any with the same id.
embeddingsgives their vectors, in order.When one call repeats an id, the last of its documents is stored, as if they were added in turn.
- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- delete(ids)[source]#
Remove chunks by id. Ids not stored are ignored.
- Parameters:
ids (Iterable[str])
- Return type:
None
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- save(path)[source]#
Write the index and the chunks to the directory
path, creating it if needed.- Parameters:
path (str | Path)
- Return type:
None
- class wardowl.backends.vespa.VespaBackend(url, embed, *, schema='wardowl', namespace='wardowl', mode='vector', approximate=True, client=None, timeout=10.0, concurrency=8)[source]#
Chunks in a Vespa content cluster, searched with the permission filter in the YQL query.
urlis the container’s endpoint (http://localhost:8080).mode="keyword"ranks by BM25 on the text instead, under the same filter. Passclient, anhttpx.Client, for TLS client certificates, tokens or proxies (Vespa Cloud, say). Writes goconcurrencyat a time. Thread-safe.- Parameters:
url (str)
embed (Embed | None)
schema (str)
namespace (str)
mode (Literal['vector', 'keyword'])
approximate (bool)
client (Any)
timeout (float)
concurrency (int)
- add(documents, embeddings=None)[source]#
Store chunks, replacing any with the same id.
embeddingsgives their vectors, in order.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- wardowl.backends.vespa.schema(dim, name='wardowl')[source]#
A Vespa schema (
schemas/<name>.sd) for chunks withdim-dimensional vectors.- Parameters:
dim (int)
name (str)
- Return type:
str
- wardowl.backends.vespa.filter_to_yql(f)[source]#
Translate a filter into a YQL condition on the hex-encoded terms.
Raises
NothingMatcheswhen nothing can match, as a query without the condition matches everything.
- class wardowl.backends.cassandra.CassandraBackend(session, keyspace, table, embed, *, similarity='cosine', max_queries=256, concurrency=32, write_concurrency=4)[source]#
Chunks in a Cassandra table, searched by ANN with the permission filter in each query’s
WHERE.sessionis a connectedcassandra.cluster.Session; its execution profile sets the consistency level (LOCAL_QUORUMfor both reads and writes gives read-your-writes). Thesimilaritymust match the index: the score is that similarity function’s value. Thread-safe, as the driver’s session is.- Parameters:
session (Any)
keyspace (str)
table (str)
embed (Embed | None)
similarity (str)
max_queries (int)
concurrency (int)
write_concurrency (int)
- add(documents, embeddings=None)[source]#
Store chunks, replacing any with the same id.
embeddingsgives their vectors, in order.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- delete(ids)[source]#
Remove chunks by id. Ids not stored are ignored.
- Parameters:
ids (Iterable[str])
- Return type:
None
- set_metadata(metadata)[source]#
Replace the metadata of stored chunks, keeping their text and vectors. Ids not stored are ignored.
The ids are looked up first, and only the rows found are updated, with plain writes like those of
addanddelete. A lightweight transaction (IF EXISTS) would take its timestamp from Paxos rather than from the client, and Cassandra orders writes by timestamp, so mixed with plain writes to the same rows it could apply an older change over a newer one, bringing a revoked permission back. A chunk deleted between the lookup and the update is left as a row holding only its metadata and terms, with no text and no vector, which an ANN search never returns; a lateraddof that id replaces it.- Parameters:
metadata (Mapping[str, Mapping[str, Any]])
- Return type:
None
- wardowl.backends.cassandra.create_table(session, keyspace, table, dim, *, similarity='cosine')[source]#
Create the table for chunks with
dim-dimensional vectors, and its SAI indexes, if missing.- Parameters:
session (Any)
keyspace (str)
table (str)
dim (int)
similarity (str)
- Return type:
None
- wardowl.backends.cassandra.filter_to_conjunctions(f, limit=10000)[source]#
The filter as an OR of ANDs: a chunk matches when its terms include every term of one set.
An empty list means nothing can match. ANDs implied by others are dropped, and a filter that needs more than
limitANDs raisesValueError.
- wardowl.backends.cassandra.required_terms(f)[source]#
Terms every chunk the filter matches must have (possibly none).
- exception wardowl.backends.cassandra.TooManyQueries[source]#
The filter expands to more ANDs than allowed.
- class wardowl.backends.pinecone.PineconeBackend(host, api_key, embed, *, namespace='', wait=30.0, client=None, timeout=10.0, concurrency=8)[source]#
Chunks in a Pinecone index, searched with the permission filter in the query.
hostis the index’s host (from the console, ordescribe_index), andapi_keya Pinecone API key, kept out of every message and representation.namespacekeeps tenants or corpora apart.waitis how many secondsadd,deleteandset_metadatawait for a search to see the change (raisingTimeoutErrorafter that); None does not wait. Passclient, anhttpx.Client, for proxies or custom TLS. Thread-safe.- Parameters:
host (str)
api_key (str)
embed (Embed | None)
namespace (str)
wait (float | None)
client (Any)
timeout (float)
concurrency (int)
- add(documents, embeddings=None)[source]#
Store chunks, replacing any with the same id.
embeddingsgives their vectors, in order.- Parameters:
documents (Iterable[Document])
embeddings (Iterable[Sequence[float]] | None)
- Return type:
None
- wardowl.backends.pinecone.filter_to_pinecone(f)[source]#
Translate a filter into a Pinecone metadata filter on the hex-encoded terms.
Raises
NothingMatcheswhen nothing can match: Pinecone reads a missing filter as “match everything”.
- wardowl.backends.pinecone.record_id(doc_id)[source]#
The Pinecone record id for a chunk id: itself when it is printable ASCII of 1 to 512 characters that does not start with
sha256-, otherwisesha256-and its SHA-256.No two chunk ids share a record id: one kept as it is never starts with
sha256-, and a hashed one always does.- Parameters:
doc_id (str)
- Return type:
str
Frameworks#
- class wardowl.langchain.WardowlRetriever(*args, name=None, tags=None, metadata=None, retriever, k=4, subject=None, metadata_fields=())[source]#
Returns only the chunks the subject may read.
Each
Documentcarriesrelevance_scoreand the chunk’s metadata keys named inmetadata_fields, none by default: permission fields such asallowed_users,allowed_groupsandtenantsay who may read what, and documents reach the model, prompts and traces.- Parameters:
args (Any)
name (str | None)
tags (list[str] | None)
metadata (dict[str, Any] | None)
retriever (PermissionedRetriever)
k (int)
subject (Subject | None)
metadata_fields (tuple[str, ...])
- class wardowl.langchain.LangChainVectorStoreBackend(vectorstore, *, id_key=None)[source]#
Any LangChain
VectorStoreas a wardowl backend (post-filtered).Documents need an id:
Document.id, or the metadata field named byid_key. Scores follow the store’s ranking (best first), since stores disagree on what their raw scores mean. AQuerywith a vector is searched withsimilarity_search_by_vector.- Parameters:
vectorstore (Any)
id_key (str | None)
- class wardowl.llamaindex.WardowlRetriever(retriever, subject, *, k=4, metadata_fields=(), **kwargs)[source]#
Returns only the nodes
subjectmay read.Each node carries the chunk’s metadata keys named in
metadata_fields, none by default: permission fields such asallowed_users,allowed_groupsandtenantsay who may read what, and nodes reach the model, prompts and traces.- Parameters:
retriever (PermissionedRetriever)
subject (Subject)
k (int)
metadata_fields (Collection[str])
kwargs (Any)
- class wardowl.llamaindex.PermissionNodePostprocessor(*, callback_manager=<factory>, authorizer, subject, action='read', metadata_fields=())[source]#
Keeps only the nodes
subjectmay read, as decided byauthorizer.The nodes it keeps are copies that carry the metadata keys named in
metadata_fields, none by default, as the retriever’s nodes do: LlamaIndex puts a node’s metadata into prompts by default, and permission fields such asallowed_users,allowed_groupsandtenantsay who may read what. The authorizer sees all the metadata, and the nodes passed in are not changed.- Parameters:
callback_manager (CallbackManager)
authorizer (Any)
subject (Any)
action (str)
metadata_fields (tuple[str, ...])
- class wardowl.llamaindex.LlamaIndexBackend(index)[source]#
A LlamaIndex index (anything with
as_retriever(similarity_top_k=...)) as a backend.- Parameters:
index (Any)
- class wardowl.haystack.WardowlRetriever(*args, **kwargs)[source]#
Returns at most
top_kdocuments the run’ssubjectmay read, best first.subjectis the authenticated user’sSubject, or a function returning it, called on every run. Anything else raisesTypeError. The input is typedCallable[[], Subject] | Anyon purpose.ComponentToolleaves an input whose type holds a function out of the tool’s parameters, but it passes on every argument a model sends, converted to the input’s type with pydantic, and an input typedSubjectwould turn a JSON object into one. WithAny, the JSON stays as it is and is refused, so a model cannot name another user.Documents carry the chunk’s metadata keys named in
metadata_fields, none by default: permission fields such asallowed_users,allowed_groupsandtenantsay who may read what, and documents reach the model, prompts and traces.- Parameters:
args (Any)
kwargs (Any)
- Return type:
Any
- run(query, subject, top_k=None, query_embedding=None)[source]#
Retrieve for
subject.top_koverrides the component’s default for this run.query_embedding(from a text embedder, for example) is searched with instead of embeddingqueryagain;querymay then be empty.- Parameters:
query (str)
subject (Callable[[], Subject] | Any)
top_k (int | None)
query_embedding (list[float] | None)
- Return type:
dict[str, Any]
- class wardowl.haystack.BoundWardowlRetriever(*args, **kwargs)[source]#
Returns at most
top_kdocuments the subject fromsubject_provider()may read, best first.The subject is not an input:
subject_provideris called on every run, where the run happens (in the run’s thread, or its task forrun_async), and must return the authenticated user’sSubject, for example by reading a contextvar the web layer set for the request. Neither a pipeline’s caller nor a model calling the component as a tool can name another user. A provider that fails, or returns anything but aSubject, stops the run. Inputs, outputs andmetadata_fieldsare as forWardowlRetriever, withoutsubject.- Parameters:
args (Any)
kwargs (Any)
- Return type:
Any
- wardowl.mcp.add_search_tool(server, retriever, subject, *, name='search', description="Search the organisation's documents. Returns only passages the calling user is allowed to read, most relevant first, each with its id, text and relevance score.", limit=5, max_limit=20, max_query_length=4000, metadata=())[source]#
Register a permission-aware search tool on
server.subjectis a function from the request’s verifiedAccessTokento aSubject, or one fixedSubjectfor a server only that person can reach; an async function is awaited, and a synchronous one runs in a worker thread. The tool takesquery(at mostmax_query_lengthcharacters) andlimit(defaultlimit, at mostmax_limit);metadatanames the metadata keys included in each result.- Parameters:
server (MCPServer[Any])
retriever (PermissionedRetriever)
subject (Subject | Callable[[AccessToken], Subject | Awaitable[Subject]])
name (str)
description (str)
limit (int)
max_limit (int)
max_query_length (int)
metadata (Sequence[str])
- Return type:
None
- wardowl.server.create_app(retriever, authenticate, *, max_k=50, max_query_length=4000, max_vector_length=4096, max_body_bytes=262144, metadata_fields=(), title='wardowl', docs=False)[source]#
A FastAPI application serving
retrieverto callers thatauthenticateidentifies.authenticatereceives the bearer token and returns theSubject, or raisesInvalidToken;OIDCAuthenticatoris one. An async authenticator is awaited, and a synchronous one runs in a worker thread.metadata_fieldslists the metadata returned with each hit (none by default). A request body overmax_body_bytesis refused with 413.docsserves the interactive API pages at/docs.- Parameters:
retriever (PermissionedRetriever)
authenticate (Callable[[str], Subject | Awaitable[Subject]])
max_k (int)
max_query_length (int)
max_vector_length (int)
max_body_bytes (int)
metadata_fields (Collection[str])
title (str)
docs (bool)
- Return type:
FastAPI
- class wardowl.server.OIDCAuthenticator(issuer, audience, jwks_url, *, algorithms=('RS256', 'ES256'), groups_claim='groups', tenant_claim=None, leeway=0, lifespan=300, cooldown=30, timeout=5, ssl_context=None)[source]#
Verifies JWT access tokens from an OpenID Connect provider against its published keys.
The token must name (
kid) a key injwks_urlwhose algorithm is one ofalgorithms(asymmetric only, and never taken from the token), be signed by it, be issued byissuerforaudience, and be unexpired. The subject is thesubclaim, its groups the list ingroups_claim, and, whentenant_claimis set, itstenantattribute the string in that claim, which must then be present. A token that fails any check raisesInvalidToken.The key set is fetched with a
timeout(seconds) and kept forlifespanseconds, so a key the provider has rotated out stops verifying within that time. A token naming a key that is not in the set fetches it again, at most once everycooldownseconds; until then such a token is invalid at once. A key endpoint that cannot be reached, or that returns no usable keys, raisesAuthorizationError. Calls are synchronous:create_appruns them in a worker thread.The key set is fetched over HTTPS with the system’s trust store;
ssl_contextreplaces it, for an identity provider whose certificate a private CA signed.- Parameters:
issuer (str)
audience (str | Sequence[str])
jwks_url (str)
algorithms (Sequence[str])
groups_claim (str)
tenant_claim (str | None)
leeway (float)
lifespan (float)
cooldown (float)
timeout (float)
ssl_context (SSLContext | None)
Conformance testing#
See Extending wardowl.
- wardowl.testing.check_backend(factory, *, examples=25, seed=0, max_documents=20, filters_per_example=4, exact=True, query='doc')[source]#
Check a backend against the reference semantics. Raises
ConformanceErroron the first failure.factory(documents)must return a new backend holding exactly those documents. For each ofexamplesrandom corpora (up tomax_documentseach) it checks that:a search without a filter returns stored documents only, min(k, stored) of them for k up to the whole corpus, best first, with text and metadata exactly as stored;
a search with a filter (when
supports_filtersis true) returns only documentsmatchesselects, and min(k, matching) of them, forfilters_per_examplerandom nested filters;asearch, if the backend has one, returns the same hits assearchfor every one of these searches, with and without a filter: the same ids in the same order, with the same text, metadata and scores, and refuses withValueErrorevery filter thatsearchrefuses;retrieval through
PermissionedRetrieverwithMetadataACLAuthorizer, throughretrieveandaretrievealike, never returns a document the subject may not read, reports no violations, and returns as many readable documents as were asked for (with"prefilter"and"postfilter"where available);if the backend has
set_metadata, replacing the metadata of some documents (and of an id that is not stored) leaves their text as it was, changes what filters select asmatchessays it should, keeps no key of the old metadata, and stores nothing new.
The data holds the values stores tend to confuse: 1 and “1”, “a” and “A”, one letter composed and decomposed, and field names with a dot or letters outside ASCII. A backend may refuse a filter on such a field name by raising
ValueError, which returns nothing, fromsearchandasearchalike; it may not return wrong hits.exact=Falsesuits approximate indexes, which may miss matching documents: results must still never include a document that does not match, but may contain fewer than expected.Every search uses
query. PassQuery(vector=...)to check a backend searched with vectors you compute, for exampleQuery(QUERY, HashingEmbedder()(QUERY))against a backend built withoutembedand filled withadd(documents, embeddings=...).- Parameters:
factory (Callable[[Sequence[Document]], SearchBackend])
examples (int)
seed (int)
max_documents (int)
filters_per_example (int)
exact (bool)
query (str | Query)
- Return type:
None
- wardowl.testing.check_authorizer(authorizer, subjects=None, hits=None, *, action='read', seed=0)[source]#
Check that an authorizer’s answers are well formed and agree with each other.
Without
subjectsandhits, random users and groups andallowed_users/allowed_groupsmetadata are used, which suits authorizers that read permissions from the chunks (MetadataACLAuthorizer, aFunctionAuthorizerover metadata,CachingAuthorizeraround them). For an authorizer backed by a service, pass subjects and hits from your test data. EveryCachingAuthorizerin it is emptied before each call, so the authorizer it wraps answers. For every subject it checks that:checkreturns one boolean per hit, and[]for no hits;the answer for a hit does not depend on the other hits or their order;
when
filter_forreturns a filter,checkagrees withmatches(filter, hit.metadata)for every hit. If they differ, pre-filtered and post-filtered results differ, and the re-check of pre-filtered hits reports violations;acheck,afilter_forandascope_for, where it has them, give the same answers ascheck,filter_forandscope_for:aretrieveuses them.
- Parameters:
authorizer (Authorizer)
subjects (Sequence[Subject] | None)
hits (Sequence[Hit] | None)
action (str)
seed (int)
- Return type:
None
- exception wardowl.testing.ConformanceError[source]#
A backend or authorizer disagreed with wardowl’s reference semantics.
- class wardowl.testing.HashingEmbedder(dim=16)[source]#
Deterministic bag-of-words embeddings for tests: no model, no network.
Texts that share words get similar vectors, so search results are meaningful enough for tests, and the same text always gets the same vector.
- Parameters:
dim (int)
- wardowl.testing.random_documents(rng, n)[source]#
ndocuments containingQUERY, with metadata that mixes strings, integers, lists of either, booleans and missing fields: the cases where backends’ filter translations tend to differ from the reference.- Parameters:
rng (Random)
n (int)
- Return type:
list[Document]