Ollama RAG: Preguntas y respuestas con base de conocimiento privada local

En este capítulo, combinamos las capacidades de embeddings, recuperación y generación aprendidas anteriormente en una aplicación realmente utilizable: alimenta tus documentos al modelo local para que responda preguntas únicamente según tus materiales e indique la fuente.

El código completo tiene menos de 100 líneas, cero dependencias de bibliotecas vectoriales externas: cópialo y ejecútalo.


Desglose de requisitos y selección de tecnología

Primero aclaremos el problema: el modelo grande no conoce los materiales de tu disco duro; preguntarle directamente solo produce respuestas inventadas. La idea de RAG (generación aumentada por recuperación) es un "examen a libro abierto": primero se recuperan fragmentos relevantes de los materiales, se insertan en el prompt y el modelo responde basándose en ellos.

RAG 双阶段流程:离线索引与在线问答

La selección de tecnología sigue el principio de "dependencia mínima":

EtapaSelecciónJustificación
Modelo de embeddingsembeddinggemmaTamaño pequeño, rápido en local; alternativa: qwen3-embedding
Modelo de generaciónqwen3.5:4bBueno en chino, se ejecuta con 16 GB de RAM
Almacenamiento de vectoresLista de Python + similitud cosenoCero dependencias, claro para la enseñanza; para volúmenes grandes, cambia a bibliotecas profesionales como Chroma
Formato de documentostxt / mdEl texto plano basta para explicar el principio; para PDF, consulta la ampliación al final del artículo

Necesitas descargar (pull) dos modelos:

ollama pull embeddinggemma
ollama pull qwen3.5:4b

EmbeddingGemma es un modelo de embeddings de texto ligero, multilingüe y de código abierto de Google, diseñado específicamente para convertir texto en vectores. Es muy adecuado para la implementación offline en dispositivos locales. Ollama ya ha adaptado y encapsulado este modelo, por lo que puedes descargarlo y usarlo directamente.


Paso 1: Análisis y segmentación de documentos

El contexto del modelo es limitado y el documento completo no cabe, por lo que hay que dividirlo en fragmentos pequeños. La estrategia de segmentación usa el enfoque más simple de "longitud fija + solapamiento": cada fragmento tiene 300 caracteres y los fragmentos adyacentes se solapan en 50 caracteres para evitar cortar frases clave.

Ejemplo

# Ruta del archivo: rag.py
import os

def load_chunks(folder, size=300, overlap=50):
    """Leer todos los archivos txt/md en la carpeta, dividirlos en fragmentos con marcadores de origen."""
    chunks = []
    for name in sorted(os.listdir(folder)):
        if not name.endswith(('.txt', '.md')):
            continue
        path = os.path.join(folder, name)
        text = open(path, encoding='utf-8').read().strip()
        # Corte de ventana deslizante: paso = size - overlap
        step = size - overlap
        for i in range(0, len(text), step):
            piece = text[i:i + size].strip()
            if len(piece) > 20:  # Filtrar fragmentos demasiado cortos
                chunks.append({'file': name, 'text': piece})
    return chunks

Paso 2: Vectorización y recuperación

Convierte todos los fragmentos y la pregunta del usuario en vectores y usa la similitud coseno para encontrar los top-k fragmentos más relevantes para la pregunta.

Ejemplo

import ollama

EMBED_MODEL = 'embeddinggemma'

def embed(texts):
    """Genera vectores en lote (lista de entrada, lista de salida de vectores)"""
    result = ollama.embed(model=EMBED_MODEL, input=texts)
    return result['embeddings']

def cosine(a, b):
    """Similitud coseno: cuanto más cerca de 1, más similar"""
    dot = sum(x * y for x, y in zip(a, b))
    na = sum(x * x for x in a) ** 0.5
    nb = sum(x * x for x in b) ** 0.5
    return dot / (na * nb)

def search(question, chunks, vectors, top_k=3):
    """Recuperación: devuelve los k fragmentos más relevantes y sus similitudes"""
    q_vec = embed([question])[0]
    scored = [
        (cosine(q_vec, v), c)
        for v, c in zip(vectors, chunks)
    ]
    scored.sort(key=lambda x: -x[0])
    return scored[:top_k]

EmbeddingGemma genera vectores con normalización L2; aquí se mantiene la fórmula completa de la similitud coseno para que el principio quede claro; si se sustituye por el producto punto, el resultado es el mismo.


Paso 3: Ensamblar el prompt y generar respuestas con citas

Escribe los fragmentos recuperados junto con sus números de fuente en el prompt y pide explícitamente al modelo que "responda solo según los materiales" e indique las citas.

Ejemplo

from ollama import chat

GEN_MODEL = 'qwen3.5:4b'

