AlleksDev commited on
Commit
0d02fb4
·
unverified ·
1 Parent(s): c5c4fd2

Use Spanish FastText embeddings with pgvector

Browse files
.dockerignore CHANGED
@@ -12,4 +12,5 @@ htmlcov/
12
  .agents/
13
  .codex/
14
  chroma_data/
 
15
  *.log
 
12
  .agents/
13
  .codex/
14
  chroma_data/
15
+ .models/
16
  *.log
.env.example CHANGED
@@ -28,13 +28,20 @@ PGVECTOR_WRITER_USER=nlp_writer
28
  PGVECTOR_WRITER_PASSWORD=CAMBIA_ESTA_PASSWORD_WRITER
29
  PGVECTOR_SSL_MODE=require
30
 
31
- EMBEDDING_DIMENSION=16
32
- EMBEDDING_MODEL=mock-embedding
33
- EMBEDDING_VERSION=v1
 
 
 
 
 
34
 
35
  BM25_K1=1.5
36
  BM25_B=0.75
37
  BM25_RELEVANCE_THRESHOLD=3.0
 
 
38
 
39
  LOG_LEVEL=INFO
40
  REQUEST_TIMEOUT_SECONDS=10
 
28
  PGVECTOR_WRITER_PASSWORD=CAMBIA_ESTA_PASSWORD_WRITER
29
  PGVECTOR_SSL_MODE=require
30
 
31
+ EMBEDDING_PROVIDER=fasttext
32
+ EMBEDDING_DIMENSION=300
33
+ EMBEDDING_MODEL=facebook/fasttext-es-vectors
34
+ EMBEDDING_VERSION=common-crawl-300-v1
35
+ FASTTEXT_MODEL_PATH=.models/fasttext-es/model.bin
36
+ FASTTEXT_MODEL_REPO_ID=facebook/fasttext-es-vectors
37
+ FASTTEXT_MODEL_FILENAME=model.bin
38
+ FASTTEXT_AUTO_DOWNLOAD=true
39
 
40
  BM25_K1=1.5
41
  BM25_B=0.75
42
  BM25_RELEVANCE_THRESHOLD=3.0
43
+ SEMANTIC_NO_MATCH_THRESHOLD=0.30
44
+ SEMANTIC_RELEVANCE_THRESHOLD=0.50
45
 
46
  LOG_LEVEL=INFO
47
  REQUEST_TIMEOUT_SECONDS=10
.gitignore CHANGED
@@ -19,3 +19,4 @@ build/
19
  .vscode/
20
  chroma/
21
  chroma_data/
 
 
19
  .vscode/
20
  chroma/
21
  chroma_data/
22
+ .models/
Dockerfile CHANGED
@@ -3,6 +3,7 @@ FROM python:3.12-slim
3
  ENV PYTHONDONTWRITEBYTECODE=1
4
  ENV PYTHONUNBUFFERED=1
5
  ENV PORT=7860
 
6
 
7
  WORKDIR /app
8
 
@@ -14,6 +15,15 @@ COPY requirements.txt .
14
  RUN pip install --no-cache-dir --upgrade pip \
15
  && pip install --no-cache-dir -r requirements.txt
16
 
 
 
 
 
 
 
 
 
 
17
  COPY app ./app
18
  COPY sql ./sql
19
  COPY README.md .
 
3
  ENV PYTHONDONTWRITEBYTECODE=1
4
  ENV PYTHONUNBUFFERED=1
5
  ENV PORT=7860
6
+ ENV FASTTEXT_MODEL_PATH=/opt/models/fasttext-es/model.bin
7
 
8
  WORKDIR /app
9
 
 
15
  RUN pip install --no-cache-dir --upgrade pip \
16
  && pip install --no-cache-dir -r requirements.txt
17
 
18
+ # Keep the 300-dimensional Spanish FastText model in a cached image layer.
19
+ # Normal source-code changes do not download the multi-GB model again.
20
+ COPY app/shared/nlp/embeddings/download_fasttext_model.py /tmp/download_fasttext_model.py
21
+ RUN HF_HOME=/tmp/hf-cache python /tmp/download_fasttext_model.py \
22
+ --repo-id facebook/fasttext-es-vectors \
23
+ --filename model.bin \
24
+ --destination ${FASTTEXT_MODEL_PATH} \
25
+ && rm -rf /tmp/hf-cache /tmp/download_fasttext_model.py
26
+
27
  COPY app ./app
28
  COPY sql ./sql
29
  COPY README.md .
README.md CHANGED
@@ -7,7 +7,7 @@ pinned: false
7
 
8
  # Frimeet API NLP
9
 
10
- Servicio NLP independiente para recuperacion de candidatos con pgvector, ranking BM25, recomendaciones, embeddings y redaccion conversacional con Llama via Groq.
11
 
12
  La API principal sigue siendo la fuente de verdad de lugares, posts, usuarios, sesiones, permisos y reportes. Este servicio NLP solo trabaja con datos derivados para busqueda semantica.
13
 
@@ -21,8 +21,8 @@ API principal
21
  Hugging Face API NLP
22
  |-- usa credenciales nlp_reader
23
  |-- consulta RDS PostgreSQL + pgvector
24
- |-- genera embedding solo del query del usuario
25
- |-- ordena candidatos con BM25
26
  `-- usa Groq/Llama para embellecer recomendaciones y chat
27
 
28
  Hugging Face Jobs
@@ -74,13 +74,20 @@ PGVECTOR_WRITER_USER=nlp_writer
74
  PGVECTOR_WRITER_PASSWORD=CAMBIA_ESTA_PASSWORD_WRITER
75
  PGVECTOR_SSL_MODE=require
76
 
77
- EMBEDDING_DIMENSION=16
78
- EMBEDDING_MODEL=mock-embedding
79
- EMBEDDING_VERSION=v1
 
 
 
 
 
80
 
81
  BM25_K1=1.5
82
  BM25_B=0.75
83
  BM25_RELEVANCE_THRESHOLD=3.0
 
 
84
 
85
  LOG_LEVEL=INFO
86
  REQUEST_TIMEOUT_SECONDS=10
@@ -132,6 +139,11 @@ El proyecto incluye `Dockerfile` para Hugging Face Spaces. El contenedor expone
132
  uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
133
  ```
134
 
 
 
 
 
 
135
  ## Endpoints
136
 
137
  ```http
@@ -169,7 +181,8 @@ python -m app.jobs.sync_place_embeddings
169
  python -m app.jobs.sync_post_embeddings
170
  ```
171
 
172
- Los jobs calculan `content_hash`; si el hash no cambio, omiten regenerar el embedding.
 
173
 
174
  ## SQL RDS
175
 
@@ -188,11 +201,43 @@ docs/pgvector_post_embeddings_schema.md
188
 
189
  Ese SQL debe ejecutarse una vez con un rol administrador/DBA fuera de Hugging Face. La API NLP usa solo `nlp_reader`; los jobs usan solo `nlp_writer`.
190
 
191
- ## Ranking BM25 Y Llama Via Groq
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- `/places/search` y `/places/recommendations` recuperan candidatos reales filtrados desde pgvector y los reordenan con Okapi BM25, siguiendo `Lab3_BM25_y_Evaluacion.ipynb`. Los valores iniciales son `k1=1.5` y `b=0.75`. Las etiquetas se ponderan `x6` y la categoria `x2` antes de calcular BM25.
 
 
 
 
194
 
195
- Las respuestas incluyen metricas de la consulta actual: origen de candidatos (`pgvector` en produccion), parametros BM25, cobertura de terminos, cantidad de candidatos reales, resultados no cero y estadisticas `min`, `max` y `mean`. `match_quality` puede ser `no_match`, `low_confidence` o `confident` segun `BM25_RELEVANCE_THRESHOLD`.
 
 
196
 
197
  Ambos endpoints aceptan filtro geografico mediante `lat`, `lng` y `radius` en metros. Cuando se proporcionan coordenadas, el servicio NLP consulta `GET /api/v1/places/nearby` en la API principal y limita pgvector a los IDs devueltos. Las coordenadas siguen perteneciendo a la API principal; no es necesario guardarlas en pgvector, truncar tablas ni regenerar embeddings.
198
 
@@ -208,7 +253,11 @@ Ambos endpoints aceptan filtro geografico mediante `lat`, `lng` y `radius` en me
208
 
209
  El bloque `metrics` tambien indica `location_filter_applied`, `nearby_place_count` y `radius_meters` para hacer visible la aplicacion del radio.
210
 
211
- `POST /places/recommendations` no mezcla el benchmark fijo con la consulta del usuario. Si el score maximo es `0`, envia a Llama el modo `no_match` y devuelve `places: []`. Si `0 < max_score < BM25_RELEVANCE_THRESHOLD`, envia `low_confidence`; de lo contrario usa `confident`. Llama solo embellece el tono correspondiente y recibe exclusivamente los lugares ya seleccionados.
 
 
 
 
212
 
213
  `GET` o `POST /places/search/metrics?k=5` conserva un benchmark offline separado llamado `built_in_places_v3_bm25`. Contiene doce lugares controlados, diez consultas y qrels graduados para calcular honestamente `Precision@k`, `Recall@k`, `MRR`, `MAP` y `nDCG@k`. Estas metricas requieren juicios de relevancia y por eso no se presentan como si midieran una consulta arbitraria de produccion.
214
 
@@ -218,7 +267,9 @@ La respuesta incluye `metric_definitions` con etiquetas y descripciones claras,
218
  GET /places/search/metrics?k=5
219
  ```
220
 
221
- Para habilitar el filtro por IDs cercanos en una base existente, vuelve a ejecutar solamente el bloque `CREATE OR REPLACE FUNCTION match_places` de `sql/aws_pgvector_contract.sql`. La firma no cambia y no se requiere truncar `place_embeddings`.
 
 
222
 
223
  Groq/Llama se usa en `/places/recommendations` y `/places/chat` para redactar una respuesta conversacional. No decide que lugares recomendar, no hace busqueda y no inventa lugares.
224
 
 
7
 
8
  # Frimeet API NLP
9
 
10
+ Servicio NLP independiente para busqueda semantica con FastText + pgvector, recomendaciones y redaccion conversacional con Llama via Groq.
11
 
12
  La API principal sigue siendo la fuente de verdad de lugares, posts, usuarios, sesiones, permisos y reportes. Este servicio NLP solo trabaja con datos derivados para busqueda semantica.
13
 
 
21
  Hugging Face API NLP
22
  |-- usa credenciales nlp_reader
23
  |-- consulta RDS PostgreSQL + pgvector
24
+ |-- genera el embedding FastText del query del usuario
25
+ |-- ordena por similitud coseno en pgvector
26
  `-- usa Groq/Llama para embellecer recomendaciones y chat
27
 
28
  Hugging Face Jobs
 
74
  PGVECTOR_WRITER_PASSWORD=CAMBIA_ESTA_PASSWORD_WRITER
75
  PGVECTOR_SSL_MODE=require
76
 
