Volver

Proyecto laboral · Quantit · Q1 2025

Normalizador de direcciones

Una empresa de logística de última milla recibe pedidos de cientos de fuentes distintas, y cada una escribe las direcciones a su manera. Una de cada cinco no se podía geolocalizar y terminaba en una cola donde una persona la corregía a mano, consumiendo unas 216 horas de trabajo por mes. Desarrollamos una cascada de modelos de lenguaje, validada contra un geocoder, que recupera casi el 95% de esas direcciones por US$ 0,003 cada una.

Por Santiago Fiorino ·

Diagrama del flujo: distintas empresas (Mercado Libre, Nike, Día, entre otras) envían direcciones en formatos dispares, con campos juntos, separados o faltantes; el normalizador las recibe y devuelve siempre los mismos cinco campos separados (calle, altura, código postal, provincia y ciudad) para que el repartidor llegue a la puerta correcta.

Contexto personal

Este fue mi segundo proyecto en Quantit, arrancado en marzo de 2025, mientras seguía trabajando en el agente de preventas inmobiliario. Ya habiendo trabajado unos meses con modelos de lenguaje en producción, pude encarar este proyecto con una idea más clara de cómo aplicarlos, qué cosas medir y cómo organizar un pipeline que sea robusto y mantenible.

Al igual que el proyecto de preventas, trabajé junto a otro ingeniero de Quantit, pero en este caso comenzamos el proyecto desde cero, sin una línea de código preexistente.

El problema

En logística de última milla el camión tiene que llegar a una puerta concreta. Para eso, antes de armar cualquier ruta, la dirección tiene que convertirse en un par de coordenadas. Si la dirección no se puede geolocalizar, el paquete no se puede entregar.

El problema es de dónde vienen esas direcciones. Los pedidos entran desde muchísimas fuentes: tiendas online, marketplaces, ERPs de empresas, integraciones de otros proveedores. Y cada fuente guarda las direcciones a su manera: algunas mandan todo en un solo string, otras separan calle y número, otras ponen la provincia en el campo de la ciudad, otras mandan un código en lugar del nombre de la provincia. No es necesariamente un error de ningún proveedor, simplemente no usan el mismo formato.

No solo es un problema de formatos

Si el problema terminara ahí, sería casi trivial. Habría que escribir un adaptador por proveedor: leer el esquema de cada uno, mapear sus campos a un estándar definido y listo. Perfectamente resoluble con código común y parsers determinísticos.

El tema es que la variabilidad no está solamente entre proveedores, está adentro de cada proveedor. Esto se debe a que el fondo, detrás de cada sitio web de ventas, está el usuario final escribiendo su propia dirección en uno o varios campos de texto libre.

Por ejemplo, estas son cosas que aparecían dentro del campo "Calle" de direcciones que la empresa fallaba en localizar:

No fue un problema del formato en el que se generó/guardó la dirección, fueron personas escribiendo lo que ellos interpretaban como la dirección. Tampoco es un error per se, los usuarios no hicieron nada mal, solo están tratando de explicarle a quien vaya a tocar el timbre cómo encontrar su casa.

Pero esto genera que una misma tienda pueda mandar hoy una dirección impecable, perfectamente separada en sus campos, y en el pedido siguiente todo apretado en una línea con un número de teléfono en el medio. Un algoritmo que normalice por proveedor no resuelve nada, seguiría enfrentando la misma variación humana. Ahí es donde las ideas de algoritmos basados en reglas se terminan y un modelo de lenguaje empieza a tener sentido.

Una de cada cinco, a mano

La empresa ya tenía un normalizador propio, con un geocoder. Resolvía la mayoría de los casos fáciles, pero sobre un histórico de 200.504 direcciones de 2024 y principios de 2025, 41.587 (el 20,7%, una de cada cinco) habían terminado en corrección manual (una persona leyendo la dirección, entendiendo qué quiso decir quien la escribió, buscando manualmente en el mapa y reescribiéndola campo por campo).

Y esto es lo que realmente se buscaba optimizar. Había gente cuyo trabajo era, en buena medida, tipear direcciones. No solo es una tarea aburridísima, es lenta. Leer un string ambiguo, deducir qué parte es la calle, buscar la localidad en un mapa, decidir si el código postal que vino es el correcto y, en los peores casos, llamar por teléfono al comprador. Un minuto por dirección es una estimación generosa.

