Além da Latitude e Longitude: Criando Embeddings Espaciais para RAG

Read in English

Capa do artigo Além da Latitude e Longitude: Criando Embeddings Espaciais para RAG

Quando comecei a misturar modelos de linguagem (LLMs) com dados geográficos, a primeira tentação foi tratar latitude e longitude como “apenas mais duas features” e seguir adiante. Logo percebi o problema, coordenadas são ótimas, mas não capturam contexto. Latitude e longitude não dizem nada sobre vizinhança. Elas não capturam bem a noção de vizinhança, não funcionam como identidades discretas e não oferecem uma estrutura hierárquica clara, como bairro, rua e quarteirão, dificultando então fazer essa conexão.

Neste artigo, compartilho um caminho simples e prático para transformar localização em algo que se comporte como um embedding, isto é, uma representação vetorial que pode ser indexada, comparada e utilizada em retrieval. O melhor é que esse processo é totalmente determinístico, não exige treinamento, tem baixo custo e escala bem. Para isso, vamos usar o H3, biblioteca de indexação espacial criada pela Uber, muito usada quando fazemos operações e análise de camadas vetoriais.

Definindo o recorte: o Recife como exemplo

Para tornar a explicação mais concreta, escolhi trabalhar com um recorte do Recife e pontos de interesse do OpenStreetMap (OSM). A partir desse conjunto, construí um retrieval geográfico básico e, em seguida, dei o passo para combinar geografia e texto e criar uma base consistente de RAG geográfico, algo que uma LLM consegue utilizar de forma eficaz.

Antes de buscar os dados, foi necessário definir o universo do experimento. Em geoprocessamento, isso é mais relevante do que parece, pois o recorte influencia a densidade, os tipos de lugares e até o comportamento do retrieval. No meu caso, defini um bounding box aproximado do Recife.

# Recife
SOUTH, WEST, NORTH, EAST = -8.15, -35.02, -7.95, -34.85

Com o recorte estabelecido, consultei os pontos do OSM por meio da Overpass API. Selecionei categorias comuns em ambientes urbanos, como hospital, escola, farmácia, polícia e abrigo. Essas classes são fáceis de validar e representam bem a dinâmica da cidade. Assim, obtive um dataset realista e, ao mesmo tempo, interpretável.

import requests, pandas as pd

OVERPASS_URL = "https://overpass-api.de/api/interpreter"
AMENITIES = ["hospital", "school", "pharmacy", "police", "shelter"]

query = f"""
[out:json][timeout:60];
(
  node["amenity"~"^({'|'.join(AMENITIES)})$"]({SOUTH},{WEST},{NORTH},{EAST});
);
out center;
"""

resp = requests.post(OVERPASS_URL, data=query)
data = resp.json()
len(data["elements"])
1250

Estruturando os dados para indexação

Com os dados coletados, organizei tudo em um formato simples de indexar e depurar, gerando um DataFrame com colunas essenciais, como id, nome, categoria, latitude e longitude. Parece bobagem, mas esse passo facilita muito depois. Principalmente quando você quer evoluir o pipeline, por exemplo, ao trocar node por way ou relation e utilizar center no lugar das coordenadas diretas.

rows = []
for el in data["elements"]:
    tags = el.get("tags", {})
    amenity = tags.get("amenity")
    name = tags.get("name", None)

    if el["type"] == "node":
        lat, lon = el.get("lat"), el.get("lon")
    else:
        center = el.get("center", {})
        lat, lon = center.get("lat"), center.get("lon")

    rows.append({
        "osm_type": el["type"],
        "osm_id": el["id"],
        "name": name,
        "category": amenity,
        "lat": lat,
        "lon": lon
    })

df = pd.DataFrame(rows)
df.head()
Index OSM Type OSM ID Nome Categoria Latitude Longitude
0 node 560397916 - pharmacy -8.0624473 -34.8964475
1 node 560398646 Colégio GGE school -8.0620287 -34.8987418
2 node 560400267 Delegacia de Polícia Civil police -8.0458839 -34.9275611
3 node 560628925 Colégio Sancarlos school -8.0477011 -34.9282542
4 node 561522263 Colégio Souza Leão school -8.0541609 -34.9198574