77
+ EMBEDDING_PROVIDER=fasttext
78
+ EMBEDDING_DIMENSION=300
79
+ EMBEDDING_MODEL=facebook/fasttext-es-vectors
80
+ EMBEDDING_VERSION=common-crawl-300-v1
81
+ FASTTEXT_MODEL_PATH=.models/fasttext-es/model.bin
82
+ FASTTEXT_MODEL_REPO_ID=facebook/fasttext-es-vectors
83
+ FASTTEXT_MODEL_FILENAME=model.bin
84
+ FASTTEXT_AUTO_DOWNLOAD=true
85
 
86
  BM25_K1=1.5
87
  BM25_B=0.75
88
  BM25_RELEVANCE_THRESHOLD=3.0
89
+ SEMANTIC_NO_MATCH_THRESHOLD=0.30
90
+ SEMANTIC_RELEVANCE_THRESHOLD=0.50
91
 
92
  LOG_LEVEL=INFO
93
  REQUEST_TIMEOUT_SECONDS=10
 
139
  uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
140
  ```
141
 
142
+ Durante el build, Docker descarga `model.bin` desde el repositorio oficial
143
+ `facebook/fasttext-es-vectors` y lo guarda en `/opt/models/fasttext-es/model.bin`.
144
+ La capa queda cacheada, por lo que un cambio normal de codigo no vuelve a descargar
145
+ el modelo de varios GB.
146
+
147
  ## Endpoints
148
 
149
  ```http
 
181
  python -m app.jobs.sync_post_embeddings
182
  ```
183
 
184
+ Los jobs calculan un `content_hash` versionado con el contenido, modelo, version y
185
+ dimension. Un cambio de modelo fuerza la regeneracion aunque el texto no haya cambiado.
186
 
187
  ## SQL RDS
188
 
 
201
 
202
  Ese SQL debe ejecutarse una vez con un rol administrador/DBA fuera de Hugging Face. La API NLP usa solo `nlp_reader`; los jobs usan solo `nlp_writer`.
203
 
204
+ ### Migracion De VECTOR(16) A FastText VECTOR(300)
205
+
206
+ La guia operativa completa esta en `docs/fasttext_deployment.md`.
207
+
208
+ Los vectores son datos derivados. Para esta migracion no se intenta convertir los
209
+ 16 valores mock en 300 valores semanticos: se vacian ambas tablas y se reconstruyen
210
+ desde la API principal.
211
+
212
+ Con la API NLP y los jobs pausados, ejecuta como `nlp_owner` o administrador:
213
+
214
+ ```powershell
215
+ psql "host=<host> port=5432 dbname=nlp_vectors user=<admin> sslmode=require" -f sql/migrate_fasttext_300.sql
216
+ psql "host=<host> port=5432 dbname=nlp_vectors user=<admin> sslmode=require" -f sql/aws_pgvector_contract.sql
217
+ ```
218
+
219
+ Despues configura las variables FastText, despliega la nueva imagen y repuebla:
220
+
221
+ ```powershell
222
+ python -m app.jobs.initial_load_place_embeddings
223
+ python -m app.jobs.initial_load_post_embeddings
224
+ psql "host=<host> port=5432 dbname=nlp_vectors user=<admin> sslmode=require" -f sql/verify_fasttext_embeddings.sql
225
+ ```
226
+
227
+ La verificacion debe reportar dimension `300`, modelo
228
+ `facebook/fasttext-es-vectors` y normas cercanas a `1`.
229
+
230
+ ## Ranking Semantico FastText Y Llama Via Groq
231
 
232
+ `/places/search` y `/places/recommendations` aplican el flujo de
233
+ `Lab5_Embeddings_Busqueda_Semantica.ipynb`: tokenizan el texto, obtienen los vectores
234
+ FastText de cada termino, calculan su promedio, normalizan el documento y consultan
235
+ pgvector mediante similitud coseno. FastText usa subpalabras, por lo que puede relacionar
236
+ variantes morfologicas y palabras fuera de vocabulario.
237
 
238
+ Las requests y responses HTTP no cambian. Las metricas existentes ahora describen el
239
+ motor `fasttext_mean_embeddings`, similitud coseno y dimension 300. `match_quality`
240
+ usa `SEMANTIC_NO_MATCH_THRESHOLD` y `SEMANTIC_RELEVANCE_THRESHOLD`.
241
 
242
  Ambos endpoints aceptan filtro geografico mediante `lat`, `lng` y `radius` en metros. Cuando se proporcionan coordenadas, el servicio NLP consulta `GET /api/v1/places/nearby` en la API principal y limita pgvector a los IDs devueltos. Las coordenadas siguen perteneciendo a la API principal; no es necesario guardarlas en pgvector, truncar tablas ni regenerar embeddings.
243
 
 
253
 
254
  El bloque `metrics` tambien indica `location_filter_applied`, `nearby_place_count` y `radius_meters` para hacer visible la aplicacion del radio.
255
 
256
+ `POST /places/recommendations` no mezcla el benchmark fijo con la consulta del usuario.
257
+ Si el score maximo no supera `SEMANTIC_NO_MATCH_THRESHOLD`, envia a Llama el modo
258
+ `no_match` y devuelve `places: []`. Entre ese valor y
259
+ `SEMANTIC_RELEVANCE_THRESHOLD` usa `low_confidence`; por encima usa `confident`.
260
+ Llama solo embellece el tono y recibe exclusivamente los lugares seleccionados.
261
 
262
  `GET` o `POST /places/search/metrics?k=5` conserva un benchmark offline separado llamado `built_in_places_v3_bm25`. Contiene doce lugares controlados, diez consultas y qrels graduados para calcular honestamente `Precision@k`, `Recall@k`, `MRR`, `MAP` y `nDCG@k`. Estas metricas requieren juicios de relevancia y por eso no se presentan como si midieran una consulta arbitraria de produccion.
263
 
 
267
  GET /places/search/metrics?k=5
268
  ```
269
 
270
+ Para actualizar funciones o permisos sin cambiar nuevamente la dimension, vuelve a
271
+ ejecutar `sql/aws_pgvector_contract.sql`. No repitas la migracion destructiva una vez
272
+ que las columnas ya sean `VECTOR(300)`.
273
 
274
  Groq/Llama se usa en `/places/recommendations` y `/places/chat` para redactar una respuesta conversacional. No decide que lugares recomendar, no hace busqueda y no inventa lugares.
275
 
app/jobs/sync_place_embeddings.py CHANGED
@@ -6,10 +6,11 @@ from app.modules.places.infrastructure.main_api_place_source import (
6
  MainApiPlacesClient,
7
  PlaceSourceRecord,
8
  )
9
- from app.shared.config.settings import get_settings
10
  from app.shared.logging.config import configure_logging, get_logger
11
- from app.shared.nlp.embeddings.mock import MockEmbeddingProvider
12
- from app.shared.nlp.preprocessing.text import prepare_for_embedding
 
13
  from app.shared.vector_store.aws_pgvector import AwsPgvectorClient
14
  from app.shared.vector_store.models import VectorUpsertRecord
15
 
@@ -34,7 +35,7 @@ async def main() -> None:
34
 
35
  source = MainApiPlacesClient(settings)
36
  vector_client = AwsPgvectorClient(settings, role="writer")
37
- embedding_provider = MockEmbeddingProvider(dimension=settings.embedding_dimension)
38
  counters = SyncCounters()
39
  batch: list[PlaceSourceRecord] = []
40
 
@@ -47,10 +48,24 @@ async def main() -> None:
47
  ):
48
  batch.append(place)
49
  if len(batch) >= args.batch_size:
50
- await _flush_batch(batch, vector_client, embedding_provider, counters, args.dry_run)
 
 
 
 
 
 
 
51
  batch = []
52
 
53
- await _flush_batch(batch, vector_client, embedding_provider, counters, args.dry_run)
 
 
 
 
 
 
 