Ese histórico de direcciones corregidas nos sirvió como ground truth: para cada dirección estaban la versión original, la versión corregida a mano, qué campos se habían tocado y las coordenadas finales. Un dataset con 41.587 ejemplos de la tarea que había que automatizar.

Primero los datos

Antes de escribir una línea de código, nos sentamos a etiquetar las correcciones de las direcciones que fueron hechas a mano: observamos qué había corregido la persona en cada caso y por qué. Y lo primero que apareció fue que había dos categorías de errores en las direcciones bien marcadas: la primera, eran las que tienen el error en la calle (el nombre, la altura, el piso) y la segunda, las que tienen el error en el municipio (la provincia, la ciudad, el código postal).

La distinción importa porque son dos problemas de naturaleza distinta y se arreglan de maneras distintas. Esa división terminó ordenando todo el pipeline. Dentro de cada categoría, estos fueron los errores más comunes:

Calle

  • 85direcciones tenían toda la información junta en el campo “Calle“, con los demás campos vacíos
  • 9direcciones tenían información extra en el nombre de la calle. Ej: “entre X e Y”
  • 6direcciones tenían el nombre de una calle inexistente o mal escrito

Municipio

  • 45direcciones tenían el nombre de la provincia sin normalizar
  • 34direcciones tenían la ciudad sin especificar
  • 14direcciones tenían el código postal incorrecto
  • 7direcciones tenían el nombre de la ciudad sin normalizar

El error más común, por amplia diferencia, es que toda la dirección venga apretada en un solo campo, con el resto vacíos. Y el más común del lado del municipio es que la provincia venga sin normalizar. Para entender qué significa “sin normalizar”, miremos los valores que llegaban en ese campo cuando la respuesta correcta era “Santa Fe”:

Valor recibidoVecesQué es
9211.894Código fiscal de la provincia
Santa Fé1.300Typo
S1.210ISO 3166-2, recortado
SANTA FÉ592Typo
ROSARIO280Localidad, no provincia
AR-S212ISO 3166-2
STA FE71Abreviatura
FUNES25Localidad, no provincia

Nueve formas distintas de escribir la misma provincia, y eso es una sola de las 24. El patrón se repetía en todas: código fiscal, código ISO, código ISO recortado a una letra, typos con y sin tilde, problemas de encoding (C?rdoba) y, cada tanto, directamente una localidad metida en el campo de la provincia.

El caso La Plata

Después apareció un caso que rompe cualquier regla que se te ocurra escribir: en La Plata las calles se llaman con números. Entonces una dirección como 123 3686 significa “calle 123, altura 3686”. No hay heurística de “el número es la altura” que sobreviva a eso, y a la vez es una de las ciudades con más volumen. Para medirla correctamente, decidimos hacerlo por separado. Le armamos un conjunto de validación propio con 50 direcciones de La Plata.

La idea: el modelo propone, el geocoder decide

Un LLM es buenísimo para esta tarea. Lee “Itapiru 279 (Casa con rejas verdes) 1708 Argentina” y separa calle, altura, código postal y aclaración rápida, barata y confiablemente. Pero tiene un problema serio para un pipeline de producción: cuando se equivoca, se equivoca con total seguridad. Cuando inventa una calle o le pone un código postal incorrecto, ya sea por alucinaciones, por equivocación en la separación de los elementos o por error de usuario, no hay nada en su salida que indique que eso está mal.

La decisión de diseño que ordenó todo el proyecto fue no confiarle nunca la última palabra: el modelo propone una hipótesis y el geocoder que ya tenían en la empresa es el juez. Cada dirección candidata se manda al geocoder, que devuelve coordenadas y un puntaje de confianza. Si el puntaje supera un umbral, decidimos que la dirección está resuelta y se corta ahí. Si no, se escala al paso siguiente.

El pipeline

