DEV Community

Cover image for RAG local en .NET: Chatea con tu Documentación (sin nube, sin API keys)
Juan Gómez
Juan Gómez

Posted on

RAG local en .NET: Chatea con tu Documentación (sin nube, sin API keys)

RAG local en .NET: Chatea con tu Documentación (sin nube, sin API keys)

Pregúntale a un modelo de lenguaje sobre la documentación de tu producto y míralo improvisar. Nunca leyó tus docs internas de la API, así que rellena los huecos con respuestas seguras, plausibles y completamente inventadas. Eso es una alucinación, y no es que el modelo sea tonto: es que le pediste algo para lo que jamás le diste el material.

RAG lo arregla. En vez de rezar para que el modelo memorizara tus docs, le entregas las páginas relevantes en el momento de la pregunta — como dejar que un estudiante haga un examen a libro abierto. En este tutorial construimos un pequeño sistema RAG en .NET 10 que responde preguntas sobre la documentación de un producto, y lo hacemos 100% local: sin nube, sin API keys, sin factura a fin de mes.


¿Qué es RAG, sin humo?

RAG significa Retrieval-Augmented Generation (Generación Aumentada por Recuperación). Quítale las siglas y quedan tres ideas simples:

  • Retrieval (recuperar) — encontrar los pedazos de tus docs que se relacionan con la pregunta.
  • Augmented (aumentar) — pegar esos pedazos al prompt.
  • Generation (generar) — dejar que el modelo escriba la respuesta usándolos.

Eso es todo. Lo ingenioso está en el primer paso: ¿cómo encuentras los pedazos relevantes? No basta con buscar palabras iguales, porque alguien puede preguntar "¿cómo inicio sesión?" mientras tu doc dice "autenticación". Palabras distintas, mismo significado.

El truco son los embeddings. Un embedding es simplemente una lista de números (un vector) que captura el significado de un texto. Los textos que significan cosas parecidas obtienen números parecidos — así "iniciar sesión" y "autenticación" quedan cerca, aunque no compartan ni una letra. Encuentra los vectores más cercanos al vector de la pregunta y habrás encontrado los docs relevantes. No es magia, es geometría.


Los tres pasos de RAG

Este es el pipeline completo, de principio a fin:

  1. Ingesta (una sola vez, al inicio): parte los docs en trozos pequeños, convierte cada trozo en un embedding y guárdalos.
  2. Recuperación (por cada pregunta): convierte la pregunta en un embedding y busca los trozos cuyos embeddings estén más cerca.
  3. Aumento y generación (por cada pregunta): mete esos trozos en el prompt y pide al modelo que responda usándolos.

Los pasos 2 y 3 corren cada vez que alguien pregunta algo. El paso 1 corre una vez y se reutiliza para siempre (o hasta que cambien tus docs).


Lo que vamos a construir + Setup

Armaremos una app de consola que ingiere la documentación de un producto y luego responde preguntas sobre ella desde la terminal. Tres herramientas hacen el trabajo pesado:

  • Ollama corre los modelos de IA en tu máquina — uno para crear embeddings, otro para escribir respuestas.
  • SQLite guarda todo en un único archivo.
  • sqlite-vec, una extensión de SQLite, hace por nosotros la búsqueda de "los vectores más cercanos".

Tu laptop está a punto de tener un segundo empleo como mini data center de IA. El único costo es un ventilador un poco más caliente.

# 1. Crea el proyecto
dotnet new console -n OrbitDocsRag
cd OrbitDocsRag
dotnet add package Microsoft.Data.Sqlite

# 2. Instala Ollama desde https://ollama.com y descarga los dos modelos
ollama pull nomic-embed-text   # convierte texto en vectores de 768 números
ollama pull llama3.2           # escribe la respuesta final

# 3. Coloca la librería nativa de sqlite-vec junto a tu app.
#    Descarga vec0.dll (Windows) / vec0.so (Linux) / vec0.dylib (macOS)
#    desde https://github.com/asg017/sqlite-vec/releases y ponla en la
#    carpeta del proyecto para que aterrice en bin/ al compilar.
Enter fullscreen mode Exit fullscreen mode

Ollama expone una API REST sencilla en http://localhost:11434. Hablaremos con ella usando un HttpClient pelado — sin SDK — para que veas que no hay nada escondido detrás de un wrapper. De verdad son solo un par de POST por HTTP.


