Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

### Features

- **#1558**: `QUERY /v2/search/` (and `POST /v2/search/` for clients that cannot send
QUERY) searches an explicit set of projects in one database with one query. The body
is the project search body plus `project_ids`, a required list of internal ids the
caller has already authorized; an empty list answers no rows and there is no way to
ask for every project. Full-text, vector, and hybrid retrieval run the same reader
the project route runs, so one project here ranks exactly as its own route does, and
results are one ranking over the union rather than merged per-project pages. Hits are
hydrated only from projects in scope. Every search result, on both routes, now
carries `project_id` and `project_external_id`.

- **#1512**: Word, PowerPoint, and CSV files get the same sidecar Markdown note a
PDF gets. `bm import document <path>` indexes the project, extracts the file,
and writes `<file>.<ext>.md` next to it plus a run note under
Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
knowledge_router as v2_knowledge,
project_router as v2_project,
memory_router as v2_memory,
scoped_search_router as v2_scoped_search,
search_router as v2_search,
resource_router as v2_resource,
directory_router as v2_directory,
Expand Down Expand Up @@ -136,6 +137,8 @@ async def workspace_permalink_context_middleware(request: Request, call_next):
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
app.include_router(v2_inspect, prefix="/v2/projects/{project_id}")
app.include_router(v2_project, prefix="/v2")
# Database-scoped search: one query over an explicit set of projects.
app.include_router(v2_scoped_search, prefix="/v2")

# Legacy web app proxy paths (compat with /proxy/projects/projects)
app.include_router(v2_project, prefix="/proxy/projects")
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/api/v2/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
from basic_memory.api.v2.routers.project_router import router as project_router
from basic_memory.api.v2.routers.memory_router import router as memory_router
from basic_memory.api.v2.routers.scoped_search_router import router as scoped_search_router
from basic_memory.api.v2.routers.search_router import router as search_router
from basic_memory.api.v2.routers.resource_router import router as resource_router
from basic_memory.api.v2.routers.directory_router import router as directory_router
Expand All @@ -22,6 +23,7 @@
"knowledge_router",
"project_router",
"memory_router",
"scoped_search_router",
"search_router",
"resource_router",
"directory_router",
Expand Down
114 changes: 114 additions & 0 deletions src/basic_memory/api/v2/routers/scoped_search_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Database-scoped search: one query over an explicit set of projects.

The caller names the projects. Nothing here discovers a scope, and an empty set
answers no rows; Cloud resolves authorization first and passes the effective ids.
The route shares the project route's reader, so one project here ranks exactly as
that project's own route does, and several projects are one ranking over the union
rather than per-project pages merged afterwards.
"""

import asyncio

import logfire
from fastapi import APIRouter, Query, Response

from basic_memory import db
from basic_memory.api.v2.utils import (
load_temporal_metadata,
search_error_boundary,
to_search_results,
)
from basic_memory.deps import AppConfigDep, SessionMakerDep
from basic_memory.repository.search_repository import create_search_reader
from basic_memory.repository.search_scope import ProjectScope
from basic_memory.schemas.search import ScopedSearchQuery, SearchResponse, SearchRetrievalMode
from basic_memory.services.scoped_search_service import ScopedSearchService

# App registration mounts this router at /v2.
router = APIRouter(tags=["search"])


@router.api_route(
"/search/",
methods=["QUERY"],
response_model=SearchResponse,
include_in_schema=False,
)
@router.post("/search/", response_model=SearchResponse)
async def search_scope(
query: ScopedSearchQuery,
app_config: AppConfigDep,
session_maker: SessionMakerDep,
response: Response,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=1000),
) -> SearchResponse:
"""Search an explicit set of projects in this database.

