Volver al Blog
AI & Development14 de enero de 202511 min de lectura

RAG local con Obsidian: una base de conocimiento de equipo para agentes, sin pagar APIs

DR

Doug Ribeiro

11 min de lectura

Cuando un equipo decide que sus agentes necesitan una base de conocimiento, la primera pregunta suele ser qué producto SaaS pagar. Ninguno. Corriendo en el mismo Docker que ya tienes: una carpeta de markdown para las notas, pgvector (extensión gratuita de Postgres) para búsqueda semántica, y un modelo de embedding local que se descarga una vez y nunca más hace una llamada externa. Cero API hospedada, cero clave, cero costo por request. El editor, si lo quieres, es Obsidian, que también es local y gratuito de usar, en casa y en el trabajo. VS Code abre los mismos archivos.

Este es el setup que corro contra mi propio vault, y el que pondría frente a un equipo: acceso vía retrieval-augmented a convenciones, runbooks y decisiones de arquitectura, sin SaaS, sin costo por asiento y sin lock-in de proveedor. El agente lee de un vault que los humanos editan. Los humanos lo editan porque la experiencia de edición es agradable. Esa última parte es todo el truco.

Qué es RAG realmente

Retrieval-Augmented Generation. No metes tu base de conocimiento dentro del LLM. La pones al lado del LLM y, en el momento de la inferencia, recuperas los fragmentos relevantes y los inyectas en el prompt. Así es como los agentes saben de cosas que no estaban en los datos de entrenamiento: convenciones de equipo, runbooks, decisiones de arquitectura del último trimestre. Puro in-context, sin fine-tuning, sin proveedor.

Las piezas que necesitas: algún lugar para guardar el texto (archivos markdown), algún lugar para guardar los embeddings (pgvector) y un pipeline que mantenga ambos en sincronía. Es todo. La complejidad viene de operar esto en producción, no de construirlo.

Qué es un embedding y por qué lo necesitas

La búsqueda por palabra clave (SQL LIKE '%...%') no sirve para RAG. Si una nota habla de 'deploy a producción' y el agente pregunta 'cómo subir código al servidor', quieres que la nota aparezca aunque las palabras no coincidan. Eso es búsqueda por significado, no por string. Los embeddings resuelven esto: cada chunk de texto se convierte en un vector de números (por ejemplo, 384 o 1024 floats) que representa el significado del texto. Textos con significado parecido generan vectores parecidos. La búsqueda se vuelve geometría. Dame los vectores más cercanos a este. Eso es lo que hace pgvector.

Alguien tiene que generar esos vectores. Ese alguien es el modelo de embedding, y esa es la decisión donde la mayoría de los tutoriales te entierran en una API paga sin justificación. Existe la opción 100% local que corre en tu propio stack de Docker, al lado de Postgres, sin API externa, sin clave, sin costo. Úsala como default. Solo considera una API hospedada si tienes una razón concreta (volumen de re-embedding, problemas de recall medidos, notas que el modelo local no puede leer), y el post cubre las tres al final.

