← Ruta Perico
Etapa 12

Embeddings y pgvector

Especial Publicada ~1 h · 0 $
12

En la etapa 0 apareció la idea de pasada: un embedding es la lista de números con la que un modelo representa un texto, hecha de forma que dos textos que significan lo mismo den listas parecidas. Toca convertirla en una tabla.

Lo primero que hay que decidir es cuántos números. Eso es la dimensión, y no es un detalle de configuración: es cuánto matiz cabe.

Piensa en describir a una persona. Con 3 números —altura, edad, peso— distingues a mucha gente, pero confundes a un montón. Con 50 ya metes carácter, aficiones, forma de hablar. Con 5.000 capturas matices que ni sabrías nombrar. Cada salto añade precisión, y cada salto añade menos que el anterior.

Cuántos números: cómo se decidió

Había dos candidatos reales:

DimensiónCosteQué hace falta
Modelo multilingüe local3840 $Nada: se baja y corre en el portátil
text-embedding-3-small de OpenAI1.536~0,002 $ por todo el corpusCuenta nueva y clave de API

Puesta así la tabla, la pregunta obvia es la que hizo el dueño del proyecto: si el bueno cuesta dos milésimas de dólar, ¿por qué no ir directo al bueno?

Es una buena pregunta, y lo que la zanjó no fue la calidad. Fue que el criterio que él mismo había puesto era la comodidad — y Anthropic no vende embeddings, así que cualquier opción de pago obliga a abrir una cuenta nueva en otro sitio. Con la comodidad como criterio, el local gana sin discusión: ya está instalado y no hay que registrarse en nada.

Y hay una segunda mitad que hace la decisión barata: si sale mal, migrar cuesta un script y unos minutos. Regenerar 3.531 embeddings es gratis y tarda poco; la columna cambia de vector(384) a vector(1536) con un alter table. No es una decisión de arquitectura, es una decisión reversible.

Conviene decirlo claro, porque el mismo razonamiento con otro criterio da la vuelta: si el criterio hubiera sido la calidad, la respuesta correcta era pagar los 0,002 $. Lo que decide no es cuál de los dos modelos es mejor — eso ya se sabía — sino qué se dijo que importaba.

La tabla

load_supabase.py — el esquema
create extension if not exists vector;

create table if not exists phrases (
    id          bigserial primary key,
    text        text        not null,
    plain       text        not null,
    embedding   vector(384),
    devices     text[]      not null default '{}',
    confidence  text,
    video_id    text,
    title       text,
    url         text,
    source      text,
    created_at  timestamptz not null default now()
);

Las dos columnas de texto son lo importante, y no hacen lo mismo:

  • text es lo que la app devuelve: sus palabras, literales. Nunca se embebe.
  • plain es la paráfrasis en español llano. Se embebe, y no se enseña jamás.

Esa fila es la trampa de la etapa 0 convertida en columnas. La búsqueda va contra plain, porque el español normal del usuario casa contra español normal; lo que sale devuelto es text, la versión deformada. Embeber las frases originales recuperaría por tema, y el corpus es un montón de estilo.

Dos comprobaciones antes de construir nada encima

Aquí está el nudo de la etapa. La tentación al terminar de cargar la tabla es enchufarla al generador y ver qué sale. Antes hay dos medidas que cuestan cero y tardan segundos, y las dos son un suelo: si no se pasan, no hay que seguir, hay que cambiar de modelo.

1. Auto-recuperación. Coge una frase, búscala con su propia paráfrasis, y mira si vuelve la primera. Si el modelo no sabe encontrar una frase a partir de su propia versión en español llano, el espacio de embeddings sencillamente no funciona y nada de lo que venga detrás lo va a arreglar.

retrieval_checks.py
def search(cursor, vector, limit=TOP_N):
    """Nearest rows by cosine distance. `<=>` is pgvector's cosine operator."""
    cursor.execute(
        "select id, text, 1 - (embedding <=> %s::vector) as score "
        "from phrases order by embedding <=> %s::vector limit %s",
        (str(vector.tolist()), str(vector.tolist()), limit),
    )
    return cursor.fetchall()

Sobre una muestra de 500 frases: 98,8% vuelven en el puesto 1, y el 100% están entre las tres primeras. El umbral que se había fijado antes de mirar era 80%.

Ahora la parte que se olvida: eso es un suelo, no un techo. Pasarlo dice que el mecanismo funciona, no que los resultados sean buenos. Encontrar una frase a partir de su propia paráfrasis es la tarea más fácil que hay; suspenderla sería descalificatorio, aprobarla no promete nada.

2. Tasa de repetición. Cincuenta consultas variadas, y contar cuántas veces vuelve cada frase.

retrieval_checks.py — las consultas
QUERIES = [
    "mañana tengo una reunión importante en el trabajo",
    "hoy hace un frío que pela", "la cena estaba buenísima",
    "menudo atasco he pillado esta mañana", "he suspendido el examen otra vez",
    "mi gato se ha comido el cargador del móvil",
    ...
]

