Spaces:
Sleeping
Sleeping
Add: pagination at global search
Browse files- app/modules/search/api/cursor.py +67 -0
- app/modules/search/api/router.py +13 -0
- app/modules/search/api/schemas.py +46 -1
- app/modules/search/application/ports/search_provider.py +1 -0
- app/modules/search/application/use_cases/search_all.py +23 -4
- app/modules/search/domain/models.py +9 -0
- app/modules/search/infrastructure/mock_provider.py +3 -1
- app/modules/search/infrastructure/pgvector_provider.py +12 -4
- docs/api_endpoints.md +63 -0
- sql/aws_pgvector_contract.sql +8 -6
- tests/test_global_search.py +67 -0
- tests/test_pgvector_search_provider.py +14 -6
app/modules/search/api/cursor.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import secrets
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from app.modules.search.domain.models import SearchResourceType
|
| 8 |
+
from app.shared.nlp.preprocessing.text import prepare_for_embedding
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
CURSOR_VERSION = 1
|
| 12 |
+
MAX_SEARCH_OFFSET = 1000
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def encode_search_cursor(
|
| 16 |
+
resource_type: SearchResourceType,
|
| 17 |
+
normalized_query: str,
|
| 18 |
+
offset: int,
|
| 19 |
+
) -> str:
|
| 20 |
+
payload = {
|
| 21 |
+
"v": CURSOR_VERSION,
|
| 22 |
+
"r": resource_type.value,
|
| 23 |
+
"q": _query_fingerprint(normalized_query),
|
| 24 |
+
"o": offset,
|
| 25 |
+
}
|
| 26 |
+
encoded = base64.urlsafe_b64encode(
|
| 27 |
+
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 28 |
+
)
|
| 29 |
+
return encoded.decode("ascii").rstrip("=")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def decode_search_cursor(
|
| 33 |
+
cursor: str,
|
| 34 |
+
resource_type: SearchResourceType,
|
| 35 |
+
query: str,
|
| 36 |
+
) -> int:
|
| 37 |
+
if not cursor or len(cursor) > 512:
|
| 38 |
+
raise ValueError("cursor has an invalid length")
|
| 39 |
+
try:
|
| 40 |
+
padding = "=" * (-len(cursor) % 4)
|
| 41 |
+
decoded = base64.urlsafe_b64decode((cursor + padding).encode("ascii"))
|
| 42 |
+
payload: Any = json.loads(decoded.decode("utf-8"))
|
| 43 |
+
except (ValueError, UnicodeError, json.JSONDecodeError) as exc:
|
| 44 |
+
raise ValueError("cursor is not valid") from exc
|
| 45 |
+
|
| 46 |
+
if not isinstance(payload, dict) or payload.get("v") != CURSOR_VERSION:
|
| 47 |
+
raise ValueError("cursor version is not supported")
|
| 48 |
+
if payload.get("r") != resource_type.value:
|
| 49 |
+
raise ValueError("cursor belongs to another resource type")
|
| 50 |
+
|
| 51 |
+
expected_query = _query_fingerprint(prepare_for_embedding(query))
|
| 52 |
+
cursor_query = payload.get("q")
|
| 53 |
+
if not isinstance(cursor_query, str) or not secrets.compare_digest(
|
| 54 |
+
cursor_query, expected_query
|
| 55 |
+
):
|
| 56 |
+
raise ValueError("cursor belongs to another query")
|
| 57 |
+
|
| 58 |
+
offset = payload.get("o")
|
| 59 |
+
if not isinstance(offset, int) or isinstance(offset, bool):
|
| 60 |
+
raise ValueError("cursor offset is invalid")
|
| 61 |
+
if offset < 1 or offset > MAX_SEARCH_OFFSET:
|
| 62 |
+
raise ValueError("cursor offset is outside the supported range")
|
| 63 |
+
return offset
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _query_fingerprint(normalized_query: str) -> str:
|
| 67 |
+
return hashlib.sha256(normalized_query.encode("utf-8")).hexdigest()[:24]
|
app/modules/search/api/router.py
CHANGED
|
@@ -3,6 +3,7 @@ import secrets
|
|
| 3 |
from fastapi import APIRouter, Depends, Header, HTTPException
|
| 4 |
|
| 5 |
from app.modules.search.api.dependencies import get_search_all_use_case
|
|
|
|
| 6 |
from app.modules.search.api.schemas import (
|
| 7 |
GlobalSearchRequest,
|
| 8 |
GlobalSearchResponse,
|
|
@@ -40,12 +41,24 @@ async def search_all(
|
|
| 40 |
if payload.resource_types
|
| 41 |
else ALL_SEARCH_RESOURCE_TYPES
|
| 42 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
result = await use_case.execute(
|
| 44 |
query=payload.query,
|
| 45 |
resource_types=resource_types,
|
| 46 |
per_type_limit=payload.per_type_limit,
|
| 47 |
top_limit=payload.top_limit,
|
| 48 |
requester_id=str(payload.requester_id) if payload.requester_id else None,
|
|
|
|
| 49 |
)
|
| 50 |
return result_to_schema(result)
|
| 51 |
|
|
|
|
| 3 |
from fastapi import APIRouter, Depends, Header, HTTPException
|
| 4 |
|
| 5 |
from app.modules.search.api.dependencies import get_search_all_use_case
|
| 6 |
+
from app.modules.search.api.cursor import decode_search_cursor
|
| 7 |
from app.modules.search.api.schemas import (
|
| 8 |
GlobalSearchRequest,
|
| 9 |
GlobalSearchResponse,
|
|
|
|
| 41 |
if payload.resource_types
|
| 42 |
else ALL_SEARCH_RESOURCE_TYPES
|
| 43 |
)
|
| 44 |
+
try:
|
| 45 |
+
offsets = {
|
| 46 |
+
resource_type: decode_search_cursor(
|
| 47 |
+
cursor=cursor,
|
| 48 |
+
resource_type=resource_type,
|
| 49 |
+
query=payload.query,
|
| 50 |
+
)
|
| 51 |
+
for resource_type, cursor in payload.cursors.items()
|
| 52 |
+
}
|
| 53 |
+
except ValueError as exc:
|
| 54 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 55 |
result = await use_case.execute(
|
| 56 |
query=payload.query,
|
| 57 |
resource_types=resource_types,
|
| 58 |
per_type_limit=payload.per_type_limit,
|
| 59 |
top_limit=payload.top_limit,
|
| 60 |
requester_id=str(payload.requester_id) if payload.requester_id else None,
|
| 61 |
+
offsets=offsets,
|
| 62 |
)
|
| 63 |
return result_to_schema(result)
|
| 64 |
|
app/modules/search/api/schemas.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
from typing import Any
|
| 2 |
from uuid import UUID
|
| 3 |
|
| 4 |
-
from pydantic import BaseModel, ConfigDict, Field
|
| 5 |
|
|
|
|
| 6 |
from app.modules.search.domain.models import SearchAllResult, SearchHit, SearchResourceType
|
| 7 |
|
| 8 |
|
|
@@ -14,6 +15,20 @@ class GlobalSearchRequest(BaseModel):
|
|
| 14 |
per_type_limit: int = Field(default=5, ge=1, le=20)
|
| 15 |
top_limit: int = Field(default=10, ge=1, le=50)
|
| 16 |
requester_id: UUID | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class SearchHitSchema(BaseModel):
|
|
@@ -34,11 +49,19 @@ class GlobalSearchMetadataSchema(BaseModel):
|
|
| 34 |
embedding_computed_once: bool
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
class GlobalSearchResponse(BaseModel):
|
| 38 |
query: str
|
| 39 |
normalized_query: str
|
| 40 |
top_results: list[SearchHitSchema]
|
| 41 |
sections: dict[str, list[SearchHitSchema]]
|
|
|
|
| 42 |
metadata: GlobalSearchMetadataSchema
|
| 43 |
|
| 44 |
|
|
@@ -51,6 +74,28 @@ def result_to_schema(result: SearchAllResult) -> GlobalSearchResponse:
|
|
| 51 |
resource_type.value: [_hit_to_schema(hit) for hit in hits]
|
| 52 |
for resource_type, hits in result.sections.items()
|
| 53 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
metadata=GlobalSearchMetadataSchema(
|
| 55 |
strategy="parallel_hybrid_fasttext_full_text_rrf",
|
| 56 |
queried_resources=list(result.sections),
|
|
|
|
| 1 |
from typing import Any
|
| 2 |
from uuid import UUID
|
| 3 |
|
| 4 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 5 |
|
| 6 |
+
from app.modules.search.api.cursor import MAX_SEARCH_OFFSET, encode_search_cursor
|
| 7 |
from app.modules.search.domain.models import SearchAllResult, SearchHit, SearchResourceType
|
| 8 |
|
| 9 |
|
|
|
|
| 15 |
per_type_limit: int = Field(default=5, ge=1, le=20)
|
| 16 |
top_limit: int = Field(default=10, ge=1, le=50)
|
| 17 |
requester_id: UUID | None = None
|
| 18 |
+
cursors: dict[SearchResourceType, str] = Field(default_factory=dict)
|
| 19 |
+
|
| 20 |
+
@model_validator(mode="after")
|
| 21 |
+
def validate_cursors(self) -> "GlobalSearchRequest":
|
| 22 |
+
if not self.cursors:
|
| 23 |
+
return self
|
| 24 |
+
if not self.resource_types:
|
| 25 |
+
raise ValueError("resource_types is required when cursors are provided")
|
| 26 |
+
requested = set(self.resource_types)
|
| 27 |
+
unexpected = set(self.cursors) - requested
|
| 28 |
+
if unexpected:
|
| 29 |
+
names = ", ".join(sorted(resource_type.value for resource_type in unexpected))
|
| 30 |
+
raise ValueError(f"cursor resources were not requested: {names}")
|
| 31 |
+
return self
|
| 32 |
|
| 33 |
|
| 34 |
class SearchHitSchema(BaseModel):
|
|
|
|
| 49 |
embedding_computed_once: bool
|
| 50 |
|
| 51 |
|
| 52 |
+
class SearchSectionPaginationSchema(BaseModel):
|
| 53 |
+
page_size: int
|
| 54 |
+
returned_count: int
|
| 55 |
+
has_more: bool
|
| 56 |
+
next_cursor: str | None = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
class GlobalSearchResponse(BaseModel):
|
| 60 |
query: str
|
| 61 |
normalized_query: str
|
| 62 |
top_results: list[SearchHitSchema]
|
| 63 |
sections: dict[str, list[SearchHitSchema]]
|
| 64 |
+
pagination: dict[str, SearchSectionPaginationSchema]
|
| 65 |
metadata: GlobalSearchMetadataSchema
|
| 66 |
|
| 67 |
|
|
|
|
| 74 |
resource_type.value: [_hit_to_schema(hit) for hit in hits]
|
| 75 |
for resource_type, hits in result.sections.items()
|
| 76 |
},
|
| 77 |
+
pagination={
|
| 78 |
+
resource_type.value: SearchSectionPaginationSchema(
|
| 79 |
+
page_size=page.page_size,
|
| 80 |
+
returned_count=page.returned_count,
|
| 81 |
+
has_more=(
|
| 82 |
+
page.has_more
|
| 83 |
+
and page.next_offset is not None
|
| 84 |
+
and page.next_offset <= MAX_SEARCH_OFFSET
|
| 85 |
+
),
|
| 86 |
+
next_cursor=(
|
| 87 |
+
encode_search_cursor(
|
| 88 |
+
resource_type,
|
| 89 |
+
result.normalized_query,
|
| 90 |
+
page.next_offset,
|
| 91 |
+
)
|
| 92 |
+
if page.next_offset is not None
|
| 93 |
+
and page.next_offset <= MAX_SEARCH_OFFSET
|
| 94 |
+
else None
|
| 95 |
+
),
|
| 96 |
+
)
|
| 97 |
+
for resource_type, page in result.pagination.items()
|
| 98 |
+
},
|
| 99 |
metadata=GlobalSearchMetadataSchema(
|
| 100 |
strategy="parallel_hybrid_fasttext_full_text_rrf",
|
| 101 |
queried_resources=list(result.sections),
|
app/modules/search/application/ports/search_provider.py
CHANGED
|
@@ -11,6 +11,7 @@ class SearchProvider(Protocol):
|
|
| 11 |
query: str,
|
| 12 |
embedding: list[float],
|
| 13 |
limit: int,
|
|
|
|
| 14 |
requester_id: str | None,
|
| 15 |
) -> Sequence[SearchHit]:
|
| 16 |
"""Return already-ranked candidates for exactly one resource type."""
|
|
|
|
| 11 |
query: str,
|
| 12 |
embedding: list[float],
|
| 13 |
limit: int,
|
| 14 |
+
offset: int,
|
| 15 |
requester_id: str | None,
|
| 16 |
) -> Sequence[SearchHit]:
|
| 17 |
"""Return already-ranked candidates for exactly one resource type."""
|
app/modules/search/application/use_cases/search_all.py
CHANGED
|
@@ -7,6 +7,7 @@ from app.modules.search.domain.models import (
|
|
| 7 |
SearchAllResult,
|
| 8 |
SearchHit,
|
| 9 |
SearchResourceType,
|
|
|
|
| 10 |
)
|
| 11 |
from app.shared.logging.config import get_logger
|
| 12 |
from app.shared.nlp.embeddings.base import EmbeddingProvider
|
|
@@ -31,9 +32,11 @@ class SearchAllUseCase:
|
|
| 31 |
per_type_limit: int = 5,
|
| 32 |
top_limit: int = 10,
|
| 33 |
requester_id: str | None = None,
|
|
|
|
| 34 |
) -> SearchAllResult:
|
| 35 |
normalized_query = prepare_for_embedding(query)
|
| 36 |
embedding = self._embedding_provider.embed_text(normalized_query)
|
|
|
|
| 37 |
providers = [
|
| 38 |
self._providers[resource_type]
|
| 39 |
for resource_type in resource_types
|
|
@@ -44,7 +47,8 @@ class SearchAllUseCase:
|
|
| 44 |
provider.search(
|
| 45 |
query=normalized_query,
|
| 46 |
embedding=embedding,
|
| 47 |
-
limit=per_type_limit,
|
|
|
|
| 48 |
requester_id=requester_id,
|
| 49 |
)
|
| 50 |
for provider in providers
|
|
@@ -55,6 +59,7 @@ class SearchAllUseCase:
|
|
| 55 |
sections: dict[SearchResourceType, list[SearchHit]] = {
|
| 56 |
resource_type: [] for resource_type in resource_types
|
| 57 |
}
|
|
|
|
| 58 |
failures: dict[SearchResourceType, str] = {}
|
| 59 |
for provider, outcome in zip(providers, outcomes):
|
| 60 |
if isinstance(outcome, BaseException):
|
|
@@ -64,10 +69,23 @@ class SearchAllUseCase:
|
|
| 64 |
type(outcome).__name__,
|
| 65 |
)
|
| 66 |
failures[provider.resource_type] = type(outcome).__name__
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
continue
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
top_results = _diversified_top_results(sections, top_limit)
|
| 73 |
return SearchAllResult(
|
|
@@ -75,6 +93,7 @@ class SearchAllUseCase:
|
|
| 75 |
normalized_query=normalized_query,
|
| 76 |
top_results=top_results,
|
| 77 |
sections=sections,
|
|
|
|
| 78 |
failed_resources=failures,
|
| 79 |
)
|
| 80 |
|
|
|
|
| 7 |
SearchAllResult,
|
| 8 |
SearchHit,
|
| 9 |
SearchResourceType,
|
| 10 |
+
SearchSectionPagination,
|
| 11 |
)
|
| 12 |
from app.shared.logging.config import get_logger
|
| 13 |
from app.shared.nlp.embeddings.base import EmbeddingProvider
|
|
|
|
| 32 |
per_type_limit: int = 5,
|
| 33 |
top_limit: int = 10,
|
| 34 |
requester_id: str | None = None,
|
| 35 |
+
offsets: dict[SearchResourceType, int] | None = None,
|
| 36 |
) -> SearchAllResult:
|
| 37 |
normalized_query = prepare_for_embedding(query)
|
| 38 |
embedding = self._embedding_provider.embed_text(normalized_query)
|
| 39 |
+
effective_offsets = offsets or {}
|
| 40 |
providers = [
|
| 41 |
self._providers[resource_type]
|
| 42 |
for resource_type in resource_types
|
|
|
|
| 47 |
provider.search(
|
| 48 |
query=normalized_query,
|
| 49 |
embedding=embedding,
|
| 50 |
+
limit=per_type_limit + 1,
|
| 51 |
+
offset=effective_offsets.get(provider.resource_type, 0),
|
| 52 |
requester_id=requester_id,
|
| 53 |
)
|
| 54 |
for provider in providers
|
|
|
|
| 59 |
sections: dict[SearchResourceType, list[SearchHit]] = {
|
| 60 |
resource_type: [] for resource_type in resource_types
|
| 61 |
}
|
| 62 |
+
pagination: dict[SearchResourceType, SearchSectionPagination] = {}
|
| 63 |
failures: dict[SearchResourceType, str] = {}
|
| 64 |
for provider, outcome in zip(providers, outcomes):
|
| 65 |
if isinstance(outcome, BaseException):
|
|
|
|
| 69 |
type(outcome).__name__,
|
| 70 |
)
|
| 71 |
failures[provider.resource_type] = type(outcome).__name__
|
| 72 |
+
pagination[provider.resource_type] = SearchSectionPagination(
|
| 73 |
+
page_size=per_type_limit,
|
| 74 |
+
returned_count=0,
|
| 75 |
+
has_more=False,
|
| 76 |
+
)
|
| 77 |
continue
|
| 78 |
+
ranked = sorted(list(outcome), key=lambda hit: hit.score, reverse=True)
|
| 79 |
+
page_hits = ranked[:per_type_limit]
|
| 80 |
+
has_more = len(ranked) > per_type_limit
|
| 81 |
+
current_offset = effective_offsets.get(provider.resource_type, 0)
|
| 82 |
+
sections[provider.resource_type] = page_hits
|
| 83 |
+
pagination[provider.resource_type] = SearchSectionPagination(
|
| 84 |
+
page_size=per_type_limit,
|
| 85 |
+
returned_count=len(page_hits),
|
| 86 |
+
has_more=has_more,
|
| 87 |
+
next_offset=(current_offset + len(page_hits) if has_more else None),
|
| 88 |
+
)
|
| 89 |
|
| 90 |
top_results = _diversified_top_results(sections, top_limit)
|
| 91 |
return SearchAllResult(
|
|
|
|
| 93 |
normalized_query=normalized_query,
|
| 94 |
top_results=top_results,
|
| 95 |
sections=sections,
|
| 96 |
+
pagination=pagination,
|
| 97 |
failed_resources=failures,
|
| 98 |
)
|
| 99 |
|
app/modules/search/domain/models.py
CHANGED
|
@@ -27,10 +27,19 @@ class SearchHit:
|
|
| 27 |
metadata: dict[str, Any] = field(default_factory=dict)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
@dataclass(frozen=True)
|
| 31 |
class SearchAllResult:
|
| 32 |
query: str
|
| 33 |
normalized_query: str
|
| 34 |
top_results: list[SearchHit]
|
| 35 |
sections: dict[SearchResourceType, list[SearchHit]]
|
|
|
|
| 36 |
failed_resources: dict[SearchResourceType, str]
|
|
|
|
| 27 |
metadata: dict[str, Any] = field(default_factory=dict)
|
| 28 |
|
| 29 |
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class SearchSectionPagination:
|
| 32 |
+
page_size: int
|
| 33 |
+
returned_count: int
|
| 34 |
+
has_more: bool
|
| 35 |
+
next_offset: int | None = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
@dataclass(frozen=True)
|
| 39 |
class SearchAllResult:
|
| 40 |
query: str
|
| 41 |
normalized_query: str
|
| 42 |
top_results: list[SearchHit]
|
| 43 |
sections: dict[SearchResourceType, list[SearchHit]]
|
| 44 |
+
pagination: dict[SearchResourceType, SearchSectionPagination]
|
| 45 |
failed_resources: dict[SearchResourceType, str]
|
app/modules/search/infrastructure/mock_provider.py
CHANGED
|
@@ -36,6 +36,7 @@ class MockHybridSearchProvider(SearchProvider):
|
|
| 36 |
query: str,
|
| 37 |
embedding: list[float],
|
| 38 |
limit: int,
|
|
|
|
| 39 |
requester_id: str | None,
|
| 40 |
) -> Sequence[SearchHit]:
|
| 41 |
query_terms = set(prepare_for_embedding(query).split())
|
|
@@ -67,7 +68,8 @@ class MockHybridSearchProvider(SearchProvider):
|
|
| 67 |
metadata=metadata,
|
| 68 |
)
|
| 69 |
)
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def get_mock_search_documents() -> dict[SearchResourceType, list[MockSearchDocument]]:
|
|
|
|
| 36 |
query: str,
|
| 37 |
embedding: list[float],
|
| 38 |
limit: int,
|
| 39 |
+
offset: int,
|
| 40 |
requester_id: str | None,
|
| 41 |
) -> Sequence[SearchHit]:
|
| 42 |
query_terms = set(prepare_for_embedding(query).split())
|
|
|
|
| 68 |
metadata=metadata,
|
| 69 |
)
|
| 70 |
)
|
| 71 |
+
ranked = sorted(hits, key=lambda hit: hit.score, reverse=True)
|
| 72 |
+
return ranked[offset : offset + limit]
|
| 73 |
|
| 74 |
|
| 75 |
def get_mock_search_documents() -> dict[SearchResourceType, list[MockSearchDocument]]:
|
app/modules/search/infrastructure/pgvector_provider.py
CHANGED
|
@@ -21,15 +21,19 @@ class PgvectorPlaceSearchProvider(SearchProvider):
|
|
| 21 |
query: str,
|
| 22 |
embedding: list[float],
|
| 23 |
limit: int,
|
|
|
|
| 24 |
requester_id: str | None,
|
| 25 |
) -> Sequence[SearchHit]:
|
| 26 |
del query, requester_id
|
| 27 |
matches = await self._vector_client.match_places(
|
| 28 |
embedding=embedding,
|
| 29 |
filters={"is_active": True},
|
| 30 |
-
limit=limit,
|
| 31 |
)
|
| 32 |
-
return [
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class PgvectorHybridSearchProvider(SearchProvider):
|
|
@@ -46,6 +50,7 @@ class PgvectorHybridSearchProvider(SearchProvider):
|
|
| 46 |
query: str,
|
| 47 |
embedding: list[float],
|
| 48 |
limit: int,
|
|
|
|
| 49 |
requester_id: str | None,
|
| 50 |
) -> Sequence[SearchHit]:
|
| 51 |
filters = {"is_active": True}
|
|
@@ -56,9 +61,12 @@ class PgvectorHybridSearchProvider(SearchProvider):
|
|
| 56 |
query_text=query,
|
| 57 |
embedding=embedding,
|
| 58 |
filters=filters,
|
| 59 |
-
limit=limit,
|
| 60 |
)
|
| 61 |
-
return [
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def _to_search_hit(resource_type: SearchResourceType, match: VectorMatch) -> SearchHit:
|
|
|
|
| 21 |
query: str,
|
| 22 |
embedding: list[float],
|
| 23 |
limit: int,
|
| 24 |
+
offset: int,
|
| 25 |
requester_id: str | None,
|
| 26 |
) -> Sequence[SearchHit]:
|
| 27 |
del query, requester_id
|
| 28 |
matches = await self._vector_client.match_places(
|
| 29 |
embedding=embedding,
|
| 30 |
filters={"is_active": True},
|
| 31 |
+
limit=offset + limit,
|
| 32 |
)
|
| 33 |
+
return [
|
| 34 |
+
_to_search_hit(self.resource_type, match)
|
| 35 |
+
for match in matches[offset : offset + limit]
|
| 36 |
+
]
|
| 37 |
|
| 38 |
|
| 39 |
class PgvectorHybridSearchProvider(SearchProvider):
|
|
|
|
| 50 |
query: str,
|
| 51 |
embedding: list[float],
|
| 52 |
limit: int,
|
| 53 |
+
offset: int,
|
| 54 |
requester_id: str | None,
|
| 55 |
) -> Sequence[SearchHit]:
|
| 56 |
filters = {"is_active": True}
|
|
|
|
| 61 |
query_text=query,
|
| 62 |
embedding=embedding,
|
| 63 |
filters=filters,
|
| 64 |
+
limit=offset + limit,
|
| 65 |
)
|
| 66 |
+
return [
|
| 67 |
+
_to_search_hit(self.resource_type, match)
|
| 68 |
+
for match in matches[offset : offset + limit]
|
| 69 |
+
]
|
| 70 |
|
| 71 |
|
| 72 |
def _to_search_hit(resource_type: SearchResourceType, match: VectorMatch) -> SearchHit:
|
docs/api_endpoints.md
CHANGED
|
@@ -447,9 +447,14 @@ No recibe query parameters. Toda la entrada se envia en el body.
|
|
| 447 |
| `per_type_limit` | `integer` | No | `5` | Entre 1 y 20 |
|
| 448 |
| `top_limit` | `integer` | No | `10` | Entre 1 y 50 |
|
| 449 |
| `requester_id` | `UUID \| null` | No | `null` | Activa busqueda privada |
|
|
|
|
| 450 |
|
| 451 |
No se aceptan campos adicionales.
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
#### Busqueda publica
|
| 454 |
|
| 455 |
No requiere `Authorization`.
|
|
@@ -522,6 +527,26 @@ debe obtenerlo de la sesion autenticada y construir la llamada interna.
|
|
| 522 |
"events": [],
|
| 523 |
"users": []
|
| 524 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
"metadata": {
|
| 526 |
"strategy": "parallel_hybrid_fasttext_full_text_rrf",
|
| 527 |
"queried_resources": ["clubs", "events", "users"],
|
|
@@ -534,6 +559,44 @@ debe obtenerlo de la sesion autenticada y construir la llamada interna.
|
|
| 534 |
`top_results` es una seleccion global diversificada. `sections` conserva los resultados
|
| 535 |
separados por tipo de recurso.
|
| 536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
Si falla un proveedor individual, los demas pueden responder normalmente. El recurso
|
| 538 |
fallido aparece en `metadata.failed_resources`.
|
| 539 |
|
|
|
|
| 447 |
| `per_type_limit` | `integer` | No | `5` | Entre 1 y 20 |
|
| 448 |
| `top_limit` | `integer` | No | `10` | Entre 1 y 50 |
|
| 449 |
| `requester_id` | `UUID \| null` | No | `null` | Activa busqueda privada |
|
| 450 |
+
| `cursors` | `object` | No | `{}` | Cursor opaco por tipo de recurso |
|
| 451 |
|
| 452 |
No se aceptan campos adicionales.
|
| 453 |
|
| 454 |
+
En la primera solicitud se omite `cursors`. La respuesta incluye un bloque
|
| 455 |
+
`pagination` independiente para cada recurso consultado. Cuando `has_more` es `true`,
|
| 456 |
+
`next_cursor` se envia en la siguiente solicitud usando la misma consulta.
|
| 457 |
+
|
| 458 |
#### Busqueda publica
|
| 459 |
|
| 460 |
No requiere `Authorization`.
|
|
|
|
| 527 |
"events": [],
|
| 528 |
"users": []
|
| 529 |
},
|
| 530 |
+
"pagination": {
|
| 531 |
+
"clubs": {
|
| 532 |
+
"page_size": 5,
|
| 533 |
+
"returned_count": 1,
|
| 534 |
+
"has_more": false,
|
| 535 |
+
"next_cursor": null
|
| 536 |
+
},
|
| 537 |
+
"events": {
|
| 538 |
+
"page_size": 5,
|
| 539 |
+
"returned_count": 0,
|
| 540 |
+
"has_more": false,
|
| 541 |
+
"next_cursor": null
|
| 542 |
+
},
|
| 543 |
+
"users": {
|
| 544 |
+
"page_size": 5,
|
| 545 |
+
"returned_count": 0,
|
| 546 |
+
"has_more": false,
|
| 547 |
+
"next_cursor": null
|
| 548 |
+
}
|
| 549 |
+
},
|
| 550 |
"metadata": {
|
| 551 |
"strategy": "parallel_hybrid_fasttext_full_text_rrf",
|
| 552 |
"queried_resources": ["clubs", "events", "users"],
|
|
|
|
| 559 |
`top_results` es una seleccion global diversificada. `sections` conserva los resultados
|
| 560 |
separados por tipo de recurso.
|
| 561 |
|
| 562 |
+
#### Solicitar la siguiente pagina
|
| 563 |
+
|
| 564 |
+
Cada recurso se pagina de manera independiente. Para cargar mas lugares, la interfaz
|
| 565 |
+
debe reutilizar exactamente `query`, declarar `resource_types: ["places"]` y enviar el
|
| 566 |
+
cursor recibido en `pagination.places.next_cursor`:
|
| 567 |
+
|
| 568 |
+
```json
|
| 569 |
+
{
|
| 570 |
+
"query": "cafeteria tranquila",
|
| 571 |
+
"resource_types": ["places"],
|
| 572 |
+
"per_type_limit": 5,
|
| 573 |
+
"top_limit": 5,
|
| 574 |
+
"cursors": {
|
| 575 |
+
"places": "CURSOR_DEVUELTO_POR_LA_PAGINA_ANTERIOR"
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
```
|
| 579 |
+
|
| 580 |
+
La respuesta de cada seccion contiene:
|
| 581 |
+
|
| 582 |
+
| Campo | Descripcion |
|
| 583 |
+
| --- | --- |
|
| 584 |
+
| `page_size` | Limite solicitado para esa pagina |
|
| 585 |
+
| `returned_count` | Cantidad realmente devuelta |
|
| 586 |
+
| `has_more` | Indica si existe al menos otra pagina |
|
| 587 |
+
| `next_cursor` | Cursor opaco de continuacion o `null` si termino |
|
| 588 |
+
|
| 589 |
+
Reglas de los cursores:
|
| 590 |
+
|
| 591 |
+
- estan asociados al tipo de recurso y a la consulta normalizada;
|
| 592 |
+
- no deben interpretarse ni construirse en la app cliente;
|
| 593 |
+
- no pueden reutilizarse con otra consulta o con otro recurso;
|
| 594 |
+
- cuando se envia `cursors`, `resource_types` es obligatorio y debe contener sus claves;
|
| 595 |
+
- se pueden pedir varias continuaciones en una llamada enviando un cursor por recurso.
|
| 596 |
+
|
| 597 |
+
El contrato SQL utiliza el ID externo como desempate estable cuando dos resultados tienen
|
| 598 |
+
el mismo puntaje. Esto evita cambios arbitrarios de orden entre paginas consecutivas.
|
| 599 |
+
|
| 600 |
Si falla un proveedor individual, los demas pueden responder normalmente. El recurso
|
| 601 |
fallido aparece en `metadata.failed_resources`.
|
| 602 |
|
sql/aws_pgvector_contract.sql
CHANGED
|
@@ -122,7 +122,7 @@ AS $$
|
|
| 122 |
(filters ? 'place_ids') IS FALSE
|
| 123 |
OR p.external_id IN (SELECT jsonb_array_elements_text(filters->'place_ids'))
|
| 124 |
)
|
| 125 |
-
ORDER BY p.embedding <=> query_embedding
|
| 126 |
LIMIT match_count;
|
| 127 |
$$;
|
| 128 |
|
|
@@ -342,9 +342,11 @@ BEGIN
|
|
| 342 |
SELECT
|
| 343 |
e.external_id,
|
| 344 |
1 - (e.embedding <=> $1) AS semantic_score,
|
| 345 |
-
row_number() OVER (
|
|
|
|
|
|
|
| 346 |
FROM eligible e
|
| 347 |
-
ORDER BY e.embedding <=> $1
|
| 348 |
LIMIT GREATEST($3 * 3, 30)
|
| 349 |
),
|
| 350 |
lexical_results AS (
|
|
@@ -352,12 +354,12 @@ BEGIN
|
|
| 352 |
e.external_id,
|
| 353 |
ts_rank_cd(e.textsearch, query.value) AS lexical_score,
|
| 354 |
row_number() OVER (
|
| 355 |
-
ORDER BY ts_rank_cd(e.textsearch, query.value) DESC
|
| 356 |
) AS lexical_rank
|
| 357 |
FROM eligible e
|
| 358 |
CROSS JOIN websearch_to_tsquery('simple', $2) AS query(value)
|
| 359 |
WHERE e.textsearch @@ query.value
|
| 360 |
-
ORDER BY lexical_score DESC
|
| 361 |
LIMIT GREATEST($3 * 3, 30)
|
| 362 |
),
|
| 363 |
fused AS (
|
|
@@ -392,7 +394,7 @@ BEGIN
|
|
| 392 |
f.lexical_score::double precision
|
| 393 |
FROM fused f
|
| 394 |
JOIN eligible e USING (external_id)
|
| 395 |
-
ORDER BY score DESC, f.semantic_score DESC NULLS LAST
|
| 396 |
LIMIT GREATEST($3, 1)
|
| 397 |
$query$, target_table)
|
| 398 |
USING p_query_embedding, p_query_text, p_match_count, p_filters;
|
|
|
|
| 122 |
(filters ? 'place_ids') IS FALSE
|
| 123 |
OR p.external_id IN (SELECT jsonb_array_elements_text(filters->'place_ids'))
|
| 124 |
)
|
| 125 |
+
ORDER BY p.embedding <=> query_embedding, p.external_id ASC
|
| 126 |
LIMIT match_count;
|
| 127 |
$$;
|
| 128 |
|
|
|
|
| 342 |
SELECT
|
| 343 |
e.external_id,
|
| 344 |
1 - (e.embedding <=> $1) AS semantic_score,
|
| 345 |
+
row_number() OVER (
|
| 346 |
+
ORDER BY e.embedding <=> $1, e.external_id ASC
|
| 347 |
+
) AS vector_rank
|
| 348 |
FROM eligible e
|
| 349 |
+
ORDER BY e.embedding <=> $1, e.external_id ASC
|
| 350 |
LIMIT GREATEST($3 * 3, 30)
|
| 351 |
),
|
| 352 |
lexical_results AS (
|
|
|
|
| 354 |
e.external_id,
|
| 355 |
ts_rank_cd(e.textsearch, query.value) AS lexical_score,
|
| 356 |
row_number() OVER (
|
| 357 |
+
ORDER BY ts_rank_cd(e.textsearch, query.value) DESC, e.external_id ASC
|
| 358 |
) AS lexical_rank
|
| 359 |
FROM eligible e
|
| 360 |
CROSS JOIN websearch_to_tsquery('simple', $2) AS query(value)
|
| 361 |
WHERE e.textsearch @@ query.value
|
| 362 |
+
ORDER BY lexical_score DESC, e.external_id ASC
|
| 363 |
LIMIT GREATEST($3 * 3, 30)
|
| 364 |
),
|
| 365 |
fused AS (
|
|
|
|
| 394 |
f.lexical_score::double precision
|
| 395 |
FROM fused f
|
| 396 |
JOIN eligible e USING (external_id)
|
| 397 |
+
ORDER BY score DESC, f.semantic_score DESC NULLS LAST, e.external_id ASC
|
| 398 |
LIMIT GREATEST($3, 1)
|
| 399 |
$query$, target_table)
|
| 400 |
USING p_query_embedding, p_query_text, p_match_count, p_filters;
|
tests/test_global_search.py
CHANGED
|
@@ -56,3 +56,70 @@ def test_requester_scoped_search_rejects_untrusted_callers() -> None:
|
|
| 56 |
},
|
| 57 |
)
|
| 58 |
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
},
|
| 57 |
)
|
| 58 |
assert response.status_code == 403
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_global_search_paginates_each_resource_with_opaque_cursor() -> None:
|
| 62 |
+
client = TestClient(create_app())
|
| 63 |
+
first_response = client.post(
|
| 64 |
+
"/search",
|
| 65 |
+
json={
|
| 66 |
+
"query": "lugar tranquilo",
|
| 67 |
+
"resource_types": ["places"],
|
| 68 |
+
"per_type_limit": 2,
|
| 69 |
+
"top_limit": 2,
|
| 70 |
+
},
|
| 71 |
+
)
|
| 72 |
+
assert first_response.status_code == 200
|
| 73 |
+
first = first_response.json()
|
| 74 |
+
first_ids = [hit["id"] for hit in first["sections"]["places"]]
|
| 75 |
+
page = first["pagination"]["places"]
|
| 76 |
+
assert len(first_ids) == 2
|
| 77 |
+
assert page == {
|
| 78 |
+
"page_size": 2,
|
| 79 |
+
"returned_count": 2,
|
| 80 |
+
"has_more": True,
|
| 81 |
+
"next_cursor": page["next_cursor"],
|
| 82 |
+
}
|
| 83 |
+
assert isinstance(page["next_cursor"], str)
|
| 84 |
+
|
| 85 |
+
second_response = client.post(
|
| 86 |
+
"/search",
|
| 87 |
+
json={
|
| 88 |
+
"query": "lugar tranquilo",
|
| 89 |
+
"resource_types": ["places"],
|
| 90 |
+
"per_type_limit": 2,
|
| 91 |
+
"top_limit": 2,
|
| 92 |
+
"cursors": {"places": page["next_cursor"]},
|
| 93 |
+
},
|
| 94 |
+
)
|
| 95 |
+
assert second_response.status_code == 200
|
| 96 |
+
second = second_response.json()
|
| 97 |
+
second_ids = [hit["id"] for hit in second["sections"]["places"]]
|
| 98 |
+
assert len(second_ids) == 2
|
| 99 |
+
assert set(first_ids).isdisjoint(second_ids)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_search_cursor_cannot_be_reused_for_another_query() -> None:
|
| 103 |
+
client = TestClient(create_app())
|
| 104 |
+
first = client.post(
|
| 105 |
+
"/search",
|
| 106 |
+
json={
|
| 107 |
+
"query": "lugar tranquilo",
|
| 108 |
+
"resource_types": ["places"],
|
| 109 |
+
"per_type_limit": 1,
|
| 110 |
+
},
|
| 111 |
+
).json()
|
| 112 |
+
cursor = first["pagination"]["places"]["next_cursor"]
|
| 113 |
+
|
| 114 |
+
response = client.post(
|
| 115 |
+
"/search",
|
| 116 |
+
json={
|
| 117 |
+
"query": "evento deportivo",
|
| 118 |
+
"resource_types": ["places"],
|
| 119 |
+
"per_type_limit": 1,
|
| 120 |
+
"cursors": {"places": cursor},
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
assert response.status_code == 422
|
| 125 |
+
assert response.json()["detail"] == "cursor belongs to another query"
|
tests/test_pgvector_search_provider.py
CHANGED
|
@@ -26,8 +26,14 @@ class RecordingVectorClient:
|
|
| 26 |
score=0.82,
|
| 27 |
metadata={"name": "Cafe Central", "category": "cafe"},
|
| 28 |
document="cafe tranquilo para trabajar",
|
| 29 |
-
)
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
async def search_resource_embeddings(self, **kwargs: object) -> list[VectorMatch]:
|
| 33 |
raise AssertionError("Places must not use the hybrid RRF search function")
|
|
@@ -41,7 +47,8 @@ async def test_places_use_same_cosine_match_function_as_recommendations() -> Non
|
|
| 41 |
hits = await provider.search(
|
| 42 |
query="cafe tranquilo",
|
| 43 |
embedding=[0.1, 0.2, 0.3],
|
| 44 |
-
limit=
|
|
|
|
| 45 |
requester_id=None,
|
| 46 |
)
|
| 47 |
|
|
@@ -49,10 +56,11 @@ async def test_places_use_same_cosine_match_function_as_recommendations() -> Non
|
|
| 49 |
{
|
| 50 |
"embedding": [0.1, 0.2, 0.3],
|
| 51 |
"filters": {"is_active": True},
|
| 52 |
-
"limit":
|
| 53 |
}
|
| 54 |
]
|
|
|
|
| 55 |
assert hits[0].resource_type == SearchResourceType.PLACES
|
| 56 |
-
assert hits[0].score == 0.
|
| 57 |
-
assert hits[0].semantic_score == 0.
|
| 58 |
assert hits[0].lexical_score is None
|
|
|
|
| 26 |
score=0.82,
|
| 27 |
metadata={"name": "Cafe Central", "category": "cafe"},
|
| 28 |
document="cafe tranquilo para trabajar",
|
| 29 |
+
),
|
| 30 |
+
VectorMatch(
|
| 31 |
+
id="place-2",
|
| 32 |
+
score=0.75,
|
| 33 |
+
metadata={"name": "Cafe Sur", "category": "cafe"},
|
| 34 |
+
document="cafe para conversar",
|
| 35 |
+
),
|
| 36 |
+
][:limit]
|
| 37 |
|
| 38 |
async def search_resource_embeddings(self, **kwargs: object) -> list[VectorMatch]:
|
| 39 |
raise AssertionError("Places must not use the hybrid RRF search function")
|
|
|
|
| 47 |
hits = await provider.search(
|
| 48 |
query="cafe tranquilo",
|
| 49 |
embedding=[0.1, 0.2, 0.3],
|
| 50 |
+
limit=1,
|
| 51 |
+
offset=1,
|
| 52 |
requester_id=None,
|
| 53 |
)
|
| 54 |
|
|
|
|
| 56 |
{
|
| 57 |
"embedding": [0.1, 0.2, 0.3],
|
| 58 |
"filters": {"is_active": True},
|
| 59 |
+
"limit": 2,
|
| 60 |
}
|
| 61 |
]
|
| 62 |
+
assert hits[0].id == "place-2"
|
| 63 |
assert hits[0].resource_type == SearchResourceType.PLACES
|
| 64 |
+
assert hits[0].score == 0.75
|
| 65 |
+
assert hits[0].semantic_score == 0.75
|
| 66 |
assert hits[0].lexical_score is None
|