54
  logger.info(
55
  "Finished place sync processed=%s skipped=%s upserted=%s errors=%s",
56
  counters.processed,
@@ -63,7 +78,8 @@ async def main() -> None:
63
  async def _flush_batch(
64
  batch: list[PlaceSourceRecord],
65
  vector_client: AwsPgvectorClient,
66
- embedding_provider: MockEmbeddingProvider,
 
67
  counters: SyncCounters,
68
  dry_run: bool,
69
  ) -> None:
@@ -75,17 +91,26 @@ async def _flush_batch(
75
  existing_hashes = await vector_client.fetch_place_content_hashes(
76
  [record.id for record in batch]
77
  )
 
 
 
 
 
 
 
 
 
78
  changed = [
79
  record
80
  for record in batch
81
- if existing_hashes.get(record.id) != record.content_hash
82
  ]
83
  counters.skipped += len(batch) - len(changed)
84
  if not changed:
85
  return
86
 
87
  embeddings = embedding_provider.embed_batch(
88
- [prepare_for_embedding(record.document) for record in changed]
89
  )
90
  upserts = [
91
  VectorUpsertRecord(
@@ -93,7 +118,7 @@ async def _flush_batch(
93
  document=record.document,
94
  metadata=record.metadata,
95
  embedding=embedding,
96
- content_hash=record.content_hash,
97
  is_active=record.is_active,
98
  )
99
  for record, embedding in zip(changed, embeddings)
 
6
  MainApiPlacesClient,
7
  PlaceSourceRecord,
8
  )
9
+ from app.shared.config.settings import Settings, get_settings
10
  from app.shared.logging.config import configure_logging, get_logger
11
+ from app.shared.nlp.embeddings.base import EmbeddingProvider
12
+ from app.shared.nlp.embeddings.factory import create_embedding_provider
13
+ from app.shared.nlp.embeddings.versioning import versioned_embedding_hash
14
  from app.shared.vector_store.aws_pgvector import AwsPgvectorClient
15
  from app.shared.vector_store.models import VectorUpsertRecord
16
 
 
35
 
36
  source = MainApiPlacesClient(settings)
37
  vector_client = AwsPgvectorClient(settings, role="writer")
38
+ embedding_provider = create_embedding_provider(settings)
39
  counters = SyncCounters()
40
  batch: list[PlaceSourceRecord] = []
41
 
 
48
  ):
49
  batch.append(place)
50
  if len(batch) >= args.batch_size:
51
+ await _flush_batch(
52
+ batch,
53
+ vector_client,
54
+ embedding_provider,
55
+ settings,
56
+ counters,
57
+ args.dry_run,
58
+ )
59
  batch = []
60
 
61
+ await _flush_batch(
62
+ batch,
63
+ vector_client,
64
+ embedding_provider,
65
+ settings,
66
+ counters,
67
+ args.dry_run,
68
+ )
69
  logger.info(
70
  "Finished place sync processed=%s skipped=%s upserted=%s errors=%s",
71
  counters.processed,
 
78
  async def _flush_batch(
79
  batch: list[PlaceSourceRecord],
80
  vector_client: AwsPgvectorClient,
81
+ embedding_provider: EmbeddingProvider,
82
+ settings: Settings,
83
  counters: SyncCounters,
84
  dry_run: bool,
85
  ) -> None:
 
91
  existing_hashes = await vector_client.fetch_place_content_hashes(
92
  [record.id for record in batch]
93
  )
94
+ expected_hashes = {
95
+ record.id: versioned_embedding_hash(
96
+ source_content_hash=record.content_hash,
97
+ model=settings.embedding_model,
98
+ version=settings.embedding_version,
99
+ dimension=settings.embedding_dimension,
100
+ )
101
+ for record in batch
102
+ }
103
  changed = [
104
  record
105
  for record in batch
106
+ if existing_hashes.get(record.id) != expected_hashes[record.id]
107
  ]
108
  counters.skipped += len(batch) - len(changed)
109
  if not changed:
110
  return
111
 
112
  embeddings = embedding_provider.embed_batch(
113
+ [record.document for record in changed]
114
  )
115
  upserts = [
116
  VectorUpsertRecord(
 
118
  document=record.document,
119
  metadata=record.metadata,
120
  embedding=embedding,
121
+ content_hash=expected_hashes[record.id],
122
  is_active=record.is_active,
123
  )
124
  for record, embedding in zip(changed, embeddings)
app/jobs/sync_post_embeddings.py CHANGED
@@ -6,10 +6,11 @@ from app.modules.posts.infrastructure.main_api_post_source import (
6
  MainApiPostsClient,
7
  PostSourceRecord,
8
  )
9
- from app.shared.config.settings import get_settings
10
  from app.shared.logging.config import configure_logging, get_logger
11
- from app.shared.nlp.embeddings.mock import MockEmbeddingProvider
12
- from app.shared.nlp.preprocessing.text import prepare_for_embedding
 
13
  from app.shared.vector_store.aws_pgvector import AwsPgvectorClient
14
  from app.shared.vector_store.models import VectorUpsertRecord
15
 
@@ -34,7 +35,7 @@ async def main() -> None:
34
 
35
  source = MainApiPostsClient(settings)
36
  vector_client = AwsPgvectorClient(settings, role="writer")
37
- embedding_provider = MockEmbeddingProvider(dimension=settings.embedding_dimension)
38
  counters = SyncCounters()
39
  batch: list[PostSourceRecord] = []
40
 
@@ -47,10 +48,24 @@ async def main() -> None:
47
  ):
48
  batch.append(post)
49
  if len(batch) >= args.batch_size:
50
- await _flush_batch(batch, vector_client, embedding_provider, counters, args.dry_run)
 
 
 
 
 
 
 
51
  batch = []
52
 
53
- await _flush_batch(batch, vector_client, embedding_provider, counters, args.dry_run)
 
 
 
 
 
 
 
54
  logger.info(
55
  "Finished post sync processed=%s skipped=%s upserted=%s errors=%s",
56
  counters.processed,
@@ -63,7 +78,8 @@ async def main() -> None:
63
  async def _flush_batch(
64
  batch: list[PostSourceRecord],
65
  vector_client: AwsPgvectorClient,
66
- embedding_provider: MockEmbeddingProvider,
 
67
  counters: SyncCounters,
68
  dry_run: bool,
69
  ) -> None:
@@ -75,17 +91,26 @@ async def _flush_batch(
75
  existing_hashes = await vector_client.fetch_post_content_hashes(
76
  [record.id for record in batch]
77
  )
 
 
 
 
 
 
 
 
 
78
  changed = [
79
  record
80
  for record in batch
81
- if existing_hashes.get(record.id) != record.content_hash
82
  ]
83
  counters.skipped += len(batch) - len(changed)
84
  if not changed:
85
  return
86
 
87
  embeddings = embedding_provider.embed_batch(
88
- [prepare_for_embedding(record.document) for record in changed]
89
  )
90
  upserts = [
91
  VectorUpsertRecord(
@@ -93,7 +118,7 @@ async def _flush_batch(
93
  document=record.document,
94
  metadata=record.metadata,
95
  embedding=embedding,
96
- content_hash=record.content_hash,
97
  is_active=record.is_active,
98
  )
99
  for record, embedding in zip(changed, embeddings)
 
6
  MainApiPostsClient,
7
  PostSourceRecord,
8
  )
9
+ from app.shared.config.settings import Settings, get_settings
10
  from app.shared.logging.config import configure_logging, get_logger
11
+ from app.shared.nlp.embeddings.base import EmbeddingProvider
12
+ from app.shared.nlp.embeddings.factory import create_embedding_provider
13
+ from app.shared.nlp.embeddings.versioning import versioned_embedding_hash
14
  from app.shared.vector_store.aws_pgvector import AwsPgvectorClient
15
  from app.shared.vector_store.models import VectorUpsertRecord
16
 
 
35
 
36
  source = MainApiPostsClient(settings)
37
  vector_client = AwsPgvectorClient(settings, role="writer")
38
+ embedding_provider = create_embedding_provider(settings)
39
  counters = SyncCounters()
40
  batch: list[PostSourceRecord] = []
41
 
 
48
  ):
49
  batch.append(post)
50
  if len(batch) >= args.batch_size:
51
+ await _flush_batch(
52
+ batch,
53
+ vector_client,
54
+ embedding_provider,
55
+ settings,
56
+ counters,
57
+ args.dry_run,
58
+ )
59
  batch = []
60
 
61
+ await _flush_batch(
62
+ batch,
63
+ vector_client,
64
+ embedding_provider,
65
+ settings,
66
+ counters,
67
+ args.dry_run,
68
+ )
69
  logger.info(
70
  "Finished post sync processed=%s skipped=%s upserted=%s errors=%s",
71
  counters.processed,
 
78
  async def _flush_batch(
79
  batch: list[PostSourceRecord],
80
  vector_client: AwsPgvectorClient,
81
+ embedding_provider: EmbeddingProvider,
82
+ settings: Settings,
83
  counters: SyncCounters,
84
  dry_run: bool,
85
  ) -> None:
 
91
  existing_hashes = await vector_client.fetch_post_content_hashes(
92
  [record.id for record in batch]
93
  )
94
+ expected_hashes = {
95
+ record.id: versioned_embedding_hash(
96
+ source_content_hash=record.content_hash,
97
+ model=settings.embedding_model,
98
+ version=settings.embedding_version,
99
+ dimension=settings.embedding_dimension,
100
+ )
101
+ for record in batch
102
+ }
103
  changed = [
104
  record
105
  for record in batch
106
+ if existing_hashes.get(record.id) != expected_hashes[record.id]
107
  ]
108
  counters.skipped += len(batch) - len(changed)
109
  if not changed:
110
  return
111
 
112
  embeddings = embedding_provider.embed_batch(
113
+ [record.document for record in changed]
114
  )
115
  upserts = [
116
  VectorUpsertRecord(
 
118
  document=record.document,
119
  metadata=record.metadata,
120
  embedding=embedding,
121
+ content_hash=expected_hashes[record.id],
122
  is_active=record.is_active,
123
  )
124
  for record, embedding in zip(changed, embeddings)
app/modules/places/api/dependencies.py CHANGED
@@ -19,6 +19,7 @@ from app.modules.places.infrastructure.place_search_benchmark import (
19
  QRELS_SOURCE,
20
  get_default_place_search_benchmark,
21
  )
 
22
  from app.shared.cache.memory import SimpleTTLCache
23
  from app.shared.config.settings import get_settings
24
  from app.shared.dependencies import get_embedding_provider, get_llm_provider
@@ -36,9 +37,9 @@ def get_place_repository() -> MockPlaceVectorRepository | AwsPgvectorPlaceReposi
36
 
37
 
38
  @lru_cache
39
- def get_place_ranker() -> Bm25PlaceRanker:
40
  settings = get_settings()
41
- return Bm25PlaceRanker(k1=settings.bm25_k1, b=settings.bm25_b)
42
 
43
 
44
  @lru_cache
@@ -60,7 +61,8 @@ def get_search_places_use_case() -> SearchPlacesUseCase:
60
  ranker=get_place_ranker(),
61
  cache=get_place_search_cache(),
62
  nearby_place_provider=get_nearby_place_provider(),
63
- relevance_threshold=get_settings().bm25_relevance_threshold,
 
64
  )
65
 
66
 
 
19
  QRELS_SOURCE,
20
  get_default_place_search_benchmark,
21
  )
22
+ from app.modules.places.infrastructure.semantic_place_ranker import SemanticPlaceRanker
23
  from app.shared.cache.memory import SimpleTTLCache
24
  from app.shared.config.settings import get_settings
25
  from app.shared.dependencies import get_embedding_provider, get_llm_provider
 
37
 
38
 
39
  @lru_cache
40
+ def get_place_ranker() -> SemanticPlaceRanker:
41
  settings = get_settings()
42
+ return SemanticPlaceRanker(dimension=settings.embedding_dimension)
43
 
44
 
45
  @lru_cache
 
61
  ranker=get_place_ranker(),
62
  cache=get_place_search_cache(),
63
  nearby_place_provider=get_nearby_place_provider(),
64
+ relevance_threshold=get_settings().semantic_relevance_threshold,
65
+ no_match_threshold=get_settings().semantic_no_match_threshold,
66
  )
67
 
68
 
app/modules/places/application/use_cases/recommend_places.py CHANGED
@@ -91,8 +91,11 @@ class RecommendPlacesUseCase:
91
  places=places,
92
  metrics=metrics,
93
  metadata={
94
- "strategy": "pgvector_candidates_plus_bm25_ranking",
95
- "ranking": "bm25",
 
 
 
96
  "response_mode": response_mode,
97
  "relevance_threshold": search_result.metrics.relevance_threshold,
98
  "llm_provider": llm_provider,
 
91
  places=places,
92
  metrics=metrics,
93
  metadata={
94
+ "strategy": (
95
+ f"{search_result.metrics.candidate_retrieval}_candidates_plus_"
96
+ f"{search_result.metrics.engine}"
97
+ ),
98
+ "ranking": search_result.metrics.engine,
99
  "response_mode": response_mode,
100
  "relevance_threshold": search_result.metrics.relevance_threshold,
101
  "llm_provider": llm_provider,
app/modules/places/application/use_cases/search_places.py CHANGED
@@ -30,6 +30,7 @@ class SearchPlacesUseCase:
30
  cache: SimpleTTLCache | None = None,
31
  nearby_place_provider: NearbyPlaceProvider | None = None,
32
  relevance_threshold: float = 3.0,
 
33
  ) -> None:
34
  self._embedding_provider = embedding_provider
35
  self._place_repository = place_repository
@@ -37,6 +38,7 @@ class SearchPlacesUseCase:
37
  self._cache = cache
38
  self._nearby_place_provider = nearby_place_provider
39
  self._relevance_threshold = relevance_threshold
 
40
 
41
  async def execute(
42
  self,
@@ -138,7 +140,11 @@ class SearchPlacesUseCase:
138
  field_weights=self._ranker.field_weights,
139
  ranking_parameters=self._ranker.ranking_parameters,
140
  relevance_threshold=self._relevance_threshold,
141
- match_quality=_match_quality(max_score, self._relevance_threshold),
 
 
 
 
142
  query_token_count=len(query_terms),
143
  matched_query_token_count=len(matched_query_terms),
144
  query_coverage=(
@@ -179,8 +185,12 @@ class SearchPlacesUseCase:
179
  return json.dumps(payload, sort_keys=True, ensure_ascii=True)
180
 
181
 
182
- def _match_quality(max_score: float, relevance_threshold: float) -> str:
183
- if max_score <= 0:
 
 
 
 
184
  return "no_match"
185
  if max_score < relevance_threshold:
186
  return "low_confidence"
 
30
  cache: SimpleTTLCache | None = None,
31
  nearby_place_provider: NearbyPlaceProvider | None = None,
32
  relevance_threshold: float = 3.0,
33
+ no_match_threshold: float = 0.0,
34
  ) -> None:
35
  self._embedding_provider = embedding_provider
36
  self._place_repository = place_repository
 
38
  self._cache = cache
39
  self._nearby_place_provider = nearby_place_provider
40
  self._relevance_threshold = relevance_threshold
41
+ self._no_match_threshold = no_match_threshold
42
 
43
  async def execute(
44
  self,
 
140
  field_weights=self._ranker.field_weights,
141
  ranking_parameters=self._ranker.ranking_parameters,
142
  relevance_threshold=self._relevance_threshold,
143
+ match_quality=_match_quality(
144
+ max_score,
145
+ self._relevance_threshold,
146
+ self._no_match_threshold,
147
+ ),
148
  query_token_count=len(query_terms),
149
  matched_query_token_count=len(matched_query_terms),
150
  query_coverage=(
 
185
  return json.dumps(payload, sort_keys=True, ensure_ascii=True)
186
 
187
 
188
+ def _match_quality(
189
+ max_score: float,
190
+ relevance_threshold: float,
191
+ no_match_threshold: float = 0.0,
192
+ ) -> str:
193
+ if max_score <= no_match_threshold:
194
  return "no_match"
195
  if max_score < relevance_threshold:
196
  return "low_confidence"
app/modules/places/infrastructure/semantic_place_ranker.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Sequence
2
+
3
+ from app.modules.places.application.ports.ranker import PlaceRanker
4
+ from app.modules.places.domain.models import PlaceCandidate, PlaceFilters
5
+
6
+
7
+ class SemanticPlaceRanker(PlaceRanker):
8
+ """Preserve the cosine-similarity order returned by PGVector."""
9
+
10
+ engine_name = "fasttext_mean_embeddings"
11
+ score_metric = "cosine_similarity"
12
+ field_weights = {"document": 1}
13
+
14
+ def __init__(self, dimension: int = 300) -> None:
15
+ self.ranking_parameters = {"dimension": float(dimension)}
16
+
17
+ def rank(
18
+ self,
19
+ query: str,
20
+ places: Sequence[PlaceCandidate],
21
+ filters: PlaceFilters,
22
+ limit: int,
23
+ ) -> list[PlaceCandidate]:
24
+ del query, filters
25
+ return sorted(places, key=lambda place: place.score, reverse=True)[:limit]
app/shared/config/settings.py CHANGED
@@ -71,9 +71,29 @@ class Settings(BaseSettings):
71
  pgvector_ssl_mode: str = Field(default="require", alias="PGVECTOR_SSL_MODE")
72
  pgvector_places_table: str = "place_embeddings"
73
  pgvector_posts_table: str = "post_embeddings"
74
- embedding_dimension: int = Field(default=16, alias="EMBEDDING_DIMENSION")
75
- embedding_model: str = Field(default="mock-embedding", alias="EMBEDDING_MODEL")
76
- embedding_version: str = Field(default="v1", alias="EMBEDDING_VERSION")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  bm25_k1: float = Field(default=1.5, gt=0, alias="BM25_K1")
79
  bm25_b: float = Field(default=0.75, ge=0, le=1, alias="BM25_B")
@@ -82,6 +102,18 @@ class Settings(BaseSettings):
82
  gt=0,
83
  alias="BM25_RELEVANCE_THRESHOLD",
84
  )
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  log_level: str = Field(default="INFO", alias="LOG_LEVEL")
87
  request_timeout_seconds: int = Field(default=10, alias="REQUEST_TIMEOUT_SECONDS")
 
71
  pgvector_ssl_mode: str = Field(default="require", alias="PGVECTOR_SSL_MODE")
72
  pgvector_places_table: str = "place_embeddings"
73
  pgvector_posts_table: str = "post_embeddings"
74
+ embedding_provider: str = Field(default="fasttext", alias="EMBEDDING_PROVIDER")
75
+ embedding_dimension: int = Field(default=300, alias="EMBEDDING_DIMENSION")
76
+ embedding_model: str = Field(
77
+ default="facebook/fasttext-es-vectors",
78
+ alias="EMBEDDING_MODEL",
79
+ )
80
+ embedding_version: str = Field(default="common-crawl-300-v1", alias="EMBEDDING_VERSION")
81
+ fasttext_model_path: str = Field(
82
+ default=".models/fasttext-es/model.bin",
83
+ alias="FASTTEXT_MODEL_PATH",
84
+ )
85
+ fasttext_model_repo_id: str = Field(
86
+ default="facebook/fasttext-es-vectors",
87
+ alias="FASTTEXT_MODEL_REPO_ID",
88
+ )
89
+ fasttext_model_filename: str = Field(
90
+ default="model.bin",
91
+ alias="FASTTEXT_MODEL_FILENAME",
92
+ )
93
+ fasttext_auto_download: bool = Field(
94
+ default=True,
95
+ alias="FASTTEXT_AUTO_DOWNLOAD",
96
+ )
97
 
98
  bm25_k1: float = Field(default=1.5, gt=0, alias="BM25_K1")
99
  bm25_b: float = Field(default=0.75, ge=0, le=1, alias="BM25_B")
 
102
  gt=0,
103
  alias="BM25_RELEVANCE_THRESHOLD",
104
  )
105
+ semantic_no_match_threshold: float = Field(
106
+ default=0.30,
107
+ ge=-1,
108
+ le=1,
109
+ alias="SEMANTIC_NO_MATCH_THRESHOLD",
110
+ )
111
+ semantic_relevance_threshold: float = Field(
112
+ default=0.50,
113
+ ge=-1,
114
+ le=1,
115
+ alias="SEMANTIC_RELEVANCE_THRESHOLD",
116
+ )
117
 
118
  log_level: str = Field(default="INFO", alias="LOG_LEVEL")
119
  request_timeout_seconds: int = Field(default=10, alias="REQUEST_TIMEOUT_SECONDS")
app/shared/dependencies.py CHANGED
@@ -4,7 +4,7 @@ from app.shared.cache.memory import SimpleTTLCache
4
  from app.shared.config.settings import get_settings
5
  from app.shared.nlp.embeddings.base import EmbeddingProvider
6
  from app.shared.nlp.embeddings.cached import CachedEmbeddingProvider
7
- from app.shared.nlp.embeddings.mock import MockEmbeddingProvider
8
  from app.shared.nlp.llm.base import LLMProvider
9
  from app.shared.nlp.llm.groq_llama import GroqLlamaProvider
10
  from app.shared.nlp.llm.mock import MockLLMProvider
@@ -14,7 +14,7 @@ from app.shared.nlp.llm.mock import MockLLMProvider
14
  def get_embedding_provider() -> EmbeddingProvider:
15
  settings = get_settings()
16
  return CachedEmbeddingProvider(
17
- provider=MockEmbeddingProvider(dimension=settings.embedding_dimension),
18
  cache=SimpleTTLCache(default_ttl_seconds=settings.embedding_cache_ttl_seconds),
19
  )
20
 
 
4
  from app.shared.config.settings import get_settings
5
  from app.shared.nlp.embeddings.base import EmbeddingProvider
6
  from app.shared.nlp.embeddings.cached import CachedEmbeddingProvider
7
+ from app.shared.nlp.embeddings.factory import create_embedding_provider
8
  from app.shared.nlp.llm.base import LLMProvider
9
  from app.shared.nlp.llm.groq_llama import GroqLlamaProvider
10
  from app.shared.nlp.llm.mock import MockLLMProvider
 
14
  def get_embedding_provider() -> EmbeddingProvider:
15
  settings = get_settings()
16
  return CachedEmbeddingProvider(
17
+ provider=create_embedding_provider(settings),
18
  cache=SimpleTTLCache(default_ttl_seconds=settings.embedding_cache_ttl_seconds),
19
  )
20
 
app/shared/nlp/embeddings/download_fasttext_model.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ from pathlib import Path
3
+ import shutil
4
+
5
+
6
+ DEFAULT_REPO_ID = "facebook/fasttext-es-vectors"
7
+ DEFAULT_FILENAME = "model.bin"
8
+ DEFAULT_DESTINATION = "/opt/models/fasttext-es/model.bin"
9
+
10
+
11
+ def ensure_fasttext_model(
12
+ destination: str,
13
+ repo_id: str = DEFAULT_REPO_ID,
14
+ filename: str = DEFAULT_FILENAME,
15
+ ) -> Path:
16
+ target = Path(destination).expanduser()
17
+ if target.is_file():
18
+ return target
19
+
20
+ target.parent.mkdir(parents=True, exist_ok=True)
21
+ try:
22
+ from huggingface_hub import hf_hub_download
23
+ except ImportError as exc:
24
+ raise RuntimeError(
25
+ "huggingface-hub is required to download the FastText model"
26
+ ) from exc
27
+
28
+ downloaded = Path(
29
+ hf_hub_download(
30
+ repo_id=repo_id,
31
+ filename=filename,
32
+ local_dir=target.parent,
33
+ )
34
+ )
35
+ if downloaded.resolve() == target.resolve():
36
+ return target
37
+ temporary_target = target.with_suffix(target.suffix + ".part")
38
+ shutil.copyfile(downloaded, temporary_target)
39
+ temporary_target.replace(target)
40
+ return target
41
+
42
+
43
+ def main() -> None:
44
+ parser = argparse.ArgumentParser(description="Download the Spanish FastText model.")
45
+ parser.add_argument("--repo-id", default=DEFAULT_REPO_ID)
46
+ parser.add_argument("--filename", default=DEFAULT_FILENAME)
47
+ parser.add_argument("--destination", default=DEFAULT_DESTINATION)
48
+ args = parser.parse_args()
49
+ path = ensure_fasttext_model(
50
+ destination=args.destination,
51
+ repo_id=args.repo_id,
52
+ filename=args.filename,
53
+ )
54
+ print(path)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
app/shared/nlp/embeddings/factory.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.shared.config.settings import Settings
2
+ from app.shared.nlp.embeddings.base import EmbeddingProvider
3
+ from app.shared.nlp.embeddings.fasttext import FastTextEmbeddingProvider
4
+ from app.shared.nlp.embeddings.mock import MockEmbeddingProvider
5
+
6
+
7
+ def create_embedding_provider(settings: Settings) -> EmbeddingProvider:
8
+ provider = settings.embedding_provider.casefold()
9
+ if provider == "fasttext":
10
+ return FastTextEmbeddingProvider(
11
+ model_path=settings.fasttext_model_path,
12
+ expected_dimension=settings.embedding_dimension,
13
+ repo_id=settings.fasttext_model_repo_id,
14
+ filename=settings.fasttext_model_filename,
15
+ auto_download=settings.fasttext_auto_download,
16
+ )
17
+ if provider == "mock":
18
+ return MockEmbeddingProvider(dimension=settings.embedding_dimension)
19
+ raise ValueError(f"Unsupported EMBEDDING_PROVIDER: {settings.embedding_provider}")
app/shared/nlp/embeddings/fasttext.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from pathlib import Path
3
+ from typing import Any, Callable, Protocol, Sequence
4
+
5
+ from app.shared.nlp.embeddings.base import EmbeddingProvider
6
+ from app.shared.nlp.embeddings.download_fasttext_model import ensure_fasttext_model
7
+ from app.shared.nlp.preprocessing.text import tokenize_for_embeddings
8
+
9
+
10
+ class FastTextModel(Protocol):
11
+ def get_dimension(self) -> int: ...
12
+
13
+ def get_word_vector(self, word: str) -> Sequence[float]: ...
14
+
15
+
16
+ ModelLoader = Callable[[str], FastTextModel]
17
+
18
+
19
+ class FastTextEmbeddingProvider(EmbeddingProvider):
20
+ """Mean-pooled, L2-normalized Spanish FastText document embeddings."""
21
+
22
+ def __init__(
23
+ self,
24
+ model_path: str,
25
+ expected_dimension: int = 300,
26
+ repo_id: str = "facebook/fasttext-es-vectors",
27
+ filename: str = "model.bin",
28
+ auto_download: bool = True,
29
+ model_loader: ModelLoader | None = None,
30
+ ) -> None:
31
+ if model_loader is None:
32
+ resolved_path = self._resolve_model_path(
33
+ model_path=model_path,
34
+ repo_id=repo_id,
35
+ filename=filename,
36
+ auto_download=auto_download,
37
+ )
38
+ model_loader = _load_fasttext_model
39
+ else:
40
+ resolved_path = Path(model_path)
41
+
42
+ self._model = model_loader(str(resolved_path))
43
+ self.dimension = int(self._model.get_dimension())
44
+ if self.dimension != expected_dimension:
45
+ raise ValueError(
46
+ "FastText model dimension does not match EMBEDDING_DIMENSION: "
47
+ f"model={self.dimension}, configured={expected_dimension}"
48
+ )
49
+
50
+ def embed_text(self, text: str) -> list[float]:
51
+ tokens = tokenize_for_embeddings(text)
52
+ if not tokens:
53
+ return [0.0] * self.dimension
54
+
55
+ summed = [0.0] * self.dimension
56
+ for token in tokens:
57
+ vector = self._model.get_word_vector(token)
58
+ if len(vector) != self.dimension:
59
+ raise ValueError(
60
+ f"FastText returned dimension {len(vector)} for token {token!r}"
61
+ )
62
+ for index, value in enumerate(vector):
63
+ summed[index] += float(value)
64
+
65
+ averaged = [value / len(tokens) for value in summed]
66
+ return _l2_normalize(averaged)
67
+
68
+ def embed_batch(self, texts: list[str]) -> list[list[float]]:
69
+ return [self.embed_text(text) for text in texts]
70
+
71
+ @staticmethod
72
+ def _resolve_model_path(
73
+ model_path: str,
74
+ repo_id: str,
75
+ filename: str,
76
+ auto_download: bool,
77
+ ) -> Path:
78
+ path = Path(model_path).expanduser()
79
+ if path.is_file():
80
+ return path
81
+ if not auto_download:
82
+ raise FileNotFoundError(
83
+ f"FastText model not found at {path}. "
84
+ "Download it or enable FASTTEXT_AUTO_DOWNLOAD."
85
+ )
86
+ return ensure_fasttext_model(
87
+ destination=str(path),
88
+ repo_id=repo_id,
89
+ filename=filename,
90
+ )
91
+
92
+
93
+ def _load_fasttext_model(path: str) -> Any:
94
+ try:
95
+ import fasttext
96
+ except ImportError as exc:
97
+ raise RuntimeError(
98
+ "fasttext-wheel is required for FastText embeddings"
99
+ ) from exc
100
+ return fasttext.load_model(path)
101
+
102
+
103
+ def _l2_normalize(vector: list[float]) -> list[float]:
104
+ norm = math.sqrt(sum(value * value for value in vector))
105
+ if norm == 0:
106
+ return vector
107
+ return [value / norm for value in vector]
app/shared/nlp/embeddings/versioning.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.shared.content_hash import stable_content_hash
2
+
3
+
4
+ def versioned_embedding_hash(
5
+ source_content_hash: str,
6
+ model: str,
7
+ version: str,
8
+ dimension: int,
9
+ ) -> str:
10
+ """Invalidate derived vectors when content or embedding configuration changes."""
11
+ return stable_content_hash(
12
+ {
13
+ "source_content_hash": source_content_hash,
14
+ "embedding_model": model,
15
+ "embedding_version": version,
16
+ "embedding_dimension": dimension,
17
+ }
18
+ )
app/shared/nlp/preprocessing/text.py CHANGED
@@ -2,6 +2,66 @@ import re
2
  import unicodedata
3
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  def clean_text(text: str) -> str:
6
  text = text or ""
7
  text = re.sub(r"[\r\n\t]+", " ", text)
@@ -26,3 +86,17 @@ def strip_accents(text: str) -> str:
26
 
27
  def prepare_for_embedding(text: str) -> str:
28
  return normalize_text(text, remove_accents=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import unicodedata
3
 
4
 
5
+ SEMANTIC_TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
6
+ SEMANTIC_STOPWORDS = {
7
+ "a",
8
+ "al",
9
+ "algo",
10
+ "algun",
11
+ "alguna",
12
+ "algunas",
13
+ "algunos",
14
+ "con",
15
+ "cual",
16
+ "cuando",
17
+ "de",
18
+ "del",
19
+ "donde",
20
+ "el",
21
+ "ella",
22
+ "en",
23
+ "es",
24
+ "esta",
25
+ "este",
26
+ "hay",
27
+ "la",
28
+ "las",
29
+ "lo",
30
+ "los",
31
+ "me",
32
+ "mi",
33
+ "mis",
34
+ "para",
35
+ "pero",
36
+ "por",
37
+ "que",
38
+ "quiero",
39
+ "se",
40
+ "ser",
41
+ "su",
42
+ "sus",
43
+ "te",
44
+ "tener",
45
+ "tu",
46
+ "tus",
47
+ "un",
48
+ "una",
49
+ "unas",
50
+ "uno",
51
+ "unos",
52
+ "ver",
53
+ "y",
54
+ "ya",
55
+ "yo",
56
+ "busco",
57
+ "buscar",
58
+ "lugar",
59
+ "lugares",
60
+ "necesito",
61
+ "puedo",
62
+ }
63
+
64
+
65
  def clean_text(text: str) -> str:
66
  text = text or ""
67
  text = re.sub(r"[\r\n\t]+", " ", text)
 
86
 
87
  def prepare_for_embedding(text: str) -> str:
88
  return normalize_text(text, remove_accents=True)
89
+
90
+
91
+ def tokenize_for_embeddings(text: str) -> list[str]:
92
+ """Tokenize Spanish text for mean FastText document embeddings."""
93
+ normalized = prepare_for_embedding(text)
94
+ tokens = [
95
+ token
96
+ for token in SEMANTIC_TOKEN_PATTERN.findall(normalized)
97
+ if token not in SEMANTIC_STOPWORDS
98
+ and (len(token) > 1 or token.isdigit())
99
+ ]
100
+ if tokens:
101
+ return tokens
102
+ return SEMANTIC_TOKEN_PATTERN.findall(normalized)
app/shared/nlp/prompts/place_chat.py CHANGED
@@ -9,7 +9,7 @@ Eres un redactor conversacional para una app de planes y recomendaciones de luga
9
  Tu trabajo es embellecer la respuesta final usando solo los lugares proporcionados por el sistema.
10
 
11
  Reglas obligatorias:
12
- - No decidas que lugares recomendar; la lista ya fue seleccionada por PGVector, filtros y BM25.
13
  - No inventes nombres de lugares.
14
  - No inventes horarios, precios, direcciones, calificaciones ni promociones.
15
  - No digas que un lugar esta abierto si el contexto no lo indica.
 
9
  Tu trabajo es embellecer la respuesta final usando solo los lugares proporcionados por el sistema.
10
 
11
  Reglas obligatorias:
12
+ - No decidas que lugares recomendar; la lista ya fue seleccionada por FastText, PGVector y filtros.
13
  - No inventes nombres de lugares.
14
  - No inventes horarios, precios, direcciones, calificaciones ni promociones.
15
  - No digas que un lugar esta abierto si el contexto no lo indica.
docs/fasttext_deployment.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Migracion Y Despliegue De FastText
2
+
3
+ Esta migracion cambia los vectores derivados de 16 dimensiones mock a embeddings
4
+ FastText reales de 300 dimensiones. No cambia ningun contrato HTTP.
5
+
6
+ ## 1. Preparar Y Validar Localmente
7
+
8
+ ```powershell
9
+ cd C:\Users\aleja\Desktop\Uni\9o\MID\C2\Frimeet-API-NLP
10
+ .\.venv\Scripts\python.exe -m pip install -r requirements.txt
11
+ .\.venv\Scripts\python.exe -m pytest -q
12
+ .\.venv\Scripts\python.exe -m app.shared.nlp.embeddings.download_fasttext_model `
13
+ --repo-id facebook/fasttext-es-vectors `
14
+ --filename model.bin `
15
+ --destination .models/fasttext-es/model.bin
16
+ ```
17
+
18
+ Configura el `.env` local con:
19
+
20
+ ```env
21
+ EMBEDDING_PROVIDER=fasttext
22
+ EMBEDDING_DIMENSION=300
23
+ EMBEDDING_MODEL=facebook/fasttext-es-vectors
24
+ EMBEDDING_VERSION=common-crawl-300-v1
25
+ FASTTEXT_MODEL_PATH=.models/fasttext-es/model.bin
26
+ FASTTEXT_MODEL_REPO_ID=facebook/fasttext-es-vectors
27
+ FASTTEXT_MODEL_FILENAME=model.bin
28
+ FASTTEXT_AUTO_DOWNLOAD=true
29
+ SEMANTIC_NO_MATCH_THRESHOLD=0.30
30
+ SEMANTIC_RELEVANCE_THRESHOLD=0.50
31
+ ```
32
+
33
+ Antes de tocar la base, valida una pagina real sin escribir datos:
34
+
35
+ ```powershell
36
+ .\.venv\Scripts\python.exe -m app.jobs.sync_place_embeddings `
37
+ --dry-run --max-pages 1 --page-limit 5
38
+ .\.venv\Scripts\python.exe -m app.jobs.sync_post_embeddings `
39
+ --dry-run --max-pages 1 --page-limit 5
40
+ ```
41
+
42
+ Ambos comandos deben terminar con `errors=0`. Este ensayo comprueba la descarga y
43
+ carga de FastText, la lectura de la API principal y las credenciales de lectura del
44
+ contrato pgvector, pero no modifica RDS.
45
+
46
+ ## 2. Publicar Codigo En GitHub
47
+
48
+ ```powershell
49
+ git add .
50
+ git commit -m "Use Spanish FastText embeddings with pgvector"
51
+ git push origin hf-deploy
52
+ ```
53
+
54
+ ## 3. Pausar El Space Y Migrar RDS
55
+
56
+ Pausa el Space antes de cambiar la dimension; la API anterior genera vectores de 16
57
+ dimensiones y no puede consultar una columna `VECTOR(300)`.
58
+
59
+ Haz un snapshot de RDS y ejecuta con un usuario administrador o `nlp_owner`:
60
+
61
+ ```powershell
62
+ psql "host=<host> port=5432 dbname=nlp_vectors user=<admin> sslmode=require" `
63
+ -f sql/migrate_fasttext_300.sql
64
+ psql "host=<host> port=5432 dbname=nlp_vectors user=<admin> sslmode=require" `
65
+ -f sql/aws_pgvector_contract.sql
66
+ ```
67
+
68
+ La migracion trunca `place_embeddings` y `post_embeddings` porque son indices
69
+ derivados incompatibles. No toca la base transaccional de la API principal.
70
+
71
+ ## 4. Repoblar PGVector
72
+
73
+ Con las credenciales writer en `.env`:
74
+
75
+ ```powershell
76
+ .\.venv\Scripts\python.exe -m app.jobs.initial_load_place_embeddings
77
+ .\.venv\Scripts\python.exe -m app.jobs.initial_load_post_embeddings
78
+ psql "host=<host> port=5432 dbname=nlp_vectors user=<admin> sslmode=require" `
79
+ -f sql/verify_fasttext_embeddings.sql
80
+ ```
81
+
82
+ La verificacion debe mostrar filas, dimension 300, el modelo FastText configurado y
83
+ normas cercanas a 1.
84
+
85
+ ## 5. Actualizar Hugging Face
86
+
87
+ En Settings del Space conserva `VECTOR_STORE_PROVIDER=aws_pgvector`, las credenciales
88
+ actuales de RDS/Groq y configura estas variables de embedding:
89
+
90
+ ```env
91
+ EMBEDDING_PROVIDER=fasttext
92
+ EMBEDDING_DIMENSION=300
93
+ EMBEDDING_MODEL=facebook/fasttext-es-vectors
94
+ EMBEDDING_VERSION=common-crawl-300-v1
95
+ FASTTEXT_MODEL_PATH=/opt/models/fasttext-es/model.bin
96
+ FASTTEXT_MODEL_REPO_ID=facebook/fasttext-es-vectors
97
+ FASTTEXT_MODEL_FILENAME=model.bin
98
+ FASTTEXT_AUTO_DOWNLOAD=false
99
+ SEMANTIC_NO_MATCH_THRESHOLD=0.30
100
+ SEMANTIC_RELEVANCE_THRESHOLD=0.50
101
+ ```
102
+
103
+ El Dockerfile descarga el modelo durante el build. Publica la rama local en `main`
104
+ del Space:
105
+
106
+ ```powershell
107
+ git push hf hf-deploy:main
108
+ ```
109
+
110
+ Cuando el build termine, reinicia el Space y revisa `/ready` y una consulta real a
111
+ `POST /places/recommendations`.
112
+
113
+ ## Rollback
114
+
115
+ No intentes insertar vectores de 16 dimensiones en las columnas nuevas. Para volver
116
+ al motor anterior se requiere restaurar el snapshot o ejecutar una migracion inversa,
117
+ repoblar los indices de 16 dimensiones y desplegar el commit anterior.
docs/pgvector_place_embeddings_schema.md CHANGED
@@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS place_embeddings (
13
  external_id TEXT PRIMARY KEY,
14
  document TEXT NOT NULL,
15
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
16
- embedding VECTOR(16) NOT NULL,
17
  content_hash TEXT NOT NULL,
18
  embedding_model TEXT NOT NULL,
19
  embedding_version TEXT NOT NULL,
@@ -22,19 +22,8 @@ CREATE TABLE IF NOT EXISTS place_embeddings (
22
  );
23
  ```
24
 
25
- `VECTOR(16)` corresponde al `MockEmbeddingProvider` actual.
26
-
27
- Cuando se cambie a embeddings reales, hay que cambiar:
28
-
29
- ```env
30
- EMBEDDING_DIMENSION=<dimension_real>
31
- ```
32
-
33
- y tambien:
34
-
35
- ```sql
36
- embedding VECTOR(<dimension_real>)
37
- ```
38
 
39
  ## Columnas
40
 
@@ -43,8 +32,8 @@ embedding VECTOR(<dimension_real>)
43
  | `external_id` | `TEXT` | ID del lugar en la API principal. |
44
  | `document` | `TEXT` | Texto construido para generar el embedding. |
45
  | `metadata` | `JSONB` | Datos estructurados utiles para filtros y respuesta. |
46
- | `embedding` | `VECTOR(16)` | Embedding del `document`. |
47
- | `content_hash` | `TEXT` | SHA-256 estable de document + metadata + is_active. |
48
  | `embedding_model` | `TEXT` | Nombre del modelo usado para generar embeddings. |
49
  | `embedding_version` | `TEXT` | Version logica del embedding. |
50
  | `is_active` | `BOOLEAN` | Estado derivado desde la API principal. |
@@ -85,13 +74,14 @@ Cafe Centro cafe Tuxtla Gutierrez Chiapas osm cafe tranquilo Un lugar para plati
85
 
86
  ## Content Hash
87
 
88
- `content_hash` se calcula con SHA-256 sobre:
89
 
90
  ```json
91
  {
92
- "document": "...",
93
- "metadata": {...},
94
- "is_active": true
 
95
  }
96
  ```
97
 
@@ -102,7 +92,7 @@ Si el hash no cambia, el job omite regenerar embedding.
102
  La API NLP usa:
103
 
104
  ```sql
105
- match_places(query_embedding VECTOR(16), match_count INTEGER, filters JSONB)
106
  ```
107
 
108
  `filters.place_ids` acepta un arreglo de IDs generado por la consulta geografica a la API principal. `match_places` limita los resultados a esos `external_id`; no almacena coordenadas en esta tabla.
@@ -114,7 +104,7 @@ upsert_place_embedding(
114
  p_external_id TEXT,
115
  p_document TEXT,
116
  p_metadata JSONB,
117
- p_embedding VECTOR(16),
118
  p_content_hash TEXT,
119
  p_embedding_model TEXT,
120
  p_embedding_version TEXT,
 
13
  external_id TEXT PRIMARY KEY,
14
  document TEXT NOT NULL,
15
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
16
+ embedding VECTOR(300) NOT NULL,
17
  content_hash TEXT NOT NULL,
18
  embedding_model TEXT NOT NULL,
19
  embedding_version TEXT NOT NULL,
 
22
  );
23
  ```
24
 
25
+ `VECTOR(300)` corresponde al modelo preentrenado
26
+ `facebook/fasttext-es-vectors` usado por `FastTextEmbeddingProvider`.
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  ## Columnas
29
 
 
32
  | `external_id` | `TEXT` | ID del lugar en la API principal. |
33
  | `document` | `TEXT` | Texto construido para generar el embedding. |
34
  | `metadata` | `JSONB` | Datos estructurados utiles para filtros y respuesta. |
35
+ | `embedding` | `VECTOR(300)` | Promedio normalizado de embeddings FastText del `document`. |
36
+ | `content_hash` | `TEXT` | SHA-256 del contenido y la configuracion/version del embedding. |
37
  | `embedding_model` | `TEXT` | Nombre del modelo usado para generar embeddings. |
38
  | `embedding_version` | `TEXT` | Version logica del embedding. |
39
  | `is_active` | `BOOLEAN` | Estado derivado desde la API principal. |
 
74
 
75
  ## Content Hash
76
 
77
+ `content_hash` se calcula con SHA-256 sobre el hash del contenido mas:
78
 
79
  ```json
80
  {
81
+ "source_content_hash": "...",
82
+ "embedding_model": "facebook/fasttext-es-vectors",
83
+ "embedding_version": "common-crawl-300-v1",
84
+ "embedding_dimension": 300
85
  }
86
  ```
87
 
 
92
  La API NLP usa:
93
 
94
  ```sql
95
+ match_places(query_embedding VECTOR(300), match_count INTEGER, filters JSONB)
96
  ```
97
 
98
  `filters.place_ids` acepta un arreglo de IDs generado por la consulta geografica a la API principal. `match_places` limita los resultados a esos `external_id`; no almacena coordenadas en esta tabla.
 
104
  p_external_id TEXT,
105
  p_document TEXT,
106
  p_metadata JSONB,
107
+ p_embedding VECTOR(300),
108
  p_content_hash TEXT,
109
  p_embedding_model TEXT,
110
  p_embedding_version TEXT,
docs/pgvector_post_embeddings_schema.md CHANGED
@@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS post_embeddings (
13
  external_id TEXT PRIMARY KEY,
14
  document TEXT NOT NULL,
15
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
16
- embedding VECTOR(16) NOT NULL,
17
  content_hash TEXT NOT NULL,
18
  embedding_model TEXT NOT NULL,
19
  embedding_version TEXT NOT NULL,
@@ -22,19 +22,8 @@ CREATE TABLE IF NOT EXISTS post_embeddings (
22
  );
23
  ```
24
 
25
- `VECTOR(16)` corresponde al `MockEmbeddingProvider` actual.
26
-
27
- Cuando se cambie a embeddings reales, hay que cambiar:
28
-
29
- ```env
30
- EMBEDDING_DIMENSION=<dimension_real>
31
- ```
32
-
33
- y tambien:
34
-
35
- ```sql
36
- embedding VECTOR(<dimension_real>)
37
- ```
38
 
39
  ## Columnas
40
 
@@ -43,8 +32,8 @@ embedding VECTOR(<dimension_real>)
43
  | `external_id` | `TEXT` | ID del post en la API principal. |
44
  | `document` | `TEXT` | Texto construido para generar el embedding. |
45
  | `metadata` | `JSONB` | Datos estructurados utiles para filtros y respuesta. |
46
- | `embedding` | `VECTOR(16)` | Embedding del `document`. |
47
- | `content_hash` | `TEXT` | SHA-256 estable de document + metadata + is_active. |
48
  | `embedding_model` | `TEXT` | Nombre del modelo usado para generar embeddings. |
49
  | `embedding_version` | `TEXT` | Version logica del embedding. |
50
  | `is_active` | `BOOLEAN` | Estado derivado desde la API principal. |
@@ -81,13 +70,14 @@ Plan de cafe Tuxtla Gutierrez Chiapas internal cafe amigos Una publicacion para
81
 
82
  ## Content Hash
83
 
84
- `content_hash` se calcula con SHA-256 sobre:
85
 
86
  ```json
87
  {
88
- "document": "...",
89
- "metadata": {...},
90
- "is_active": true
 
91
  }
92
  ```
93
 
@@ -98,7 +88,7 @@ Si el hash no cambia, el job omite regenerar embedding.
98
  La API NLP usa:
99
 
100
  ```sql
101
- match_posts(query_embedding VECTOR(16), match_count INTEGER, filters JSONB)
102
  ```
103
 
104
  Los jobs usan:
@@ -108,7 +98,7 @@ upsert_post_embedding(
108
  p_external_id TEXT,
109
  p_document TEXT,
110
  p_metadata JSONB,
111
- p_embedding VECTOR(16),
112
  p_content_hash TEXT,
113
  p_embedding_model TEXT,
114
  p_embedding_version TEXT,
 
13
  external_id TEXT PRIMARY KEY,
14
  document TEXT NOT NULL,
15
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
16
+ embedding VECTOR(300) NOT NULL,
17
  content_hash TEXT NOT NULL,
18
  embedding_model TEXT NOT NULL,
19
  embedding_version TEXT NOT NULL,
 
22
  );
23
  ```
24
 
25
+ `VECTOR(300)` corresponde al modelo preentrenado
26
+ `facebook/fasttext-es-vectors` usado por `FastTextEmbeddingProvider`.
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  ## Columnas
29
 
 
32
  | `external_id` | `TEXT` | ID del post en la API principal. |
33
  | `document` | `TEXT` | Texto construido para generar el embedding. |
34
  | `metadata` | `JSONB` | Datos estructurados utiles para filtros y respuesta. |
35
+ | `embedding` | `VECTOR(300)` | Promedio normalizado de embeddings FastText del `document`. |
36
+ | `content_hash` | `TEXT` | SHA-256 del contenido y la configuracion/version del embedding. |
37
  | `embedding_model` | `TEXT` | Nombre del modelo usado para generar embeddings. |
38
  | `embedding_version` | `TEXT` | Version logica del embedding. |
39
  | `is_active` | `BOOLEAN` | Estado derivado desde la API principal. |
 
70
 
71
  ## Content Hash
72
 
73
+ `content_hash` se calcula con SHA-256 sobre el hash del contenido mas:
74
 
75
  ```json
76
  {
77
+ "source_content_hash": "...",
78
+ "embedding_model": "facebook/fasttext-es-vectors",
79
+ "embedding_version": "common-crawl-300-v1",
80
+ "embedding_dimension": 300
81
  }
82
  ```
83
 
 
88
  La API NLP usa:
89
 
90
  ```sql
91
+ match_posts(query_embedding VECTOR(300), match_count INTEGER, filters JSONB)
92
  ```
93
 
94
  Los jobs usan:
 
98
  p_external_id TEXT,
99
  p_document TEXT,
100
  p_metadata JSONB,
101
+ p_embedding VECTOR(300),
102
  p_content_hash TEXT,
103
  p_embedding_model TEXT,
104
  p_embedding_version TEXT,
requirements.txt CHANGED
@@ -5,5 +5,8 @@ python-dotenv>=1.0,<2.0
5
  httpx>=0.27,<1.0
6
  asyncpg>=0.29,<1.0
7
  groq>=0.9,<1.0
 
 
 
8
  pytest>=8.0,<9.0
9
  pytest-asyncio>=0.23,<1.0
 
5
  httpx>=0.27,<1.0
6
  asyncpg>=0.29,<1.0
7
  groq>=0.9,<1.0
8
+ fasttext-wheel==0.9.2
9
+ huggingface-hub>=0.34,<2.0
10
+ numpy>=1.26,<2.0
11
  pytest>=8.0,<9.0
12
  pytest-asyncio>=0.23,<1.0
sql/aws_pgvector_contract.sql CHANGED
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS place_embeddings (
7
  external_id TEXT PRIMARY KEY,
8
  document TEXT NOT NULL,
9
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
10
- embedding VECTOR(16) NOT NULL,
11
  content_hash TEXT NOT NULL,
12
  embedding_model TEXT NOT NULL,
13
  embedding_version TEXT NOT NULL,
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS post_embeddings (
19
  external_id TEXT PRIMARY KEY,
20
  document TEXT NOT NULL,
21
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
22
- embedding VECTOR(16) NOT NULL,
23
  content_hash TEXT NOT NULL,
24
  embedding_model TEXT NOT NULL,
25
  embedding_version TEXT NOT NULL,
@@ -40,7 +40,7 @@ CREATE INDEX IF NOT EXISTS post_embeddings_metadata_gin_idx
40
  ON post_embeddings USING gin (metadata);
41
 
42
  CREATE OR REPLACE FUNCTION match_places(
43
- query_embedding VECTOR(16),
44
  match_count INTEGER,
45
  filters JSONB DEFAULT '{}'::jsonb
46
  )
@@ -77,7 +77,7 @@ AS $$
77
  $$;
78
 
79
  CREATE OR REPLACE FUNCTION match_posts(
80
- query_embedding VECTOR(16),
81
  match_count INTEGER,
82
  filters JSONB DEFAULT '{}'::jsonb
83
  )
@@ -109,7 +109,7 @@ CREATE OR REPLACE FUNCTION upsert_place_embedding(
109
  p_external_id TEXT,
110
  p_document TEXT,
111
  p_metadata JSONB,
112
- p_embedding VECTOR(16),
113
  p_content_hash TEXT,
114
  p_embedding_model TEXT,
115
  p_embedding_version TEXT,
@@ -157,7 +157,7 @@ CREATE OR REPLACE FUNCTION upsert_post_embedding(
157
  p_external_id TEXT,
158
  p_document TEXT,
159
  p_metadata JSONB,
160
- p_embedding VECTOR(16),
161
  p_content_hash TEXT,
162
  p_embedding_model TEXT,
163
  p_embedding_version TEXT,
 
7
  external_id TEXT PRIMARY KEY,
8
  document TEXT NOT NULL,
9
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
10
+ embedding VECTOR(300) NOT NULL,
11
  content_hash TEXT NOT NULL,
12
  embedding_model TEXT NOT NULL,
13
  embedding_version TEXT NOT NULL,
 
19
  external_id TEXT PRIMARY KEY,
20
  document TEXT NOT NULL,
21
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
22
+ embedding VECTOR(300) NOT NULL,
23
  content_hash TEXT NOT NULL,
24
  embedding_model TEXT NOT NULL,
25
  embedding_version TEXT NOT NULL,
 
40
  ON post_embeddings USING gin (metadata);
41
 
42
  CREATE OR REPLACE FUNCTION match_places(
43
+ query_embedding VECTOR(300),
44
  match_count INTEGER,
45
  filters JSONB DEFAULT '{}'::jsonb
46
  )
 
77
  $$;
78
 
79
  CREATE OR REPLACE FUNCTION match_posts(
80
+ query_embedding VECTOR(300),
81
  match_count INTEGER,
82
  filters JSONB DEFAULT '{}'::jsonb
83
  )
 
109
  p_external_id TEXT,
110
  p_document TEXT,
111
  p_metadata JSONB,
112
+ p_embedding VECTOR(300),
113
  p_content_hash TEXT,
114
  p_embedding_model TEXT,
115
  p_embedding_version TEXT,
 
157
  p_external_id TEXT,
158
  p_document TEXT,
159
  p_metadata JSONB,
160
+ p_embedding VECTOR(300),
161
  p_content_hash TEXT,
162
  p_embedding_model TEXT,
163
  p_embedding_version TEXT,
sql/aws_pgvector_full_setup.psql.sql CHANGED
@@ -3,7 +3,7 @@
3
  -- psql "host=<host> port=5432 dbname=postgres user=<admin> sslmode=require" -f sql/aws_pgvector_full_setup.psql.sql
4
  --
5
  -- Replace the passwords before running.
6
- -- VECTOR(16) must match EMBEDDING_DIMENSION=16 in the API environment.
7
 
8
  \set ON_ERROR_STOP on
9
 
@@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS public.place_embeddings (
41
  external_id TEXT PRIMARY KEY,
42
  document TEXT NOT NULL,
43
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
44
- embedding VECTOR(16) NOT NULL,
45
  content_hash TEXT NOT NULL,
46
  embedding_model TEXT NOT NULL,
47
  embedding_version TEXT NOT NULL,
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS public.post_embeddings (
53
  external_id TEXT PRIMARY KEY,
54
  document TEXT NOT NULL,
55
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
56
- embedding VECTOR(16) NOT NULL,
57
  content_hash TEXT NOT NULL,
58
  embedding_model TEXT NOT NULL,
59
  embedding_version TEXT NOT NULL,
@@ -74,7 +74,7 @@ CREATE INDEX IF NOT EXISTS post_embeddings_metadata_gin_idx
74
  ON public.post_embeddings USING gin (metadata);
75
 
76
  CREATE OR REPLACE FUNCTION public.match_places(
77
- query_embedding VECTOR(16),
78
  match_count INTEGER,
79
  filters JSONB DEFAULT '{}'::jsonb
80
  )
@@ -111,7 +111,7 @@ AS $$
111
  $$;
112
 
113
  CREATE OR REPLACE FUNCTION public.match_posts(
114
- query_embedding VECTOR(16),
115
  match_count INTEGER,
116
  filters JSONB DEFAULT '{}'::jsonb
117
  )
@@ -143,7 +143,7 @@ CREATE OR REPLACE FUNCTION public.upsert_place_embedding(
143
  p_external_id TEXT,
144
  p_document TEXT,
145
  p_metadata JSONB,
146
- p_embedding VECTOR(16),
147
  p_content_hash TEXT,
148
  p_embedding_model TEXT,
149
  p_embedding_version TEXT,
@@ -191,7 +191,7 @@ CREATE OR REPLACE FUNCTION public.upsert_post_embedding(
191
  p_external_id TEXT,
192
  p_document TEXT,
193
  p_metadata JSONB,
194
- p_embedding VECTOR(16),
195
  p_content_hash TEXT,
196
  p_embedding_model TEXT,
197
  p_embedding_version TEXT,
 
3
  -- psql "host=<host> port=5432 dbname=postgres user=<admin> sslmode=require" -f sql/aws_pgvector_full_setup.psql.sql
4
  --
5
  -- Replace the passwords before running.
6
+ -- VECTOR(300) must match EMBEDDING_DIMENSION=300 in the API environment.
7
 
8
  \set ON_ERROR_STOP on
9
 
 
41
  external_id TEXT PRIMARY KEY,
42
  document TEXT NOT NULL,
43
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
44
+ embedding VECTOR(300) NOT NULL,
45
  content_hash TEXT NOT NULL,
46
  embedding_model TEXT NOT NULL,
47
  embedding_version TEXT NOT NULL,
 
53
  external_id TEXT PRIMARY KEY,
54
  document TEXT NOT NULL,
55
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
56
+ embedding VECTOR(300) NOT NULL,
57
  content_hash TEXT NOT NULL,
58
  embedding_model TEXT NOT NULL,
59
  embedding_version TEXT NOT NULL,
 
74
  ON public.post_embeddings USING gin (metadata);
75
 
76
  CREATE OR REPLACE FUNCTION public.match_places(
77
+ query_embedding VECTOR(300),
78
  match_count INTEGER,
79
  filters JSONB DEFAULT '{}'::jsonb
80
  )
 
111
  $$;
112
 
113
  CREATE OR REPLACE FUNCTION public.match_posts(
114
+ query_embedding VECTOR(300),
115
  match_count INTEGER,
116
  filters JSONB DEFAULT '{}'::jsonb
117
  )
 
143
  p_external_id TEXT,
144
  p_document TEXT,
145
  p_metadata JSONB,
146
+ p_embedding VECTOR(300),
147
  p_content_hash TEXT,
148
  p_embedding_model TEXT,
149
  p_embedding_version TEXT,
 
191
  p_external_id TEXT,
192
  p_document TEXT,
193
  p_metadata JSONB,
194
+ p_embedding VECTOR(300),
195
  p_content_hash TEXT,
196
  p_embedding_model TEXT,
197
  p_embedding_version TEXT,
sql/migrate_fasttext_300.sql ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- One-time destructive migration from the old 16-dimensional mock vectors
2
+ -- to the 300-dimensional Spanish FastText vectors.
3
+ --
4
+ -- The tables contain derived search indexes. The main API remains the source
5
+ -- of truth, so old incompatible vectors are intentionally discarded.
6
+ -- Run as nlp_owner or the RDS administrator while the NLP API/jobs are paused.
7
+
8
+ BEGIN;
9
+
10
+ LOCK TABLE public.place_embeddings IN ACCESS EXCLUSIVE MODE;
11
+ LOCK TABLE public.post_embeddings IN ACCESS EXCLUSIVE MODE;
12
+
13
+ DO $$
14
+ DECLARE
15
+ place_type TEXT;
16
+ post_type TEXT;
17
+ BEGIN
18
+ SELECT format_type(attribute.atttypid, attribute.atttypmod)
19
+ INTO place_type
20
+ FROM pg_attribute attribute
21
+ WHERE attribute.attrelid = 'public.place_embeddings'::regclass
22
+ AND attribute.attname = 'embedding'
23
+ AND NOT attribute.attisdropped;
24
+
25
+ SELECT format_type(attribute.atttypid, attribute.atttypmod)
26
+ INTO post_type
27
+ FROM pg_attribute attribute
28
+ WHERE attribute.attrelid = 'public.post_embeddings'::regclass
29
+ AND attribute.attname = 'embedding'
30
+ AND NOT attribute.attisdropped;
31
+
32
+ IF place_type <> 'vector(16)' OR post_type <> 'vector(16)' THEN
33
+ RAISE EXCEPTION
34
+ 'Expected vector(16) columns, found place=% and post=%',
35
+ place_type,
36
+ post_type;
37
+ END IF;
38
+ END $$;
39
+
40
+ DROP INDEX IF EXISTS public.place_embeddings_embedding_hnsw_idx;
41
+ DROP INDEX IF EXISTS public.post_embeddings_embedding_hnsw_idx;
42
+
43
+ TRUNCATE TABLE public.place_embeddings, public.post_embeddings;
44
+
45
+ ALTER TABLE public.place_embeddings
46
+ ALTER COLUMN embedding TYPE VECTOR(300);
47
+
48
+ ALTER TABLE public.post_embeddings
49
+ ALTER COLUMN embedding TYPE VECTOR(300);
50
+
51
+ CREATE INDEX place_embeddings_embedding_hnsw_idx
52
+ ON public.place_embeddings USING hnsw (embedding vector_cosine_ops);
53
+
54
+ CREATE INDEX post_embeddings_embedding_hnsw_idx
55
+ ON public.post_embeddings USING hnsw (embedding vector_cosine_ops);
56
+
57
+ COMMIT;
58
+
59
+ -- Immediately run sql/aws_pgvector_contract.sql after this migration so the
60
+ -- match/upsert functions also declare VECTOR(300).
sql/verify_fasttext_embeddings.sql ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SELECT
2
+ 'place_embeddings' AS table_name,
3
+ count(*) AS row_count,
4
+ min(vector_dims(embedding)) AS min_dimensions,
5
+ max(vector_dims(embedding)) AS max_dimensions,
6
+ min(embedding_model) AS embedding_model,
7
+ min(embedding_version) AS embedding_version,
8
+ min(vector_norm(embedding)) AS min_norm,
9
+ max(vector_norm(embedding)) AS max_norm
10
+ FROM public.place_embeddings
11
+ UNION ALL
12
+ SELECT
13
+ 'post_embeddings' AS table_name,
14
+ count(*) AS row_count,
15
+ min(vector_dims(embedding)) AS min_dimensions,
16
+ max(vector_dims(embedding)) AS max_dimensions,
17
+ min(embedding_model) AS embedding_model,
18
+ min(embedding_version) AS embedding_version,
19
+ min(vector_norm(embedding)) AS min_norm,
20
+ max(vector_norm(embedding)) AS max_norm
21
+ FROM public.post_embeddings;
tests/conftest.py CHANGED
@@ -1,4 +1,8 @@
1
  import os
2
 
3
  os.environ["VECTOR_STORE_PROVIDER"] = "mock"
 
 
 
 
4
  os.environ["GROQ_API_KEY"] = ""
 
1
  import os
2
 
3
  os.environ["VECTOR_STORE_PROVIDER"] = "mock"
4
+ os.environ["EMBEDDING_PROVIDER"] = "mock"
5
+ os.environ["EMBEDDING_DIMENSION"] = "16"
6
+ os.environ["EMBEDDING_MODEL"] = "mock-embedding"
7
+ os.environ["EMBEDDING_VERSION"] = "test-v1"
8
  os.environ["GROQ_API_KEY"] = ""
tests/test_api_endpoints.py CHANGED
@@ -20,11 +20,11 @@ def test_places_search_endpoint() -> None:
20
  payload = response.json()
21
  assert payload["query"] == "lugares tranquilos para cenar"
22
  assert payload["places"]
23
- assert payload["metrics"]["engine"] == "bm25"
24
  assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings"
25
- assert payload["metrics"]["score_metric"] == "bm25"
26
- assert payload["metrics"]["ranking_parameters"] == {"k1": 1.5, "b": 0.75}
27
- assert payload["metrics"]["field_weights"]["tags"] == 6
28
  assert payload["metrics"]["returned_count"] == len(payload["places"])
29
  assert payload["metrics"]["max_score"] >= payload["metrics"]["mean_score"]
30
 
@@ -87,7 +87,7 @@ def test_places_chat_endpoint_returns_trace_and_structured_places() -> None:
87
  assert payload["metadata"]["places_used_as_context"]
88
 
89
 
90
- def test_places_recommendations_returns_llm_message_and_bm25_metadata() -> None:
91
  client = TestClient(create_app())
92
 
93
  response = client.post(
@@ -104,8 +104,8 @@ def test_places_recommendations_returns_llm_message_and_bm25_metadata() -> None:
104
  payload = response.json()
105
  assert payload["message"]
106
  assert payload["places"]
107
- assert payload["metrics"]["engine"] == "bm25"
108
- assert payload["metrics"]["score_metric"] == "bm25"
109
  assert payload["metrics"]["returned_count"] == len(payload["places"])
110
  assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings"
111
  assert payload["metrics"]["query_token_count"] > 0
@@ -113,18 +113,19 @@ def test_places_recommendations_returns_llm_message_and_bm25_metadata() -> None:
113
  assert payload["metrics"]["scope"] == "current_query"
114
  assert payload["metrics"]["ground_truth_available"] is False
115
  assert "evaluation_metrics" not in payload
116
- assert payload["metadata"]["ranking"] == "bm25"
117
  assert payload["metadata"]["response_mode"] == "confident"
118
  assert payload["metadata"]["used_llm"] is True
119
 
120
 
121
- def test_places_recommendations_returns_no_places_without_bm25_matches() -> None:
122
  client = TestClient(create_app())
123
 
124
  response = client.post(
125
  "/places/recommendations",
126
  json={
127
  "query": "xqzv blorf 998zz",
 
128
  "filters": {"is_active": True},
129
  "limit": 3,
130
  },
 
20
  payload = response.json()
21
  assert payload["query"] == "lugares tranquilos para cenar"
22
  assert payload["places"]
23
+ assert payload["metrics"]["engine"] == "fasttext_mean_embeddings"
24
  assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings"
25
+ assert payload["metrics"]["score_metric"] == "cosine_similarity"
26
+ assert payload["metrics"]["ranking_parameters"] == {"dimension": 16.0}
27
+ assert payload["metrics"]["field_weights"]["document"] == 1
28
  assert payload["metrics"]["returned_count"] == len(payload["places"])
29
  assert payload["metrics"]["max_score"] >= payload["metrics"]["mean_score"]
30
 
 
87
  assert payload["metadata"]["places_used_as_context"]
88
 
89
 
90
+ def test_places_recommendations_returns_llm_message_and_semantic_metadata() -> None:
91
  client = TestClient(create_app())
92
 
93
  response = client.post(
 
104
  payload = response.json()
105
  assert payload["message"]
106
  assert payload["places"]
107
+ assert payload["metrics"]["engine"] == "fasttext_mean_embeddings"
108
+ assert payload["metrics"]["score_metric"] == "cosine_similarity"
109
  assert payload["metrics"]["returned_count"] == len(payload["places"])
110
  assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings"
111
  assert payload["metrics"]["query_token_count"] > 0
 
113
  assert payload["metrics"]["scope"] == "current_query"
114
  assert payload["metrics"]["ground_truth_available"] is False
115
  assert "evaluation_metrics" not in payload
116
+ assert payload["metadata"]["ranking"] == "fasttext_mean_embeddings"
117
  assert payload["metadata"]["response_mode"] == "confident"
118
  assert payload["metadata"]["used_llm"] is True
119
 
120
 
121
+ def test_places_recommendations_returns_no_places_without_candidates() -> None:
122
  client = TestClient(create_app())
123
 
124
  response = client.post(
125
  "/places/recommendations",
126
  json={
127
  "query": "xqzv blorf 998zz",
128
+ "city": "Ciudad inexistente",
129
  "filters": {"is_active": True},
130
  "limit": 3,
131
  },
tests/test_fasttext_embeddings.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import pytest
4
+
5
+ from app.shared.nlp.embeddings.fasttext import FastTextEmbeddingProvider
6
+ from app.shared.nlp.embeddings.versioning import versioned_embedding_hash
7
+
8
+
9
+ class FakeFastTextModel:
10
+ vectors = {
11
+ "agua": [1.0, 0.0, 0.0],
12
+ "problemas": [0.0, 1.0, 0.0],
13
+ "hidrico": [0.8, 0.2, 0.0],
14
+ }
15
+
16
+ def get_dimension(self) -> int:
17
+ return 3
18
+
19
+ def get_word_vector(self, word: str) -> list[float]:
20
+ return self.vectors.get(word, [0.0, 0.0, 1.0])
21
+
22
+
23
+ def test_fasttext_provider_mean_pools_and_normalizes_tokens() -> None:
24
+ provider = FastTextEmbeddingProvider(
25
+ model_path="unused.bin",
26
+ expected_dimension=3,
27
+ model_loader=lambda _: FakeFastTextModel(),
28
+ )
29
+
30
+ embedding = provider.embed_text("problemas de agua")
31
+
32
+ expected = 1 / math.sqrt(2)
33
+ assert embedding == pytest.approx([expected, expected, 0.0])
34
+ assert sum(value * value for value in embedding) == pytest.approx(1.0)
35
+
36
+
37
+ def test_fasttext_provider_rejects_mismatched_dimension() -> None:
38
+ with pytest.raises(ValueError, match="does not match"):
39
+ FastTextEmbeddingProvider(
40
+ model_path="unused.bin",
41
+ expected_dimension=300,
42
+ model_loader=lambda _: FakeFastTextModel(),
43
+ )
44
+
45
+
46
+ def test_embedding_hash_changes_with_model_configuration() -> None:
47
+ first = versioned_embedding_hash("source", "fasttext", "v1", 300)
48
+ second = versioned_embedding_hash("source", "fasttext", "v2", 300)
49
+
50
+ assert first != second