Inicio / Artículos

Un pipeline RAG mínimo en Go con embeddings de langchaingo, Groq y Jina

Un pipeline RAG mínimo en Go con langchaingo: un almacén de vectores en memoria, búsqueda por coseno, Groq para obtener respuestas y embeddings de Jina, además de una demostración de contexto incorrecto.

Editor: REACTAPP.TOP10 min de lectura2251 palabras

Fuentes: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go

RAG, abreviatura de Retrieval-Augmented Generation, significa que el modelo responde a partir de documentos externos que obtiene en el momento de la consulta, en lugar de depender únicamente de lo que absorbió durante su entrenamiento. Ya no es necesario codificar los datos de forma fija en el prompt del sistema. Se obtiene el contexto más relevante y se le entrega al modelo junto con la pregunta, de modo que las respuestas permanecen vinculadas a fuentes que usted controla.

Por qué un asistente lo necesita

Cualquiera que construya un asistente personalizado eventualmente se topa con el mismo problema: el modelo no sabe nada sobre su mundo. Nunca ha visto los nombres de sus productos, sus políticas internas ni los datos específicos de su área de negocio. La solución obvia es pegar todo en el prompt del sistema, lo cual es frágil, costoso y rápidamente llega a un límite de tamaño.

RAG invierte esa situación. En lugar de incluir el contexto en un prompt, el modelo cuenta con una herramienta para obtener el contexto adecuado cuando llega una pregunta. Usted decide en qué realidad vive el asistente, sin sobrecargar el prompt. También le proporciona un patrón ordenado para organizar el conocimiento específico del negocio: sus términos, sus documentos, su verdad, recuperados según sea necesario.

A continuación se muestra un pipeline mínimo en Go con muy pocas dependencias.

Las bibliotecas involucradas

Si las herramientas de LLM en Go son algo nuevo para usted, las importaciones parecerán un galimatías; así que aquí está quién es quién antes de cualquier código.

