sqlite-utils, tu navaja suiza para SQLite

Vistas: 1
0:00 / 0:00
sqlite-utils, tu navaja suiza para SQLite

Llevo varias semanas construyendo, pieza a pieza, mi propio grafo de conocimiento con GraphRAG sobre SQLite. Nodos, aristas, ficheros, embeddings y un asistente local que consulta todo ese contexto para responderme con fundamento. Y justo cuando me disponía a enseñártelo entero en este episodio, me di cuenta de que lo había borrado. Así, sin querer. Generé una base de datos de ejemplo para probar una cosa y me llevé por delante la buena. Tuve que empezar de cero y, a día de hoy, apenas llevo 657 nodos de los 20.000 o 25.000 que llegué a tener. Paciencia y calma 😅. La parte buena es que de ese accidente ha salido la excusa perfecta para hablarte de la herramienta que me faltaba en la mochila, sqlite-utils, esa navaja suiza que te permite inspeccionar, consultar, transformar y encadenar una base de datos SQLite sin abrir Python ni un cliente gráfico.

Por qué SQLite y por qué sqlite-utils

Antes de entrar en materia, conviene recordar de dónde venimos, porque este episodio no va suelto. En esta serie llevamos unos cuantos capítulos montando un pipeline completo para una base de conocimientos. Te hablé de jq y yq para domar JSON y YAML, te hablé de ripgrep para encontrar palabras dentro de documentos en milisegundos, te hablé de embeddings y búsqueda semántica con Ollama, y en la trilogía de GraphRAG hemos ido construyendo el grafo, primero entendiendo cómo se arma y después viendo qué operaciones podemos hacer con él.

La cuestión es que, cuando llegó el momento de decidir dónde guardar todo ese grafo, tenía varias opciones sobre la mesa. Podía haber levantado un PostgreSQL, que es lo que hace todo el mundo cuando habla de embeddings y de búsqueda semántica. Podía haber tirado de alguna solución más compleja, con su servidor, sus servicios y su orquestación. Y sin embargo, me decanté por lo más sencillo que existe, por SQLite.

¿Y por qué SQLite? Pues por una razón muy simple y muy poco glamurosa, porque SQLite es un fichero. Un solo fichero. Y ese fichero te lo puedes llevar a donde quieras. Si tus notas ocupan doscientos megas y tu base de datos ocupa otro tanto, eso cabe en un USB sin despeinarse. Te vas de viaje, te llevas el pincho, y tienes todo tu conocimiento al alcance de la mano, nunca mejor dicho. No necesitas un servidor, no necesitas una conexión, no necesitas nada. Y por si fuera poco, es una base de datos tremendamente fiable y ubicua, probablemente una de las más utilizadas del planeta, aunque viva en la sombra sin que casi nadie le preste atención.

Hay herramientas que se llevan los titulares y herramientas que se llevan el trabajo. SQLite pertenece a las segundas. Está en tu móvil, en tu navegador, en tu televisión y en tu coche, y aun así la gente sigue mirándola por encima del hombro.

Ahora bien, SQLite tiene una particularidad que a mí me ha dado más de un quebradero de cabeza, y es que la interfaz que trae de serie no acompaña. Para mirar lo que hay dentro tienes que abrir sqlite3 en modo interactivo, o un cliente gráfico, o tirar de Python. Y ahí es donde entra sqlite-utils.

El problema de abrir el cliente gráfico cada vez

Seguro que te ha pasado. Tienes una base de datos SQLite ahí tirada, quieres comprobar cuatro cosas, y solo con pensar en abrir DB Browser for SQLite o el cliente gráfico de turno se te cae la mano a los pies. Arrancar el programa, esperar unos segundos a que cargue, navegar por los menús, buscar la tabla, doblar el ratón. Y al final, por pura pereza, lo dejas estar. Que le vamos a hacer, el cerebro humano es así de vago cuando el coste de la primera acción supera la recompensa.

La otra alternativa es abrir Python y escribir unas líneas con la librería sqlite3 de la biblioteca estándar. Funciona, por supuesto, pero eso ya no es inspeccionar, eso es ponerse a programar. Y yo, cuando estoy en la terminal con el flow del trabajo, lo que quiero es un comando. Una línea. Que me devuelva lo que necesito y que me deje seguir.