def answer(question, chunks, vectors):
    hits = search(question, chunks, vectors, top_k=3)

    # Concatenar los fragmentos con los números de origen en el contexto
    context = '\n\n'.join(
        f'[Datos{idx}] (de {c["file"]})\n{c["text"]}'
        for idx, (score, c) in enumerate(hits, 1)
    )
    prompt = fResponde a la pregunta basándote únicamente en la siguiente información y marca el número de la información utilizada.
Si la información es insuficiente para responder, indique directamente que no lo sabe.

Datos:
{context}

Pregunta: {question}'''


    response = chat(
        model=GEN_MODEL,
        messages=[{'role': 'user', 'content': prompt}],
        options={'temperature': 0.2},  # Baja temperatura, reduce la volatilización
    )
    print(response.message.content)
    print('\nFuente de referencia: ')
    for idx, (score, c) in enumerate(hits, 1):
        print(f[Documento {idx}] {c["file"]} (similitud {score:.3f}))

Ejecución completa: punto de entrada del programa principal

Ejemplo

# Agregar al final de rag.py
if __name__ == '__main__':
    folder = 'docs'  # Pon tus documentos txt/md en la carpeta docs
    chunks = load_chunks(folder)

    # Indexación fuera de línea: vectorizar todos los segmentos de una sola vez
    print(f'Indexando {len(chunks)} fragmentos...')
    vectors = embed([c['text'] for c in chunks])
    print('Indexación completada.\n')

    # Bucle de preguntas y respuestas en línea
    while True:
        q = input(Pregunta (exit para salir):).strip()
        if q.lower() == 'exit':
            break
        if q:
            answer(q, chunks, vectors)
            print()

Prepara los materiales y ejecuta:

mkdir docs
cp EJEMPLO-python-notes.md docs/
python rag.py

Salida:

$ python rag.py
正在索引 42 个片段...
索引完成。

问题(exit 退出):Python 的切片是什么?
Python 切片是用 [start:stop:step] 从序列截取子序列的语法 [资料1]。
例如 s[1:4] 取索引 1 到 3 的字符 [资料2]。

参考来源:
  [资料1] EJEMPLO-python-notes.md(相似度 0.712)
  [资料2] EJEMPLO-python-notes.md(相似度 0.668)

Observa la indicación de fuente al final de la respuesta: cada frase se puede rastrear hasta un archivo concreto de tu directorio docs. Este es precisamente el valor central de RAG frente a "preguntar directamente al modelo".


Optimización de resultados y preguntas frecuentes

Si el resultado de RAG no es bueno, en el 80% de los casos el problema está en la recuperación, no en la generación. Las palancas de optimización, ordenadas por impacto:

PalancaValor predeterminadoDirección de ajuste
Tamaño de fragmento (size)300 caracteresSi las respuestas quedan demasiado fragmentadas, auméntalo; si es demasiado grande, la recuperación pierde precisión. Rango recomendado: 200~500
Solapamiento (overlap)50 caracteresSi las frases se cortan con frecuencia, auméntalo.
Número de resultados a recuperar (top_k)3Auméntalo cuando los materiales sean densos, pero ocupará más contexto.
Umbral de similitudNo activadoFiltra los fragmentos con similitud demasiado baja para reducir interferencias irrelevantes.
Temperatura de generación0.2Mantén una temperatura baja; la base de conocimiento no necesita creatividad.

Consulta rápida de problemas frecuentes:

SíntomaCausaSolución
La respuesta no está relacionada con los materialesLa recuperación no encontró coincidenciasComprueba si los fragmentos son demasiado pequeños o demasiado grandes y confirma que el idioma de la pregunta coincide con el de los materiales.
El modelo sigue inventando respuestasRestricciones insuficientes en el promptRefuerza la expresión "solo según los materiales"; si no se recupera nada, responde directamente "no lo sé".
La indexación es muy lentaDemasiados fragmentos, embeddings uno por unoUsa embed por lotes; cuando el volumen de documentos sea grande, cambia a una biblioteca vectorial profesional con indexación incremental.
Las respuestas empeoran con documentos largosLa información clave se fragmentaSegmenta por límites semánticos, como títulos/párrafos, en lugar de usar longitud fija.

Hoja de ruta hacia producción: cuando el volumen de documentos supere las decenas de miles o se necesite análisis de PDF, reemplaza la "biblioteca vectorial basada en listas" por una biblioteca vectorial profesional como Chroma (que ofrece persistencia y búsqueda eficiente de vecinos cercanos), usa pypdf para extraer el texto de los PDF y el resto del esqueleto del código no cambia: este es precisamente el valor de desacoplar las etapas en este artículo.

Otras ampliaciones