Spaces:
Running
Running
Use Spanish FastText embeddings with pgvector
Browse files- .dockerignore +1 -0
- .env.example +10 -3
- .gitignore +1 -0
- Dockerfile +10 -0
- README.md +63 -12
- app/jobs/sync_place_embeddings.py +35 -10
- app/jobs/sync_post_embeddings.py +35 -10
- app/modules/places/api/dependencies.py +5 -3
- app/modules/places/application/use_cases/recommend_places.py +5 -2
- app/modules/places/application/use_cases/search_places.py +13 -3
- app/modules/places/infrastructure/semantic_place_ranker.py +25 -0
- app/shared/config/settings.py +35 -3
- app/shared/dependencies.py +2 -2
- app/shared/nlp/embeddings/download_fasttext_model.py +58 -0
- app/shared/nlp/embeddings/factory.py +19 -0
- app/shared/nlp/embeddings/fasttext.py +107 -0
- app/shared/nlp/embeddings/versioning.py +18 -0
- app/shared/nlp/preprocessing/text.py +74 -0
- app/shared/nlp/prompts/place_chat.py +1 -1
- docs/fasttext_deployment.md +117 -0
- docs/pgvector_place_embeddings_schema.md +12 -22
- docs/pgvector_post_embeddings_schema.md +12 -22
- requirements.txt +3 -0
- sql/aws_pgvector_contract.sql +6 -6
- sql/aws_pgvector_full_setup.psql.sql +7 -7
- sql/migrate_fasttext_300.sql +60 -0
- sql/verify_fasttext_embeddings.sql +21 -0
- tests/conftest.py +4 -0
- tests/test_api_endpoints.py +10 -9
- tests/test_fasttext_embeddings.py +50 -0
.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 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 25 |
-
|-- ordena
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`
|
|
|
|
| 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 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
`/places/search` y `/places/recommendations`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
Las
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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.
|
| 12 |
-
from app.shared.nlp.
|
|
|
|
| 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 =
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
batch = []
|
| 52 |
|
| 53 |
-
await _flush_batch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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.
|
| 82 |
]
|
| 83 |
counters.skipped += len(batch) - len(changed)
|
| 84 |
if not changed:
|
| 85 |
return
|
| 86 |
|
| 87 |
embeddings = embedding_provider.embed_batch(
|
| 88 |
-
[
|
| 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.
|
| 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.
|
| 12 |
-
from app.shared.nlp.
|
|
|
|
| 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 =
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
batch = []
|
| 52 |
|
| 53 |
-
await _flush_batch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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.
|
| 82 |
]
|
| 83 |
counters.skipped += len(batch) - len(changed)
|
| 84 |
if not changed:
|
| 85 |
return
|
| 86 |
|
| 87 |
embeddings = embedding_provider.embed_batch(
|
| 88 |
-
[
|
| 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.
|
| 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() ->
|
| 40 |
settings = get_settings()
|
| 41 |
-
return
|
| 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().
|
|
|
|
| 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":
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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=
|
| 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
|
| 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(
|
| 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(
|
| 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(
|
| 47 |
-
| `content_hash` | `TEXT` | SHA-256
|
| 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 |
-
"
|
| 93 |
-
"
|
| 94 |
-
"
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 47 |
-
| `content_hash` | `TEXT` | SHA-256
|
| 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 |
-
"
|
| 89 |
-
"
|
| 90 |
-
"
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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"] == "
|
| 24 |
assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings"
|
| 25 |
-
assert payload["metrics"]["score_metric"] == "
|
| 26 |
-
assert payload["metrics"]["ranking_parameters"] == {"
|
| 27 |
-
assert payload["metrics"]["field_weights"]["
|
| 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
|
| 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"] == "
|
| 108 |
-
assert payload["metrics"]["score_metric"] == "
|
| 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"] == "
|
| 117 |
assert payload["metadata"]["response_mode"] == "confident"
|
| 118 |
assert payload["metadata"]["used_llm"] is True
|
| 119 |
|
| 120 |
|
| 121 |
-
def
|
| 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
|