使用 langchaingo、Groq 和 Jina 嵌入模型构建的 Go 语言简易 RAG 流水线
使用 langchaingo 构建的 Go 语言最小化 RAG 流水线:包含内存向量存储、余弦搜索、用于生成答案的 Groq 以及 Jina 嵌入模型,同时还展示了不良上下文的情况。
来源: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go
RAG,即检索增强生成,指的是模型在响应查询时从外部文档中获取信息来作答,而非仅依赖训练时吸收的内容。这样一来,事实就无需硬编码到系统提示词中。系统会检索出最相关的上下文并将其与问题一同提供给模型,从而使回复始终基于你可控制的来源。
为什么助手需要它
任何构建自定义助手的人最终都会遇到同样的问题:模型对你所在领域一无所知,它从未见过你的产品名称、内部政策或行业相关事实。显而易见的解决办法是将所有信息都粘贴到系统提示词中,但这种方式既脆弱又成本高昂,而且很快就会达到长度限制。
RAG扭转了这一状况。模型无需在提示词中嵌入上下文,而是拥有一个工具,能够在收到问题时获取合适的上下文。你可以决定助手所处的“现实世界”,而无需让提示词变得臃肿。此外,它还为你提供了一种清晰的框架,用于整理业务相关的知识——使用你自己的术语、文档和事实,并按需检索。
以下是一个依赖项极少的最小化Go开发流程。
涉及的库
如果你还不熟悉用Go语言开发LLM工具,那些导入语句可能会看起来像天书,因此在展示代码之前先为大家介绍相关库的作用。
langchaingo(github.com/tmc/langchaingo)是知名 Python LangChain 生态系统的 Go 语言实现版本。它由一系列小型可组合的包组成,用于封装大型语言模型应用中繁琐的部分,从而无需为每个服务提供商手动编写 HTTP 调用和 JSON 解析代码。此处使用了其中的四个子包:
llms负责模型抽象。通过统一的llms.Model接口以及llms.GenerateFromSinglePrompt辅助函数,无论后端是 OpenAI、Groq、Ollama 还是本地模型,功能都能保持一致。该模块会将整理好的提示语发送出去并返回文本结果。embeddings提供embeddings.Embedder接口。嵌入器可将字符串转换为浮点数列表,即能够体现语义的向量。语义相似的句子会生成相似的向量,而无关句子则不会,这一数学原理正是检索功能的基础。
schema 定义了 schema.Document,这是一个包含 PageContent(文本内容)、元数据以及用于结果排序的 Score 字段的简单结构体。vectorstores 主要提供选项类型和规范。由于其具体的存储实现并未被使用,而手动构建的 memStore 已经证明了实际所需的功能极为有限。有两个提供程序适配器将这些接口与真实的 API 相连接:
openai使用符合 OpenAI 标准的 HTTP 协议,顾名思义。Groq 也在https://api.groq.com/openai/v1上提供相同的协议,因此将 OpenAI 客户端指向该地址即可实现快速、免费的推理,且无需额外的 SDK。jina是 Jina 嵌入模型适配器。通过jina-embeddings-v3,Jina 提供了丰富的免费套餐,可用于生成高质量向量,这也是该嵌入器选择它的原因。
一个便于理解的类比: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方法执行三项任务:提取每份文档的原始文本,批量嵌入所有内容,随后存储文档和向量,并返回稳定的标识符。
首先,提取文本并对其进行嵌入:
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
}
该函数会读取文件,将其拆分为多行,跳过空行和注释,然后在每行第一个=符号处进行分割,去掉值两端的引号,仅当该变量未被定义时才为其赋值。这最后的检查确保了在shell中导出的变量始终具有优先级。手动编写此代码还可以避免引入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 密钥。
第二阶段负责构建大型语言模型:
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采用OpenAI API的格式,因此OpenAI客户端会指向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并不会让模型变得更聪明,它只会让模型重复其所接收的任何上下文内容,因此如果检索层提供的是无意义的信息,助手也会自信地将其说出来。
第5阶段会对文档进行索引:
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管道需要重新排序器(以及实现方法 — 该实用笔记的详细操作指南,包括适用于采用该模式的团队的契约、检查项以及可直接插入的代码片段。