feat(search): support multiple folders in the semantic-search path filter

Extend the ADR-027 Phase 2 path filter from a single path_prefix to a
list of folders. The new normalize_path_prefixes() helper is the single
source of truth for trimming, dropping blanks, and de-duplicating, and
folds the legacy single path_prefix into the list for backward
compatibility.

build_base_filter_conditions() adds one MatchText to the must clause for
a single folder (unchanged shape) and OR-s multiple folders via a nested
Filter(should=[...]) so a file under any selected folder matches while
still AND-ing against the ACL/doc_type/date conditions.

path_prefixes is threaded through every search surface: the
nc_semantic_search MCP tool, the visualization API (JSON body), and the
viz route (CSV query param). The Astrolabe frontend folder picker that
produces these lists ships in a companion astrolabe PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-03 12:51:37 +02:00
co-authored by Claude Opus 4.8
parent b91af923d2
commit de6c4b360d
9 changed files with 269 additions and 50 deletions
+66 -7
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import logging
import time
from collections import OrderedDict
from collections.abc import Iterable
from typing import Any, Protocol
from qdrant_client.models import (
@@ -189,6 +190,43 @@ def build_ownership_filter(
return Filter(should=conditions)
def normalize_path_prefixes(
path_prefix: str | None = None,
path_prefixes: Iterable[str] | None = None,
) -> list[str]:
"""Merge the legacy single ``path_prefix`` and list ``path_prefixes`` into
one clean, de-duplicated list of folder filters.
Blank/whitespace entries are dropped (an empty UI field must mean "no
filter", not "match everything"), surrounding whitespace is stripped, and
order is preserved while removing duplicates. Accepting both inputs keeps
the pre-ADR-027-Phase-2 single-value contract working while callers migrate
to the multi-folder list.
Args:
path_prefix: Legacy single folder filter (deprecated; folded into the
returned list).
path_prefixes: Zero or more folder filters.
Returns:
Ordered, de-duplicated list of non-empty folder filters (possibly empty).
"""
raw: list[str] = []
if path_prefix:
raw.append(path_prefix)
if path_prefixes:
raw.extend(path_prefixes)
seen: set[str] = set()
cleaned: list[str] = []
for value in raw:
stripped = value.strip()
if stripped and stripped not in seen:
seen.add(stripped)
cleaned.append(stripped)
return cleaned
def build_base_filter_conditions(
user_id: str,
accessible_owners: list[str] | None = None,
@@ -196,6 +234,7 @@ def build_base_filter_conditions(
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
path_prefixes: list[str] | None = None,
) -> list[Condition]:
"""Build the common ``must`` conditions shared by every search algorithm.
@@ -211,7 +250,10 @@ 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.
5. ``file_path`` text match — only when a path filter is given. One folder
adds a single ``MatchText`` to ``must``; multiple folders are OR-ed via a
nested ``Filter(should=[...])`` so a file under *any* selected folder
matches.
Args:
user_id: Querying user.
@@ -220,10 +262,13 @@ 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``
path_prefix: Deprecated single folder/path filter; folded into
``path_prefixes``. Kept for backward compatibility.
path_prefixes: Optional folder/path filters on the ``file_path`` payload
field (ADR-027 Phase 2). Each is implemented with ``MatchText``
against the text-indexed ``file_path`` and multiple folders are
OR-ed together. ``file_path`` is only written for
``doc_type == "file"`` points, so any non-empty path filter
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
@@ -255,9 +300,23 @@ def build_base_filter_conditions(
)
)
if path_prefix:
# One folder ⇒ a single ``must`` condition (the original Phase 2 shape).
# Multiple folders ⇒ OR them in a nested ``Filter(should=...)`` so a file
# under any selected folder matches, while still AND-ing against the other
# ``must`` conditions (ACL, doc_type, date).
folders = normalize_path_prefixes(path_prefix, path_prefixes)
if len(folders) == 1:
conditions.append(
FieldCondition(key="file_path", match=MatchText(text=path_prefix))
FieldCondition(key="file_path", match=MatchText(text=folders[0]))
)
elif len(folders) > 1:
conditions.append(
Filter(
should=[
FieldCondition(key="file_path", match=MatchText(text=folder))
for folder in folders
]
)
)
return conditions
+8 -4
View File
@@ -300,6 +300,7 @@ class SearchAlgorithm(ABC):
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
path_prefixes: list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute search with the given parameters.
@@ -321,10 +322,13 @@ 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.
path_prefix: Deprecated single folder/path filter; folded into
``path_prefixes``. Kept for backward compatibility.
path_prefixes: Optional folder/path filters on the ``file_path``
payload field (ADR-027 Phase 2), OR-ed together. Only
``doc_type == "file"`` points carry ``file_path``, so any
non-empty value implicitly restricts results to files. ``None``
or empty ⇒ no path filter.
**kwargs: Algorithm-specific parameters
Returns:
+7 -2
View File
@@ -75,6 +75,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
path_prefixes: list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""
@@ -100,8 +101,11 @@ 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).
path_prefix: Deprecated single folder filter; folded into
``path_prefixes`` (ADR-027 Phase 2).
path_prefixes: Folder/path filters on ``file_path`` (files only),
OR-ed together; ``None``/empty ⇒ no path filter (ADR-027
Phase 2).
**kwargs: Additional parameters (score_threshold override)
Returns:
@@ -152,6 +156,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
path_prefixes=path_prefixes,
)
query_filter = Filter(must=filter_conditions)
+7 -2
View File
@@ -55,6 +55,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
modified_after: int | None = None,
modified_before: int | None = None,
path_prefix: str | None = None,
path_prefixes: list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute semantic search using vector similarity.
@@ -79,8 +80,11 @@ 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).
path_prefix: Deprecated single folder filter; folded into
``path_prefixes`` (ADR-027 Phase 2).
path_prefixes: Folder/path filters on ``file_path`` (files only),
OR-ed together; ``None``/empty ⇒ no path filter (ADR-027
Phase 2).
**kwargs:
- score_threshold (float): override the instance default
@@ -122,6 +126,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
modified_after=modified_after,
modified_before=modified_before,
path_prefix=path_prefix,
path_prefixes=path_prefixes,
)
# ACL pre-filter (design §11), opt-in via ACL_PREFILTER_ENABLED and OFF