Deja de escribir Python, doma YAML y JSON con yq y jq

Vistas: 1
0:00 / 0:00
Deja de escribir Python, doma YAML y JSON con yq y jq

Hace un par de episodios, en el 819 y el 821, te conté cómo estaba extrayendo el front matter de mis notas Markdown con Python para montar un grafo de conocimiento. Y aquello funcionó, vaya si funcionó. Pero se me quedó una espinita clavada, porque cada vez que quería responder a una pregunta tan simple como qué tags he usado este mes, tenía que abrir el script de Python, recordar cómo estaba montado, tocar el código y volver a ejecutarlo. Para una consulta rápida, demasiados pasos. Y yo soy muy fan de la filosofía de UNIX, esa de herramientas que hacen una cosa y la hacen bien. Así que me puse a buscar algo más directo, algo que fuera casi como un grep pero para datos estructurados, y me topé de bruces con un dúo dinámico que hoy te quiero presentar, jq y yq. Con ellos vas a poder extraer, filtrar, transformar y conectar los metadatos de todas tus notas sin escribir ni una línea de Python, solo con tu shell, unos cuantos pipes y un poquito de sintaxis. 😊

Y no, no hace falta que montes un GraphRAG ni que tengas un modelo de lenguaje corriendo para empezar. De hecho, la gracia de todo esto es que la primera mitad del camino la puedes hacer completamente solo, con herramientas que ya tienes instaladas o que instalas en diez segundos. La IA local vendrá después, como guinda, cuando ya tengas tus metadatos ordenados y limpios. Esa es precisamente la historia que quiero contarte hoy.

jq, el procesador de JSON por excelencia

jq es el estándar de facto para procesar JSON en la terminal desde 2012. Está escrito en C y su modelo mental es muy sencillo de entender, porque no es un lenguaje al uso, sino un filtro. Recibe una entrada, produce una salida, y todo lo que escribes es una expresión que transforma esa entrada. Si has jugado alguna vez con sed o awk, esto te va a sonar muchísimo, porque la idea es exactamente la misma.

Instalarlo es trivial, porque está en los repositorios de prácticamente todas las distribuciones.

# Debian, Ubuntu y derivados
sudo apt install jq

# Arch Linux
sudo pacman -S jq

# macOS
brew install jq

# Comprobar la versión
jq --version
# jq-1.8.2 en mi máquina

Lo básico de jq son los filtros, y el más simple de todos es el punto, .. Ese filtro devuelve el objeto tal cual, ya embellecido, lo cual viene de perlas para leer un JSON que llega en una sola línea interminable. A partir de ahí, la cosa se pone interesante.

echo '{"datos":{"nombre":"Lorenzo","edad":50,"tags":["linux","ia"]}}' | jq '.datos'
echo '{"datos":{"nombre":"Lorenzo","edad":50,"tags":["linux","ia"]}}' | jq '.datos.nombre'
echo '{"datos":{"nombre":"Lorenzo","edad":50,"tags":["linux","ia"]}}' | jq -r '.datos.nombre'

Fíjate en la diferencia de la última línea. Por defecto, jq te devuelve el string con sus comillas, porque sigue siendo JSON. Si le añades la flag -r, de raw output, te lo devuelve sin comillas, que es lo que quieres cuando la salida va a parar a otro comando o a un fichero de texto. Este detalle parece una tontería, pero es la diferencia entre una salida usable y una salida que tienes que limpiar a mano.

Los arrays también se manejan con una naturalidad pasmosa, y aquí hay un matiz que conviene grabarse a fuego.

echo '["a","b","c","d","e"]' | jq '.[0]'    # primer elemento: "a"
echo '["a","b","c","d","e"]' | jq '.[-1]'   # último elemento: "e"
echo '["a","b","c","d","e"]' | jq '.[]'     # itera: "a" "b" "c" "d" "e"
echo '["a","b","c","d","e"]' | jq '.[2:4]'  # slice: ["c","d"]

La clave está en distinguir .[0], que te devuelve el primer elemento como un todo, de .[], que itera sobre el array y te va escupiendo cada elemento por separado. Esto, que ahora parece una sutileza, va a ser la piedra angular cuando aplanemos los arrays de tags de todo un vault de notas. Cada nota tiene su propio array de etiquetas, y necesitamos recorrerlos todos como si fueran uno solo.

