使用pgvector和Gemini在Go中构建生产RAG API

一个RAG演示需要一个下午:嵌入一些文档,运行相似性搜索,将结果粘贴到提示中。您可以放在真实用户面前的RAG API需要更长的时间,因为大部分工作不是LLM调用。是周围的一切。

我构建了餐馆建议系统(RSS)作为Go/Gin后端,用户可以通过聊天获得菜品和餐馆建议。答案来自带有pgvector和Gemini嵌入的PostgreSQL中的RAG over real menu data。因为这些建议必须考虑到过敏症,“模型通常是正确的”从来都不够好。

这篇文章介绍了这条管道的形状以及使其可靠的Go模式。https://www.skdaren.com这些片段被简化并专注于想法,而不是从私人回购中复制。

管道

每个聊天请求都经过相同的步骤:

把…嵌入Gemini的用户消息(加上存储的首选项)。

恢复来自具有pgvector的Postgres的候选人,具有SQL中应用的过敏原和健康过滤器

得分围棋中的决胜候选人。

产生与LLM的对话式回答,仅使用最佳候选人作为上下文。

使生效针对我们派出的候选人的回答。

后退如果LLM速度慢或不可用,则转换为纯矢量响应。

所有这一切背后的规则是:LLM解释和交流。https://www.iissbbs.com它不能决定什么是安全的,什么是存在的。

架构:将过滤器保持为真实的列

把所有东西都放进嵌入的文本中,然后让相似性来分类,这是很有诱惑力的。不要为了任何与安全有关的事情而这样做。过敏源属于一个你可以精确过滤的栏目。

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE menu_items (

    id            BIGSERIAL PRIMARY KEY,

    restaurant_id BIGINT NOT NULL REFERENCES restaurants(id),

    name          TEXT NOT NULL,

    description  TEXT NOT NULL,

    allergens    TEXT[] NOT NULL DEFAULT '{}',

    price_cents  INT NOT NULL,

    is_available  BOOLEAN NOT NULL DEFAULT TRUE,

    embedding    vector(768) NOT NULL

);

CREATE INDEX ON menu_items USING hnsw (embedding vector_cosine_ops);

CREATE INDEX ON menu_items USING gin (allergens);

维度(这里是768)必须匹配嵌入模型的输出配置。选择一个,将其存储在config中,如果他们不同意,则在启动时快速失败。在生产中发现一个无声的维度不匹配是一个很糟糕的错误。

双子座的嵌入

RSS在两个地方使用了Gemini嵌入:索引菜单项时,以及在登录时捕获用户偏好作为向量。将提供者包装在一个小接口后面,这样剩下的代码就不会关心您使用哪个SDK了:

type Embedder interface {

    Embed(ctx contextContext, text string) ([]float32, error)

}

type GeminiEmbedder struct {

    client *genaiClient

    model  string

    dims  int32

}

func (g *GeminiEmbedder) Embed(ctx contextContext, text string) ([]float32, error) {

    ctx, cancel := contextWithTimeout(ctx, 5*timeSecond)

    defer cancel()

    res, err := gclientModelsEmbedContent(ctx, gmodel,

        genaiText(text),

        &genaiEmbedContentConfig{OutputDimensionality: &gdims},

    )

    if err != nil {

        return nil, fmtErrorf("embed: %w", err)

    }

    if len(resEmbeddings) == 0 || len(resEmbeddings[0]Values) != int(gdims) {

        return nil, errorsNew("embed: unexpected embedding shape")

    }

    return resEmbeddings[0]Values, nil

}

两个值得复制的习惯:每次外部调用的硬超时,以及对响应的形状检查。这个接口也使得测试变得容易,因为一个假的嵌入器返回固定的向量。

检索:在LLM之前的SQL中过滤

这是系统中最重要的设计决策。过敏原和健康过滤器运行在检索查询中,因此排除的项目永远不会到达模型。类似“不建议吃花生”的提示就是请求。AWHERE条款是一种保证。

type Candidate struct {

    ID        int64

    Name      string

    Allergens  []string

    PriceCents int

    Distance  float64

}

const retrieveSQL = `

SELECT id, name, allergens, price_cents, embedding <=> $1 AS distance

FROM menu_items

WHERE is_available

  AND NOT (allergens && $2::text[])

ORDER BY embedding <=> $1

LIMIT $3`

