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:
co-authored by
Claude Opus 4.8
parent
b91af923d2
commit
de6c4b360d
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user