API reference#
The core is importable from needtoknow and has no dependencies. Adapters live in their own
modules and need their extra.
Retrieval#
- class needtoknow.PermissionedRetriever(backend, authorizer, *, strategy='auto', action='read', overfetch=4, max_candidates=1000, recheck=True, observers=(), audit_key=None, reranker=None, rerank_depth=50)[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.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_keyadds 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), the reranker orders them, and the first k are returned. The reranker never sees a chunk the subject may not read.- 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)
- 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. Nothing is returned in any of these cases.- Parameters:
- Return type:
- class needtoknow.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 needtoknow 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 needtoknow.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 needtoknow.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 needtoknow.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 needtoknow.Subject(id, groups=(), attributes=None)[source]#
Who is asking: a user id, the groups they belong to, and any attributes your policy needs.
- Parameters:
id (str)
groups (frozenset[str])
attributes (Mapping[str, Any])
- id: str#
- groups: frozenset[str] = frozenset({})#
- attributes: Mapping[str, Any]#
- class needtoknow.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 needtoknow.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 needtoknow.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)
Filters#
Filter is In | And | Or.
- class needtoknow.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 needtoknow.sync.Access(users=(), groups=(), everyone=False)[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.- Parameters:
users (frozenset[str])
groups (frozenset[str])
everyone (bool)
- metadata(*, users_field='allowed_users', groups_field='allowed_groups', everyone='*')[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.- Parameters:
users_field (str)
groups_field (str)
everyone (str)
- Return type:
dict[str, list[str]]
- needtoknow.sync.with_access(metadata, access, *, users_field='allowed_users', groups_field='allowed_groups', everyone='*')[source]#
A chunk’s metadata with its permission fields replaced by
access, and every other key kept.- Parameters:
metadata (Mapping[str, Any])
access (Access)
users_field (str)
groups_field (str)
everyone (str)
- Return type:
dict[str, Any]
- class needtoknow.sync.google_drive.GoogleDriveACL(service, *, link_sharing=False, domain_prefix='domain:', domains=None)[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. Errors from the API, such as a file the credentials cannot see, are raised too: 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)
- needtoknow.sync.google_drive.drive_access(permissions, *, link_sharing=False, domain_prefix='domain:', members_only=False, domains=None, 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. 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)
now (datetime | None)
- Return type:
- needtoknow.sync.google_drive.FIELDS = 'nextPageToken,permissions(id,type,role,emailAddress,domain,allowFileDiscovery,deleted,expirationTime,view,permissionDetails,inheritedPermissionsDisabled)'#
The fields
GoogleDriveACLasks Drive for: everythingdrive_accessreads.
- needtoknow.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 needtoknow.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”. None when the error has no cause, as for a malformed answer.
- class needtoknow.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])
- needtoknow.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 needtoknow.otel.OpenTelemetryObserver(tracer=None, *, record_subject=False)[source]#
Records each
RetrievalEventas a span namedneedtoknow.retrieve.tracerdefaults to theneedtoknowtracer 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 needtoknow.otel.OpenTelemetryMetrics(meter=None)[source]#
Records each
RetrievalEventas OpenTelemetry metrics.meterdefaults to theneedtoknowmeter of the global meter provider. Every instrument is broken down byneedtoknow.actionandneedtoknow.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".needtoknow.retrievals(counter): retrievals byneedtoknow.outcome, which iscomplete,incompleteorerror.needtoknow.search.duration,needtoknow.authorize.duration(histograms, s): time spent in the backend and in the authorizer during each retrieval.needtoknow.rerank.duration(histogram, s): time spent in the reranker, for retrievals that reranked.needtoknow.chunks.returned,needtoknow.chunks.examined,needtoknow.chunks.denied(counters): chunks returned, checked by the authorizer, and withheld.needtoknow.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 needtoknow.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])
- class needtoknow.backends.pgvector.PgVectorBackend(pool, embed=None, *, table='needtoknow_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)
- needtoknow.backends.pgvector.create_schema(pool, dim, *, table='needtoknow_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
- needtoknow.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 (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 equality and containment compare by type either way, matching the reference semantics inneedtoknow.filters.matches.
- class needtoknow.backends.chroma.ChromaBackend(collection, embed=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].- Parameters:
collection (Any)
embed (Embed | None)
- needtoknow.backends.chroma.filter_to_where(f)[source]#
Translate a filter into a Chroma
wheredocument, orNEVERwhen nothing can match.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.
- class needtoknow.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
- needtoknow.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
- needtoknow.backends.elasticsearch.permission_terms(metadata)[source]#
The value of
needtoknow_termsfor a chunk with this metadata.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class needtoknow.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; setef_searchin the index settings instead.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
- needtoknow.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
- needtoknow.backends.opensearch.permission_terms(metadata)[source]#
The value of
needtoknow_termsfor a chunk with this metadata.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class needtoknow.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 vectorneedtoknow_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)
- needtoknow.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).text_search=Truealso creates the sparse vectorneedtoknow_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
- needtoknow.backends.qdrant.filter_to_qdrant(f, metadata_key='metadata')[source]#
Translate a filter into a Qdrant
Filter, orNonewhen 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.- Parameters:
f (Filter)
metadata_key (str)
- Return type:
models.Filter | None
- needtoknow.backends.qdrant.point_id(document_id)[source]#
The Qdrant point id for a document id: the id itself if it is a UUID, otherwise a UUID derived from it.
- Parameters:
document_id (str)
- Return type:
str
- needtoknow.backends.qdrant.bm25_terms(text)[source]#
A chunk’s BM25 term weights, as the indices and values of the sparse vector
needtoknow_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 needtoknow.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)
- needtoknow.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,needtoknow_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
- needtoknow.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), orNonewhen nothing can match. EachInbecomesARRAY_CONTAINS_ANY(needtoknow_terms, {pN})with the tagged terms inparams["pN"].
- needtoknow.backends.milvus.permission_terms(metadata)[source]#
The value of
needtoknow_termsfor a chunk with this metadata, for rows written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class needtoknow.backends.mongodb.MongoDBBackend(collection, embed=None, *, mode='vector', index='needtoknow', 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("needtoknow_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
- needtoknow.backends.mongodb.create_search_index(collection, dim, *, name='needtoknow', 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
- needtoknow.backends.mongodb.create_text_index(collection, *, name='needtoknow_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
- needtoknow.backends.mongodb.filter_to_mongo(f)[source]#
Translate a filter into a
$vectorSearchfilter on the tagged terms, orNonewhen nothing can match.
- needtoknow.backends.mongodb.filter_to_search(f)[source]#
Translate a filter into an Atlas Search operator on the tagged terms, for
$search, orNonewhen nothing can match:infor eachIn, andcompoundforAndandOr.
- needtoknow.backends.mongodb.permission_terms(metadata)[source]#
The value of
needtoknow_termsfor a chunk with this metadata, for documents written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- class needtoknow.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)
- needtoknow.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
- needtoknow.backends.weaviate.filter_to_weaviate(f)[source]#
Translate a filter into a Weaviate filter on the tagged terms, or
Nonewhen nothing can match.
- needtoknow.backends.weaviate.permission_terms(metadata)[source]#
The value of
needtoknow_termsfor a chunk with this metadata, for objects written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
list[str]
- needtoknow.backends.weaviate.point_id(document_id)[source]#
The Weaviate object id for a document id: the id itself if it is a UUID, otherwise one derived from it.
- Parameters:
document_id (str)
- Return type:
str
- class needtoknow.backends.redis.RedisBackend(client, index, embed=None, *, mode='vector', prefix='needtoknow:', 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)
- needtoknow.backends.redis.create_index(client, index, dim, *, prefix='needtoknow:', 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
- needtoknow.backends.redis.filter_to_query(f)[source]#
Translate a filter into a Query Engine expression on the hex-encoded terms, or
Nonewhen nothing can match.
- needtoknow.backends.redis.permission_terms(metadata)[source]#
The value of
needtoknow_termsfor a chunk with this metadata, for hashes written by other tools.- Parameters:
metadata (Mapping[str, Any])
- Return type:
str
- class needtoknow.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)
- needtoknow.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
- needtoknow.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
Frameworks#
- class needtoknow.langchain.NeedToKnowRetriever(*args, name=None, tags=None, metadata=None, retriever, k=4, subject=None)[source]#
Returns only the chunks the subject may read.
- Parameters:
args (Any)
name (str | None)
tags (list[str] | None)
metadata (dict[str, Any] | None)
retriever (PermissionedRetriever)
k (int)
subject (Subject | None)
- class needtoknow.langchain.LangChainVectorStoreBackend(vectorstore, *, id_key=None)[source]#
Any LangChain
VectorStoreas a needtoknow 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 needtoknow.llamaindex.NeedToKnowRetriever(retriever, subject, *, k=4, **kwargs)[source]#
Returns only the nodes
subjectmay read.- Parameters:
retriever (PermissionedRetriever)
subject (Subject)
k (int)
kwargs (Any)
- class needtoknow.llamaindex.PermissionNodePostprocessor(*, callback_manager=<factory>, authorizer, subject, action='read')[source]#
Keeps only the nodes
subjectmay read, as decided byauthorizer.- Parameters:
callback_manager (CallbackManager)
authorizer (Any)
subject (Any)
action (str)
- class needtoknow.llamaindex.LlamaIndexBackend(index)[source]#
A LlamaIndex index (anything with
as_retriever(similarity_top_k=...)) as a backend.- Parameters:
index (Any)
- class needtoknow.haystack.NeedToKnowRetriever(*args, **kwargs)[source]#
Returns at most
top_kdocuments the run’ssubjectmay read, best first.- 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 (Subject)
top_k (int | None)
query_embedding (list[float] | None)
- Return type:
dict[str, Any]
- needtoknow.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, 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. The tool takesqueryandlimit(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)
metadata (Sequence[str])
- Return type:
None
Conformance testing#
See Extending needtoknow.
- needtoknow.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; andasearch, if the backend has one, returns the same ids in the same order;retrieval through
PermissionedRetrieverwithMetadataACLAuthorizernever 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.
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
- needtoknow.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. 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.
- Parameters:
authorizer (Authorizer)
subjects (Sequence[Subject] | None)
hits (Sequence[Hit] | None)
action (str)
seed (int)
- Return type:
None
- exception needtoknow.testing.ConformanceError[source]#
A backend or authorizer disagreed with needtoknow’s reference semantics.
- class needtoknow.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)
- needtoknow.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]