Мінімальны 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. Гэта набор маленькіх взаімадзейнаючых пакетаў, якія абгружаюць складныя часткі прыкладнасцей на аднойчыных мовах, таму вам не трэба ручна пісаць запыты HTTP і парсаваць JSON для кожнага прадаўца. Чатыры з ягоя падпакетаў вікорыстоўваюцца тут:
llms— абстракцыя модэля. Адна інтэрфейс,llms.Model, і дапаможны функцыяllms.GenerateFromSinglePromptпрацуюць аднакова незалежна ад таго, чы робіцца гэта з бэкендам OpenAI, Groq, Ollama чы з локальным модэлем. Ён адправляе складзены запыт і вяртае тэкст.embeddingsапранае інтэрфейсembeddings.Embedder. Embedder ператварае строку на спіс чыслаў з паводлівымі знакамі, вектар, які фіксуе значэнне. Падобныя рэчы даюць падобныя вектары, а незвязаныя — ні, і самэ гэта ўтварае матэматычную базу для аднаходжэння інфармацыі.
schema адзначае schema.Document — маленькую структуру, якая складаецца з PageContent (тэкста), метаданых і поля Score для ранжавання рэзультатаў.vectorstores даўае пераважна тыпы опцыяў і правілы ўжыцця. Яго конкрэтныя сховішчы не выкарыстоўваюцца, адтуды што самастойна створаны memStore паказвае, насколькі мало на самай працэ патрэбна.Два адаптэра-прадаўцы паў’язуюць гэтыя інтэрфейсы з рэальнымі API:
openaiвыкарыстоўвае протакол HTTP, сумэжны з OpenAI, як і паазначае яго назыв. Groq таксама выкарыстоўвае гэты протакол пад адрэсамhttps://api.groq.com/openai/v1; таму, калі настроіць кліент OpenAI на гэты адрэс, можна атрымаць шырокі, безкоштовны рэзультат без дадатковага SDK.jina— гэта адаптар для Jina embeddings. Jina адаптаваецца з ўзніжаным безплатным тарыфам для стварэння якісных вектараў за дапамойjina-embeddings-v3, і самэ гэта прычына, чырэй адаптар яго вікорыстоўвае.
Практычны абраз: llms плюс openai — гэта «рот», які стварае адпаведзі. embeddings плюс jina — гэта «памяць», якая ператварае тэкст у матэматычныя даны, якія можна шукаты. schema плюс спецыяльны memStore — гэта шуфляда, якая храніць і выкарыстоўвае даны. Рэшта — гэта з’яеднанне гэтых трох, без якіх-лебе магіі фрэймворка, пры чым кожны інтэфейс выконвае адну задачу.
Дапаможныя функцыі
Дзеякія дапаможнікі выкананыя раней за основны main прайом. Яны дапамагаюць у процэсе выкарыстоўвання даных.
Хранэнне вектараў у памяці
Дакументы і ўсе іх вкладкі патрабуюць месца для зберагчэння, а таксама спосабу для пошуку. Спецыяльны 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 выкананае троха задач: яна выявляе необработаны тэкст кожнага дакумента, вбудоввае ўсё заодно, а пасля зберагчае як дакументы, так і вектары, вяртаючы пры гэму стабільныя ID.
Спачатку выявіце тэкст і вбудоввайце яго:
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
}
Код пазначае месца, дзе пачынаюцца новыя дакументы, дадзеўае дакументы і вектары ў хранілнік, а таксама стварае последовальныя ID, такія як 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 уключае запіт, оцінюе кожны дакумент, сортавае іх і вяртае Н найлепшых.
Спачатку уключыце запіт і оценіце кожны дакумент:
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 спаўнаючы ўсе пасляўпа, паэтапна.
Першы этап — запуск і налаштаванні:
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.
На 2-му этапе ствараецца 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 екземпляраў.
На 4-му этапе ствараецца хранальнік і дакументы:
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.