一个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。确保回退路径也有自己的测试。