func (r *Repo) Retrieve(ctx contextContext, q []float32, exclude []string, k int) ([]Candidate, error) {

    rows, err := rdbQuery(ctx, retrieveSQL, pgvectorNewVector(q), exclude, k)

    if err != nil {

        return nil, err

    }

    defer rowsClose()

    var out []Candidate

    for rowsNext() {

        var c Candidate

        if err := rowsScan(&cID, &cName, &cAllergens, &cPriceCents, &cDistance); err != nil {

            return nil, err

        }

        out = append(out, c)

    }

    return out, rowsErr()

}

一个pgvector问题:对于HNSW这样的近似索引,会发生过滤在之后该指数返回其最近的邻居。使用严格的过滤器,您可以获得少于k向后几排。pgvector 08+支持迭代索引扫描(SET hnswiterative_scan = relaxed_order;),它会一直扫描,直到有足够多的行通过过滤器。在旧版本上,提高hnswef_search或者过度提取和修整。无论哪种方式,编写一个测试,其中大多数行被过滤掉,并检查您仍然得到结果。

确定性评分

向量距离是一个信号,而不是排名。RSS使用确定性评分引擎对筛选出的候选人进行排名,因此相同的输入给出相同的顺序,并且每个推荐都可以得到解释。

type Weights struct {

    Similarity, Preference, Budget float64

}

func Score(c Candidate, prefSim float64, budgetCents int, w Weights) float64 {

    s := wSimilarity*(1-cDistance) + wPreference*prefSim

    if budgetCents > 0 && cPriceCents <= budgetCents {

        s += wBudget

    }

    return s

}

func Rank(cands []Candidate, scores map[int64]float64) {

    sortSliceStable(cands, func(i, j int) bool {

        si, sj := scores[cands[i]ID], scores[cands[j]ID]

        if si != sj {

            return si > sj

        }

        return cands[i]ID < cands[j]ID // stable tie-break

    })

}

注意ID上的平局。没有它,相同的分数可能会在不同的测试中出现,你的测试也会变得不可靠。这里的信号是说明性的;关键是排名存在于你可以进行单元测试的普通程序中,而不是在提示符中。

代:接地,然后检查

只有最优秀的候选人才进入提示,每个候选人都有自己的ID。我在此基础上推荐一种模式:向模型请求引用这些id的结构化输出,并在返回之前验证响应:

type LLMAnswer struct {

    Message string  `json:"message"`

    ItemIDs []int64 `json:"item_ids"`

}

func validateAnswer(a LLMAnswer, sent []Candidate) error {

    allowed := make(map[int64]bool, len(sent))

    for _, c := range sent {

        allowed[cID] = true

    }

    for _, id := range aItemIDs {

        if !allowed[id] {

            return fmtErrorf("model referenced unknown item %d", id)

        }

    }

    return nil

}

如果模型提到了你没有给它的东西,那从定义上来说就是幻觉,几行字就能察觉出来。

回退:仅矢量结果

LLM APIs超时、命中率限制和停机。RSS通过适度降级来处理这个问题:如果LLM调用失败,API仍然会返回向量搜索中经过过滤和评分的候选项。在下面的草图中,验证失败的答案采用相同的路径:

func (s *Service) Recommend(ctx contextContext, req ChatRequest) (ChatResponse, error) {

    cands, err := sretrieveAndRank(ctx, req)

    if err != nil {

        return ChatResponse{}, err // no data, no answer

    }

    genCtx, cancel := contextWithTimeout(ctx, 8*timeSecond)

    defer cancel()

    ans, err := sllmAnswer(genCtx, reqMessage, cands)

    if err == nil {

        err = validateAnswer(ans, cands)

    }

    if err != nil {

        slogWarn("llm unavailable, using vector-only fallback", "err", err)

        return fallbackResponse(cands), nil

    }

    return ChatResponse{Message: ansMessage, ItemIDs: ansItemIDs, Source: "llm"}, nil

}

因为安全过滤器在LLM之前运行,所以回退与完整路径一样安全。它不那么健谈,而不是不那么正确。返回Source字段还可以帮助前端和你的仪表板看到你降级的频率。

生产清单的其余部分

RAG管道可能是整个系统的三分之一。剩下的是仍然重要的普通后端工作:

授权:无密码的电子邮件OTP、JWT和基于角色的访问控制,因此管理端点仅由管理员控制。

数据接收:CRUD加CSV/JSON批量上传和AWS文件上传。无论你的摄取路径是什么,确保每次写入都刷新项目的嵌入,否则搜索会失效。

隐私:在不暴露PII的情况下构建管理分析。

运营:Docker中带有pgvector的Postgres,所以本地、CI和生产运行同一个数据库。

测试:一个纽曼(邮差)的端到端套件,击中了真正的API。确保回退路径也有自己的测试。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容