DEV Community

Cover image for ¿La búsqueda semántica de código supera de verdad a grep en un repositorio real?

¿La búsqueda semántica de código supera de verdad a grep en un repositorio real?

La demo para vender la indexación de un código con embeddings siempre es la misma: preguntas "¿dónde bloqueamos a los usuarios?", grep no encuentra nada porque el código lo llama cooldown, y la búsqueda vectorial cae justo en la función correcta. Es una buena demo. La pregunta es con qué frecuencia funciona con código que nadie eligió para la demo.

Esta es una medición sobre un repositorio que casi todo desarrollador de Python ha leído alguna vez, psf/requests en la etiqueta v2.32.3, con diez preguntas en las que grep con la frase evidente devuelve cero líneas. El pipeline copia el que usa la aplicación de escritorio osFoundry para su índice de carpetas locales (reglas de chunking de folder_index.rs, ranking híbrido de folder_search.rs), reescrito en menos de 200 líneas de Python y ejecutado en un Mac M1 de 8 GB, solo con CPU. La respuesta corta: los embeddings por sí solos encontraron el fragmento correcto entre los 5 primeros en 4 de 10 preguntas. Un ranking de palabras clave simple sobre la misma pregunta encontró 6. Los dos combinados encontraron 7 entre los 10 primeros, y 9 cuando los tests y la documentación dejaron de competir con el código fuente.

Chunking con números de línea

Los embeddings trabajan con trozos, no con archivos. La aplicación corta cada archivo en chunks de unos 700 tokens con un solapamiento de 80 tokens, aproximando un token como 4 caracteres, y conserva la primera y la última línea de cada chunk para poder informar un resultado como path:start-end.

TARGET_TOKENS, OVERLAP_TOKENS = 700, 80

def chunk_with_lines(text):
    lines = text.splitlines()
    target, overlap = TARGET_TOKENS * 4, OVERLAP_TOKENS * 4   # ~4 chars per token
    out, start = [], 0
    while start < len(lines):
        chars, end = 0, start
        while end < len(lines) and chars < target:
            chars += len(lines[end]) + 1
            end += 1
        body = "\n".join(lines[start:end])
        if body.strip():
            out.append((body, start + 1, end))          # 1-based, inclusive
        if end >= len(lines):
            break
        back, idx = 0, end
        while idx > start and back < overlap:            # step back ~80 tokens
            idx -= 1
            back += len(lines[idx]) + 1
        start = idx if idx > start else end              # always move forward
    return out
Enter fullscreen mode Exit fullscreen mode

Los chunks se cortan en límites de línea, nunca a mitad de una línea, y la última protección importa: un archivo minificado con una sola línea de 200 KB retrocedería al mismo inicio para siempre.

El escaneo previo omite node_modules, dist, build, .git, .venv y carpetas similares, los lockfiles por nombre completo (package-lock.json y pnpm-lock.yaml no terminan en .lock, y por eso un filtro solo por extensión se los deja pasar), los archivos de más de 1 MB y todo lo que tenga un byte nulo en sus primeros 512 bytes. En requests quedaron 117 de 129 archivos (10 binarios, 1 de más de 1 MB, 1 SVG excluido), 625.261 bytes y 318 chunks.

Hay algo que la regla de 4 caracteres oculta: el código se tokeniza peor que la prosa. Medido con el propio tokenizador de bge-m3, el chunk mediano tenía 771 tokens, no 700, y el más largo 2.050. Eso importa en el siguiente paso.

Embeddings en local

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-m3", device="cpu")
model.max_seq_length = 2048
texts = [f"{c['path']}\n{c['text']}" for c in chunks]   # the path carries meaning too
vecs = model.encode(texts, batch_size=2, normalize_embeddings=True)
Enter fullscreen mode Exit fullscreen mode

Los scripts se ejecutan con uv run, que instala sentence-transformers y torch a partir de los metadatos en línea al principio de cada archivo. Como comparación, los mismos chunks pasaron también por bge-small-en-v1.5, un modelo inglés de 33 M de parámetros unas 17 veces más pequeño (a sus consultas se les antepone el prefijo recomendado por el modelo, "Represent this sentence for searching relevant passages: ").