Ese es exactamente el hueco que rellena sqlite-utils. Y no solo para el día a día, sino también para el pipeline que hemos construido. Porque además de inspeccionar y consultar, te permite dirigir el resultado a donde tú quieras. Haces una consulta sobre tu base, obtienes un JSON, y ese JSON se lo enchufas, nunca mejor dicho, a tu modelo de lenguaje para que haga lo que tenga que hacer. Todo ello sin escribir una línea de Python y sin una gota de SQL, aunque cuando quieras potencia, la tienes.

Qué es sqlite-utils y quién está detrás

Conviene que sepas qué es y, sobre todo, qué no es. En la documentación oficial se dice bien claro que sqlite-utils no pretende ser un ORM completo. No es un ORM. Es un conjunto de utilidades, una librería de Python y una herramienta de línea de comandos, para crear, poblar, consultar y transformar bases de datos SQLite. La máxima del proyecto es hacer que crear una base de datos y meterle datos sea lo más productivo posible. Ni más, ni menos.

Detrás está Simon Willison, que si no te suena por el nombre probablemente te suene por sus obras. Es el creador de Datasette, la herramienta con la que publicas y exploras bases SQLite en la web, es el creador del CLI llm para hablar con modelos de lenguaje desde la terminal, y es nada menos que cofundador de Django. Así que hablamos de alguien que sabe un rato largo de Python y de datos.

La filosofía de la herramienta encaja como un guante con lo que hemos ido construyendo. Datasette es para explorar y publicar. sqlite-utils es para crear y poblar. Una va antes, la otra va después, y las dos hablan el mismo idioma: SQLite. Está publicada bajo licencia Apache 2.0, está escrita en Python y su CLI está construida con Click. Necesita Python 3.10 o superior para funcionar.

Un detalle de transparencia, porque me gusta decir las cosas como son. La última versión publicada en PyPI es la 4.2.1, del mes de agosto. En mi máquina, en cambio, tengo instalada la 4.1.1. Para todo lo que vamos a ver hoy no hay diferencia práctica, pero si tú instalas la tuya desde cero, te llevarás la 4.2.1 sin enterarte.

Instalar sqlite-utils con uv

La instalación es de esas que duran menos que el café. Yo mi recomendación particular, por encima de todas las opciones que existen, es que lo hagas con uv. Y lo digo con conocimiento de causa, porque para mí uv se ha convertido en la herramienta fundamental que hay que usar a partir de ahora. El potencial que te da es increíble y te permite hacer las cosas de una forma absurdamente sencilla.

Así que instalamos la CLI en un entorno aislado gestionado por uv, con este comando,

uv tool install sqlite-utils

Si no quieres instalarla de forma permanente y solo la vas a probar un rato, uv también te deja ejecutarla al vuelo, sin dejar rastro. Esto es lo que hace el propio Simon en algunos de sus artículos,

uvx sqlite-utils --version

Y comprobamos que ha quedado instalada,

sqlite-utils --version

Si prefieres otras vías, también tienes la clásica pipx, que la aísla igual de bien, o pip a secas, aunque esta última te la desaconsejo porque ensucia el Python del sistema. Y si estás en macOS o en Linux con Homebrew, tienes la fórmula de siempre.

pipx install sqlite-utils
brew install sqlite-utils

Un extra que marca la diferencia y del que casi nadie se acuerda son las completions del shell. Si las activas, tendrás autocompletado para todos los subcomandos, que son unos cuantos. Para zsh, añades esta línea a tu .zshrc,

eval "$(_SQLITE_UTILS_COMPLETE=zsh_source sqlite-utils)"

Y para bash, el equivalente en tu .bashrc, cambiando el idioma del shell,

eval "$(_SQLITE_UTILS_COMPLETE=bash_source sqlite-utils)"

Inspeccionar tablas, columnas y esquemas

