Мінімальний пайплайн RAG на Go з використанням ембеддингів langchaingo, Groq та Jina
Мінімальний пайплайн RAG на Go з langchaingo: пам’ятний сховище векторів, пошук за косинусом, Groq для отримання відповідей та ембеддинги Jina, а також демонстрація поганого контексту.
Джерела: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go
RAG, скорочення від Retrieval-Augmented Generation, означає, що модель відповідає на основі документів, які вона завантажує під час обробки запиту, а не лише на основі того, що вона засвоїла під час навчання. Факти більше не потрібно вбудовувати як жорстко закодовані дані у системний запит. Найбільш релевантний контекст завантажується та надається моделі разом із запитанням, тож відповіді залишаються пов’язаними з джерелами, якими ви керуєте.
Чому асистенту це потрібно
Кожен, хто створює власного асистента, рано чи пізно стикається з однією й тією самою проблемою: модель нічого не знає про ваш світ. Вона ніколи не бачила назв ваших продуктів, внутрішніх правил чи фактів вашої галузі. Очевидним рішенням є вставка всього цього у системний запит, що є крихким, дорогим та швидко досягає обмежень за обсягом.
RAG змінює ситуацію. Замість того, щоб вкладати контекст у запит, модель отримує інструмент для отримання потрібного контексту при надходженні запитання. Ви самі вирішуєте, в якій реальності працює асистент, не збільшуючи обсяг запиту. Це також надає вам чітку структуру для організації знань, специфічних для бізнесу: ваші терміни, ваші документи, ваша інформація, яка отримується за потребою.
Далі наведено мінімальний пайплайн на Go із дуже малою кількістю залежностей.
Використовувані бібліотеки
Якщо інструменти LLM на Go є для вас новими, імпорти можуть здатися складними, тому спочатку розповімо, хто що є, перед тим, як показати код.
langchaingo (github.com/tmc/langchaingo) — це версія для мови Go відомої екосистеми Python LangChain. Це набір невеликих взаємопов’язаних пакетів, які обгортають складні частини додатків на основі LLM, тож вам не доводиться вручну писати запити HTTP та обробляти JSON для кожного постачальника. Тут використовуються чотири з його підпакетів:
llms— це абстракція моделі. Один інтерфейсllms.Modelта допоміжна функціяllms.GenerateFromSinglePromptпрацюють однаково незалежно від того, чи є бекенд OpenAI, Groq, Ollama чи локальною моделлю. Він надсилає підготовлений запит та повертає текст.embeddingsнадає інтерфейсembeddings.Embedder. Ембеддер перетворює рядок на список чисел з плаваючою комою — вектор, який фіксує сенс. Схожі речення створюють схожі вектори, а нерелевантні — ні, і саме на цій математичній основі ґрунтується процес пошуку інформації.
schema визначає schema.Document — невелику структуру, яка містить PageContent (текст), метадані та поле Score для ранжування результатів.vectorstores переважно надає типи параметрів та правила використання. Його конкретні сховища не використовуються, оскільки самостійно створене memStore демонструє, наскільки мало насправді потрібно.Два адаптери-провайдери під’єднують ці інтерфейси до реальних API:
openaiвикористовує протокол HTTP, сумісний з OpenAI, як і випливає з назви. Groq пропонує той самий протокол за адресоюhttps://api.groq.com/openai/v1, тож налаштування клієнта OpenAI на цю адресу забезпечує швидке та безкоштовне оброблення даних без додаткових SDK.jina— це адаптер для ембеддингів Jina. Jina пропонує щедрий безкоштовний тариф для створення якісних векторів за допомогоюjina-embeddings-v3, саме тому ембеддер його використовує.
Зручне уявлення: llms разом із openai — це рот, який формує відповіді. embeddings разом із jina — це пам’ять, яка перетворює текст на математичні дані, придатні для пошуку. schema разом із користувацьким memStore — це шухляда, яка зберігає та знаходить дані. Решта — це з’єднання цих трьох компонентів без використання якихось магічних фреймворків, причому кожен інтерфейс виконує одну функцію.
Допоміжні функції
Деякі допоміжні засоби використовуються перед основним потоком виконання. Вони забезпечують можливість пошуку даних.
База даних векторів у пам’яті
Документи та їхні ембеддинги потребують місця зберігання та способу пошуку. Спеціальний memStore обгортає інструмент створення ембеддингів та зберігає документи поруч із їхніми векторами.
type memStore struct {
embedder embeddings.Embedder
docs []schema.Document
vectors [][]float32
}
func newMemStore(embedder embeddings.Embedder) *memStore {
return &memStore{embedder: embedder}
}
Він містить три елементи: інструмент вбудовування, який перетворює текст на вектори, оригінальні документи та вектори типу float32, що їх представляють. newMemStore — це звичайний конструктор, який інжектує інструмент вбудовування.
Метод AddDocuments виконує три завдання. Він витягує необроблений текст кожного документа, вбудовує все разом у одну партію, потім зберігає як документи, так і вектори, повертаючи при цьому стабільні ідентифікатори.
Спочатку витягніть текст та вбудуйте його:
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
}
Одна ітерація збирає кожен PageContent у звичайний []string, а EmbedDocuments повертає фрагмент векторів. Невдача під час вбудовування призводить до раннього завершення.
По-друге, додають та присвоюють ID:
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
}
Код визначає місце початку нових документів, додає документи та вектори до сховища та генерує послідовні ідентифікатори на кшталт doc-0 та doc-1. Ці ідентифікатори повертаються викликаючому коду, щоб він міг звертатися до збережених даних.
Косинусна схожість
Процес пошуку зводиться до знаходження векторів, які найбільш близькі до вектора запиту. Для порівняння використовується косинусна схожість — нормована скалярна добуток, яка ігнорує модуль векторів.
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))
}
Щоб залишатися в межах, функція використовує меншу з двох довжин. Вона накопичує скалярний добуток та обидві норми, а потім ділить скалярний добуток на добуток квадратних коренів цих норм. Повернення 0 для нульового вектора уникає ділення на нуль.
Пошук схожості
Після зберігання векторів та визначення метрики SimilaritySearch інкорпорує запит, оцінює кожен документ, сортує їх та повертає N найкращих.
Спочатку інкорпорують запит та оцінюють кожен документ:
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}
}
Запит вбудовується один раз. Потім функція проходить кожен збережений вектор, обчислює коефіцієнт косинуса порівняно з запитом та зберігає кожен документ разом із його оцінкою у сегменті scoredDoc.
По-друге, сортується та вибираються найкращі результати:
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
}
Результати сортуються за оцінкою від високої до низької, numDocuments обмежується кількістю наявних документів, а найкращі записи копіюються у результат. Найбільш схожі документи йдуть першими.
Допоміжні інструменти налаштувань
Два невеликі інструменти полегшують запуск демонстрації. envOr читає змінну середовища з альтернативним значенням. loadDotEnv парсує файл .env, тож немає потреби вручну експортувати дані.
envOr спочатку:
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Він повертає значення з середовища, якщо воно встановлене, а інакше — значення за замовчуванням.
Далі 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
}
Функція читає файл, розділяє його на рядки, ігнорує порожні рядки та коментарі, розділяє кожен рядок на першому =, видаляє лапки навколо значення та встановлює змінну лише у разі, якщо вона невизначена. Ця остання перевірка означає, що експорти, створені в шеллі, завжди мають пріоритет. Розробка цього коду вручну також уникає необхідності використання пакета dotenv.
Цикл запитів
runQuery є основою RAG: отримання інформації, збір контексту, генерація результату. У запиті міститься сувора інструкція відповідати лише на основі наданого контексту, що змушує модель діяти чесно.
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)
}
Він виводить запит, отримує два найкращі документи, об’єднує їхній текст у один блок контексту, створює запит, який забороняє використання знань ззовні, генерує результат та виводить його.
Основна функція та навмисний трюк
main поступово поєднує все разом, етап за етапом.
Етап 1 — це запуск та налаштування:
func main() {
ctx := context.Background()
_ = loadDotEnv(".env")
apiKey := os.Getenv("GROQ_API_KEY")
if apiKey == "" {
log.Fatal("Please set GROQ_API_KEY environment variable")
}
Він створює контекст фону, завантажує .env (помилка ігнорується, тож відсутність файлу не призводить до збоїв), а потім вимагає наявності ключа Groq.
На другому етапі створюється 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 використовує формат API OpenAI, тому клієнт OpenAI налаштовується на базову URL Groq із обраним стандартним моделлю.
На третьому етапі створюється модуль вбудовування:
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 надає безкоштовні ембеддинги. Код вимагає наявності цього ключа, потім створює модуль ембеддингу за допомогою стандартної моделі та надсилає тексти пакетами по 16 штук.
На четвертому етапі створюються сховище та документи:
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.",
}
Уважно подивіться на другий документ. У ньому навмисно міститься абсурдний факт: що збирач сміття мови Go був створений Ісусом. Саме в цьому суть демонстрації. RAG не робить модель розумнішою. Він змушує модель повторювати все те, що їй надається у контексті, тож якщо шар пошуку надає нісенітницю, асистент буде впевнено це повторювати.
На п’ятому етапі індексуються документи:
fmt.Println("Indexing documents...")
for _, text := range docs {
_, err := store.AddDocuments(ctx, []schema.Document{{PageContent: text}})
if err != nil {
log.Fatal(err)
}
}
Кожен рядок обгортається у schema.Document та передається до AddDocuments, який його вбудовує та зберігає.
На етапі 6 виконуються запити:
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)
}
}
Три запити працюють із цим потоком обробки даних, і саме третій заслуговує уваги. Модель не має жодного уявлення про те, хто створив механізм збирання сміття в Go. Однак у отриманому контексті зазначено Ісуса, а у вказівках сказано відповідати лише на основі цього контексту, тож модель дотримується цих вимог. Це найважливіший урок RAG: асистент є настільки ж правдивим, наскільки правдивими є документи, які він отримує. Якщо дані некоректні, результат також буде некоректним.
Запуск
У репозиторії є файли .env, що полегшує тестування, і достатньо лише виконати команду go run, оскільки конфігурація завантажується автоматично. Groq надіслав електронний лист щодо анулювання вбудованого ключа, тому може знадобитися новий ключ.
Це необроблений вихідний текст з терміналу Linux, включаючи теологічний сюрприз у кінці:
$ 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.
Пов’язана література
- Практичні нотатки: Перестаньте стежити за вашим агентом кодування: створіть систему, якій можна довіряти — Детальний посібник з Практичних нотаток: Перестаньте стежити за вашим агентом кодування: створіть систему, якій можна довіряти: контракти, перевірки та шаблони коду для команд, які використовують цю модель.
- Практичні нотатки: Чому вашій підсистемі RAG потрібен повторний ранжер (і як це зробити) — Детальний посібник з Практичних нотаток: Чому вашій підсистемі RAG потрібен повторний ранжер (і як це зробити): контракти, перевірки та шаблони коду для команд, які використовують цю модель.