Results are one ranking across the whole scope and carry ``project_id`` and
``project_external_id``. Hydration reads only from projects in scope.
"""
response.headers["Accept-Query"] = "application/json"
# The read cache is keyed by one project's generation. A set of projects has no
# single generation to invalidate on, so this route is never cached.
response.headers["Cache-Control"] = "no-store"

scope = ProjectScope.of(query.project_ids)
service = ScopedSearchService(
session_maker, scope, create_search_reader(session_maker, scope, app_config)
)
temporal_requested = query.has_temporal_filter()
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
offset = (page - 1) * page_size

with logfire.span(
"api.request.search_scope",
entrypoint="api",
domain="search",
action="search_scope",
project_count=len(scope.project_ids),
page=page,
page_size=page_size,
retrieval_mode=query.retrieval_mode.value,
has_temporal_filter=temporal_requested,
):
with search_error_boundary():
if exact_count_available:
results, total = await asyncio.gather(
service.search(query, limit=page_size, offset=offset),
service.count(query),
)
has_more = offset + len(results) < total
else:
# Trigger: semantic modes would need another vector or hybrid pass to count.
# Why: a search should not pay for a second semantic retrieval.
# Outcome: probe one row past the page, leave total at 0, mark it inexact.
results = await service.search(query, limit=page_size + 1, offset=offset)
total = 0
has_more = len(results) > page_size
results = results[:page_size]

temporal_by_source = {}
project_external_ids: dict[int, str] = {}
if results:
async with db.scoped_session(session_maker) as session:
project_external_ids = await service.project_external_ids(session, results)
if temporal_requested:
temporal_by_source = await load_temporal_metadata(service, session, results)
search_results = await to_search_results(
service,
results,
temporal_by_source=temporal_by_source,
project_external_ids=project_external_ids,
)
return SearchResponse(
results=search_results,
current_page=page,
page_size=page_size,
total=total,
total_is_exact=exact_count_available,
has_more=has_more,
# None, not False, when nothing was asked: an ordinary search payload stays
# exactly what it was before valid time existed.
temporal_applied=True if temporal_requested else None,
)
37 changes: 12 additions & 25 deletions src/basic_memory/api/v2/routers/search_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
from contextlib import nullcontext
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Path, Response
from fastapi import APIRouter, Depends, Path, Response

import logfire
from basic_memory import db
from basic_memory.api.v2.utils import load_temporal_metadata, to_search_results
from basic_memory.api.v2.utils import (
load_temporal_metadata,
search_error_boundary,
to_search_results,
)
from basic_memory.deps import (
EntityServiceV2ExternalDep,
MemoryTimeIndexRepositoryV2ExternalDep,
Expand All @@ -32,12 +36,6 @@
read_cache_request_digest,
)
from basic_memory.read_cache.policy import SEARCH_READ_CACHE_TTL_SECONDS
from basic_memory.repository.semantic_errors import (
RerankProviderContractError,
RerankTransientError,
SemanticDependenciesMissingError,
SemanticSearchDisabledError,
)
from basic_memory.schemas.search import SearchQuery, SearchResponse, SearchRetrievalMode
from basic_memory.services.search_guidance import unspaced_script_query_hint

Expand Down Expand Up @@ -91,6 +89,7 @@ async def search(
session_maker: SessionMakerDep,
read_cache: SearchReadCacheDep,
response: Response,
internal_project_id: ProjectExternalIdPathDep,
project_id: str = Path(..., description="Project external UUID"),
page: int = 1,
page_size: int = 10,
Expand Down Expand Up @@ -157,7 +156,7 @@ async def search(

offset = (page - 1) * page_size
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS
try:
with search_error_boundary():
with logfire.span(
"api.search.search.execute_query",
domain="search",
Expand All @@ -176,21 +175,6 @@ async def search(
query, limit=page_size + 1, offset=offset
)
total = 0
except SemanticSearchDisabledError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SemanticDependenciesMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RerankTransientError as exc:
# Returning raw retrieval order would make pagination inconsistent with
# earlier reranked pages. Preserve ordering semantics and make the outage
# explicitly retryable instead.
raise HTTPException(status_code=503, detail=str(exc)) from exc
except RerankProviderContractError as exc:
# Upstream reranker returned a malformed response — an upstream fault, not a
# client error and not a transient outage (those map to a retryable 503).
raise HTTPException(status_code=502, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

with logfire.span(
"api.search.search.paginate_results",
Expand Down Expand Up @@ -228,7 +212,10 @@ async def search(
temporal_repository, session, results
)
search_results = await to_search_results(
entity_service, results, temporal_by_source=temporal_by_source
entity_service,
results,
temporal_by_source=temporal_by_source,
project_external_ids={internal_project_id: project_id},
)
with logfire.span(
"api.search.search.build_response",
Expand Down
44 changes: 43 additions & 1 deletion src/basic_memory/api/v2/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
from collections import defaultdict
from collections.abc import Mapping
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from typing import Any, Protocol, Optional, List, Sequence

import logfire
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from basic_memory.models import MemoryTimeIndex
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.repository.semantic_errors import (
RerankProviderContractError,
RerankTransientError,
SemanticDependenciesMissingError,
SemanticSearchDisabledError,
)
from basic_memory.schemas.memory import (
EntitySummary,
ObservationSummary,
Expand Down Expand Up @@ -54,6 +62,28 @@ async def find_for_sources(
type TemporalMetadataBySource = Mapping[tuple[str, int], list[TemporalResultMetadata]]


@contextmanager
def search_error_boundary() -> Iterator[None]:
"""Map search failures onto HTTP statuses the same way on every search route."""
try:
yield
except SemanticSearchDisabledError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SemanticDependenciesMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RerankTransientError as exc:
# Returning raw retrieval order would make pagination inconsistent with
# earlier reranked pages. Preserve ordering semantics and make the outage
# explicitly retryable instead.
raise HTTPException(status_code=503, detail=str(exc)) from exc
except RerankProviderContractError as exc:
# Upstream reranker returned a malformed response: an upstream fault, not a
# client error and not a transient outage (those map to a retryable 503).
raise HTTPException(status_code=502, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc


async def get_entities_by_id_lookup(
entity_service: EntityServiceBatchLookup,
entity_ids: Sequence[int],
Expand Down Expand Up @@ -304,7 +334,13 @@ async def to_search_results(
results: List[SearchIndexRow],
*,
temporal_by_source: TemporalMetadataBySource | None = None,
project_external_ids: Mapping[int, str] | None = None,
) -> list[SearchResult]:
"""Shape one page of search rows into the public result.