De ahí salió la decisión de implementar un pipeline con forma de cascada. Cada paso propone una dirección, la valida con el geocoder y, si pasa, termina. Los pasos están ordenados de más barato y menos robusto a más caro pero con mejor precisión, así el volumen grande se resuelve con lo económico y el gasto queda solo en los casos difíciles.

  1. Paso 0

    Normalizar provincia por diccionario

    Traduce las provincias mediante diccionarios. Busca normalizar los códigos fiscales (921 → Santa Fe), los códigos ISO 3166-2 (AR-S, S → Santa Fe), las abreviaturas y los typos más comunes. Es determinístico, gratis e instantáneo.

    Se valida contra el geocoder

  2. si no valida
    Paso 1

    Modelo barato separa los campos

    Gemini Flash recibe la dirección cruda y devuelve los siete campos separados, con salida estructurada. Es el paso que resuelve la enorme mayoría de los casos.

    Se valida contra el geocoder

  3. si no valida
    Paso 2

    Modelo caro separa los campos

    Si el geocoder rechazó la propuesta anterior, vuelve a intentar con Gemini Pro (o el modelo más caro de otro proveedor). Solo para las direcciones que el modelo barato no pudo resolver.

    Se valida contra el geocoder

  4. si no valida
    Paso 3

    Sugerencia del propio geocoder

    Cuando el geocoder rechaza una dirección, igual devuelve su mejor interpretación. Si esa interpretación cambia el código postal, se la toma como hipótesis nueva y se la valida.

    Se valida contra el geocoder

  5. si no valida
    Paso 4

    Google Address Validation

    Al pasarle la dirección a Google, nos devuelve la dirección componente por componente, cada uno con su nivel de confirmación. Si la provincia, ciudad y código postal no están los tres confirmados, asumimos que es un problema de municipio y se abren dos correcciones:

  6. si no valida
    Paso 4a

    Corregir municipio desde el código postal

    Se asume correcto el código postal y se busca a qué provincia y localidad corresponde (en una tabla de 23.070 filas). La mitad de los códigos postales mapean a más de un municipio, así que ahí desempata un LLM eligiendo entre los candidatos reales.

    Se valida contra el geocoder

  7. si no valida
    Paso 4b

    Corregir código postal desde el municipio

    Al revés: se asume correcto el municipio y se busca su código postal. Un filtro por distancia de Levenshtein arma una lista corta de candidatos de la tabla postal y el LLM elige uno.

    Se valida contra el geocoder

Cuando retornamos la dirección final, también devolvemos un campo fixed_by que indica qué paso la resolvió. Esto permite saber qué tan confiable es la dirección y, si hace falta, a qué paso escalarla para revisarla.

Si ningún paso valida, el normalizador devuelve igual su mejor intento, pero con el campo fixed_by en null, para que del otro lado sepan que esa dirección no quedó resuelta y no la traten como si lo estuviera.

El modelo elije, no inventa

Un buen tip que se puede sacar del proyecto está en los pasos 4a y 4b. Preguntarle a un modelo “¿cuál es el código postal de esta localidad?” sería pedirle que lo genere de memoria, en base a su entrenamiento, y ahí es donde se presta mucho a alucinaciones. Pero existe una tabla oficial de códigos postales y localidades, así que la respuesta correcta siempre está en una lista finita.

Entonces en vez de simplemente pedirle al LLM el código postal, filtramos la tabla por distancia de Levenshtein normalizada para armar una lista corta de candidatos reales, y el LLM solo elige uno de esa lista. La tarea del LLM no es generar, sino desambiguar. Esto fue clave, porque de los 3.448 códigos postales de la tabla, 1.722 (la mitad) mapean a más de un municipio: el 1900, por ejemplo, corresponde a La Plata, Los Hornos y Tolosa a la vez.

Cómo se midió

El conjunto de validación se armó tomando direcciones al azar de las 41.587 que habían ido a corrección manual, quedándonos con las 40.866 que tenían coordenadas finales confiables. El ground truth es el punto al que llegó la persona que la corrigió a mano. Sobre 500 direcciones se midieron dos cosas:

Ese segundo criterio es el que hace válida la medición. Una dirección puede ser validada perfectamente por el geocoder pero no ser la correcta, y en última milla eso es un paquete que no llega.

Después, dos conjuntos más: uno de 50 direcciones exclusivamente de La Plata, y una batería de tests. Tests unitarios por módulo (estandarización de provincias, separación de campos, corrección de municipio, corrección de código postal) y tests de regresión con una dirección real por cada rama de la cascada: una que se arregla con el LLM, una con la sugerencia del geocoder, una con el municipio desde el CP, una con Google, una con el CP desde el municipio. Así un refactor no puede matar una rama en silencio, que es exactamente el tipo de bug que un pipeline con salidas de escape se come sin avisar.

Resultados

Sobre las direcciones que la empresa no podía procesar y mandaba a corrección manual:

94%Direcciones recuperadas que antes iban a corrección manual
11%Direcciones que necesitaron llegar a un servicio externo pago
US$ 0,003Costo por dirección normalizada
216 h → US$ 39Lo que costaba normalizar a mano un mes de direcciones, contra lo que cuesta ahora