Una vez instalado, llega la parte divertida. Lo primero que quieres saber cuando te enfrentas a una base de datos que ha generado otra herramienta es qué hay dentro. Con sqlite-utils eso es tan sencillo como ejecutar el subcomando tables sobre tu base de datos. En mi caso, la base de pruebas se llama demo.db y la de verdad, donde reconstruyo el grafo, también la llevo así.

sqlite-utils tables demo.db

Lo que te devuelve es un array de objetos del tipo clave-valor, un JSON en toda regla. En mi grafo aparecen tablas como nodes, edges, notes y toda la retahíla de tablas auxiliares que genera la búsqueda de texto completo, las del tipo notes_fts, notes_fts_data y compañía. Y aquí está el primer detalle bonito, como es JSON, lo puedes encadenar directamente con un jq si quieres darle otra forma.

Me hace mucha gracia este patrón. Media docena de herramientas modernas de terminal hablan JSON por defecto, y eso convierte a jq en el pegamento universal. sqlite-utils lo entendió desde el primer día.

Si no te gusta leer JSON crudo, cosa bastante razonable, siempre puedes pedirle que te lo formatee en una tabla bien alineada. Basta con añadir la opción table,

sqlite-utils tables demo.db --table

Y ya que estamos, añadamos toda la información que de verdad importa. Con --counts obtienes el número de elementos de cada tabla y con --columns la lista de columnas de cada una. Todo en un solo comando,

sqlite-utils tables demo.db --counts --columns --table

Esto te da un listado de todas tus tablas, con sus columnas y con el número de filas que tiene cada una. A día de hoy, mi tabla de nodos ronda los 657 registros, y va subiendo cada vez que relanzo el proceso de indexado sobre el grafo que estoy reconstruyendo. Es como un \dt+ de PostgreSQL, pero con esteroides.

Lo siguiente que quieres ver es el esquema. Las sentencias CREATE TABLE de toda la vida. Y para eso está el subcomando schema,

sqlite-utils schema demo.db

Si le pasas el nombre de una tabla concreta, te devuelve solo el esquema de esa tabla, que es comodísimo cuando tienes veinte y solo te interesa una,

sqlite-utils schema demo.db nodes

En mi caso me devuelve algo como esto, con las tres columnas que tengo en los nodos, la clave, la etiqueta y el tipo,

CREATE TABLE [nodes] (
   [key]   TEXT PRIMARY KEY,
   [label] TEXT,
   [type]  TEXT
);

Y aquí viene un truco que me parece directamente brillante. Como schema escupe SQL puro y duro, si en un momento determinado quieres reconstruir tu base de datos, o llevártela de un sitio a otro, puedes volcar todo el esquema a un fichero y guardarlo,

sqlite-utils schema demo.db > esquema.sql

Con eso tienes la estructura reproducible en un fichero de texto. Ya solo te faltarían los datos, pero para eso tenemos el dump, que veremos más adelante.

Navegar los datos con rows

Vale, ya sabemos qué tablas hay y cómo están hechas. Ahora toca lo interesante, mirar los datos. El subcomando rows es el que te permite ver las filas de una tabla concreta dentro de una base concreta, controlando cuántas quieres y en qué formato.

sqlite-utils rows demo.db nodes --limit 10 --table

Con esto te salen las primeras diez filas de la tabla de nodos, con todas sus columnas. En mi caso particular la cosa se complica un poco, porque a estas alturas tengo los embeddings metidos en medio y el resultado se hace difícil de leer. Así que, para simplificar, tengo la opción de elegir exactamente qué columnas quiero ver, con la opción -c, que se puede repetir tantas veces como columnas necesites,

sqlite-utils rows demo.db nodes -c key -c label -c type --limit 10 --table

Mucho más limpio. Ahora te salen solo las tres columnas que me interesan. Y fíjate en el encadenamiento de opciones, primero el límite, luego las columnas, y todo en el mismo comando. Esto es lo que hace que la herramienta sea tan agradable de usar.

El siguiente paso, como era de esperar, es filtrar. Y para eso tenemos la opción where, que acepta una condición SQL directamente,

sqlite-utils rows demo.db nodes --where 'type = "tool"' --limit 10 --table