Y con objetos anidados, la notación de puntos te lleva hasta el fondo sin despeinarte.

echo '{"commit":{"author":{"name":"Lorenzo"}}}' | jq -r '.commit.author.name'
# Lorenzo

Junto a los filtros, merece la pena tener a mano unas cuantas flags que son las que convierten a jq en una herramienta de verdad y no en un juguete. Las que más vas a usar son -c, para salida compacta con un objeto por línea, -s, el famoso slurp del que te hablaré largo y tendido dentro de un momento, y -n, que ignora la entrada y te deja construir un JSON desde cero. También están -S, que ordena las claves de cada objeto, y --arg, que sirve para inyectar variables desde la shell.

jq -n --arg nombre "Lorenzo" '{"autor": $nombre}'
# {"autor":"Lorenzo"}

jq -n --argjson edad 50 '{"edad": $edad}'
# {"edad":50}

jq -n --arg edad 50 '{"edad": $edad}'
# {"edad":"50"}

Ahí tienes la diferencia entre --arg y --argjson, que es una de esas cosas que te hace perder media hora la primera vez. --arg mete siempre un string, aunque le pases un número, mientras que --argjson mete el valor ya codificado como JSON, así que un 50 sigue siendo un número. Parece una minucia, pero cuando empiezas a hacer comparaciones y filtros, tener el tipo correcto importa, y mucho.

Las funciones que convierten jq en una navaja suiza

Con lo que hemos visto ya puedes hacer consultas, pero es cuando llegas a las funciones cuando jq enseña los dientes. Hay cinco que vas a repetir una y otra vez, y con ellas se resuelve prácticamente todo lo que necesitas sobre metadatos.

La primera es select, que es el WHERE de SQL con otra piel. Conserva un elemento solo si la condición que le pasas es verdadera, y para todo lo demás lo descarta.

echo '[1,5,3,0,7]' | jq 'map(select(. >= 2))'
# [5,3,7]

echo '[{"id":"first","val":1},{"id":"second","val":2}]' \
  | jq '.[] | select(.id == "second")'
# {"id":"second","val":2}

La segunda es unique, que elimina los duplicados de un array. Y aquí viene una advertencia que a mí me mordió más de una vez, y es que unique además ordena. No es un simple deduplicador, es un sort con esteroides. Si el orden original te importaba, ya puedes olvidarte de él.

echo '[1,2,5,3,5,3,1,3]' | jq 'unique'
# [1,2,3,5]

echo '[{"foo":1,"bar":2},{"foo":1,"bar":3},{"foo":4,"bar":5}]' | jq 'unique_by(.foo)'
# [{"foo":1,"bar":2},{"foo":4,"bar":5}]

Existe además unique_by, que deduplica tomando como referencia una ruta concreta en lugar del valor entero. Muy útil cuando tienes objetos repetidos pero con algún campo distinto y solo te interesa uno de ellos.

La tercera es map, que hace exactamente lo que su nombre promete, aplicar una transformación a cada elemento del array, igual que en Python o en JavaScript. Y si lo que tienes es un objeto y no un array, existe su hermana map_values.

echo '[1,2,3]' | jq 'map(.+1)'                    # [2,3,4]
echo '{"a":1,"b":2,"c":3}' | jq 'map_values(.+1)' # {"a":2,"b":3,"c":4}

La cuarta es group_by, que agrupa los elementos que comparten el mismo valor de una ruta y te devuelve un array de arrays, ordenado por ese valor. Es exactamente lo que usaremos para contar cuántas notas comparten cada etiqueta, y es la pieza que convierte una lista plana de tags en un hit parade con frecuencias.

echo '[{"foo":1,"bar":10},{"foo":3,"bar":100},{"foo":1,"bar":1}]' | jq 'group_by(.foo)'
# [[{"foo":1,"bar":10},{"foo":1,"bar":1}],[{"foo":3,"bar":100}]]

Y la quinta es sort_by, para ordenar por una o varias claves. El truco que no todo el mundo conoce es que si le pones un signo menos delante de la ruta, ordena en sentido descendente, que es justo lo que querrás para poner los tags más usados arriba.

echo '[8,3,null,6]' | jq 'sort'
# [null,3,6,8]

echo '[{"foo":4,"bar":10},{"foo":3,"bar":20},{"foo":2,"bar":1},{"foo":3,"bar":10}]' \
  | jq 'sort_by(.foo, .bar)'
