feat(search): ADR-027 Phase 1 — modified-date range filter
Add a modified_after/modified_before date-range filter to semantic search, honoured on both the MCP tool path (BM25HybridSearchAlgorithm) and the dense-only visualization/API path (SemanticSearchAlgorithm) through one shared contract. - Promote modified_after/modified_before to explicit keyword params on the SearchAlgorithm ABC and both concrete algorithms; factor the shared placeholder+ownership+doc_type+date filter into access_filter.build_base_filter_conditions so new filters land in one place. - nc_semantic_search: accept RFC 3339 / ISO 8601 (or Unix seconds) bounds via utils.validation.parse_modified_timestamp; Annotated/Field constraints on the numeric args; explicit McpError guard for after > before. Thread the parsed bounds through the cross-app and per-doc_type dispatch. - /api/v1 search endpoints + viz route parse the same formats and 400 on bad or inverted ranges. - Add a modified_at INTEGER payload index to _PAYLOAD_INDEX_FIELDS; the idempotent _ensure_payload_indexes() startup path migrates existing collections with no content re-index. - Update ADR-027 to resolve the review feedback (validation placement, shared algorithm contract, deferral of nc_semantic_search_answer, payload index, RFC-3339-at-the-boundary rationale). Add unit tests. Refs ADR-027. Deck #177. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f6ab04b2d9
commit
c2c8dc1a08
@@ -1,6 +1,6 @@
|
||||
# ADR-027: Rich Search Filters for Semantic Search
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Accepted (Phase 1 implemented; Phases 2–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
|
||||
@@ -66,16 +66,33 @@ Filters can only be applied to fields that exist in the Qdrant payload (built in
|
||||
|
||||
| Desired filter | Payload field | Type | Status |
|
||||
|---|---|---|---|
|
||||
| Modified-date range | `modified_at` | `int` (Unix ts) | ✅ **Ready** — numeric, range-filterable today |
|
||||
| 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) | `str` | ⚠️ Stored but **not keyword-indexed** — prefix/match needs a payload index |
|
||||
| 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_at` is the cheap win.** It is already a numeric Unix timestamp on every point, so a
|
||||
`Range` condition works against the existing index with no re-index.
|
||||
- **`modified_at` is 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 a `Range`
|
||||
efficiently 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
|
||||
single `modified_at: INTEGER` entry 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` (not `FLOAT`)
|
||||
because the stored value is an int Unix-second timestamp.
|
||||
- **Why not a Qdrant `datetime` index?** Qdrant's `datetime` index / `DatetimeRange` would let us
|
||||
filter with RFC 3339 strings directly, but its indexer only ingests *string* payload values
|
||||
(`value.as_str()` in Qdrant's `numeric_index/value_indexer.rs`) — it silently skips integer
|
||||
payloads. Adopting it would mean re-storing `modified_at` as RFC 3339 strings on every point,
|
||||
i.e. the full re-index Phase 1 is designed to avoid. We instead keep int storage + a numeric
|
||||
`Range` and accept RFC 3339 only at the request boundary (see §3).
|
||||
- **Tags / path / category are not free.** `file_path` filtering needs a Qdrant payload index
|
||||
before `MatchText`/prefix matching is performant; `tags` and `category` are not in the payload at
|
||||
all and require extending `processor.py` plus a full re-index. Conflating these with the date
|
||||
@@ -83,40 +100,97 @@ Two consequences:
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Generalise the filter contract
|
||||
### 1. Generalise the filter contract through one shared helper
|
||||
|
||||
Every structured filter follows the `doc_type` path: **tool parameter → `search()` keyword arg →
|
||||
`FieldCondition` appended to `filter_conditions` → `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.
|
||||
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.
|
||||
|
||||
Date/range bounds use `qdrant_client.models.Range`:
|
||||
**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:
|
||||
|
||||
```python
|
||||
from qdrant_client.models import FieldCondition, Range
|
||||
|
||||
if modified_after is not None or modified_before is not None:
|
||||
filter_conditions.append(
|
||||
FieldCondition(
|
||||
key="modified_at",
|
||||
range=Range(gte=modified_after, lte=modified_before), # None bounds are open-ended
|
||||
# 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. Validation that `modified_after <= modified_before` lives in the Pydantic
|
||||
request model, not the algorithm.
|
||||
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 of `Annotated`/`Field` on a tool; the existing numeric knobs (`limit` `ge=1`,
|
||||
`score_threshold` `0.0–1.0`, `context_chars` `ge=0`) are tightened the same way and the pattern
|
||||
is what future filters reuse.
|
||||
- **Format normalization for the date bounds.** `modified_after` / `modified_before` are typed
|
||||
`str | int | None` and accept an **RFC 3339 / ISO 8601 datetime** (e.g. `"2026-01-01T00:00:00Z"`,
|
||||
naive ⇒ UTC) *or* Unix seconds. A shared `utils/validation.py::parse_modified_timestamp` helper
|
||||
normalizes either to int Unix seconds (the payload representation) and raises `ValueError` on an
|
||||
unparseable value; the tool converts that to `McpError`, the HTTP endpoints to a 400. The same
|
||||
helper is reused by `nc_semantic_search`, the `/api/v1` search endpoints, and the viz route so
|
||||
every surface accepts identical formats.
|
||||
- **The cross-field invariant `modified_after <= modified_before`** can't be a per-field
|
||||
constraint, so it is an explicit guard (on the *parsed* int values) that raises
|
||||
`McpError(ErrorData(code=-1, ...))` in the tool — the established input-error idiom (mirrors the
|
||||
`VECTOR_SYNC_ENABLED` guard) — 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` (Unix seconds, UTC) to `nc_semantic_search` and `bm25_hybrid.search()`. No
|
||||
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.
|
||||
`modified_before` (RFC 3339 / ISO 8601 at the boundary, Unix seconds accepted too) to the
|
||||
`SearchAlgorithm` contract and both algorithm impls (so the MCP tool *and* the dense-only
|
||||
`/api/v1` path honour it), plus a `modified_at` INTEGER 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_answer` is explicitly deferred, not part of Phase 1.** It is a thin RAG
|
||||
wrapper that today threads only `query`/`limit`/`score_threshold`/`fusion`/context options to
|
||||
`nc_semantic_search` and does not even expose `doc_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_timestamp` pattern; doing it now would add an unused parameter and
|
||||
widen the change for no user-visible gain.
|
||||
- **Phase 2 — directory / path.** Create a Qdrant payload index on `file_path`, add a `path_prefix`
|
||||
parameter, and add an `NcFilePicker` folder chooser. Scoped to `doc_type == "file"`.
|
||||
parameter, and add an `NcFilePicker` folder chooser. Scoped to `doc_type == "file"`: the frontend
|
||||
must **hide/disable the path filter unless the file doc type is selected**, because `file_path`
|
||||
is only written to the payload for `doc_type == "file"` (`processor.py`) — a path condition on
|
||||
any other type matches nothing and silently returns zero results.
|
||||
- **Phase 3 — tags (and optionally category).** Add a `tags: list[str]` payload field in
|
||||
`processor.py`, propagate Nextcloud system tags during scanning, trigger a re-index, then wire
|
||||
`NcSelectTags` (`MatchAny` over tags). Re-index cost lives here, isolated from the cheap wins.
|
||||
@@ -124,17 +198,32 @@ request model, not the algorithm.
|
||||
### 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 `NcChip` (the same component Nextcloud Unified Search uses):
|
||||
**active** filter as a closable chip:
|
||||
|
||||
- Modified-date range → `NcDateTimePicker type="datetime-range"` (model is `[Date, Date]`).
|
||||
- Modified-date range → two native `<input type="datetime-local">` fields (a local wall-clock
|
||||
picker the browser validates), serialized to RFC 3339 UTC via `Date.toISOString()`. Phase 1
|
||||
deliberately uses native inputs + lightweight CSS chips rather than taking a hard dependency on a
|
||||
specific `@nextcloud/vue` component version (`NcDateTimePicker` / `NcChip` remain 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/3) path → `NcFilePicker`; tags → `NcSelectTags :fetch-tags`.
|
||||
|
||||
The `/apps/astrolabe/api/search` endpoint moves from `GET` with query params to **`POST` with a JSON
|
||||
body**, because the filter set is structured and multi-valued and will keep growing. Dates are sent
|
||||
as **Unix seconds (UTC)** to match the `modified_at` payload representation exactly — no timezone or
|
||||
string-parsing ambiguity crosses the wire. Empty or partially-filled filters are omitted from the
|
||||
body rather than sent as nulls.
|
||||
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
|
||||
|
||||
@@ -150,8 +239,9 @@ body rather than sent as nulls.
|
||||
|
||||
- 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.
|
||||
- Moving `/api/search` to POST is a breaking change to that endpoint's contract; the PHP app and the
|
||||
MCP backend must ship together.
|
||||
- The eventual `/api/search` GET→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 `Range` can still under-fill `limit` if the candidate
|
||||
pool (`limit * 2`) is exhausted; if this proves a problem we revisit the prefetch multiplier
|
||||
rather than filtering post-fusion.
|
||||
|
||||
Reference in New Issue
Block a user