Con eso te devuelve las diez primeras filas cuyo tipo sea tool, que en mi grafo son las herramientas. Así de sencillo. Y como ya te estarás imaginando, también puedes ordenar el resultado por cualquier columna con la opción order,

sqlite-utils rows demo.db nodes --order 'label asc' --limit 5 --table

Lo que me devuelve, ordenado alfabéticamente por etiqueta, es una lista tal que Docker, Docker Compose, Graph, Kubernetes y así sucesivamente. Muy útil para darte cuenta en un vistazo de qué herramientas tienes indexadas.

Y aquí llega el momento en el que dejo de formatear todo como una tabla, porque la tabla es muy bonita para leer, pero para procesar lo que quiero es JSON. Si quito la opción table, la salida vuelve a ser un array de objetos JSON, y entonces ya puedo hacer con ella lo que me venga en gana. Es el puente perfecto hacia el jq que vimos hace un par de episodios.

Filtrar sin miedo a la inyección SQL

Antes de seguir, un aviso de seguridad que importa y mucho. Cuando escribes la condición del where pegando el valor directamente, estás confiando en que ese valor nunca llega de fuera. Y si llega de fuera, ya sabes cómo acaba la historia, con una inyección SQL y la base de datos de vacaciones en manos ajenas.

sqlite-utils te lo pone fácil para evitarlo. En lugar de pegar el valor en la condición, usas un parámetro con nombre, el :param de toda la vida, y luego pasas el valor aparte con la opción -p. Fíjate en la diferencia,

sqlite-utils rows demo.db nodes --where 'type = :tipo' -p tipo tool --limit 10 --table

La consulta se escribe con :tipo en el lugar del valor, y el valor se pasa limpio y separado con -p tipo tool. La librería se encarga de citarlo y escaparlo por ti. Es la forma correcta de hacerlo, y te ahorra cualquier quebradero de cabeza. Tú escribe siempre los parámetros con nombre, que luego no hay que arreglar nada.

Consultas complejas, joins y agregaciones

Hasta ahora hemos usado los flags de la línea de comandos, que dan para mucho, pero llega un momento en que necesitas más potencia. Necesitas agrupar, necesitas unir tablas, necesitas hacer un JOIN en condiciones. Y aquí está lo mejor de todo, porque sqlite-utils también te deja escribir SQL de verdad.

El subcomando por defecto, cuando no le pones ninguno, es query. Es decir, sqlite-utils demo.db "SELECT ..." y a correr. Así que si quieres saber cuántos nodos hay de cada tipo, agrupados y ordenados de mayor a menor, lo escribes tal cual,

sqlite-utils demo.db \
  "select type, count(*) as total from nodes group by type order by total desc" \
  --table

Eso te devuelve un recuento por tipo de nodo. Y ojo, que estos son los primeros pasos del análisis que hará el asistente después. Pero donde de verdad brilla es en los JOIN. Un grafo sin aristas se queda en una lista de la compra, así que vamos a cruzar los nodos con sus relaciones. En mi caso la tabla edges guarda un source, un target y un kind, de modo que puedo ver qué nodo se relaciona con cuál y de qué manera,

sqlite-utils demo.db \
  "select n.label, e.kind as relacion from edges e join nodes n on e.source = n.key limit 15" \
  --table

Y si quieres tirar del hilo de un nodo concreto, nada te impide encadenar dos JOIN para reconstruir la relación completa, con el nodo de origen y el de destino,

sqlite-utils query demo.db \
  "select e.kind, n2.label
     from edges e
     join nodes n1 on e.source = n1.key
     join nodes n2 on e.target = n2.key
    where n1.label = 'Docker'" \
  --table

Brutal, simplemente brutal. Puedes hacer un JOIN, dos, tres o los que quieras, y luego exportar el resultado o pasárselo a lo que necesites. Igual que haces un JOIN puedes hacer un LIKE para buscar por texto parcial, y además puedes combinar el LIKE con los parámetros con nombre para reutilizar la misma consulta cambiando solo el término de búsqueda,

sqlite-utils query demo.db \
  "select label, type from nodes
    where label like '%' || :buscar || '%'
    order by type, label
    limit 20" \
  -p buscar docker --table

