feat(search): ADR-027 Phase 2 — file-path filter

Add a path_prefix filter to semantic search, honoured on both the MCP tool and
the dense-only visualization/API paths through the shared filter contract.

- build_base_filter_conditions: append FieldCondition(file_path,
  MatchText(path_prefix)) when set. file_path is only on doc_type == "file"
  points, so a non-empty path_prefix implicitly restricts to files.
- Promote path_prefix to an explicit keyword param on the SearchAlgorithm ABC
  and both algorithms; thread it through nc_semantic_search (blank ⇒ no filter),
  the /api/v1 search endpoints, and the viz route.
- Add a file_path TEXT payload index to _PAYLOAD_INDEX_FIELDS (no content
  re-index; idempotent startup migration). MatchText tokenizes on server Qdrant
  and matches by substring on local/embedded qdrant-client — both serve folder
  scoping.
- Update ADR-027 (Phase 2 implemented; readiness table; semantics note). Tests.

Refs ADR-027 Phase 2. Deck #177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-03 00:51:12 +02:00
co-authored by Claude Opus 4.8
parent c2c8dc1a08
commit ab128bef5b
11 changed files with 121 additions and 10 deletions
@@ -225,6 +225,8 @@ async def unified_search(request: Request) -> JSONResponse:
include_pca = body.get("include_pca", False)
include_chunks = body.get("include_chunks", True)
doc_types = body.get("doc_types") # Optional filter
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter.
path_prefix = (body.get("path_prefix") or "").strip() or None
if not query:
return JSONResponse({"results": [], "total_found": 0})
@@ -266,6 +268,7 @@ async def unified_search(request: Request) -> JSONResponse:
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
)
# Sort, then cap to a fixed over-fetch budget before the result
@@ -285,6 +288,7 @@ async def unified_search(request: Request) -> JSONResponse:
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
return results
@@ -436,6 +440,8 @@ async def vector_search(request: Request) -> JSONResponse:
limit = min(body.get("limit", 10), 50) # Enforce max limit
include_pca = body.get("include_pca", True)
doc_types = body.get("doc_types") # Optional list of document types
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter.
path_prefix = (body.get("path_prefix") or "").strip() or None
# ADR-027 modified-date range filter. Accepts RFC 3339 / ISO 8601
# datetimes or Unix seconds; normalized to int Unix seconds. None ⇒ open.
try:
@@ -501,6 +507,7 @@ async def vector_search(request: Request) -> JSONResponse:
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
)
# Sort merged results by score and limit
@@ -515,6 +522,7 @@ async def vector_search(request: Request) -> JSONResponse:
accessible_owners=owners,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
return results
+5
View File
@@ -144,6 +144,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
doc_types_param = request.query_params.get("doc_types", "")
doc_types = doc_types_param.split(",") if doc_types_param else None
# ADR-027 Phase 2 path filter (files only); blank ⇒ no filter.
path_prefix = (request.query_params.get("path_prefix") or "").strip() or None
# Parse ADR-027 modified-date range filter. Accepts RFC 3339 / ISO 8601
# datetimes or Unix seconds; normalized to int Unix seconds. Absent ⇒
# open-ended. Unparseable input or an inverted range returns 400.
@@ -232,6 +235,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
accessible_owners=accessible_owners,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
all_results.extend(unverified_results)
else:
@@ -254,6 +258,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
accessible_owners=accessible_owners,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
all_results.extend(unverified_results)
# Sort by score, then cap to the same limit*2 over-fetch budget
@@ -34,6 +34,7 @@ from qdrant_client.models import (
FieldCondition,
Filter,
MatchAny,
MatchText,
MatchValue,
Range,
)
@@ -194,6 +195,7 @@ def build_base_filter_conditions(
doc_type: str | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
) -> list[Condition]:
"""Build the common ``must`` conditions shared by every search algorithm.
@@ -209,6 +211,7 @@ def build_base_filter_conditions(
2. ``build_ownership_filter(...)`` — ACL-aware ``owner_id``/``user_id`` scope.
3. ``doc_type`` exact match — only when ``doc_type`` is truthy.
4. ``modified_at`` range — only when at least one bound is given.
5. ``file_path`` text match — only when ``path_prefix`` is given.
Args:
user_id: Querying user.
@@ -217,6 +220,16 @@ def build_base_filter_conditions(
doc_type: Optional single document-type filter.
modified_after: Inclusive lower bound on ``modified_at`` (Unix seconds).
modified_before: Inclusive upper bound on ``modified_at`` (Unix seconds).
path_prefix: Optional folder/path filter on the ``file_path`` payload
field (ADR-027 Phase 2). Implemented with ``MatchText`` against the
text-indexed ``file_path``. ``file_path`` is only written for
``doc_type == "file"`` points, so a non-empty ``path_prefix``
implicitly restricts results to files. NOTE the match semantics
differ by backend: server Qdrant tokenizes (AND-of-tokens, so
``"/Projects/Reports"`` matches files whose path contains both the
``Projects`` and ``Reports`` tokens), while the local/embedded
qdrant-client matches by substring containment. Both serve folder
scoping; neither is a strict left-anchored prefix.
Returns:
A list of Qdrant ``Condition`` objects for a parent ``must`` clause.
@@ -242,4 +255,9 @@ def build_base_filter_conditions(
)
)
if path_prefix:
conditions.append(
FieldCondition(key="file_path", match=MatchText(text=path_prefix))
)
return conditions
@@ -291,6 +291,7 @@ class SearchAlgorithm(ABC):
accessible_owners: list[str] | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute search with the given parameters.
@@ -312,6 +313,10 @@ class SearchAlgorithm(ABC):
``accessible_owners`` (ADR-027). ``None`` ⇒ open-ended.
modified_before: Optional inclusive upper bound on ``modified_at``
(Unix seconds, UTC). ``None`` ⇒ open-ended.
path_prefix: Optional folder/path filter on the ``file_path`` payload
field (ADR-027 Phase 2). Only ``doc_type == "file"`` points carry
``file_path``, so a non-empty value implicitly restricts results
to files. ``None`` ⇒ no path filter.
**kwargs: Algorithm-specific parameters
Returns:
@@ -74,6 +74,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
accessible_owners: list[str] | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""
@@ -99,6 +100,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
modified_before: Inclusive upper bound on ``modified_at`` (Unix
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
path_prefix: Folder/path filter on ``file_path`` (files only);
``None`` ⇒ no path filter (ADR-027 Phase 2).
**kwargs: Additional parameters (score_threshold override)
Returns:
@@ -148,6 +151,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
doc_type=doc_type,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
query_filter = Filter(must=filter_conditions)
+4
View File
@@ -54,6 +54,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
accessible_owners: list[str] | None = None,
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute semantic search using vector similarity.
@@ -78,6 +79,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
modified_before: Inclusive upper bound on ``modified_at`` (Unix
seconds, UTC); ``None`` ⇒ open-ended (ADR-027).
path_prefix: Folder/path filter on ``file_path`` (files only);
``None`` ⇒ no path filter (ADR-027 Phase 2).
**kwargs:
- score_threshold (float): override the instance default
@@ -118,6 +121,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
doc_type=doc_type,
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
)
# ACL pre-filter (design §11), opt-in via ACL_PREFILTER_ENABLED and OFF
+22
View File
@@ -84,6 +84,17 @@ def configure_semantic_tools(mcp: FastMCP):
),
),
] = None,
path_prefix: Annotated[
str | None,
Field(
description=(
"Restrict to files under this folder/path "
"(e.g. '/Projects/Reports'). Matches the file_path of "
"indexed files only, so setting it implicitly limits "
"results to files. None = no path filter."
),
),
] = None,
) -> SemanticSearchResponse:
"""
Search Nextcloud content using BM25 hybrid search with cross-app support.
@@ -116,6 +127,9 @@ def configure_semantic_tools(mcp: FastMCP):
modified_before: Only return documents whose last-modified time is at or before this
instant. Same formats as modified_after. None = no upper bound (default). Must be
>= modified_after when both are supplied.
path_prefix: Restrict to files under this folder/path (e.g. "/Projects/Reports").
Matches the file_path of indexed files only — setting it implicitly limits results
to files. None = no path filter (default).
Returns:
SemanticSearchResponse with matching documents ranked by fusion scores.
@@ -186,6 +200,12 @@ def configure_semantic_tools(mcp: FastMCP):
)
)
# Treat a blank/whitespace path_prefix as "no filter" so an empty UI
# field doesn't filter out every result (ADR-027 Phase 2).
path_prefix = path_prefix.strip() if path_prefix else None
if not path_prefix:
path_prefix = None
# Expand the caller's identity to every owner whose content they
# have read access to via Nextcloud shares. Lets a user find files
# owners have shared with them without having to re-index those
@@ -232,6 +252,7 @@ def configure_semantic_tools(mcp: FastMCP):
accessible_owners=accessible_owners,
modified_after=modified_after_ts,
modified_before=modified_before_ts,
path_prefix=path_prefix,
)
all_results.extend(unverified_results)
else:
@@ -259,6 +280,7 @@ def configure_semantic_tools(mcp: FastMCP):
accessible_owners=accessible_owners,
modified_after=modified_after_ts,
modified_before=modified_before_ts,
path_prefix=path_prefix,
)
all_results.extend(unverified_results)
@@ -62,6 +62,14 @@ _PAYLOAD_INDEX_FIELDS: dict[str, PayloadSchemaType] = {
# is idempotent, so existing collections gain this index at startup with no
# content re-index and no operator action.
"modified_at": PayloadSchemaType.INTEGER,
# file_path is the ADR-027 Phase 2 path filter field: searches apply
# MatchText(key="file_path", text=path_prefix) (see
# search/access_filter.build_base_filter_conditions). MatchText needs a TEXT
# index on server Qdrant; the value is already on every file point
# (processor.py, doc_type == "file" only), so this is a no-content-re-index
# migration like modified_at. Local/embedded qdrant-client matches by
# substring without an index, so dev stacks work without it too.
"file_path": PayloadSchemaType.TEXT,
}
# Sentinel point that records "this collection has been backfilled to str