ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

给 Supabase 装上 AI 大脑:向量检索 + 自然语言查询本地化实战

给 Supabase 装上 AI 大脑:向量检索 + 自然语言查询本地化实战 给 Supabase 装上 AI 大脑向量检索 自然语言查询本地化实战【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabasePostgres 正在成为 AI 应用的默认底座而 Supabase 是这个方向最具代表性的开发平台。社区里Supabase 是 vibe coding 的默认后端百亿美元估值的开源数据库公司这类说法背后真正扎实的支撑点只有一个它把向量检索、全文本检索、行级安全、Edge Functions 这些能力全部收进了同一个 Postgres 生态里让检索 生成这条 AI 链路不再需要第二个数据库。本仓库supabase/supabase的 monorepo本身就是一座完整的实战样板间官方文档站点的语义搜索、AI 问答助手Supabase Clippy、嵌入索引的自动化管线全部真实地跑在这套代码里。本文直接基于仓库源码拆解三层核心链路如何用 pgvector 在 Supabase 上搭建向量检索与索引维护、如何打通自然语言 → SQL/检索的转换链路、以及 RAG 问答场景的完整落地与优化建议。一、向量检索基建从一条迁移文件看懂 pgvector 的工程化姿势很多教程教你建个表、存向量、跑个但生产级做法远不止这些。仓库里的 supabase/migrations/20230126220613_doc_embeddings.sql 给出了 Supabase 文档搜索的第一版真实 schemacreate extension if not exists vector with schema public; create table public.page ( id bigserial primary key, path text not null unique, checksum text, meta jsonb ); create table public.page_section ( id bigserial primary key, page_id bigint not null references public.page on delete cascade, content text, token_count int, embedding vector(1536) );这里有几个值得注意的工程决策文档与切片分离page只存元数据与路径page_section存正文与向量两者一对多。切片chunk是 RAG 的基本单元单独成表让按页面聚合检索结果变得自然。向量维度写死 1536这是 OpenAItext-embedding-ada-002的输出维度。维度必须与模型严格一致换模型就要同步改表结构。checksum字段这是增量更新的关键后文会展开。第二版迁移 supabase/migrations/20230128004504_embedding_similarity_search.sql 定义了核心的相似度匹配函数match_page_sections注释里把 pgvector 的三个距离算子讲得很清楚-- The dot product is negative because of a Postgres limitation, so we negate it and (page_section.embedding # embedding) * -1 match_threshold -- OpenAI embeddings are normalized to length 1, so -- cosine similarity and dot product will produce the same results. -- Using dot product which can be computed slightly faster. order by page_section.embedding # embedding limit match_count;要点拆解用#负内积而非-欧氏距离或余弦距离。因为 OpenAI 的 embedding 归一化后长度恒为 1此时内积与余弦相似度等价而内积计算更快。仓库注释明确写着这一优化理由。* -1的符号处理pgvector 的内积是负内积越大越不相似所以取负号转成相似度再与阈值比较。阈值 最小长度过滤min_content_length过滤掉过短的切片避免无意义片段污染结果match_threshold控制召回质量。后续演进中仓库把匹配函数升级成了两个版本各自解决不同问题supabase/migrations/20230403222943_reusable_match_function.sql 的match_page_sections_v2返回setof page_section注释直接点明动机Return a setof page_section so that we can use PostgREST resource embeddings (joins with other tables)。返回表类型而不是扁平列才能让 PostgREST 把结果当资源用允许客户端继续 JOIN 其他表——这是 BaaS 场景下很关键的一步。supabase/migrations/20250423133137_improve_vector_search.sql 引入了match_embedding加了set search_path 做防御性设置并把page_section的聚合、URL 生成get_full_content_url、按页面分组的search_content函数都补全返回subsections json[]直接给前端渲染目录式问答卡片。二、索引维护增量、批处理、重试与清理向量检索的另一个工程大头是索引怎么维护。仓库在 apps/docs/scripts/search/generate-embeddings.ts 里给出了一套完整的生产级管线几个设计尤其值得抄作业1. 增量更新靠 checksum。每次抓取源文档时计算内容checksum与库里的旧值比对const { error: fetchPageError, data: existingPage } await supabaseClient .from(pageTable) .select(id, path, checksum) .filter(path, eq, path) .limit(1) .maybeSingle() if (!shouldRefresh existingPage?.checksum checksum) { // 内容没变只更新版本号与刷新时间跳过嵌入生成 await supabaseClient.from(pageTable).update({...}).filter(id, eq, existingPage.id) return }内容不变就不重算 embedding省下大量 API 调用内容变了则先删旧切片、再整页重嵌。2. 批处理 指数退避重试。嵌入生成按OPENAI_BATCH_SIZE: 128分批失败自动重试 3 次退避带随机抖动jitterfunction exponentialBackoff(attempt: number, baseDelay: number, maxDelay: number 30_000): number { const exponentialDelay baseDelay * Math.pow(2, attempt) const jitter (Math.random() - 0.5) * 0.1 * exponentialDelay return Math.min(Math.max(0, exponentialDelay jitter), maxDelay) }3. 上下文超限自动截断。OpenAI embedding 接口有 token 上限脚本识别context length exceeded错误后把超过 16000 字符的超长切片截断后重试一次——这是所有拿长文做嵌入场景都会踩的坑。4. 版本号清旧。每次全量重建都生成一个uuidv4版本号跑完后删除所有版本号不匹配的旧页面天然支持内容下线即从索引消失。三、自然语言查询链路语义召回 全文本的双引擎社区里 Self-Query-Supabase 这类项目的思路是用 LLM 把自然语言转成结构化查询再执行但仓库给出的官方解法更进一步不强行转 SQL而是同时跑语义检索与关键词检索再做融合排序。先看全文本这一路。supabase/migrations/20231127222412_search_full_text_for_fts.sql 展示了 Supabase 文档搜索从纯向量走向混合的关键一步alter table page add column fts_tokens tsvector generated always as (to_tsvector(english, content)) stored; create index fts_search_index_page on page using gin(fts_tokens); alter table page add column title_tokens tsvector generated always as (to_tsvector(english, coalesce(meta - title, ))) stored;两个细节值得注意用生成列generated column存 tsvector内容写入时自动分词查询走 GIN 索引无需手工维护标题单独建索引并加权 10 倍排序用greatest(least(10 * ts_rank(title_tokens, ...), 1), ts_rank(fts_tokens, ...))标题命中优先于正文命中——这是搜索产品里标题匹配 正文匹配的经典经验。混合检索的融合算法在 supabase/migrations/20250714120000_hybrid_search.sql 的search_content_hybrid里实现用的是Reciprocal Rank FusionRRF倒数排名融合rrf as ( select coalesce(full_text.id, semantic.id) as id, coalesce(1.0 / (rrf_k full_text.rank_ix), 0.0) * full_text_weight coalesce(1.0 / (rrf_k semantic.rank_ix), 0.0) * semantic_weight as rrf_score from full_text full outer join semantic on full_text.id semantic.id )RRF 的核心思想是不看相似度绝对值只看每条结果在各自列表里的排名用1 / (k rank)打分再求和k默认 50是平滑常数避免排第一的结果分数过高。这种做法天然规避了向量相似度和文本相关性分数不可比的问题而且full_text_weight与semantic_weight两个权重参数可以按业务调——比如代码仓库场景希望关键词优先就把全文权重调高。而自然语言 → 检索的最后一公里仓库用 Edge Function 封成了 API。supabase/functions/search-embeddings/index.ts 是这条链路的最小闭环运行在 Deno 上// 1. 先做内容审核OpenAI Moderation违规直接拒绝 const moderationResponse await openai.createModeration({ input: sanitizedQuery }) if (results.flagged) { throw new UserError(Flagged content, { flagged: true, categories: results.categories }) } // 2. 把用户问题转成 embedding const embeddingResponse await openai.createEmbedding({ model: text-embedding-ada-002, input: sanitizedQuery.replaceAll(\n, ), }) // 3. 通过 RPC 调用数据库里的向量匹配函数 const { error: matchError, data: pages } await supabaseClient.rpc(searchFunction, { embedding, match_threshold: 0.78, })这条链路把自然语言和SQL 执行解耦得很干净前端只发一句话Edge Function 负责审核、向量化、调 RPC数据库只负责相似度计算。用户问题不经 LLM 改写直接以 embedding 形式进入语义空间召回的是语义近邻而非字面匹配——这正是自然语言查询本地化的正解不依赖 LLM 生成 SQL而是把查询本身变成向量在 Postgres 内部完成匹配。四、RAG 问答落地带权限的检索增强生成检索链路通了RAG 问答就是水到渠成。仓库的 apps/docs/content/guides/ai/examples/nextjs-vector-search.mdx 展示了完整路线MDX 文档 → OpenAI 嵌入 → pgvector 存储 → Edge Function 回答用户提问最终形态就是文档站里的 AI 搜索助手RAG 最容易翻车的地方不是检索而是权限泄漏如果向量表对所有人开放私有文档就会被语义检索这条隐蔽通道带出去。仓库在 apps/docs/content/guides/ai/rag-with-permissions.mdx 里给出了标准解法——既然 pgvector 是 Postgres 扩展就可以用行级安全RLS直接给向量检索上锁-- enable row level security alter table document_sections enable row level security; -- 只允许检索属于当前用户的文档切片 create policy Users can query their own document sections on document_sections for select to authenticated using ( document_id in ( select id from documents where (owner_id (select auth.uid())) ) );注意这里的用法是document_id in (select ...)而非直接比较owner_id——因为权限挂在父表documents上子表document_sections通过外键间接继承权限。这就是document_id in (subquery)这种写法的来历。更进一步如果用户数据在外部数据库仓库还演示了用 FDWForeign Data Wrapper把外部库的权限表映射进来让 RLS 策略跨库生效。仓库自己的文档表page_section还留了一个细节rag_ignore boolean default false见 supabase/migrations/20240123195252_add_rag_ignore_column.sql。某些内容比如版本变更说明、临时公告不该进 RAG 上下文标记后即可在检索层排除——不是所有内容都适合喂给大模型这是 RAG 落地中容易被忽略的工程细节。五、实战优化清单综合仓库源码与官方文档把可复用的优化经验收敛成一张清单1. 算子选择归一化向量用内积。OpenAI embedding 长度为 1#负内积与余弦结果一致但更快。若换用未归一化的本地模型则改用索引也要相应换成vector_cosine_ops。2. 索引策略全文用 GIN向量用 HNSW。fts_tokens上建gin索引向量列上建hnsw索引近似最近邻检索快、支持高并发。小数据集可省 HNSW但生产环境务必建。注意 HNSW 的 operator class 必须与查询算子匹配用#就配vector_ip_ops。3. 阈值与 top-k 是质量开关。仓库把match_threshold默认设为 0.78max_results默认 30。阈值过低召回一堆噪声切片过高则召回为空建议基于真实查询做一次阈值-精度扫描别用拍脑袋的数。4. 混合检索的权重可调。RRF 里full_text_weight/semantic_weight默认各 1。用户可能输入精确型号、报错码适合关键词也可能输入模糊意图适合语义按产品形态调权重必要时做 AB 实验。5. 权限与检索必须同层。向量表务必开 RLS用auth.uid()做归属过滤不要指望服务端 RPC 天然安全服务端密钥一旦泄露RLS 是最后一道闸。6. 嵌入管线工程化。批处理128 条/批、指数退避重试、超长内容截断、checksum 增量更新、版本号清理这五件套直接决定了索引维护是跑批脚本还是生产系统。7. 同模型原则。建索引与查询必须用同一个 embedding 模型混用模型得到的相似度毫无意义。仓库在迁移注释里反复强调这一点因为它是最隐蔽的返工来源。结语回到标题的问题给 Supabase 装上 AI 大脑本质上是三件事——用 pgvector 把相似度计算下沉到数据库、用全文本检索补齐关键词召回、用 RLS 把权限和检索绑在同一层。这套组合拳的最大价值不在于单个功能多炫而在于所有环节都发生在同一个 Postgres 里没有额外的向量数据库要同步、没有独立的权限服务要打通、没有第二套运维要背。仓库里那些从 2023 年初一路演进到今天的迁移文件就是这份工程判断最诚实的注脚。【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进