Ese || es el operador de concatenación de cadenas en SQLite, y gracias a él puedes montar el comodín alrededor del parámetro. La consulta la escribes una vez, y luego la reutilizas cambiando el valor de buscar. Todo ello, recuerda, sin abrir un cliente gráfico, sin montar un IDE, y sin dejar la terminal.

El combo killer, sqlite-utils + jq + LLM

Y ahora llegamos a lo que de verdad convierte a sqlite-utils en la navaja suiza del título. La clave está en un flag que no parece gran cosa y lo cambia todo, --nl, que significa newline-delimited JSON. Lo que hace es volcar cada fila como un objeto JSON en su propia línea, en lugar de un único array gigante. Y ese formato es el idioma nativo de las tuberías de Unix.

Con --nl puedes empalmar la salida directamente con jq y hacer filigranas. Por ejemplo, filtrar todos los nodos cuyo nombre contenga la palabra docker, sin importar mayúsculas ni minúsculas,

sqlite-utils rows demo.db nodes --nl \
  | jq 'select(.label | test("(?i)docker"))'

O contar cuántos nodos hay por tipo, con las funciones de agrupación de jq,

sqlite-utils rows demo.db nodes --nl \
  | jq -s 'group_by(.type) | map({tipo: .[0].type, total: length})'

O quedarte con los cinco nodos modificados más recientemente, ordenando por fecha y recortando el array,

sqlite-utils rows demo.db nodes --nl \
  | jq -s 'sort_by(.updated_at) | reverse | .[:5] | .[].label'

Aquí el límite lo pones tú. Cualquier cosa que puedas consultar en una base de datos, unida a cualquier cosa que puedas tratar con jq, te lleva a límites insospechados. Y la guinda es cuando al final de esa tubería pones un modelo de lenguaje.

El caso de uso que me tiene enamorado es este. Extraes el contexto de tu grafo, cada nodo con su etiqueta y sus relaciones, y lo vuelcas a un fichero en formato JSONL,

sqlite-utils query demo.db \
  "select n.label, n.type, e.kind as relacion
     from nodes n
     left join edges e on n.key = e.source
    where n.type = 'tool'
    order by n.label
    limit 50" \
  --nl > contexto.jsonl

Ahora tienes un fichero contexto.jsonl con cincuenta líneas, cada una un objeto JSON con la etiqueta, el tipo y la relación de cada nodo. Y ese fichero se lo puedes enchufar directamente a llm, el CLI de Simon Willison, para que te explique lo que está viendo,

< contexto.jsonl llm "Resume qué herramientas aparecen y cómo se relacionan:"

¿Te das cuenta de lo que acabo de hacer? He conectado mi grafo de conocimiento con un modelo de lenguaje en un par de comandos. Sin servidores intermedios, sin APIs de terceros, sin moverme de la terminal. Eso, amigos, es un pipeline RAG de verdad 🔥. Y todo lo que he contado en episodios anteriores sobre jq y sobre embeddings termina encajando aquí, en esta tubería.

Escribir, exportar y transformar

Hasta ahora hemos estado leyendo datos, pero sqlite-utils también sabe escribir, y lo hace con una elegancia que da gusto. Imagina que tienes un objeto JSON y quieres meterlo en SQLite. Pues se lo pasas por la entrada estándar, con el guión como nombre de fichero, y la herramienta hace el resto,

echo '{"key": "podman", "label": "Podman", "type": "tool"}' \
  | sqlite-utils insert demo.db nodes -

Fíjate en el detalle, no solo te inserta la fila, sino que si la base de datos no existe te la crea, y si la tabla no existe también te la crea, con las columnas que hagan falta. Un solo comando y ya tienes la estructura montada.

Desde un fichero CSV es igual de sencillo, y desde la versión 4 incluso te detecta los tipos de las columnas automáticamente, algo que antes había que pedir a mano,

sqlite-utils insert demo.db nodes nodos.csv --csv

Y si los datos los tienes en una API, tampoco hay problema. Aquí el truco está en indicar cuál es la clave primaria con --pk, para que si vuelves a insertar el mismo registro no te duplique las filas,