bge-m3 bge-small-en-v1.5
Tamaño del vector 1.024 384
Entrada máxima 8.192 tokens (limitado a 2.048 aquí) 512 tokens
Chunks truncados 1 de 318 235 de 318
Tiempo para generar embeddings de 318 chunks (M1, CPU) 631 s 20,5 s
Memoria máxima 1,6 GB no medida
Vectores en disco (float32) 1,3 MB 0,49 MB
Embedding de una consulta 682 ms 21 ms

Diez minutos para un repositorio de 625 KB es lento, unos 2 segundos por chunk en esta máquina, y el coste crece con el código, no con el número de archivos. Es un coste único: la aplicación identifica los chunks por un hash de su texto, así que un chunk sin cambios conserva su vector, y editar una función solo recalcula unos pocos chunks. La velocidad del modelo pequeño tiene una pega que la tabla muestra con claridad: con 512 tokens solo leyó la primera parte de 235 de los 318 chunks.

Hay una regla que el código fuente de la aplicación marca con una señal roja y que es fácil romper en el código propio. La consulta tiene que convertirse en embedding con el mismo modelo que construyó el índice. El vector de un chunk de bge-m3 y el vector de una consulta de otro modelo de 1.024 dimensiones tienen la misma forma, así que no salta ningún error y los resultados simplemente son incorrectos.

Palabras clave, vectores y cómo combinarlos

A grep se le dio la frase que uno escribiría, sin distinguir mayúsculas, sobre los mismos 117 archivos. Como referencia de palabras clave más justa, la pregunta entera también pasó por BM25 sobre los chunks, separando los identificadores en _ y camelCase para que should_strip_auth coincida con "strip". (El lado de palabras clave de la aplicación es tantivy sobre archivos completos, proyectado sobre los primeros chunks de cada archivo; el ranking por chunk es una simplificación.)

Los dos rankings se mezclan con Reciprocal Rank Fusion con k = 60, la misma constante que usa la aplicación, tomando los 30 primeros de cada lista:

def rrf(lists, k=60, n=30):
    s = Counter()
    for lst in lists:
        for r, i in enumerate(lst[:n]):
            s[i] += 1 / (k + r + 1)
    return [i for i, _ in s.most_common()]
Enter fullscreen mode Exit fullscreen mode

RRF ignora las puntuaciones y solo usa las posiciones, y ese es el objetivo. Una puntuación BM25 y una similitud coseno no están en la misma escala, y normalizar una contra la otra inventa una relación que no existe. Un chunk en 3.º lugar en una lista y 5.º en la otra supera a uno que es 1.º en una lista y no aparece en la otra.

La respuesta para cada pregunta es el chunk que contiene la línea def de la función objetivo, elegida leyendo el código antes de ejecutar nada. El número que se indica es la posición de ese chunk entre los 318.

Resultados

Pregunta frase en grep una palabra en grep BM25 bge-m3 Híbrido bge-small
no enviar la contraseña a otro sitio tras una redirección (should_strip_auth) "another site": 0 password: 47 9 1 1 1
por qué un POST se convierte en GET tras una redirección (rebuild_method) "turn into": 0 GET: 625 65 10 19 6
dejar de seguir redirecciones que dan vueltas en bucle (resolve_redirects) "redirect loop": 0 loop: 6 5 28 5 14
volver a poner la subida al principio para poder enviarla otra vez (rewind_body) "back to the start": 0 start: 54 1 4 2 20
qué tamaño tiene el cuerpo que vamos a enviar (super_len) "how big": 0 big: 8 54 65 ninguno 233
saltarse el proxy para equipos de la red local (should_bypass_proxies) "local network": 0 local: 94 2 20 6 10
asegurarse de que un archivo nunca queda a medio escribir si el proceso muere (atomic_open) "half-written": 0 half: 4 58 16 29 1
distinguir UTF-16 de UTF-32 mirando los primeros bytes del JSON (guess_json_utf) "first bytes": 0 bytes: 98 4 4 5 111
leer los inicios de sesión guardados de un archivo en la carpeta personal (get_netrc_auth) "home folder": 0 login: 4 1 112 7 17
responder al 401 del servidor con una contraseña con hash (handle_401) "hashed password": 0 hash: 17 1 2 1 1
raise_for_status 15 2 70 12 244
UnrewindableBodyError 9 1 2 2 6

Las posiciones son sobre 318 chunks; los números de grep son líneas coincidentes. "Híbrido" es BM25 combinado con bge-m3.

Destacan tres cosas.