langchaingo (github.com/tmc/langchaingo) es la versión en Go del conocido ecosistema Python LangChain. Se trata de un conjunto de paquetes pequeños y componibles que ocultan las partes complejas de las aplicaciones basadas en LLM, de modo que no es necesario escribir manualmente llamadas HTTP ni procesar JSON para cada proveedor. Aquí se utilizan cuatro de sus subpaquetes:

  • llms es la abstracción del modelo. Una única interfaz, llms.Model, junto con una función auxiliar, llms.GenerateFromSinglePrompt, funcionan de la misma manera independientemente de que el backend sea OpenAI, Groq, Ollama o un modelo local. Esta herramienta envía el prompt preparado y devuelve el texto resultante.
  • embeddings proporciona la interfaz embeddings.Embedder. Un embedder convierte una cadena de texto en una lista de valores flotantes, es decir, en un vector que captura su significado. Frases similares generan vectores similares, mientras que las no relacionadas lo hacen de forma distinta; esta es la base matemática en la que se sustenta la recuperación de información.
  • schema define schema.Document, una estructura pequeña que contiene PageContent (el texto), metadatos y un campo Score para clasificar los resultados.
  • vectorstores aporta principalmente tipos de opciones y convenciones. Sus almacenes concretos no se utilizan, ya que un memStore creado manualmente demuestra cuán poco se necesita realmente.
  • Existen dos adaptadores de proveedor que conectan estas interfaces con APIs reales:

    • openai utiliza el protocolo HTTP compatible con OpenAI, como su nombre indica. Groq ofrece el mismo protocolo en https://api.groq.com/openai/v1; por lo tanto, dirigir el cliente de OpenAI allí permite realizar inferencias rápidas y gratuitas sin necesidad de un SDK adicional.
    • jina es el adaptador de embeddings de Jina. Jina ofrece una versión gratuita generosa para generar vectores de calidad con jina-embeddings-v3, y por eso el generador de embeddings lo utiliza.

    Una imagen mental útil: llms junto con openai representa la boca, que produce respuestas. embeddings junto con jina son la memoria, que convierte el texto en datos matemáticos buscables. schema junto con el memStore personalizado es el archivador que almacena y recupera información. Lo demás consiste en conectar estos tres componentes, sin magia de frameworks y con cada interfaz encargada de una tarea específica.

    Las funciones auxiliares

    Algunos ayudantes se ejecutan antes del flujo principal. Ellos hacen posible la recuperación de información.

    El almacén de vectores en memoria

    Los documentos y sus embeddings necesitan un lugar donde almacenarse, además de una forma de buscarlos. Un memStore personalizado envuelve un generador de embeddings y mantiene los documentos junto a sus vectores.

    type memStore struct {
    	embedder embeddings.Embedder
    	docs     []schema.Document
    	vectors  [][]float32
    }
    
    func newMemStore(embedder embeddings.Embedder) *memStore {
       return &memStore{embedder: embedder}
    }
    

    Alberga tres elementos: el incrustador que convierte el texto en vectores, los documentos originales y los vectores float32 que los representan. newMemStore es un constructor sencillo que inserta el incrustador.

    El método AddDocuments realiza tres tareas. Extrae el texto en bruto de cada documento, incrusta todo en un solo lote y, a continuación, almacena tanto los documentos como los vectores, devolviendo al mismo tiempo IDs estables.

    Primero, extraiga el texto e incrustándolo:

    func (m *memStore) AddDocuments(ctx context.Context, docs []schema.Document, _ ...vectorstores.Option) ([]string, error) {
    	texts := make([]string, len(docs))
    	for i, d := range docs {
    		texts[i] = d.PageContent
    	}
    	vecs, err := m.embedder.EmbedDocuments(ctx, texts)
    	if err != nil {
    		return nil, err
    	}
    

    Un bucle simple recopila cada PageContent en un []string simple, y EmbedDocuments devuelve un slice de vectores. Un fallo en el embebedado provoca una salida anticipada.

    En segundo lugar, se añaden y asignan IDs:

            start := len(m.docs)
    	m.docs = append(m.docs, docs...)
    	m.vectors = append(m.vectors, vecs...)
    	ids := make([]string, len(docs))
    	for i := range ids {
    		ids[i] = fmt.Sprintf("doc-%d", start+i)
    	}
    	return ids, nil
    }
    

    El código indica dónde comienzan los nuevos documentos, agrega documentos y vectores al almacén, y genera IDs secuenciales como doc-0 y doc-1. Estos IDs se devuelven al llamante para que pueda hacer referencia a lo que se guardó.

    Similitud coseno

    La recuperación se reduce a encontrar los vectores más cercanos al vector de consulta. La similitud coseno, que es un producto escalar normalizado que ignora la magnitud, realiza esa comparación.

    func cosine(a, b []float32) float64 {
    	n := len(a)
    	if len(b) < n {
    		n = len(b)
    	}
    	var dot, na, nb float64
    	for i := 0; i < n; i++ {
    		dot += float64(a[i]) * float64(b[i])
    		na += float64(a[i]) * float64(a[i])
    		nb += float64(b[i]) * float64(b[i])
    	}
    	if na == 0 || nb == 0 {
    		return 0
    	}
    	return dot / (math.Sqrt(na) * math.Sqrt(nb))
    }
    

    Para mantenerse dentro de los límites, la función utiliza la longitud más corta de las dos. Acumula el producto escalar y ambas normas, y finalmente divide el producto escalar entre las raíces cuadradas multiplicadas de las normas. Devolver 0 para un vector nulo evita la división por cero.

    Búsqueda de similitud

    Con los vectores almacenados y una métrica definida, SimilaritySearch incrusta la consulta, califica cada documento, los ordena y devuelve los N mejores.

    Primero, incrusta la consulta y califica cada documento:

    func (m *memStore) SimilaritySearch(ctx context.Context, query string, numDocuments int, _ ...vectorstores.Option) ([]schema.Document, error) {
    	q, err := m.embedder.EmbedQuery(ctx, query)
    	if err != nil {
    		return nil, err
    	}
    	type scoredDoc struct {
    		doc   schema.Document
    		score float64
    	}
    	results := make([]scoredDoc, len(m.vectors))
    	for i, v := range m.vectors {
    		score := cosine(q, v)
    		doc := m.docs[i]
    		doc.Score = float32(score)
    		results[i] = scoredDoc{doc: doc, score: score}
    	}
    

    La consulta se incrusta una sola vez. Luego, la función recorre cada vector almacenado, calcula la puntuación de coseno en relación con la consulta y guarda cada documento junto con su puntuación en un segmento scoredDoc.

    En segundo lugar, se ordenan y se seleccionan los mejores resultados:

            sort.Slice(results, func(a, b int) bool { return results[a].score > results[b].score })
    	if numDocuments > len(results) {
    		numDocuments = len(results)
    	}
    	out := make([]schema.Document, numDocuments)
    	for i := 0; i < numDocuments; i++ {
    		out[i] = results[i].doc
    	}
    	return out, nil
    }
    

    Los resultados se ordenan por puntaje de mayor a menor, numDocuments está limitado a la cantidad real de documentos, y los primeros resultados se copian en la salida. Los documentos más similares aparecen primero.

    Utilidades de configuración

    Existen dos pequeñas herramientas que facilitan la ejecución de la demostración. envOr lee una variable de entorno con un valor de respaldo. loadDotEnv analiza un archivo .env para que no sea necesario exportar nada manualmente.

    envOr primero:

    func envOr(key, def string) string {
    	if v := os.Getenv(key); v != "" {
    		return v
    	}
    	return def
    }
    

    Devuelve el valor del entorno cuando está configurado y el valor por defecto en caso contrario.

    Luego loadDotEnv:

    func loadDotEnv(path string) error {
    	data, err := os.ReadFile(path)
    	if err != nil {
    		return err
    	}
    	for _, line := range strings.Split(string(data), "\n") {
    		line = strings.TrimSpace(line)
    		if line == "" || strings.HasPrefix(line, "#") {
    			continue
    		}
    		kv := strings.SplitN(line, "=", 2)
    		if len(kv) != 2 {
    			continue
    		}
    		key := strings.TrimSpace(kv[0])
    		value := strings.Trim(strings.TrimSpace(kv[1]), `"'`)
    		if os.Getenv(key) == "" {
    			os.Setenv(key, value)
    		}
    	}
    	return nil
    }
    

    La función lee el archivo, lo divide en líneas, omite las líneas en blanco y los comentarios, divide cada línea en la primera =, elimina las comillas que rodean el valor y establece la variable solo si está indefinida. Esa última verificación significa que las exportaciones realizadas en la shell siempre tienen prioridad. Escribir esto a mano también evita la necesidad de utilizar el paquete dotenv.

    El bucle de consulta

    runQuery es el núcleo de RAG: recuperar, reunir contexto y generar. La instrucción proporcionada exige responder únicamente a partir del contexto suministrado, lo que mantiene al modelo fiel a las instrucciones.

    func runQuery(ctx context.Context, store *memStore, llm llms.Model, query string) {
         fmt.Printf("\nQuery: %s\n", query)
         results, err := store.SimilaritySearch(ctx, query, 2)
         if err != nil {
            log.Fatal(err)
         }
    
         contextText := ""
         for _, res := range results {
            contextText += res.PageContent + "\n"
         }
    
         prompt := fmt.Sprintf("Answer the question based ONLY on the following context:\n\n%s\n\nQuestion: %s", contextText, query)
         completion, err := llms.GenerateFromSinglePrompt(ctx, llm, prompt)
         if err != nil {
            log.Fatal(err)
         }
         fmt.Println("\n--- RAG Response ---")
         fmt.Println(completion)
    }
    

    Imprime la consulta, obtiene los dos documentos principales, une su texto en un único bloque de contexto, crea una instrucción que prohíbe el uso de conocimientos externos, genera una respuesta completa y la imprime.

    La función principal y una maniobra deliberada

    main une todo paso a paso.

    La etapa 1 corresponde al arranque inicial y a la configuración:

    func main() {
           ctx := context.Background()
           _ = loadDotEnv(".env")
           apiKey := os.Getenv("GROQ_API_KEY")
    
           if apiKey == "" {
              log.Fatal("Please set GROQ_API_KEY environment variable")
           }
    

    Crea un contexto de fondo, carga .env (el error se ignora para que la falta de un archivo no provoque fallos) y luego exige una clave Groq.

    La etapa 2 construye el LLM:

            llm, err := openai.New(
    	    openai.WithToken(apiKey),
    	    openai.WithBaseURL("https://api.groq.com/openai/v1"),
    	    openai.WithModel(envOr("GROQ_MODEL", "llama-3.3-70b-versatile")),
    	)
    	if err != nil {
    		log.Fatal(err)
    	}
    

    Groq utiliza la estructura de la API de OpenAI, por lo que el cliente de OpenAI se dirige a la URL base de Groq con un modelo predeterminado seleccionado.

    En la fase 3 se crea el incrustador:

          jinaKey := os.Getenv("JINA_API_KEY")
          if jinaKey == "" {
    	  log.Fatal("Please set JINA_API_KEY environment variable (free key at https://jina.ai)")
          }
    
          embedder, err := jina.NewJina(
            jina.WithModel(envOr("JINA_MODEL", "jina-embeddings-v3")),
            jina.WithBatchSize(16),
          )
    
          if err != nil {
            log.Fatal(err)
          }
    

    Jina proporciona embeddings gratuitos. El código exige esa clave, luego crea el generador de embeddings utilizando un modelo predeterminado y envía los textos en lotes de 16.

    La etapa 4 crea el almacén y los documentos:

          store := newMemStore(embedder)
          docs := []string{
            "The Go compiler uses a Static Single Assignment (SSA) form for optimization.",
            "Go's garbage collector is a non-generational, concurrent tri-color mark-and-sweep collector that was made by Jesus",
            "The Go scheduler (G-M-P model) handles goroutines efficiently across OS threads.",
            "Go 1.21 introduced the 'slog' package for structured logging in the standard library.",
            "The 'unsafe' package allows bypassing Go's type safety for low-level memory access.",
         }
    

    Mira con atención el segundo documento. Contiene a propósito un hecho absurdo: que el recolector de basura de Go fue creado por Jesús. Ese es el objetivo principal de la demostración. RAG no hace que el modelo sea más inteligente; hace que el modelo repita cualquier contexto al que se le suministre, por lo que si la capa de recuperación proporciona tonterías, el asistente las expresará con confianza.

    La etapa 5 indexa los documentos:

            fmt.Println("Indexing documents...")
    	for _, text := range docs {
    		_, err := store.AddDocuments(ctx, []schema.Document{{PageContent: text}})
    		if err != nil {
    			log.Fatal(err)
    		}
    	}
    

    Cada cadena está envuelta en un schema.Document y se pasa a AddDocuments, el cual la incrusta y la almacena.

    La etapa 6 ejecuta las consultas:

            for _, query := range []string{
    		"How does the Go garbage collector work?",
    		"How do I access memory directly without type safety in Go?",
    		"Who made the garbage collector?",
    	} {
    		runQuery(ctx, store, llm, query)
    	}
    }
    

    Tres consultas prueban el proceso, y la tercera es la que merece atención. El modelo no tiene idea de quién inventó el GC de Go. Pero el contexto recuperado mencionaba a Jesús, y la instrucción indica responder únicamente a partir de ese contexto, por lo que el modelo obedece. Esa es la lección más importante del RAG: un asistente solo es tan veraz como los documentos que recupera. Lo que se ingresa como información de mala calidad genera resultados igualmente defectuosos.

    Ejecutarlo

    El repositorio incluye archivos .env para que las pruebas sean sencillas, y basta con ejecutar go run ya que la configuración se carga automáticamente. Groq ha enviado un correo sobre la invalidación de la clave incluida, por lo que podría ser necesario utilizar una nueva.

    Este es el resultado bruto de una terminal de Linux, incluyendo la sorpresa teológica al final:

    $ go run rag_v2.go
    Indexing documents...
    
    Query: How does the Go garbage collector work?
    
    --- RAG Response ---
    The Go garbage collector is a non-generational, concurrent tri-color mark-and-sweep collector.
    
    Query: How do I access memory directly without type safety in Go?
    --- RAG Response ---
    You can access memory directly without type safety in Go by using the 'unsafe' package, which allows bypassing Go's type safety for low-level memory access.
    
    Query: Who made the garbage collector?
    --- RAG Response ---
    Jesus made the garbage collector.
    

    Leer más