# [{"foo":2,"bar":1},{"foo":3,"bar":10},{"foo":3,"bar":20},{"foo":4,"bar":10}]

Merece la pena que te quedes con el orden de comparación que usa jq, porque a veces sorprende. Va primero null, después false, luego true, los números, los strings por codepoint Unicode, y finalmente los arrays y los objetos. Así que si mezclas tipos en un mismo array, ya sabes por dónde va a salir la ordenación.

Con estas cinco funciones y los filtros que ya conoces tienes el noventa por ciento del trabajo hecho. Pero hay un puñado de utilidades menores que redondean la herramienta y que conviene tener fichadas, como to_entries, que transforma un objeto en un array de pares clave-valor, su inversa from_entries, with_entries para editar en el vuelo, keys, length, has, min_by y max_by, o del para eliminar una ruta. No las vas a usar todos los días, pero cuando las necesites, te alegrarás de saber que están ahí.

yq, el mismo concepto pero para YAML

jq está muy bien para JSON, pero nosotros trabajamos con Markdown, y el front matter de las notas Markdown es YAML. Y para YAML necesitamos yq. No confundas este yq con el viejo python-yq, que era un envoltorio de jq en Python. El que nos interesa es el de Mike Farah, escrito en Go, que se distribuye como un binario único sin dependencias, y cuya sintaxis es casi calcada a la de jq.

Instalarlo es igual de sencillo, aunque no suele estar en los repositorios con ese nombre.

# Binario único desde GitHub Releases
wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/local/bin/yq && \
  chmod +x /usr/local/bin/yq

# Homebrew
brew install yq

# Snap
snap install yq

# Arch Linux (ojo, el paquete se llama go-yq, pero el binario es yq)
pacman -S go-yq

# Go
go install github.com/mikefarah/yq/v4@latest

Y ojo con una cosa del paquete snap, porque viene con strict confinement y no puede tocar ficheros de fuera de su jaula. Si necesitas escribir en ficheros del sistema con la edición in-place, tendrás que pasar por un temporal o por sponge.

sudo cat /etc/fichero.yaml | yq '.a.path = "valor"' | sudo sponge /etc/fichero.yaml

La sintaxis, como te decía, es prácticamente idéntica a la de jq.

yq '.a.b[0].c' fichero.yaml          # leer un valor
yq '.a.b[0].c' < fichero.yaml        # leer desde STDIN
yq -i '.a.b[0].c = "cool"' fichero.yaml   # editar in-place

Y aquí es donde conviene entender la distinción entre eval y eval-all, porque es la clave de todo lo que viene después. El comando por defecto desde la versión 4.18.1 es eval, y aplica la expresión a cada documento de cada fichero, uno detrás de otro. En cambio eval-all carga todos los documentos de todos los ficheros de golpe y ejecuta la expresión una sola vez, que es lo que necesitas para fusionar y agregar.