Local (el camino default)sentence-transformers all-MiniL…nomic-embed-text via Ollama (76…Runs in the same container as t…Zero network call, zero API keyCost: zeroAPI hospedada (upgrade opcional)Voyage AI voyage-3 (1024 dim, t…OpenAI text-embedding-3-small (…Marginally better quality on hi…Extra network latency per callCost: pay per token embeddedVS

Por qué Obsidian específicamente

Yo uso Obsidian por default porque el vault es solo una carpeta de archivos markdown. Pones Obsidian encima de esa carpeta y tienes un editor excelente con backlinks, tags, graph view y búsqueda. Quitas Obsidian y todavía tienes una carpeta de archivos markdown. Sin lock-in. Git versiona el vault. El agente lee los archivos directamente.

Obsidian es un desktop app (Windows, Mac, Linux, mobile) que corre 100% en tu máquina. El vault nunca sale de tu disco. Sus servicios pagos (Sync y Publish) son add-ons opcionales que ignoras por completo: la sincronización entre dispositivos en este setup es con git. Usarlo en el trabajo también es gratis. Desde febrero de 2025, Obsidian es gratuito para uso comercial, y la Commercial license ($50 por usuario por año) es apoyo opcional en vez de un requisito. Eso cambió un mes después de que este post se publicara por primera vez, cuando las empresas de dos o más todavía tenían que comprar la licencia.

Si el equipo prefiere no usar Obsidian, el setup funciona idéntico. El agente lee los archivos .md. No le importa si editaste en VS Code, en Obsidian o en vim.

La arquitectura

Vault de Obsidian File watcher Chunker + embedder Store pgvector Query del agente

El indexador

El indexador es un pequeño servicio en Python. Observa el directorio del vault, hace chunking de las notas cuando cambian, genera embedding de cada chunk con un modelo que corre dentro del propio container y hace upsert en pgvector. Ninguna llamada externa. Corre en Docker al lado del resto del stack.

python
# libs/memory/indexer.py
import os
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from sentence_transformers import SentenceTransformer
import psycopg

VAULT_PATH = Path(os.environ["VAULT_PATH"])
DB_URL = os.environ["DATABASE_URL"]

# Local model. Downloads once (~90MB) to the container disk.
# Zero API, zero key, zero per-call cost.
# Resulting vector: 384 dimensions.
model = SentenceTransformer("all-MiniLM-L6-v2")

def chunk(text: str, max_chars: int = 1500) -> list[str]:
    paragraphs = text.split("\n\n")
    chunks, buf = [], ""
    for p in paragraphs:
        if len(buf) + len(p) > max_chars:
            chunks.append(buf)
            buf = p
        else:
            buf = f"{buf}\n\n{p}" if buf else p
    if buf:
        chunks.append(buf)
    return chunks

def embed(text: str) -> list[float]:
    return model.encode(text).tolist()

def reindex(file_path: Path):
    text = file_path.read_text()
    source = str(file_path.relative_to(VAULT_PATH))
    chunks = chunk(text)
    with psycopg.connect(DB_URL) as conn:
        for i, c in enumerate(chunks):
            emb = embed(c)
            # The %s::vector cast is not optional. psycopg3 sends a Python
            # list as float8[] and Postgres refuses to store that in a
            # VECTOR column. The alternative is register_vector(conn) from
            # pgvector.psycopg, which teaches psycopg the type once.
            conn.execute(
                "INSERT INTO knowledge (source, chunk_index, content, embedding) "
                "VALUES (%s, %s, %s, %s::vector) "
                "ON CONFLICT (source, chunk_index) DO UPDATE SET "
                "content = EXCLUDED.content, "
                "embedding = EXCLUDED.embedding, "
                "updated_at = NOW()",
                (source, i, c, emb)
            )
        # A note that got shorter leaves orphan chunks behind.
        conn.execute(
            "DELETE FROM knowledge WHERE source = %s AND chunk_index >= %s",
            (source, len(chunks))
        )
        conn.commit()

class Handler(FileSystemEventHandler):
    def on_modified(self, event):
        if event.src_path.endswith(".md"):
            reindex(Path(event.src_path))

if __name__ == "__main__":
    # Full initial indexing
    for md in VAULT_PATH.rglob("*.md"):
        reindex(md)
    # Watch for changes
    observer = Observer()
    observer.schedule(Handler(), str(VAULT_PATH), recursive=True)
    observer.start()
    observer.join()

Ese es el indexador entero. No es pseudo-código mínimo. Es Python real que corre. Se encarga de la indexación inicial, de la detección de cambios de archivo y de la regeneración de embedding cuando hay edición. El tamaño de chunk de 1500 caracteres es un punto medio: lo suficientemente pequeño para recuperar snippets precisos, lo suficientemente grande para preservar el contexto de párrafo. Una advertencia: all-MiniLM-L6-v2 trunca toda entrada de más de 256 word pieces, más o menos 1.000 caracteres de inglés, así que la cola de un chunk completo de 1500 caracteres nunca llega al embedding. Si tus notas son densas, baja max_chars o elige un modelo con una ventana de entrada más larga.

El schema

sql
CREATE EXTENSION IF NOT EXISTS vector;

-- VECTOR(384) matches all-MiniLM-L6-v2.
-- If you swap the embedding model, adjust the dimension and reindex.
CREATE TABLE knowledge (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source TEXT NOT NULL,
  chunk_index INTEGER NOT NULL,
  content TEXT NOT NULL,
  embedding VECTOR(384) NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (source, chunk_index)
);

CREATE INDEX idx_knowledge_embedding
  ON knowledge USING hnsw (embedding vector_cosine_ops);

CREATE INDEX idx_knowledge_source ON knowledge (source);

Una tabla. Texto del chunk, embedding, ruta de origen. La ruta de origen es lo que importa cuando el agente le muestra el contexto recuperado a un humano: 'esto vino de conventions/testing.md', no 'esto vino del embedding ID 8d3f'.

Cómo el agente usa esto

python
# libs/memory/retrieval.py
# Reuses the same model loaded in the indexer (one instance, CPU).
def retrieve(query: str, top_k: int = 5) -> list[dict]:
    q_emb = model.encode(query).tolist()
    with psycopg.connect(DB_URL) as conn:
        rows = conn.execute(
            # Inner query stays on the HNSW index. Outer query re-ranks
            # the candidates and demotes anything older than 6 months.
            "WITH candidates AS ("
            "  SELECT source, chunk_index, content, updated_at, "
            "         1 - (embedding <=> %s::vector) AS similarity "
            "  FROM knowledge "
            "  ORDER BY embedding <=> %s::vector "
            "  LIMIT 20"
            ") "
            "SELECT source, chunk_index, content, "
            "       similarity - CASE "
            "         WHEN updated_at < NOW() - INTERVAL '6 months' THEN 0.05 "
            "         ELSE 0 END AS score "
            "FROM candidates "
            "ORDER BY score DESC "
            "LIMIT %s",
            (q_emb, q_emb, top_k)
        ).fetchall()
    return [
        {"source": r[0], "chunk": r[1], "content": r[2], "score": r[3]}
        for r in rows
    ]

El agente arma una query a partir del payload del trigger más su razonamiento actual, llama a retrieve y recibe de vuelta los top 5 chunks. Esos chunks van al system prompt con sus rutas de origen. Si el agente cita la base de conocimiento en la salida, cita la ruta de origen, no un ID opaco. Un humano puede abrir exactamente ese archivo y verificar. La consulta en dos pasos es deliberada: traes un conjunto amplio de candidatos con el índice vectorial y después re-rankeas en SQL. Re-rankear en la consulta externa evita que la penalización por antigüedad tire el índice a la basura.

Qué poner en el vault

No todo. Sé deliberado sobre qué pertenece a una base de conocimiento de equipo que los agentes leen.

Pertenece al vaultConvenciones de equipo (nomencl…Runbooks (playbooks de oncall, …Decisiones de arquitectura (ADR…Glosario de dominio (qué signif…Post-mortems (qué salió mal, qu…No perteneceCredenciales o secretos (nunca)PII de cliente (nunca)Notas de standup diario (efímer…Actas de reunión sin desenlacesRespuestas de API copiadas y pe…VS

La regla que uso: si la nota todavía es útil dentro de 12 meses, pertenece al vault. Si es un snapshot de un momento, va a Slack o a un ticket.

Costo

Por default, este setup cuesta cero. El modelo de embedding se descarga una vez al disco del container (alrededor de 90MB) y corre en la CPU del servidor que ya tienes. pgvector es una extensión gratuita de Postgres. La única infra paga es el propio servidor donde todo corre, y si ya corres Postgres para cualquier otra cosa, esto es cero infraestructura adicional.

Para un vault de 500 notas con un promedio de 2 chunks cada una, la indexación inicial con all-MiniLM-L6-v2 en una CPU moderna termina en bastante menos de un minuto. El re-embedding continuo en ediciones es instantáneo desde el punto de vista de quien edita. Si escalas a decenas de miles de notas y empiezas a sentir latencia de indexación, ahí tiene sentido revisitar la decisión. Hasta entonces, resiste la tentación de 'mejorar' cambiando a una API paga antes de tener un número que lo justifique.

Los errores que ya cometí

Primer error: construí una estrategia de chunking complicada con overlap y splitting semántico en la primera versión. Tuvo peor desempeño que el chunking basado en párrafo porque las fronteras eran menos predecibles. Empieza simple. Chunks de párrafo con tamaño máximo. Solo ajusta cuando la calidad del retrieval te diga que ajustes.

Segundo error: traté la base de conocimiento como append-only y dejé que contenido obsoleto se acumulara. Si lo dejas así, termina con el agente citando una convención deprecada de un runbook que nadie tocó en un año. La consulta de retrieval de arriba es el arreglo: trae un conjunto de candidatos más amplio y después le resta una penalización pequeña a cualquier cosa cuyo updated_at tenga más de 6 meses. Una nota vieja igual puede ganar si no hay nada más fresco cerca, que es el comportamiento que quieres.

Tercer error: le di al agente acceso de escritura al vault para que pudiera 'aprender' actualizando notas. Ese es el instinto equivocado. Una corrida de baja confianza puede reescribir una nota en algo sutilmente equivocado, nada en el pipeline lo atrapa, y el siguiente retrieval trata el texto del propio agente como material fuente. El vault lo editan humanos. La salida del agente va a una carpeta separada agent_learnings/ que un humano lee antes de que algo se promueva al vault.

La base de conocimiento de donde tus agentes leen y la base de conocimiento que tu equipo edita deberían ser la misma carpeta. Cualquier arquitectura que separa las dos hace que las dos diverjan, y memoria divergente es peor que ninguna memoria.

Upgrades opcionales

Tres situaciones donde el modelo local por default deja de ser la respuesta correcta. Solo esas tres.

Volumen masivo. Si el vault pasa de decenas de miles de notas y el re-embedding local empieza a tomar horas, una API hospedada termina en minutos. Voyage AI voyage-3 es el upgrade más directo aquí porque fue entrenado específicamente para retrieval, y soporta input_type='query' vs 'document' para mejorar el recall.

Calidad de recall validada por benchmark. Si mediste retrieval local y está recuperando chunks irrelevantes en escenarios específicos, los modelos hospedados más grandes a veces resuelven. Pero mide antes de cambiar. Muchas veces el problema es chunking o ranking, no el modelo.

Entrada que el modelo local no puede representar. all-MiniLM-L6-v2 trunca a 256 word pieces y fue entrenado en inglés. Si tus notas son de formato largo o multilingües, la calidad de retrieval cae por una razón que ningún ajuste arregla. Prueba primero la opción local más grande: nomic-embed-text por Ollama acepta una entrada bastante más larga, maneja más idiomas, y sigue siendo gratis. Un modelo hospedado con ventana de contexto larga es el paso siguiente, no el anterior.

Cuando el número cierra, el cambio es de pocas líneas:

python
# --- Local default (zero cost) ---
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")  # 384 dim

def embed(text: str) -> list[float]:
    return model.encode(text).tolist()


# --- Upgrade to Voyage (pay per call) ---
import voyageai
vo = voyageai.Client()  # reads VOYAGE_API_KEY from env

def embed_doc(text: str) -> list[float]:
    result = vo.embed([text], model="voyage-3", input_type="document")
    return result.embeddings[0]

def embed_query(text: str) -> list[float]:
    result = vo.embed([text], model="voyage-3", input_type="query")
    return result.embeddings[0]

# WARNING: voyage-3 returns 1024 dim. Adjust VECTOR(1024) in the schema
# and reindex the entire vault. The migration is not transparent.

Referencias

RAGObsidianpgvectorKnowledge BaseAI Agents