AlleksDev commited on
Commit
a2a6253
·
unverified ·
1 Parent(s): ab98846

Add: pagination at global search

Browse files
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
- sections[provider.resource_type] = sorted(
69
- list(outcome), key=lambda hit: hit.score, reverse=True
70
- )[:per_type_limit]
 
 
 
 
 
 
 
 
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
- return sorted(hits, key=lambda hit: hit.score, reverse=True)[:limit]
 
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 [_to_search_hit(self.resource_type, match) for match in matches]
 
 
 
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 [_to_search_hit(self.resource_type, match) for match in matches]
 
 
 
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 (ORDER BY e.embedding <=> $1) AS vector_rank
 
 
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=5,
 
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": 5,
53
  }
54
  ]
 
55
  assert hits[0].resource_type == SearchResourceType.PLACES
56
- assert hits[0].score == 0.82
57
- assert hits[0].semantic_score == 0.82
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