La comparación entre modelos es lo que justifica la cascada de barato a caro:

ConfiguraciónValidaCosto por 1M tokens (entrada / salida)
Gemini Pro94,0%US$ 1,25 / US$ 5,00
Gemini Flash89,2%US$ 0,10 / US$ 0,40

Gemini Flash ya llega a corregir un 89,2% a una fracción del costo, y Gemini Pro lo sube al 94%. Poner el barato primero y el caro únicamente sobre lo que falla da la precisión del segundo pagando casi siempre el primero.

También probamos modelos más chicos y umbrales de validación más exigentes del geocoder. Modelos muy chicos caían a 64% en La Plata contra el 90% de Gemini Pro: en las direcciones difíciles la diferencia entre modelos se abre muchísimo. Y subir el umbral del geocoder de 70 a 80 hacía perder cobertura sin ganar precisión real, así que quedó en 70.

De horas de trabajo a centésimas de centavo

Este es el número que más recalcamos porque es el que impacta directamente en el cliente, y el que hace que el proyecto cierre. Del lado del gasto, lo que consume normalizar una dirección es sorprendentemente poco: en la enorme mayoría de los casos, una sola llamada al modelo barato y una consulta al geocoder que la empresa ya tenía. El modelo caro entra únicamente sobre lo que el barato no pudo, y apenas el 11% de las direcciones llega al servicio pago de Google (que además, con la cuota gratuita de 5.000 consultas mensuales, alcanzaba para unas 40.000 normalizaciones por mes sin pagar un peso).

Eso da US$ 0,003 por dirección. Para el volumen del cliente, del orden de 13.000 direcciones por mes, son unos US$ 39 mensuales.

Del otro lado está lo que eso reemplaza. Esas mismas 13.000 direcciones, a un minuto cada una, son 216 horas de trabajo por mes: más de una persona full-time dedicada exclusivamente a leer direcciones mal escritas y volver a escribirlas bien. Cualquier salario que le pongas a esas 216 horas convierte los US$ 39 en algo despreciable.

La entrega

Todo esto se entregó como una API en FastAPI, dockerizada, con autenticación por api-key hasheada. Un solo endpoint, que recibe una dirección, y devuelve una dirección:

POST /normalizar

{
  "Zip_code": "1650",
  "Street_name": "",
  "Street_number": "",
  "City": "San Martin",
  "State": "Buenos Aires",
  "Line": "Liniers 2037 - General San Martin Buenos Aires Argentina"
}

→

{
  "normalized_direction": {
    "Zip_code": "1650",
    "Street_name": "Liniers",
    "Street_number": "2037",
    "City": "San Martin",
    "State": "Buenos Aires",
    "Floor": null
  },
  "accuracy": 95.0,
  "point": "POINT (-58.5361 -34.5714)",
  "fixed_by": "cheap_llm_separated_directions"
}

La salida incluye tres cosas más que la dirección: el puntaje del geocoder, el punto geolocalizado y qué paso de la cascada resolvió esa dirección.

Resultados (personales)

De este proyecto hay dos cosas claves que me llevé. La primera, lo que hace una buena solución de AI Engineering es saber cuándo, para qué y cómo es conveniente usar un modelo de lenguaje. Una forma de encarar este problema hubiese sido darle la entrada a Gemini Pro y devolver lo que salga, pero se hubiese gastado más y perdido precisión. Otra decisión podría haber sido, para la corrección de códigos postales, preguntarle a la IA "¿cuál es el código postal de la localidad X?", cosa que se prestaba a alucinaciones y fallas. Estas decisiones de diseño son las que hicieron que nuestro proyecto se destaque y no sea simplemente "agregarle un LLM que ahora están de moda y andan bastante bien".

La segunda fue la importancia de las horas que pasamos etiquetando direcciones a mano, entendiendo mejor el problema y armando un conjunto de validación. Esas horas fueron las más productivas de todo el proyecto. Haber visto los ejemplos de entradas y salidas esperadas sobre las que teníamos que trabajar nos permitió entender la naturaleza del problema y cómo abordarlo. Sin haberlo hecho, habríamos escrito un prompt genérico y nos habríamos quedado muy lejos de la precisión a la que llegamos. A la vez, tener un conjunto de validación nos permitió medir correctamente la precisión e ir tomando las decisiones de diseño con datos.