yq eval '.a.b | length' f1.yml f2.yml            # documento a documento
yq eval-all '. as $item ireduce ({}; . * $item )' path/to/*.yml   # fusión masiva
yq -n '.a.b.c = "cat"'                           # crear documento desde cero

Además de YAML, yq entiende XML, INI, TOML, CSV y, por supuesto, JSON. De hecho, YAML es un superconjunto de JSON, así que todo JSON es YAML válido, aunque no al revés, y eso hace que la conversión entre ambos sea tan natural.

El front matter de tus notas ya es YAML

A lo mejor no te habías parado a pensarlo, pero si tienes un vault de notas en Obsidian, en Hugo, en Jekyll o en Astro, y esas notas empiezan con un bloque de propiedades entre triples guiones, ya estás trabajando con YAML. Aunque no lo supieras. Ese bloque es el front matter, y tiene esta pinta.

---
title: Adiós a los bots
tags:
  - linux
  - seguridad
  - podcast
created: 2026-09-28
updated: 2026-10-04
---
Contenido de la nota.

No es Markdown nativo, es YAML embebido, y por eso atacarlo con grep es una temeridad que acaba en dolor de cabeza. La buena noticia es que yq tiene soporte nativo para el front matter con el flag --front-matter, y eso es para mí la joya de la corona. Yo durante mucho tiempo estuve sacando estos datos con grep y ripgrep, sin saber que tenía una herramienta mucho más limpia a mano.

Con --front-matter=extract extraes únicamente el bloque YAML, sin tocar el cuerpo de la nota.

yq --front-matter=extract '.' nota.md
yq --front-matter=extract '.tags' nota.md
yq --front-matter=extract -r '.title' nota.md

Eso te devuelve el front matter entero, o solo los tags, o el título en crudo según lo que pidas. Y con -o=json lo conviertes directamente a JSON, que es la bisagra que conecta el mundo yq con el mundo jq.

yq --front-matter=extract -o=json '.' nota.md
{
  "title": "Adiós a los bots",
  "tags": [
    "linux",
    "seguridad",
    "podcast"
  ],
  "created": "2026-09-28",
  "updated": "2026-10-04"
}

Y ya puestos, puedes hacer consultas más elaboradas sobre el propio front matter sin salir de yq.

# ¿Tiene un tag concreto?
yq --front-matter=extract '.tags | any_c(. == "linux")' nota.md
# true

# ¿Cuántos tags tiene?
yq --front-matter=extract '.tags | length' nota.md
# 3

Fíjate en any_c, que es una función específica de yq para comprobar si algún elemento de una colección cumple la condición. Esa _c del final significa que trabaja sobre colecciones, y te evita tener que montar toda la parafernalia de select y length cuando lo único que quieres saber es un sí o un no.

El esquema de metadatos que yo recomiendo, y que hace posible todo lo que viene después, es sencillo. Un title como nodo principal, una lista de tags que serán las aristas del grafo, un created y un updated para tener las fechas a mano, y si acaso unos aliases y un draft. Con eso ya tienes material de sobra para montar un grafo navegable.

Editar en sitio y convertir entre formatos

Una de las cosas que yq hace y jq no es la edición in-place. El flag -i funciona exactamente igual que el de sed, modifica el fichero directamente sobre el disco, sin temporales ni historias. Y si además activas --front-matter=process, en lugar de extraer el bloque, reconstruye el fichero Markdown completo con el front matter modificado y el cuerpo intacto.

# Actualizar la fecha de modificación a ahora
yq -i --front-matter=process '.updated = now' nota.md

# Añadir un tag nuevo
yq -i --front-matter=process '.tags += ["nuevo"]' nota.md

# Añadir un tag solo si no existe
yq -i --front-matter=process '.tags = ((.tags // []) + ["nuevo"] | unique)' nota.md

Esa última línea es una que uso constantemente en mis scripts, porque es idempotente. Si el tag ya está, el unique lo deja igual, y si no está, lo añade y además deja la lista ordenada. Y la función now, que devuelve la fecha actual, junto con strenv(VAR) para leer variables de entorno, load("fichero") para cargar otros YAML, o filename y line para saber de dónde viene cada resultado, son funciones que jq simplemente no tiene.

También puedes encadenar varias actualizaciones en una sola pasada, y así evitas tocar el disco varias veces.

yq -i --front-matter=process '
  .updated = now |
  .author = "atareao" |
  .tags += ["linux"]
' nota.md

Las conversiones de formato se controlan con -p para la entrada y -o para la salida, y con -P para que la salida YAML salga pretty.

# YAML a JSON
yq -o=json '.' nota.yml

# JSON a YAML embellecido
yq -p=json muestra.json
yq -P -o=y muestra.json

# YAML a JSON compacto en una sola línea
yq -o=json -I=0 '.' muestra.yml

Ahora bien, aquí hay que saber una cosa que no siempre se cuenta. YAML es un superconjunto de JSON, así que todo JSON es YAML válido, pero no todo YAML es JSON. Cuando conviertes YAML a JSON con yq -o=json, por el camino se pierden los comentarios, los anclajes y los alias se resuelven, y los estilos de los escalares se normalizan. El resultado es correcto, pero no esperes un viaje de ida y vuelta byte a byte. La regla de oro es no dar la conversión por sentada y echarle un ojo a la salida.

Y volviendo a jq, recordar que no tiene -i. Es un error clásico venir de sed o de yq, escribir jq -i y comerte un error. Para modificar un JSON con jq tienes que redirigir a un temporal y luego moverlo, o tirar de sponge de moreutils.

# Redirección a temporal
jq '.tags += ["nuevo"]' archivo.json > tmp.json && mv tmp.json archivo.json

# O con sponge, que lee toda la entrada antes de escribir
jq '.tags += ["nuevo"]' archivo.json | sponge archivo.json

sponge es especialmente cómodo porque lee toda la entrada antes de escribir nada, así que no hay riesgo de que se te corrompa el fichero por escribir sobre el mismo que estás leyendo.

El pipeline: fd + yq + jq

Ya sabemos extraer el front matter de una nota. Ahora queremos hacerlo con todas las notas del vault, y aquí es donde aparece la primera trampa traicionera. La documentación oficial de yq avisa de que, cuando usas --front-matter, solo procesa el primer fichero que le pasas. La tentación es escribir esto.

# Tentador, pero peligroso
yq --front-matter=extract '.tags' *.md

Y según la documentación, te comes solo los tags del primero. La solución robusta, pase lo que pase con la versión, es no pelearse con yq y hacer que un buscador de ficheros lo ejecute una vez por archivo. Y aquí entra fd, el find moderno escrito en Rust, que ya sabéis que me tiene enamorado.

En mis pruebas con yq v4.45.1, curiosamente, el comando de arriba sí que procesó todos los ficheros, lo cual demuestra que el comportamiento ha cambiado entre versiones. Pero como la documentación sigue avisando y he visto versiones donde falla, yo no me la juego y uso siempre el bucle o fd. Es la opción que funciona en todas partes.

fd -e md .                     # todos los .md del directorio
fd -e md -x yq --front-matter=extract '.tags // []'   # yq una vez por archivo

Fíjate en el // [], que es el operador por defecto de yq. Si una nota no tiene tags, o los tiene a null, en lugar de devolverte un null que rompa el pipeline, te devuelve un array vacío. Es un detalle pequeño que te ahorra muchísimos errores.

Y ahora, el momento mágico. Conectamos la salida de yq con jq y, de una sola línea, obtenemos la lista limpia de todos los tags del vault, sin repetir.

fd -e md . \
  | xargs -I{} yq --front-matter=extract -o=json '.tags // []' {} 2>/dev/null \
  | jq -s 'add | unique'

Vamos a destriparlo, porque cada pieza cuenta. fd busca todos los .md. xargs -I{} ejecuta yq una vez por cada fichero, sustituyendo el {} por la ruta. yq extrae el array de tags de cada nota y lo emite como JSON. Y luego llega jq -s, el flag de slurp, que junta todos esos JSON que le van llegando y los convierte en un único array de arrays. A partir de ahí, add concatena todos los arrays de tags en uno solo, y unique se queda con los diferentes y los ordena. El resultado con mi vault de pruebas es exactamente este.

[
  "datos",
  "ia",
  "linux",
  "podcast",
  "seguridad",
  "sqlite"
]

Sin duplicados, sin Python, en una sola línea encadenada. Pero es que además, si en lugar de los tags únicos quieres saber cuáles se llevan la palma, solo tienes que cambiar el filtro de jq y aprovechar group_by y sort_by.

fd -e md . \
  | xargs -I{} yq --front-matter=extract -o=json '.tags // []' {} 2>/dev/null \
  | jq -s 'add | group_by(.) | map({tag: .[0], count: length}) | sort_by(-.count)'

Aquí add junta todos los tags en un array plano, group_by(.) agrupa los idénticos, map({tag: .[0], count: length}) construye un objeto por grupo tomando el nombre del primer elemento y contando cuántos hay, y sort_by(-.count) ordena de mayor a menor. El resultado, para mi vault de pruebas, es este.

[
  { "tag": "podcast",   "count": 3 },
  { "tag": "linux",     "count": 2 },
  { "tag": "datos",     "count": 1 },
  { "tag": "ia",        "count": 1 },
  { "tag": "seguridad", "count": 1 },
  { "tag": "sqlite",    "count": 1 }
]

Y esto no lo vas a escribir todos los días, así que lo suyo es hacerte un alias o, mejor todavía, un pequeño script. Yo tengo uno que llamo tags-report.sh y que te dejo publicado como gist para que lo puedas descargar directamente.

#!/usr/bin/env bash
# tags-report.sh — Top 20 tags de tu vault de notas

VAULT="${1:-.}"

echo "=========================================="
echo "  Tags Report — $(date +'%d/%m/%Y')"
echo "  Vault: $VAULT"
echo "=========================================="
echo ""

fd -e md "$VAULT" -x yq --front-matter=extract -o=json '.tags // []' 2>/dev/null \
  | jq -s '
      add
      | group_by(.)
      | map({tag: .[0], count: length})
      | sort_by(-.count)
      | .[:20][]
      | "\(.count)  \(.tag)"
    ' -r

echo ""
echo "  Notas analizadas: $(find "$VAULT" -name '*.md' | wc -l)"

La versión completa, con comprobación de dependencias y alguna floritura más, la tienes en el gist, enlazado al final del artículo. Ese 2>/dev/null tira los errores de los ficheros que no tienen front matter, y el .[:20] limita el resultado a los veinte tags más usados. El -r de jq es el que hace que la salida salga sin comillas, lista para leer.

De los metadatos al grafo de conocimiento

Todo esto está muy bien para consultas rápidas, pero la promesa de verdad es conectar esta información con la del episodio anterior, el del grafo de conocimiento. Y es que la idea es maravillosamente simple. Cada nota es un nodo, los campos de su front matter, el título y las fechas, son los atributos de ese nodo, y los tags compartidos son las aristas que conectan notas que hablan de lo mismo.

Lo primero es extraer los nodos, un objeto JSON por nota. Y aquí me encontré con una trampa curiosa que conviene contar. Si usas la función filename de yq con --front-matter=extract, no te devuelve la ruta real del fichero, te devuelve una ruta temporal del estilo /tmp/temp4001593659, porque por dentro yq copia el front matter a un temporal para procesarlo. Así que para el identificador del nodo lo mejor es pasar la ruta desde la shell con $1.

fd -e md . \
  | xargs -I{} sh -c 'yq --front-matter=extract -o=json \
      "{\"id\": \"$1\", \"title\": .title, \"tags\": (.tags // [])}" "$1" 2>/dev/null' _ {}

Eso emite, para cada nota, un objeto con su ruta real, su título y sus tags. Ahora los recogemos con jq -s y construimos el grafo completo, con nodos y aristas, en un único JSON.

fd -e md . \
  | xargs -I{} sh -c 'yq --front-matter=extract -o=json \
      "{\"id\": \"$1\", \"title\": .title, \"tags\": (.tags // [])}" "$1" 2>/dev/null' _ {} \
  | jq -s '
      . as $n
      | {
          "nodes": [ $n[] | {"id": .id, "title": .title, "tags": .tags} ],
          "edges": [
            $n[] as $a | $n[] as $b
            | select($a.id < $b.id)
            | ([ $a.tags[] | select(. as $t | $b.tags | index($t)) ]) as $c
            | select(($c | length) > 0)
            | {"source": $a.id, "target": $b.id, "tags": ($c | unique)}
          ]
        }
    ' > grafo.json

Hay un par de trucos en ese programa que merecen una explicación. El select($a.id < $b.id) es para no generar la arista dos veces, una de ida y otra de vuelta, y para que una nota no se conecte consigo misma. El bloque [ $a.tags[] | select(. as $t | $b.tags | index($t)) ] calcula los tags que comparten las dos notas. Y select(($c | length) > 0) descarta los pares que no comparten absolutamente nada. El resultado lo tienes publicado como gist, con el mismo mecanismo pero algo más pulido.

Ese grafo.json es directamente consumible por cualquier herramienta de visualización, desde Cytoscape hasta D3, o se puede importar en Neo4j. Pero si prefieres tirar por el camino clásico, el pipeline también puede generar sentencias SQL listas para poblar una base de datos SQLite.

fd -e md . \
  | xargs -I{} sh -c '
      f="$1"
      id=$(basename "$f" .md)
      title=$(yq --front-matter=extract -r ".title // \"\"" "$f")
      fecha=$(yq --front-matter=extract -r ".created // \"\"" "$f")
      q=$(printf "\047")   # comilla simple para los literales SQL
      echo "INSERT INTO docs (id, titulo, fecha, ruta) VALUES (${q}$id${q}, ${q}$title${q}, ${q}$fecha${q}, ${q}$f${q});"
      yq --front-matter=extract -r ".tags[]" "$f" 2>/dev/null | while read tag; do
        echo "INSERT INTO tags (doc_id, tag) VALUES (${q}$id${q}, ${q}$tag${q});"
      done
    ' _ {}

Una vez tienes eso en SQLite, las consultas que se abren son un mundo. Puedes preguntar qué notas comparten un tag con una nota concreta, cuáles son los tags más conectados, o, mi favorita, encontrar las notas que no comparten ningún tag con ninguna otra, que son las candidatas perfectas a necesitar un enlace temático.

-- Notas que comparten tag con una nota concreta
SELECT DISTINCT d.titulo FROM docs d
JOIN tags t ON d.id = t.doc_id
WHERE t.tag IN (SELECT tag FROM tags WHERE doc_id = 'tar');

-- Tags más conectados (los que más notas agrupan)
SELECT tag, COUNT(*) AS num_notas
FROM tags GROUP BY tag ORDER BY num_notas DESC;

Y como yq tiene edición in-place, podrías incluso cerrar el círculo. Detectar las notas huérfanas y añadirles un tag, o actualizar su fecha, sin salir de la terminal y sin abrir un solo editor.

Dándole de comer a tu IA local

Y llegamos a la guinda. Porque una vez tienes los metadatos de todo tu vault estructurados, lo único que te separa de pasárselos a un modelo de lenguaje local es un curl. Ollama expone una API HTTP en http://localhost:11434, y su endpoint /api/generate es de una sencillez engañosa.

curl http://localhost:11434/api/generate -d '{
  "model": "llama3.2",
  "prompt": "¿Por qué el cielo es azul?",
  "stream": false
}'

La respuesta es un objeto JSON cuyo campo response contiene el texto que ha generado el modelo. Y si lo que quieres es que te devuelva JSON estructurado, puedes forzarlo con "format": "json", o incluso pasarle un esquema completo. Eso sí, pídele explícitamente en el prompt que responda en JSON, porque si no, el modelo se pone a divagar y te llena la respuesta de espacios en blanco aunque hayas puesto el formato.

Ahora juntamos todo. El pipeline completo, de las notas del vault a la respuesta del modelo local, son cuatro herramientas y una tubería.

fd -e md . \
  | xargs -I{} sh -c 'yq --front-matter=extract -o=json \
      "{\"ruta\": \"$1\"} * ." "$1" 2>/dev/null' _ {} \
  | jq -s '.' \
  | jq -c '{model: "llama3.2", stream: false, format: "json",
            prompt: ("Analiza estos documentos y extrae entidades y relaciones entre ellos:\n" + tostring)}' \
  | curl -s http://localhost:11434/api/generate -H 'Content-Type: application/json' -d @- \
  | jq -r '.response'

El operador * de yq es el merge, y lo que hace en {"ruta": "$1"} * . es fusionar el objeto con la ruta del fichero y el front matter completo. Después, jq -s junta todos los documentos en un array, el segundo jq -c construye el cuerpo de la petición con el prompt y los documentos, y tostring convierte ese array en texto para incrustarlo en el mensaje. Finalmente curl lo envía y jq -r extrae la respuesta.

En mis pruebas, con el modelo llama3.2 de tres mil millones de parámetros, el resultado fue que clasificaba las notas por categorías, desarrollo, infraestructura, backup, seguridad, web, y detectaba las conexiones por temas compartidos. Todo sin Python, sin Node y sin frameworks. Solo fd, yq, jq y curl, cuatro herramientas que probablemente ya tenías instaladas. 🐧

Errores y trampas que te vas a encontrar

Te he contado la parte bonita, así que ahora toca la parte de no hagas esto porque te vas a pegar un cabezazo contra la mesa. Estas son las cuatro trampas con las que me he ido topando, más alguna que otra que he descubierto mientras preparaba este episodio.

El primer error es el del primer fichero. Ya lo he mencionado antes, pero lo repito porque es el que más veces me ha mordido. yq con --front-matter solo procesa el primer fichero según la documentación, así que un yq --front-matter=extract '.tags' *.md puede devolverte solo los del primero sin avisarte. La solución es siempre fd -x, find -exec o un bucle for.

El segundo error son los ficheros Markdown sin front matter. Si un .md no empieza con ---, yq con --front-matter=extract puede fallar o, peor aún, devolverte el cuerpo del documento como si fuera el front matter, que es justo lo que me pasó a mí en las pruebas. Tienes dos formas de evitarlo. La primera es silenciar los errores con 2>/dev/null, y la segunda, más elegante, filtrar solo los ficheros que realmente lo tienen.

fd -e md . -x sh -c '
  head -1 "$1" | grep -q "^---" && yq --front-matter=extract ".tags // []" "$1"
' _ {}

Así solo procesas los que empiezan por la línea correcta, y el resto se ignoran sin ruido. Y de paso, si tienes notas con el bloque YAML pero sin las líneas de apertura o de cierre, este filtro es exactamente lo que estabas buscando, porque head -1 te lo canta.

El tercer error es querer modificar un JSON con jq -i. jq no tiene esa flag, y el error desconcierta a quien viene de sed, yq o awk. La solución es el temporal y el mv, o sponge, como ya vimos.

El cuarto es pensar que YAML y JSON son intercambiables sin más. YAML es un superconjunto de JSON, así que al convertir pierdes cosas, comentarios, anclajes, estilos. No des nunca la conversión por sentada.

Y ahora las trampas finas, las que no salen en los tutoriales. La quinta es la de contar tags de forma incorrecta. Si escribes [.tags // []] | length, te va a devolver siempre uno, porque has construido un array de un elemento. Lo correcto es aplicar el length directamente al resultado del operador por defecto.

yq --front-matter=extract -o=json '.tags // [] | length' nota.md

La sexta es que las claves de objeto de yq deben ir entrecomilladas. Esto es un gotcha verificado en las versiones que probé, la 4.45.1 y la 4.54.1. Un {name: "hola"} sin comillas en la clave falla con un invalid input text, mientras que {"name": "hola"} funciona. Yo perdí un buen rato con esto hasta que caí en la cuenta.

yq -n '{"name": "hola"}'   # OK
yq -n '{name: "hola"}'     # Error: invalid input text

La séptima es que en Debian y Ubuntu, el binario de fd se llama fdfind, porque el nombre fd ya estaba pillado por otro paquete. Se soluciona con un enlace simbólico y a correr.

sudo apt install fd-find
ln -s "$(which fdfind)" ~/.local/bin/fd

Y la octava, la de filename que ya te conté, que con --front-matter=extract devuelve una ruta temporal en lugar de la real. Si vas a usar el nombre del fichero como identificador, pásalo desde la shell.

Conclusión

Llegados a este punto, ¿qué conclusiones he sacado?

La primera es que jq y yq son los sed y awk de los datos estructurados. Si trabajas con JSON o con YAML, y si tienes notas con front matter estás trabajando con YAML aunque no lo supieras, estas dos herramientas deberían estar en tu mochila tecnológica, hables de IA o no.

La segunda es que el front matter de tus notas es oro puro. Ahí tienes títulos, fechas, tags y categorías que pueden convertir un montón de archivos Markdown sueltos en un grafo de conocimiento navegable. Y ahora puedes extraer ese oro en segundos, con una línea de terminal, sin escribir un script y sin tocarlo cada vez que quieres cambiar una consulta.

La tercera es que la IA local se alimenta de datos estructurados. Si le pasas a Ollama un JSON bien formado con tus notas, entiende mucho mejor el contexto y las relaciones, y te da respuestas más precisas. Y el pipeline para generar ese JSON es fd | yq | jq | curl, cuatro herramientas que ya tenías.

Y todo esto no es más que la punta del iceberg. Una vez que tienes el JSON de todas tus notas, las puertas que se abren son muchas. Puedes montar un RAG local que busque en tu vault usando lenguaje natural, un dashboard que te muestre la evolución de tus tags a lo largo del tiempo, o automatizar la creación de enlaces entre notas basándote en etiquetas compartidas. En el próximo episodio vamos a construir de verdad el grafo de conocimiento de vuestras notas con un binario y un modelo local, troceando textos, extrayendo entidades y conectando relaciones. GraphRAG en toda regla, sin depender de la nube. Pero eso, como te digo, es otro tema.

Lo que te pido hoy es que lo pruebes. Abre una terminal, vete a tu vault de notas o a cualquier directorio con archivos Markdown, y ejecuta esto.

fd -e md . \
  | xargs -I{} yq --front-matter=extract -o=json '.tags // []' {} 2>/dev/null \
  | jq -s 'add | unique'

Mira qué tags tienes, sorpréndete con lo que aparece, y luego, si te animas, conéctalo con Ollama. Los scripts completos, el reporte de tags y el generador del grafo los tienes enlazados justo debajo. 😊


Más información

Deja una respuesta