- visualization.py: drop the CSV string-split branch. The Astrolabe PHP client sends path_prefixes as a JSON array, so only a list is accepted; any other shape is ignored rather than comma-split (which would corrupt folder names containing commas). - viz_routes.py: split the path_prefixes query param on newline (a comma is a valid POSIX path char; a newline is not) and pass None instead of [""] when the param is absent. - access_filter.py: widen build_base_filter_conditions' path_prefixes to Iterable[str] for consistency with normalize_path_prefixes. - ADR-027: document the newline delimiter (frontend/viz route) and JSON array (PHP->MCP body), and the PHP-side cap on list width. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
ADR-027: Rich Search Filters for Semantic Search
Status: Accepted (Phases 1–2 implemented; Phase 3 deferred) Date: 2026-06-02 Depends On: ADR-012 (Unified Multi-Algorithm Search), ADR-014 (BM25 Search), ADR-019 (Verify-on-Read for Semantic Search) Tracking: Astrolabe Cloud POC Deck card #177
Context
nc_semantic_search today exposes one structured filter — doc_types — on top of the query
string. Everything else a user might want to narrow by (when a document was last modified, which
folder it lives in, which tags it carries) is invisible to the search layer. As Astrolabe's corpus
grows across Notes, Files (PDFs), Deck cards, and News items, a single relevance ranking over the
whole index is increasingly blunt: the user knows "the spec I edited last week, somewhere under
/Projects" but can only type words and hope.
The Astrolabe PHP app surfaces semantic search through a plain NcTextField plus a doc-type
checkbox grid (astrolabe/src/App.vue). There is no visual affordance for any other dimension. We
want to add rich, visually-indicated filters — modelled on Nextcloud Unified Search's
filter-chip interaction — and weave them through the search backend without disturbing the
existing fusion + verify-on-read pipeline.
This ADR defines:
- The contract for how a structured filter travels from the MCP tool signature down to a
Qdrant
FieldCondition(so every future filter follows one pattern). - The payload-readiness of each desired filter, which drives a phased rollout.
- What the frontend sends and how it presents active filters.
How filtering works today (the pattern to generalise)
A single filter — doc_type — already threads through three layers. New filters mirror it exactly.
-
MCP tool signature —
nextcloud_mcp_server/server/semantic.py(nc_semantic_search) acceptsdoc_types: list[str] | Noneand dispatches onesearch_algo.search(...)call per type (or one call withdoc_type=Nonefor cross-app search). -
Algorithm —
nextcloud_mcp_server/search/bm25_hybrid.pysearch()receivesdoc_typeand builds the Qdrant filter:filter_conditions = [ get_placeholder_filter(), # exclude pending placeholders build_ownership_filter(user_id, accessible_owners), # ACL ] if doc_type: filter_conditions.append( FieldCondition(key="doc_type", match=MatchValue(value=doc_type)) ) query_filter = Filter(must=filter_conditions) -
Qdrant query —
query_filteris passed to both the dense and sparsePrefetchbranches of thequery_pointscall, so the filter applies before fusion. Filtering before fusion (not after) keeps thelimit * 2candidate pools meaningful and avoids returning fewer thanlimitresults when a filter is selective.
build_ownership_filter (search/access_filter.py) and get_placeholder_filter
(vector/placeholder.py) demonstrate the full matcher vocabulary we will reuse: MatchValue
(exact), MatchAny (OR-list), Range (numeric bounds), and Filter(must=...) / Filter(should=...)
for AND / OR composition.
Payload readiness governs what we can ship
Filters can only be applied to fields that exist in the Qdrant payload (built in
nextcloud_mcp_server/vector/processor.py). Auditing the payload schema:
| Desired filter | Payload field | Type | Status |
|---|---|---|---|
| Modified-date range | modified_at |
int (Unix ts) |
✅ Ready — value present on every point; needs a payload index (added in Phase 1, no content re-index) |
| Document type | doc_type |
keyword-indexed str |
✅ Implemented |
| Directory / path | file_path (files only) |
list[str] (multi-folder) |
✅ Implemented (Phase 2) — TEXT payload index (no content re-index); one or more folders filtered with MatchText, multiple OR-ed via nested Filter(should=...); picked from the native folder browser |
| Tags | — | — | ❌ Not indexed — no tags field is written during scanning |
| Category (notes) | — | — | ❌ Not in payload — fetched from the Notes API at verify time only |
modified_at is a deliberate cross-app normalization the scanner already performs: the Notes API
and Deck return int Unix seconds natively, News timestamps are unit-normalized to seconds, and
WebDAV's RFC-1123 getlastmodified string is parsed to int(dt.timestamp())
(client/webdav.py). One int field therefore covers every doc type.
Two consequences:
modified_atis the cheap win — but it still needs a payload index. The value is on every point, so no content re-index is required. However, Qdrant only evaluates aRangeefficiently against an indexed field; without an index every dated query full-scans the collection (and 400s on Qdrant Cloud strict payload-validation mode). Phase 1 therefore adds a singlemodified_at: INTEGERentry to_PAYLOAD_INDEX_FIELDS(vector/qdrant_client.py); the existing idempotent_ensure_payload_indexes()startup path creates it on new and existing collections with no operator action.INTEGER(notFLOAT) because the stored value is an int Unix-second timestamp.- Why not a Qdrant
datetimeindex? Qdrant'sdatetimeindex /DatetimeRangewould let us filter with RFC 3339 strings directly, but its indexer only ingests string payload values (value.as_str()in Qdrant'snumeric_index/value_indexer.rs) — it silently skips integer payloads. Adopting it would mean re-storingmodified_atas RFC 3339 strings on every point, i.e. the full re-index Phase 1 is designed to avoid. We instead keep int storage + a numericRangeand accept RFC 3339 only at the request boundary (see §3). - Tags / path / category are not free.
file_pathfiltering needs a Qdrant payload index beforeMatchText/prefix matching is performant;tagsandcategoryare not in the payload at all and require extendingprocessor.pyplus a full re-index. Conflating these with the date filter would make a small UX improvement wait on an expensive indexing migration.
Decision
1. Generalise the filter contract through one shared helper
Every structured filter follows the doc_type path: tool parameter → explicit search()
keyword arg → FieldCondition in the shared filter_conditions builder → Filter(must=[...])
on both prefetch branches. Filters are always applied at the Qdrant layer, before
verify-on-read (ADR-019), so that verified_chunk_count / dropped_document_count describe the
already-filtered set and the verifier never wastes Nextcloud round-trips on documents the filter
excluded.
The filter is added to the SearchAlgorithm ABC contract, not just one algorithm. The two
algorithms that back the search surfaces — BM25HybridSearchAlgorithm (the MCP tool path,
nc_semantic_search) and SemanticSearchAlgorithm (the dense-only visualization / /api/v1
path, api/visualization.py + auth/viz_routes.py) — today build a byte-identical
placeholder + ownership + doc_type filter block. Rather than copy the new condition into both,
the common block moves into one helper, search/access_filter.py::build_base_filter_conditions,
and both algorithms call it. New filters are therefore honoured on every path (hybrid and
dense-only) by editing one function:
# search/access_filter.py — the single source of the filter contract
def build_base_filter_conditions(
user_id, accessible_owners=None, doc_type=None,
modified_after=None, modified_before=None,
) -> list[Condition]:
conditions = [get_placeholder_filter(), build_ownership_filter(user_id, accessible_owners)]
if doc_type:
conditions.append(FieldCondition(key="doc_type", match=MatchValue(value=doc_type)))
if modified_after is not None or modified_before is not None:
conditions.append(
FieldCondition(
key="modified_at",
range=Range(gte=modified_after, lte=modified_before), # None bounds are open-ended
)
)
return conditions
Range treats None bounds as open, so the same condition serves after-only, before-only, and
both-bounds queries. New range/match filters are added here once; each algorithm wraps the
returned list in Filter(must=...) and may append its own additive conditions afterward (e.g.
SemanticSearchAlgorithm's opt-in ACL pre-filter, which BM25HybridSearchAlgorithm does not
apply).
modified_after / modified_before are promoted to explicit named keyword params on
SearchAlgorithm.search() (and both concrete impls), exactly as accessible_owners was — not
left in **kwargs. Explicit params keep them discoverable and make a misspelled keyword a type
error instead of a silently-ignored filter. When doc_types is a list, the tool's per-type
dispatch loop forwards the same modified_after/modified_before to each per-type
search() call.
Input validation. FastMCP exposes no request-model object to hang a Pydantic
@model_validator on, so validation is split across three layers, each handling what it can
express:
- Per-argument scalar bounds use
Annotated[..., Field(...)]on the tool signature — FastMCP builds the input schema from these and rejects bad values before the body runs. This is the repo's first use ofAnnotated/Fieldon a tool; the existing numeric knobs (limitge=1,score_threshold0.0–1.0,context_charsge=0) are tightened the same way and the pattern is what future filters reuse. - Format normalization for the date bounds.
modified_after/modified_beforeare typedstr | int | Noneand accept an RFC 3339 / ISO 8601 datetime (e.g."2026-01-01T00:00:00Z", naive ⇒ UTC) or Unix seconds. A sharedutils/validation.py::parse_modified_timestamphelper normalizes either to int Unix seconds (the payload representation) and raisesValueErroron an unparseable value; the tool converts that toMcpError, the HTTP endpoints to a 400. The same helper is reused bync_semantic_search, the/api/v1search endpoints, and the viz route so every surface accepts identical formats. - The cross-field invariant
modified_after <= modified_beforecan't be a per-field constraint, so it is an explicit guard (on the parsed int values) that raisesMcpError(ErrorData(code=-1, ...))in the tool — the established input-error idiom (mirrors theVECTOR_SYNC_ENABLEDguard) — and a 400 on the HTTP endpoints.
2. Phase the rollout by payload readiness
- Phase 1 — modified-date range (this ADR's committed scope). Add
modified_after/modified_before(RFC 3339 / ISO 8601 at the boundary, Unix seconds accepted too) to theSearchAlgorithmcontract and both algorithm impls (so the MCP tool and the dense-only/api/v1path honour it), plus amodified_atINTEGER payload index. No content re-index. Ship the frontend chip UX against this plus the existing doc-type filter to prove the end-to-end plumbing on fields that already exist.nc_semantic_search_answeris explicitly deferred, not part of Phase 1. It is a thin RAG wrapper that today threads onlyquery/limit/score_threshold/fusion/context options tonc_semantic_searchand does not even exposedoc_types— it always searches cross-app. Date scoping is a search-box affordance, not an answer-synthesis one, and there is no UI surface for it on the answer path. When demand appears it can be threaded through using exactly the same parameter +parse_modified_timestamppattern; doing it now would add an unused parameter and widen the change for no user-visible gain.
- Phase 2 — directory / path (implemented). Threaded through the same shared contract
(
build_base_filter_conditions) and backed by afile_pathTEXT payload index in_PAYLOAD_INDEX_FIELDS(no content re-index —file_pathis already on every file point).MatchTextsemantics differ by backend: server Qdrant tokenizes (AND-of-tokens, so/Projects/Reportsmatches files whose path contains both theProjectsandReportstokens), while local/embedded qdrant-client matches by substring containment — both serve folder scoping, neither is a strict left-anchored prefix (a future strict-prefix would need an indexed ancestor-path array, i.e. a re-index, so it stays out of Phase 2). Becausefile_pathis only written fordoc_type == "file", a non-empty path filter implicitly restricts results to files.- Multi-folder (list-valued). The filter accepts one or more folders via a
path_prefixes: list[str]parameter (the original singlepath_prefixis retained for backward compatibility and folded into the list bynormalize_path_prefixes, which trims, drops blanks, and de-dupes).build_base_filter_conditionsadds a singleMatchTextto themustclause for one folder, and OR-s multiple folders via a nestedFilter(should=[...])so a file under any selected folder matches while still AND-ing against the ACL/doc_type/date conditions. Every search surface parses the list: the MCP tool (nc_semantic_search) takes a reallist[str], the visualization API takes a JSON array body, and the viz route takes a newline-separated query param. Newline (not comma) is the on-the-wire delimiter because it can't appear in a POSIX path, so folder names are never split mid-value. - Frontend uses the native folder picker. Instead of a free-text path input, the Astrolabe
app opens Nextcloud's server-side folder browser via
getFilePickerBuilder()from@nextcloud/dialogs(already a dependency — no@nextcloud/vuecomponent-version coupling), configured directory-only + multi-select. Picked folders are real, validated server paths (no typos), rendered as removable chips, and sent as a newline-joinedpath_prefixesvalue. The Astrolabe PHPApiControllersplits on newline (capping the list to bound the OR-filter width) andMcpServerClientforwards a JSON array to the MCP server. The control is enabled only when the Files doc type is in scope; an empty selection means "no filter".
- Multi-folder (list-valued). The filter accepts one or more folders via a
- Phase 3 — tags (and optionally category). Add a
tags: list[str]payload field inprocessor.py, propagate Nextcloud system tags during scanning, trigger a re-index, then wireNcSelectTags(MatchAnyover tags). Re-index cost lives here, isolated from the cheap wins.
3. Frontend: filter chips, structured payload
The Astrolabe app adds filter controls to the existing collapsible advanced panel and renders each active filter as a closable chip:
- Modified-date range → two native
<input type="datetime-local">fields (a local wall-clock picker the browser validates), serialized to RFC 3339 UTC viaDate.toISOString(). Phase 1 deliberately uses native inputs + lightweight CSS chips rather than taking a hard dependency on a specific@nextcloud/vuecomponent version (NcDateTimePicker/NcChipremain a later option); this matches the component's existing native<input type="range">controls. - Doc types → existing checkbox grid, now also echoed as chips.
- (Phase 2) path → native folder picker (
getFilePickerBuilderfrom@nextcloud/dialogs), multi-select, rendered as one removable chip per folder. (Phase 3) tags →NcSelectTags :fetch-tags.
Dates cross the wire as RFC 3339 / ISO 8601 strings (e.g. "2026-01-01T00:00:00Z") — the format
Nextcloud's date pickers and Unified Search use. The MCP/HTTP boundary parses RFC 3339 (and bare
Unix seconds, for resilience) to int Unix seconds via parse_modified_timestamp before filtering,
so the friendly wire format never forces a re-index of the integer modified_at payload.
Transport. Phase 1 keeps the existing GET /apps/astrolabe/api/search: the two scalar date
strings ride as query params alongside the already comma-joined doc_types, so no transport change
is needed yet. The move to POST with a JSON body is deferred to Phase 2, when the filter set
becomes genuinely structured/multi-valued (path + tags) and outgrows query params.
The Astrolabe UI validates the date range client-side — the native picker constrains each field
to a real datetime, and performSearch rejects an after > before range before issuing the request
(McpServerClient forwards the RFC 3339 strings; ApiController re-validates and returns a 400 on a
bad/inverted range). The server-side parse_modified_timestamp + cross-field guard is the
authoritative backstop. (Astrolabe-side work is tracked on Deck card #177, label
repo:astrolabe.)
Consequences
Positive
- One filter pattern for the whole search surface; adding a filter is a localized, testable change.
- Phase 1 ships immediately with zero re-index risk and proves the UX contract end-to-end.
- Filtering before verify-on-read keeps ACL/ghost semantics intact and avoids wasted verification round-trips.
- The chip UX matches Nextcloud conventions, so it reads as native to users.
Negative / costs
- Path and tag filters require index work (a payload index; a new payload field + full re-index) that this ADR explicitly defers — the readiness table makes that cost visible rather than implicit.
- The eventual
/api/searchGET→POST migration (Phase 2) will be a breaking change to that endpoint's contract; the PHP app and the MCP backend must ship together when it lands. Phase 1 avoids it by keeping GET. - Pre-fusion filtering on a very selective
Rangecan still under-filllimitif the candidate pool (limit * 2) is exhausted; if this proves a problem we revisit the prefetch multiplier rather than filtering post-fusion.
Neutral
SemanticSearchResponseis unchanged — filters live entirely in the request. MCP clients that ignore the new parameters behave exactly as before (backward compatible).
Alternatives Considered
- Free-text
key:valuequery parsing (modified:>2026-01-01 path:/Projects). Powerful but invites injection-shaped ambiguity and a parser to maintain, and gives no visual affordance for "what can I filter by?". Structured params + chips answer the user's discoverability question directly. Could be layered on later as sugar over the same params. - Post-fusion / client-side filtering (like the current score-threshold slider). Simple, but
defeats the point of a recall layer: the index would return mostly-irrelevant candidates that get
thrown away, and
limitbecomes unpredictable. Rejected in favour of pushing filters into Qdrant. - Indexing everything up front so all filters ship at once. Forces a large re-index and couples a cheap UX win to an expensive migration. Rejected in favour of the readiness-driven phasing.