Resultado: la frase más repetida sale en el 14% de las consultas, contra un umbral del 30%, y aparecen 183 frases distintas en 250 resultados.

Lo interesante no es el número, es lo que mide. Una recuperación colapsada devuelve las mismas cuatro frases escribas lo que escribas — y eso el usuario lo nota en su segunda visita, no en la primera. Es la única de las dos medidas que se parece a lo que percibe quien usa la app.

Las dos son gratis y tardan segundos

Ese es el argumento entero. No hay que elegir entre comprobar y avanzar: se ejecutan en menos tiempo del que se tarda en escribir el primer prompt del generador.

Lo que compran es saber, antes de gastar nada, si el problema está en el generador o en la base. Sin ellas, una salida mala deja dos sospechosos y ninguna forma de separarlos.

Y el umbral de repetición estaba escrito en el documento de calidad del proyecto meses antes de que existiera nada que medir. Decidir qué contaría como «funciona» antes de poder mirar el resultado es la única manera de que el umbral no se ajuste solo hasta que apruebe.

Lo que se eligió, y qué fijó cada cosa

Se eligióY con eso quedó fijado
Modelo multilingüe localQué se recupera para cada consulta. Es el mecanismo de recuperación, no un accesorio
Dimensión 384Cuánto matiz cabe, y el tipo de la columna. Cambiarla obliga a regenerar todos los embeddings
Embeber plain y devolver textQue la búsqueda vaya por significado llano y no por tema ciclista
Sin índice vectorialResultados exactos a cambio de un recorrido completo. Deja de ser buena idea sobre unas 50.000 filas
Suelo del 80% en auto-recuperaciónQué cuenta como «el espacio funciona»
Techo del 30% de repeticiónQué cuenta como «la recuperación no ha colapsado»

Las dos últimas filas son las que más cuestan de defender y las más importantes: son números elegidos antes de medir, y por eso valen algo.

Epílogo: la alarma que venía del propio test

Semanas después, la prueba de repetición de esta etapa dio la alarma. «Esto huele ya a trampa» salía en 7 de las 50 consultas, un 14 %, y el objetivo era no pasar del 10 %.

Antes de tocar la recuperación se miró qué consultas la traían: «esto pinta mal», «qué poco me gusta esto», «estoy hasta las narices»… El conjunto de 50 consultas tenía muchas frases de fastidio, y una frase sobre fastidio las ganaba todas. Además, con 50 consultas cada una vale dos puntos, así que entre el 14 % y el 10 % había dos frases de diferencia. Y el propio 10 % era una cifra elegida, nunca medida.

Se rehízo la medida con 200 consultas en 20 temas de 10: trabajo, comida, mascotas, despistes… repartidas a partes iguales, porque la mezcla decide el resultado. La frase de la trampa bajó al 4,5 %, y con la medida original ninguna frase pasaba del 10 %. La alarma venía del test, no del índice.

Pero la medida nueva añadía algo que la vieja no tenía: en cuántos temas distintos sale cada frase. Una frase que sale mucho pero dentro de un solo tema está encajando bien. Una que sale en temas sin nada en común es un comodín: está cerca de todo en el espacio de embeddings, un fenómeno con nombre, hubness. Y mirando las 12 frases que la app envía de verdad, sí apareció uno:

FrasePeticionesTemas
«Madre mía, si yo creía que era para abajo…»12,5 %13 de 20
«Me he ido a Luxemburgo y me he perdido ahí…»10,0 %10 de 20

El remedio de manual, CSLS, que resta a cada frase su «popularidad» media, bajó la peor al 8 %… pero el número de comodines pasó de 49 a 51. No quitaba el problema, lo cambiaba de sitio.

El test también es un experimento

Una métrica hecha con un puñado de ejemplos escritos a mano mide, en parte, a quien los escribió. Antes de arreglar lo que señala una alarma, merece la pena mirar dos cosas: de qué está hecho el test y cuánto vale cada caso. Aquí, rehacerlo con cuidado cambió la conclusión dos veces. Quitó un problema que no existía y encontró uno que la medida vieja no podía ver.

Lo que se lleva uno de esta etapa

  1. La dimensión es cuánto matiz cabe, no un ajuste. Y más dimensiones rinden cada vez menos.
  2. Cuando alguien pregunte «¿por qué no el mejor?», mira el criterio, no el producto. Aquí el criterio era la comodidad y por eso ganó el gratuito; con otro criterio la respuesta habría sido la contraria.
  3. Distingue las decisiones reversibles de las de arquitectura. Cambiar de modelo de embeddings cuesta un script; equivocarse de eje de recuperación cuesta el proyecto.
  4. No añadas el índice porque lo hace el tutorial. Un índice aproximado paga velocidad con aciertos, y con tres mil filas no hay velocidad que comprar.
  5. Pon el suelo antes de mirar el resultado. Un umbral decidido después de ver el número siempre se aprueba a sí mismo.