Que grep devuelva cero con una frase no significa que las palabras clave no sirvan. Ordenando por las palabras de la pregunta, BM25 puso la respuesta entre los 5 primeros en 6 de las 10. "Redirect", "proxy", "401" y "UTF-16" aparecen en el código; lo único que no coincide es la forma de redactarlo alrededor. Las búsquedas de una sola palabra muestran el problema contrario: 625 líneas para GET, 98 para bytes. Lo encuentran, pero dentro de un montón que luego hay que leer a mano.

Las victorias de los embeddings son reales pero concretas. bge-m3 encontró should_strip_auth en primer lugar para una pregunta que no comparte ninguna palabra con él, y subió rebuild_method del puesto 65 con BM25 al 10. El modelo pequeño puso atomic_open primero para "never left half-written if the process dies", donde BM25 lo tenía en el 58; el docstring de esa función dice "atomic fashion", y el modelo sabe lo que significa atómico para un archivo. Y los vectores fallan de maneras en que las palabras clave no fallan. Con el identificador raise_for_status a secas, bge-m3 colocó el chunk que lo define en el puesto 70, y bge-small en el 244.

La combinación es la opción por defecto más segura, no la mejor en cada fila. Salvo la pregunta que nada encontró, el híbrido nunca fue la peor columna y casi siempre quedó cerca de la mejor, que es lo que se quiere de una herramienta que se usa sin saber de antemano qué tipo de pregunta se tiene. "How big is the body" falló en todas partes: super_len nunca dice qué mide con palabras que usaría una pregunta.

Aciertos en top 5 / top 10 (10 preguntas) BM25 bge-m3 Híbrido (bge-m3) bge-small Híbrido (bge-small)
Repositorio completo, 318 chunks 6 / 7 4 / 5 5 / 7 3 / 5 7 / 7
Solo src/, 84 chunks 7 / 7 5 / 7 7 / 9 5 / 7 8 / 8

Los tests y la documentación son el mayor competidor

La mayoría de los fallos no fueron aleatorios. Para "why does a POST turn into a GET", el primer resultado de bge-m3 fue tests/test_requests.py, alrededor de test_http_303_changes_post_to_get. Para "read saved logins from a file in the home folder" fue la entrada del changelog sobre .netrc en HISTORY.md, y para "move the upload back to the start" y "how big is the body" una sección de docs/user/advanced.rst. Los tests y la documentación describen el comportamiento con palabras corrientes, que es justo lo que es una pregunta, así que a menudo se parecen más a ella que la implementación.

Eso no es una respuesta incorrecta, ya que el test nombra la función. Pero el ranking cambia mucho al filtrarlos. Buscando solo en los 84 chunks de src/ con los mismos vectores, la búsqueda híbrida encontró 9 de 10 entre los 10 primeros. La mejora más barata en este repositorio no fue un modelo más grande sino un filtro por ruta, o al menos etiquetar los resultados de tests y documentación para poder saltárselos.

El modelo pequeño aguantó el ritmo pese a leer solo los primeros 512 tokens de la mayoría de los chunks, probablemente porque las primeras líneas de un chunk, con el def y su docstring, contienen casi todo lo que dice de qué trata. Diez preguntas sobre un repositorio no bastan para declarar mejor a ninguno de los dos modelos. Sí bastan para ver que la elección del modelo importó menos que combinarlo con palabras clave y apartar los tests del camino.

Cómo ejecutarlo

git clone --depth 1 --branch v2.32.3 https://github.com/psf/requests.git
python3 chunk.py requests chunks.json
uv run embed.py chunks.json BAAI/bge-m3 vec-m3.npy
uv run search.py chunks.json vec-m3.npy BAAI/bge-m3 queries.json
SCOPE=src/ uv run search.py chunks.json vec-m3.npy BAAI/bge-m3 queries.json
Enter fullscreen mode Exit fullscreen mode

La primera ejecución de embed.py descarga bge-m3 desde Hugging Face, unos 2,3 GB. Conviene escribir las consultas antes de mirar los resultados y elegir las respuestas leyendo antes el código. Es muy fácil ajustar la redacción de una pregunta hasta que gane la búsqueda vectorial.

La aplicación de escritorio osFoundry hace el mismo pipeline desde su pantalla Indexes, con bge-m3 en el propio dispositivo y BM25 y vectores combinados en su herramienta de agente folder_search. La entrada sobre chunking en su glosario trata las ventajas y desventajas del tamaño y el solapamiento de los chunks, y la aplicación es gratuita en la página de descarga de osFoundry.

Top comments (0)