curl -s https://api.github.com/repos/simonw/sqlite-utils/releases \
  | sqlite-utils insert releases.db releases - --pk id

En el momento en que empiezas a encadenar una API con sqlite-utils te das cuenta de que acabas de construir un pequeño recopilador de datos con dos líneas. Para exportar, el camino de vuelta es igual de corto. Puedes volcar una tabla a CSV,

sqlite-utils rows demo.db nodes --csv > nodos.csv

o a TSV, que es lo mismo pero con tabuladores, ideal para pegar en una hoja de cálculo,

sqlite-utils rows demo.db nodes --tsv > nodos.tsv

Pero lo que de verdad me gusta para los respaldos es el dump. Te genera un fichero con todas las sentencias SQL necesarias para reconstruir la base de datos, esquema y datos incluidos,

sqlite-utils dump demo.db > backup_demo.sql

Y restaurarlo es tan simple como crear una base vacía y meterle el volcado por la entrada estándar. Eso sí, fíjate en un detalle, el volcado lo aplica el cliente sqlite3, no el CLI de sqlite-utils, que se limita a crear la base,

sqlite-utils create-database restaurado.db
sqlite3 restaurado.db < backup_demo.sql

Y ahora una de esas cosas que en SQL nativo son un incordio y que aquí se resuelven con una línea, la transformación de tablas. Renombrar una columna, cambiarle el tipo o borrar una que sobra. En SQLite nativo todo eso es doloroso, y a menudo imposible sin trucos. Con sqlite-utils es un comando,

sqlite-utils transform demo.db nodes --rename label nombre
sqlite-utils transform demo.db nodes --type type text
sqlite-utils transform demo.db nodes --drop peso

Por detrás, la herramienta hace exactamente el truco que harías tú a mano, pero sin que tú te enteres. Crea una tabla nueva con la estructura corregida, copia todos los datos, borra la tabla vieja y renombra la nueva. Y todo dentro de una transacción, de forma atómica y segura. Tú ves una línea de comando, pero por debajo hay un trabajo fino de fontanería 💡. Esto es lo que quiero decir cuando digo que sqlite-utils es el pegamento que le faltaba a SQLite.

El módulo de Python y el RAG en 45 líneas

Llegados a este punto, puede que necesites algo que ya no se resuelve con un comando suelto, sino con un script. Y aquí es donde sqlite-utils deja de ser una CLI y se convierte en una librería de Python, sin dejar de ser igual de limpia.

El punto de entrada es siempre el mismo, importas la clase Database y creas un objeto apuntando a tu fichero. A partir de ahí, db.query() te devuelve diccionarios directamente, sin cursores, sin mapear filas a objetos, sin historias. Python puro y duro.

from sqlite_utils import Database

db = Database("demo.db")

for fila in db.query("select key, label, type from nodes limit 5"):
    print(fila)

¿Y si quieres recuperar un nodo por su clave primaria? Pues como si fuera un diccionario, con .get(),

nodo = db.table("nodes").get("docker")
print(nodo["label"], nodo["type"])

¿Y iterar las filas aplicando un filtro seguro, con el ? de toda la vida? Con rows_where(),

for fila in db.table("nodes").rows_where("type = ?", ["tool"]):
    print(fila["label"])

La API es tan sumamente limpia que, como decía en el episodio, hasta te entran ganas de escribir el script. Y si no te apetece escribirlo, siempre se lo puedes pedir a tu modelo de lenguaje, que con estos mimbres te lo borda.

Para que lo veas en acción, he preparado un script de referencia que extrae el contexto de tu grafo y lo emite como JSONL, listo para encadenarlo con cualquier LLM. Son unas 45 líneas, así que te lo dejo publicado como gist para que lo copies sin pelearte con el formateo,

Puedes enlazarlo con el mismo llm que veíamos antes en una sola tubería,

./rag-demo.py --tipo tool | llm "¿Qué herramientas tengo y cómo se relacionan entre ellas?"

Y ahí lo tienes, tu grafo de conocimiento alimentando a tu asistente local, en 45 líneas de código que caben en un pantallazo. Todo el trabajo pesado lo hace sqlite-utils por debajo.