Ao analisar a distribuição das classes, observei que as escolas predominam no conjunto. Isso é positivo, pois cria uma situação próxima da realidade. Ao buscar uma classe como hospital, não quero receber escolas próximas apenas porque são maioria. Em outras palavras, o retrieval precisa compreender intenção, e não apenas densidade.

df["category"].value_counts()
Categoria Quantidade
school 720
hospital 193
pharmacy 158
shelter 105
police 74

Até aqui, eu tinha apenas um conjunto de pontos com latitude e longitude. A partir deste ponto, vem a mudança central, no qual iremos tratar as coordenadas como números contínuos e convertê-las em tokens espaciais com o H3.

Convertendo coordenadas em tokens espaciais com H3

!pip install h3

Figura 1 do artigo Além da Latitude e Longitude: Criando Embeddings Espaciais para RAG

A ideia principal do H3 é dividir o mapa em hexágonos e atribuir um identificador único a cada célula, como na imagem ao lado. Para a LLM, esse identificador funciona como um token espacial.

Para utilizar esse índice, é possível escolher diferentes resoluções, representando o espaço em múltiplas escalas. Utilizei as resoluções 7, 9 e 11 para capturar desde regiões mais amplas até detalhes locais.

import h3

def add_h3(df: pd.DataFrame, res: int) -> pd.DataFrame:
    out = df.copy()
    out[f"h3_r{res}"] = [
        h3.latlng_to_cell(lat, lon, res) for lat, lon in zip(out["lat"], out["lon"])
    ]
    return out

df = add_h3(df, 7)
df = add_h3(df, 9)
df = add_h3(df, 11)
df[["lat","lon","h3_r7","h3_r9","h3_r11"]].head()
Index Latitude Longitude H3 R7 H3 R9 H3 R11
0 -8.0624473 -34.8964475 878183980ffffff 89818398053ffff 8b818398051cfff
1 -8.0620287 -34.8987418 878183980ffffff 89818398057ffff 8b8183980573fff
2 -8.0458839 -34.9275611 878183981ffffff 8981839810fffff 8b81839810ebfff
3 -8.0477011 -34.9282542 878183981ffffff 89818398107ffff 8b8183981050fff
4 -8.0541609 -34.9198574 878183981ffffff 898183981cfffff 8b8183981cf5fff

A partir daqui cada local deixa de ser apenas um ponto somente e passa a ser representado por um conjunto de identificadores discretos. É nesse momento que o embedding começa a tomar forma, sem necessidade de treinar uma rede.

Lidando com o problema das bordas

No mundo real, bordas importam. Dois pontos podem estar muito próximos, mas cair em hexágonos diferentes. Se esses hexágonos forem tratados como completamente distintos, o retrieval tende a falhar justamente onde deveria acertar.

Para lidar com isso, adotei uma estratégia simples, além do token da célula principal, incluí também os tokens das células vizinhas. No H3, esse conjunto é chamado de k-ring, ou grid disk. Utilizei a resolução 9 como base urbana e k = 1 para incluir os vizinhos imediatos.

Primeiro, gerei uma assinatura multiescala. Em seguida, expandi essa assinatura com as células vizinhas. Assim, o conceito de “perto” deixa de se restringir à distância e passa a incorporar a adjacência topológica, o que melhora o desempenho em áreas densas.

RESOLUTIONS = (7, 9, 11)

def spatial_signature(lat: float, lon: float, resolutions=RESOLUTIONS):
    return [h3.latlng_to_cell(lat, lon, r) for r in resolutions]

df["sig"] = [spatial_signature(lat, lon) for lat, lon in zip(df["lat"], df["lon"])]

df["sig"].head()

sig