``project_external_ids`` maps each row's project to its external id; a caller
that knows the projects passes it and results carry both identities.
"""
with logfire.span(
"search.hydrate_results",
domain="search",
Expand Down Expand Up @@ -391,6 +427,12 @@ async def to_search_results(
if temporal_by_source
else None
),
project_id=result.project_id,
project_external_id=(
project_external_ids.get(result.project_id)
if project_external_ids
else None
),
)
)
return search_results
5 changes: 5 additions & 0 deletions src/basic_memory/repository/search_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,9 @@ async def vector_only(
``candidate_limit`` is supplied only by a composed retrieval stage that
already sized the shared candidate pool.
"""
# An empty scope admits no rows; embedding the query would buy nothing.
if self.scope.is_empty:
return []
query_text = (query.search_text or "").strip()
if candidate_limit is None:
candidate_limit = self._candidate_limit(limit, offset, query_text)
Expand Down Expand Up @@ -833,6 +836,8 @@ async def hybrid(
``max(vec, fts) + FUSION_BONUS * min(vec, fts)`` preserves
the dominant signal and rewards dual-source agreement.
"""
if self.scope.is_empty:
return []
query_text = (query.search_text or "").strip()
rerank = self._active_rerank(query_text)
query_start = time.perf_counter()
Expand Down
Loading
Loading