Mantenimiento, WAL, vacuum, optimize y FTS

Para terminar, tres cositas de mantenimiento que te van a salvar más de un día, y una de búsqueda que te abre una puerta que no sabías que tenías.

Lo primero, si quieres hacer lecturas concurrentes sobre tu base de datos mientras otras cosas escriben en ella, tienes que habilitar el modo WAL, write-ahead logging. Esa maravilla que permite que los lectores no se queden bloqueados esperando a los escritores,

sqlite-utils enable-wal demo.db

Lo segundo, si has borrado una cantidad considerable de datos y quieres recuperar ese espacio en disco, la solución es el vacuum, que reescribe el fichero y compacta lo que sobra,

sqlite-utils vacuum demo.db

Y lo tercero, si quieres optimizar los índices de una forma integral, incluidos los de la búsqueda de texto completo, tienes el optimize, que hace la limpieza por ti,

sqlite-utils optimize demo.db

Y ahora, la búsqueda. sqlite-utils trae la búsqueda de texto completo, FTS, lista para usar en dos comandos. Primero habilitas el índice sobre las columnas que te interesan,

sqlite-utils enable-fts demo.db notes title body

Y después buscas sobre él, con el motor ordenando los resultados por relevancia,

sqlite-utils search demo.db notes "graphrag" --table

Con esto puedes buscar texto libremente en los títulos y en los cuerpos de tus notas, en milisegundos. Y esto, combinado con los embeddings que ya tenemos en el grafo, es exactamente la receta de la búsqueda híbrida que te voy a contar en el próximo episodio, el léxico por un lado y lo semántico por el otro.

Por último, para entenderte con tus datos, tienes el análisis. analyze recoge las estadísticas que el planificador de consultas necesita para ir rápido, y analyze-tables te cuenta la distribución de valores de cada columna de una tabla, cuántos nulos hay, cuántos valores distintos, cuáles son los más y menos comunes. En una sola orden,

sqlite-utils analyze demo.db
sqlite-utils analyze-tables demo.db nodes

Es una de esas utilidades que no echas de menos hasta que la necesitas, y entonces te preguntas cómo has podido vivir sin ella.

Conclusiones

Hagamos balance. A lo largo de este episodio hemos visto cómo sqlite-utils es, sobre todo, el pegamento entre tu base de datos SQLite y tu terminal. Inspeccionas tablas, columnas y esquemas, navegas los datos con rows, filtras sin miedo a la inyección SQL gracias a los parámetros con nombre, haces JOIN y agregaciones de verdad, encadenas con jq, lo enchufas a un LLM, escribes, exportas y transformas. Y lo haces todo sin abrir un cliente gráfico y sin tocar Python, salvo cuando tú quieras hacerlo.

Tres conclusiones me llevo del episodio, y me las apunto para no olvidarlas. La primera, que sqlite-utils es ese pegamento que le faltaba a tu terminal para tratar con SQLite como con cualquier otro texto. La segunda, que el formato --nl es la pieza que convierte un array de JSON en filas manejables por cualquier herramienta, y por eso encaja tan bien con jq y con llm. Y la tercera, la más importante, que detrás de todo esto hay SQLite. Una base de datos ubicua, fiable y terriblemente infravalorada, probablemente una de las más usadas del mundo, a la que Simon Willison le dio por fin la interfaz que se merecía.

Si algo de lo que hemos visto te ha picado la curiosidad, instálalo con uv y juega con tu propia base de datos, sea un grafo, sean tus notas o sea lo que sea. Ejecuta un sqlite-utils tables tu-base.db --counts --columns --table y cuéntame qué encuentras.

Y para el próximo lunes nos queda la guinda. Cerramos la trilogía de GraphRAG con la búsqueda híbrida, combinando la búsqueda vectorial con la búsqueda sobre el grafo, todo con ask, para que tu asistente local encuentre la aguja en el pajar tanto por significado como por las relaciones entre nodos. Porque ahora ya tenemos todas las piezas sobre la mesa. Nos vemos el lunes. 🐧


Más información

Deja una respuesta