Paso 1 — Partir los docs en trozos (chunking)

No puedes convertir un manual de 50 páginas en un solo vector — sería un promedio borroso de todo, inútil para cualquier cosa. Así que partimos cada documento en ventanas pequeñas y solapadas. Los trozos pequeños dan coincidencias precisas; el solapamiento evita que una idea que cae justo en el borde quede cortada por la mitad.

sealed record DocChunk(string Source, string Text);

static class Chunker
{
    public static IEnumerable<DocChunk> Split(
        string source, string text, int maxWords = 120, int overlap = 20)
    {
        var words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);

        for (var start = 0; start < words.Length; start += maxWords - overlap)
        {
            var end = Math.Min(start + maxWords, words.Length);
            yield return new DocChunk(source, string.Join(' ', words[start..end]));
            if (end == words.Length) break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Cada trozo recuerda su Source (el archivo del que vino) para poder citarlo después. Desliza una ventana de 120 palabras avanzando de 100 en 100, y obtienes trozos solapados que conservan su contexto.


Paso 2 — Convertir texto en vectores (embeddings)

Aquí Ollama se gana el sueldo. Hacemos POST de un texto a /api/embed y recibimos un arreglo de 768 floats — el embedding.

using System.Net.Http.Json;
using System.Text.Json;

sealed class OllamaClient
{
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
    private readonly HttpClient _http = new() { BaseAddress = new Uri("http://localhost:11434") };

    public async Task<float[]> EmbedAsync(string text)
    {
        var request = new { model = "nomic-embed-text", input = text };
        var response = await _http.PostAsJsonAsync("/api/embed", request, Json);
        response.EnsureSuccessStatusCode();

        var body = await response.Content.ReadFromJsonAsync<EmbedResponse>(Json);
        return body!.Embeddings[0];
    }

    private sealed record EmbedResponse(float[][] Embeddings);
}
Enter fullscreen mode Exit fullscreen mode

Esa es toda la parte de "IA" de los embeddings — una llamada HTTP. El modelo lee tu texto y devuelve su significado como 768 números. Dos trozos sobre autenticación regresarán con dos arreglos muy parecidos.


Paso 3 — Guardar los vectores en SQLite

Ahora guardamos cada trozo y su embedding. sqlite-vec nos da una tabla virtual especial que sabe buscar vectores de forma nativa. Le decimos que los vectores son 768 floats y que queremos distancia coseno (ya llegamos a eso). Las columnas +source y +chunk viajan como texto plano para poder recuperar el texto original tras una búsqueda.

using Microsoft.Data.Sqlite;
using System.Text.Json;

sealed class VectorStore(string path) : IDisposable
{
    private readonly SqliteConnection _connection = new($"Data Source={path}");

    public void Initialize()
    {
        _connection.Open();
        _connection.LoadExtension("vec0"); // carga la librería nativa de sqlite-vec

        using var command = _connection.CreateCommand();
        command.CommandText = """
            CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
                embedding float[768] distance_metric=cosine,
                +source text,
                +chunk text
            );
            """;
        command.ExecuteNonQuery();
    }

    public void Add(DocChunk chunk, float[] embedding)
    {
        using var command = _connection.CreateCommand();
        command.CommandText = """
            INSERT INTO vec_chunks(embedding, source, chunk)
            VALUES ($embedding, $source, $chunk);
            """;
        command.Parameters.AddWithValue("$embedding", JsonSerializer.Serialize(embedding));
        command.Parameters.AddWithValue("$source", chunk.Source);
        command.Parameters.AddWithValue("$chunk", chunk.Text);
        command.ExecuteNonQuery();
    }

    public void Dispose() => _connection.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

Guardamos el embedding como un string JSON tipo [0.12, -0.04, ...] — sqlite-vec lo interpreta sin problema.

¿Cómo funciona eso de "el más cercano"?

sqlite-vec hace la búsqueda en código nativo rápido, pero no hay ningún misterio adentro. "¿Qué tan parecidos son dos vectores?" se responde con similitud coseno, que son un puñado de líneas de C# plano:

static double CosineSimilarity(float[] a, float[] b)
{
    double dot = 0, magA = 0, magB = 0;
    for (var i = 0; i < a.Length; i++)
    {
        dot  += a[i] * b[i];
        magA += a[i] * a[i];
        magB += b[i] * b[i];
    }
    return dot / (Math.Sqrt(magA) * Math.Sqrt(magB));
}
Enter fullscreen mode Exit fullscreen mode

El resultado va de 1.0 (significado idéntico) hasta 0 (sin relación). sqlite-vec reporta la distancia — básicamente 1 - similitud — así que más pequeño es más cercano. Por eso cada consulta de abajo termina en ORDER BY distance. Eso es toda la "búsqueda vectorial" de la que todos hablan: un producto punto y una raíz cuadrada.


Paso 4 — Recuperar los trozos relevantes

Por cada pregunta, convertimos la pregunta en embedding exactamente igual, y luego le pedimos a sqlite-vec los k trozos más cercanos. La palabra clave MATCH es la señal de sqlite-vec para ejecutar una búsqueda por vecinos más cercanos.

sealed record SearchHit(string Source, string Chunk, double Distance);

// (agrega este método a VectorStore)
public List<SearchHit> Search(float[] queryEmbedding, int k)
{
    using var command = _connection.CreateCommand();
    command.CommandText = """
        SELECT source, chunk, distance
        FROM vec_chunks
        WHERE embedding MATCH $query
        ORDER BY distance
        LIMIT $k;
        """;
    command.Parameters.AddWithValue("$query", JsonSerializer.Serialize(queryEmbedding));
    command.Parameters.AddWithValue("$k", k);

    var hits = new List<SearchHit>();
    using var reader = command.ExecuteReader();
    while (reader.Read())
        hits.Add(new SearchHit(reader.GetString(0), reader.GetString(1), reader.GetDouble(2)));

    return hits;
}
Enter fullscreen mode Exit fullscreen mode

Le das el vector de la pregunta y recibes los tres trozos con mayor probabilidad de contener la respuesta — ordenados del más cercano al más lejano.


Paso 5 — Armar el prompt y generar la respuesta

El último paso es la recompensa. Cosemos los trozos recuperados dentro de un prompt con una instrucción estricta: responde solo con este contexto. Luego lo enviamos al modelo de chat vía /api/chat.

// (agrega este método a OllamaClient)
public async Task<string> ChatAsync(string prompt)
{
    var request = new
    {
        model = "llama3.2",
        messages = new[] { new { role = "user", content = prompt } },
        stream = false
    };
    var response = await _http.PostAsJsonAsync("/api/chat", request, Json);
    response.EnsureSuccessStatusCode();

    var body = await response.Content.ReadFromJsonAsync<ChatResponse>(Json);
    return body!.Message.Content;
}

// records anidados dentro de OllamaClient
private sealed record ChatResponse(ChatMessage Message);
private sealed record ChatMessage(string Role, string Content);
Enter fullscreen mode Exit fullscreen mode

Y el prompt que lo une todo:

var context = string.Join("\n\n", hits.Select(h => $"[{h.Source}] {h.Chunk}"));

var prompt = $"""
    Answer the question using ONLY the context below.
    If the answer is not in the context, say "I don't know based on the docs."

    Context:
    {context}

    Question: {question}
    """;
Enter fullscreen mode Exit fullscreen mode

Esa única instrucción — solo con este contexto — es lo que mata las alucinaciones. Sin nada en qué apoyarse, el modelo no inventará una respuesta con seguridad; admitirá que no sabe. Es la diferencia entre un estudiante que estudió y uno que improvisa frente a la clase. Y como cada trozo lleva su etiqueta [source.md], puedes mostrarle al usuario exactamente de dónde salió la respuesta.


El ejemplo completo

Este es el Program.cs de nivel superior. Trae tres "docs" diminutas embebidas para que puedas correrlo sin ningún archivo, pero cambia SampleDocs.All por una carpeta de .md reales y no cambia nada más. Mantén los tipos OllamaClient, VectorStore, Chunker, DocChunk y SearchHit de los pasos anteriores en el mismo proyecto (debajo del código de nivel superior, o en sus propios archivos) y compila tal cual.

using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Data.Sqlite;

var ollama = new OllamaClient();
using var store = new VectorStore("orbit-docs.db");
store.Initialize();

// --- Paso 1 + 2 + 3: ingesta de los docs (trocear -> embeber -> guardar) ---
foreach (var (source, text) in SampleDocs.All)
    foreach (var chunk in Chunker.Split(source, text))
    {
        var embedding = await ollama.EmbedAsync(chunk.Text);
        store.Add(chunk, embedding);
    }

Console.Write("Pregunta sobre la API de Orbit: ");
var question = Console.ReadLine() is { Length: > 0 } input ? input : "¿Cómo me autentico?";

// --- Paso 4: recuperar ---
var questionEmbedding = await ollama.EmbedAsync(question);
var hits = store.Search(questionEmbedding, k: 3);

// --- Paso 5: aumentar + generar ---
var context = string.Join("\n\n", hits.Select(h => $"[{h.Source}] {h.Chunk}"));
var prompt = $"""
    Answer the question using ONLY the context below.
    If the answer is not in the context, say "I don't know based on the docs."

    Context:
    {context}

    Question: {question}
    """;

var answer = await ollama.ChatAsync(prompt);

Console.WriteLine($"\n{answer}\n");
Console.WriteLine($"Fuentes: {string.Join(", ", hits.Select(h => h.Source).Distinct())}");

static class SampleDocs
{
    public static readonly Dictionary<string, string> All = new()
    {
        ["auth.md"] = """
            Authentication. The Orbit API uses API keys. Send your key in the
            Authorization header as 'Bearer <key>'. Keys never expire but can be
            revoked from the dashboard. Rate limits are 100 requests per minute.
            """,
        ["webhooks.md"] = """
            Webhooks. Orbit pushes events to your endpoint. Register a webhook URL
            in the dashboard. Every payload is signed with HMAC-SHA256; verify the
            X-Orbit-Signature header before trusting the body. Failed deliveries
            retry 5 times with exponential backoff.
            """,
        ["errors.md"] = """
            Error codes. Orbit returns standard HTTP status codes. A 429 means you
            hit the rate limit, so back off and retry. A 401 means your API key is
            missing or invalid. A 422 means the request body failed validation.
            """
    };
}
Enter fullscreen mode Exit fullscreen mode

Córrelo, pregunta "¿cómo inicio sesión?" y, aunque esas palabras exactas no aparecen en ningún lado de los docs, el embedding de tu pregunta aterriza junto al trozo de autenticación — y el modelo responde con las instrucciones de la API key, citando auth.md. Eso es RAG funcionando.


Cuándo usar RAG — y cuándo no

RAG es la herramienta correcta cuando tu conocimiento es grande, privado o cambiante:

  • Usa RAG para docs de empresa, manuales de producto, tickets de soporte, una base de conocimiento — cualquier cosa que el modelo base nunca vio y que se actualiza seguido. ¿Docs nuevos? Solo los embebes. Sin reentrenar.
  • Sáltate RAG y pega todo en el prompt cuando el conocimiento completo cabe cómodamente en la ventana de contexto. Si son dos párrafos, la recuperación es matar moscas a cañonazos.
  • Recurre a fine-tuning cuando necesitas cambiar el estilo, tono o formato del modelo — no sus hechos. El fine-tuning enseña comportamiento; RAG aporta conocimiento. Resuelven problemas distintos, y muchos sistemas reales usan ambos.

Algunas limitaciones honestas a tener en cuenta: tus respuestas son tan buenas como tus trozos, así que un mal chunking significa mala recuperación. Las tablas y los números se embeben mal (su significado vive en la estructura, no en la prosa). Y si la respuesta simplemente no está en los docs, un sistema RAG bien instruido debería decirlo — lo cual es una virtud, no un defecto.

La regla de oro: si tendrías que copiar y pegar un documento en el chat para obtener una buena respuesta, ese es trabajo para RAG.


Puntos clave

  • RAG son tres pasos, no magia — recuperar los trozos relevantes, aumentar el prompt, generar la respuesta. Todo lo demás es detalle.
  • Los embeddings convierten significado en geometría — un texto parecido se vuelve un vector parecido, así encuentras docs relevantes aunque las palabras no coincidan.
  • La "búsqueda vectorial" es solo similitud coseno — un producto punto y una raíz cuadrada; sqlite-vec solo lo hace rápido, no misterioso.
  • Anclar al contexto mata las alucinaciones — decirle al modelo que responda solo con el contexto recuperado convierte el disparate seguro en un honesto "no sé", y te deja citar fuentes.
  • No necesitas la nube — Ollama más SQLite corren un pipeline RAG completo en tu laptop, con cero API keys y cero costo por token.

Top comments (0)