0 [878183980ffffff, 89818398053ffff, 8b818398051...

1 [878183980ffffff, 89818398057ffff, 8b818398057...

2 [878183981ffffff, 8981839810fffff, 8b81839810e...

3 [878183981ffffff, 89818398107ffff, 8b818398105...

4 [878183981ffffff, 898183981cfffff, 8b8183981cf...

E depois expande com vizinhos:

def sig_with_neighbors(sig, k=1):
    base = sig[1]  # r9
    nbrs = list(h3.grid_disk(base, k))
    return sig + nbrs

df["sig_k1"] = [sig_with_neighbors(s, k=1) for s in df["sig"]]
df["sig_k1"].head()

sig_k1

0 878183980ffffff
89818398053ffff
8b818398...

1 878183980ffffff
89818398057ffff
8b818398...

2 878183981ffffff
8981839810fffff
8b818398...

3 878183981ffffff
89818398107ffff
8b818398...

4 878183981ffffff
898183981cfffff
8b818398...

Convertendo tudo em vetores (hashing trick)

Com os tokens definidos, o passo seguinte foi convertê-los em vetores numéricos de dimensão fixa. Isso permite utilizar similaridade por cosseno, índices vetoriais como FAISS ou HNSW e, consequentemente, aplicar RAG.

Para essa conversão, utilizei o hashing trick, onde cada token é mapeado para uma posição do vetor por meio de uma função hash, no caso MD5, acumulando contagens nessa posição. Ao final, normalizo o vetor. O resultado foi um conjunto de 1.250 locais do Recife convertidos em vetores prontos para retrieval.

import numpy as np, hashlib

def hash_to_idx(token: str, dim: int) -> int:
    h = hashlib.md5(token.encode("utf-8")).hexdigest()
    return int(h, 16) % dim

def tokens_to_vec(tokens, dim=1024):
    v = np.zeros(dim, dtype=np.float32)
    for t in tokens:
        v[hash_to_idx(t, dim)] += 1.0
    n = np.linalg.norm(v)
    return v if n == 0 else (v / n)

DIM = 1024
V_geo = np.vstack([tokens_to_vec(toks, dim=DIM) for toks in df["sig_k1"].values])
V_geo.shape
(1250, 1024)

Visualizando o retrieval no mapa

Após transformar os pontos em vetores e estruturar o índice, busquei observar o comportamento espacial do sistema. Para isso, defini um ponto de consulta e visualizei no mapa quais locais o mecanismo de retrieval considerava mais próximos.

Utilizei uma função em Python com matplotlib:

def plot_retrieval(query_lat, query_lon, results, title="", margin=0.01):
    plt.figure(figsize=(10, 8))
    ax = plt.gca()

    # Calcular bounds da região de interesse
    all_lats = np.concatenate([results["lat"].values, [query_lat]])
    all_lons = np.concatenate([results["lon"].values, [query_lon]])

    lat_min, lat_max = all_lats.min() - margin, all_lats.max() + margin
    lon_min, lon_max = all_lons.min() - margin, all_lons.max() + margin

    # Filtrar pontos de contexto que estão na região visível
    context_mask = (
        (df["lat"] >= lat_min) & (df["lat"] <= lat_max) &
        (df["lon"] >= lon_min) & (df["lon"] <= lon_max)
    )
    context_df = df[context_mask]

    # Pontos de contexto (cinza claro, pequenos)
    ax.scatter(
        context_df["lon"].values, 
        context_df["lat"].values, 
        s=20, 
        c='lightgray', 
        alpha=0.5,
        label='Outros pontos na região',
        zorder=1
    )

    # Top-k retornado (azul, médios)
    ax.scatter(
        results["lon"].values, 
        results["lat"].values, 
        s=100, 
        c='royalblue',
        edgecolors='darkblue',
        linewidths=1.5,
        label='Resultados (top-k)',
        zorder=3
    )

    # Ponto da query (vermelho, grande)
    ax.scatter(
        [query_lon], 
        [query_lat], 
        s=250, 
        c='red',
        marker='*',
        edgecolors='darkred',
        linewidths=2,
        label='Query',
        zorder=4
    )

    # Linhas da query para cada resultado
    for _, r in results.iterrows():
        ax.plot(
            [query_lon, r["lon"]], 
            [query_lat, r["lat"]], 
            'b-', 
            alpha=0.3, 
            linewidth=1,
            zorder=2
        )

    # Definir limites do plot
    ax.set_xlim(lon_min, lon_max)
    ax.set_ylim(lat_min, lat_max)

    # Adicionar anotações para os top 3
    for i, row in results.head(3).iterrows():
        ax.annotate(
            f"{i+1}. {row['name'][:20] if pd.notna(row['name']) else row['category']}\n(score: {row['score']:.3f})",
            xy=(row['lon'], row['lat']),
            xytext=(10, 10),
            textcoords='offset points',
            fontsize=8,
            bbox=dict(boxstyle='round,pad=0.5', fc='yellow', alpha=0.7),
            arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0', lw=1)
        )

    ax.set_title(title, fontsize=12, fontweight='bold')
    ax.set_xlabel("Longitude", fontsize=10)
    ax.set_ylabel("Latitude", fontsize=10)
    ax.legend(loc='upper right', fontsize=9)
    ax.grid(True, alpha=0.3)

    # Aspect ratio igual para lat/lon
    ax.set_aspect('equal', adjustable='box')

    plt.tight_layout()
    plt.show()

Retrieval “perto de mim” similaridade vetorial

Retrieval é o processo de recuperar informações relevantes a partir de um conjunto de dados indexado. Em sistemas vetoriais, isso significa identificar os itens mais semelhantes a uma consulta com base em uma métrica, como similaridade por cosseno ou distância euclidiana.

Primeiro vamos criar o retrieve geográfico, para iniciar os testes de recuperação por proximidade, por meio do cálculo a similaridade em relação ao conjunto dos dados.

Essa abordagem não busca medir distâncias em metros com precisão absoluta (o que seria lento caso muitos pontos), o objetivo é oferecer uma noção prática de proximidade que seja rápida, passível de indexação e menos sensível a efeitos de borda. Em muitos contextos urbanos, esse nível de aproximação é suficiente, em especial casos de uso como "Qual hospital mais próximos dessa coordenada?".

from sklearn.metrics.pairwise import cosine_similarity

def retrieve_geo(query_lat, query_lon, topk=10):
    q_tokens = sig_with_neighbors(spatial_signature(query_lat, query_lon), k=1)
    q_vec = tokens_to_vec(q_tokens, dim=DIM).reshape(1, -1)

    sims = cosine_similarity(q_vec, V_geo).ravel()
    top = np.argsort(-sims)[:topk]

    out = df.iloc[top][["name", "category", "lat", "lon"]].copy()
    out["score"] = sims[top]
    return out.reset_index(drop=True)

query_lat = float(df.iloc[0].lat)
query_lon = float(df.iloc[0].lon)

res_geo = retrieve_geo(query_lat, query_lon, topk=10)
display(res_geo)

plot_retrieval(
    query_lat, query_lon, res_geo,
    title=f"Retrieval GEO | lat={query_lat:.6f}, lon={query_lon:.6f} | topk=10"
)
Index Name Category Latitude Longitude Score
0 - pharmacy -8.0624473 -34.8964475 1.000000238418579
594 Real Hospital Português hospital -8.0643955 -34.8981574 0.9166668653488159
605 Instituto Cervantes school -8.0617243 -34.8971303 0.9166668653488159
603 - hospital -8.0631268 -34.8953544 0.9166668653488159
589 - hospital -8.0627699 -34.8983415 0.9166668653488159
584 - hospital -8.0628958 -34.898804 0.6172134876251221
1231 Hospital Memorial São José hospital -8.0598305 -34.8971396 0.5833334922790527
1 colégio GGE school -8.0620287 -34.8987418 0.5833334922790527

Figura 2 do artigo Além da Latitude e Longitude: Criando Embeddings Espaciais para RAG

Obviamente, isso não é perfeito, veja que ela ainda selecionou algumas classes que não são hospitais. Além disso, caso um usuário fizesse a busca bem na divisa entre dois bairros, poderia rolar uma confusão. Mas na prática, esse é um processo que pode funcionar bem o suficiente para maioria dos casos do dia a dia.

Combinando texto e geografia: o passo que cria o RAG geográfico

Até aqui, eu respondia à pergunta “o que está perto desta coordenada?”. No entanto, a dúvida real do usuário costuma ser outra “quais hospitais estão perto de mim?”.

Para incorporar a intenção semântica à busca, gerei embeddings de texto a partir do nome e da categoria utilizando o modelo SentenceTransformer. Optei pelo modelo all-MiniLM-L6-v2, que para mim ofereceu equilíbrio entre desempenho e custo computacional, bem leve e funcionou bem na minha máquina. Em seguida, concatenei os embeddings o textual e o geográfico.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

texts = (df["name"].fillna("") + " " + df["category"].fillna("")).tolist()
V_txt = model.encode(texts, normalize_embeddings=True)

V_hybrid = np.hstack([V_txt, V_geo])
V_hybrid.shape

Quando analisei o resultado o ganho foi imediato. O sistema deixa de ser apenas geográfico e passa a considerar simultaneamente localização e intenção. Em vez de retornar qualquer ponto próximo, ele passa a priorizar o que está próximo e semanticamente alinhado à consulta.

Agora sim temos um RAG geográfico de verdade. O retriever seleciona o contexto mais adequado, e a LLM assume a etapa linguística, explicando, comparando ou recomendando com base nos resultados recuperados.

def retrieve_hybrid(query_text, query_lat, query_lon, topk=10):
    # texto
    q_txt = model.encode([query_text], normalize_embeddings=True)

    # geo
    q_tokens = sig_with_neighbors(spatial_signature(query_lat, query_lon), k=1)
    q_geo = tokens_to_vec(q_tokens, dim=DIM).reshape(1, -1)

    # concat
    q = np.hstack([q_txt, q_geo])

    sims = cosine_similarity(q, V_hybrid).ravel()
    top = np.argsort(-sims)[:topk]

    out = df.iloc[top][["name", "category", "lat", "lon"]].copy()
    out["score"] = sims[top]
    return out.reset_index(drop=True)

query_text = "hospital"

res_hybrid = retrieve_hybrid(query_text, query_lat, query_lon, topk=10)
display(res_hybrid)

plot_retrieval(
    query_lat, query_lon, res_hybrid,
    title=f"Retrieval HYBRID | '{query_text}' | lat={query_lat:.6f}, lon={query_lon:.6f} | topk=10"
)
Index Name Category Latitude Longitude Score
589 - hospital -8.0627699 -34.8983415 0.9583333730697632
603 - hospital -8.0631268 -34.8953544 0.9583333730697632
584 - hospital -8.0628958 -34.898804 0.808606743812561
604 - hospital -8.0649677 -34.8943472 0.7916668057441711
597 - hospital -8.0631409 -34.9001382 0.7916668057441711
587 - hospital -8.0626232 -34.8997285 0.7916668057441711
636 - hospital -8.0662101 -34.895579 0.7916668057441711
672 - hospital -8.0623306 -34.8996346 0.7916668057441711

Figura 3 do artigo Além da Latitude e Longitude: Criando Embeddings Espaciais para RAG

Conclusão

Aqui eu parti de uma limitação clara das coordenadas. Quando usadas isoladamente, latitude e longitude não representam adequadamente o contexto. Ao converter o espaço em tokens H3 multiescala, incorporar a adjacência com k-ring e transformar essas assinaturas em vetores por meio do hashing trick, construí um embedding geográfico simples, determinístico e eficiente. Esse processo dispensa treinamento, tem baixo custo computacional e escala de forma previsível.

O ponto mais relevante, porém, não é apenas técnico. Ao transformar a localização em uma representação vetorial compatível com mecanismos modernos de retrieval, passamos a tratar o espaço como parte da linguagem do sistema. A geografia deixa de ser um filtro externo e passa a integrar o próprio raciocínio do modelo.

Quando esse embedding é combinado a representações textuais geradas pelo SentenceTransformer, surge um retriever capaz de compreender simultaneamente o que se busca e onde se busca. Nesse estágio, o RAG geográfico deixa de ser uma adaptação pontual e passa a operar como uma arquitetura integrada, unindo localização e intenção semântica de forma prática e aplicável a contextos reais, da busca contextual urbana a sistemas inteligentes de apoio à decisão.

No fim, isso vai além de indexação espacial, é uma forma de fazer LLMs entenderem geografia de verdade, não só descreverem coordenadas, o que é um passo essencial para que LLMs deixem de apenas descrever o espaço e passem, de fato, a compreendê-lo.

Continue lendo

Todos os artigos