使用Next.js和OpenAI实现向量搜索
学习如何基于Next.js、OpenAI和Supabase构建类似ChatGPT的文档搜索功能
虽然我们的无头向量搜索提供了生成式问答工具包,但在本教程中,我们将更深入地使用Next.js从头开始构建一个类似ChatGPT的自定义搜索体验。您将:
- 使用OpenAI将您的Markdown文档转换为嵌入向量
- 通过pgvector将嵌入向量存储到Postgres中
- 部署一个用于回答用户问题的函数
您可以阅读我们的Supabase Clippy博客文章查看完整示例。
我们假设您已有一个Next.js项目,其中包含嵌套在pages
目录中的.mdx
文件集合。我们将首先使用Supabase CLI进行本地开发,然后将本地数据库变更推送到托管的Supabase项目中。您可以在GitHub上找到完整的Next.js示例。
创建项目
- 在Supabase控制面板中创建新项目
- 输入项目详细信息
- 等待新数据库启动完成
准备数据库
让我们来准备数据库架构。您可以使用 SQL 编辑器中的"OpenAI 向量搜索"快速入门,或者直接复制/粘贴下面的 SQL 语句自行运行。
- 进入仪表盘中的 SQL 编辑器页面
- 点击 OpenAI 向量搜索
- 点击 运行
在构建时预处理知识库
数据库设置完成后,我们需要处理并存储 pages
目录中的所有 .mdx
文件。您可以在这里找到完整脚本,或按照以下步骤操作:
生成嵌入向量
新建文件 lib/generate-embeddings.ts
并从 GitHub 复制代码。
123curl \https://raw.githubusercontent.com/supabase-community/nextjs-openai-doc-search/main/lib/generate-embeddings.ts \-o "lib/generate-embeddings.ts"
设置环境变量
运行脚本需要一些环境变量。将它们添加到您的 .env
文件中,并确保 .env
文件不会被提交到源代码管理!
您可以通过运行 supabase status
获取本地 Supabase 凭据。
123456NEXT_PUBLIC_SUPABASE_URL=NEXT_PUBLIC_SUPABASE_ANON_KEY=SUPABASE_SERVICE_ROLE_KEY=# 在 https://platform.openai.com/account/api-keys 获取您的密钥OPENAI_API_KEY=
在构建时运行脚本
将脚本包含在您的 package.json
脚本命令中,使 Vercel 能够在构建时自动运行它。
123456"scripts": { "dev": "next dev", "build": "pnpm run embeddings && next build", "start": "next start", "embeddings": "tsx lib/generate-embeddings.ts"},
使用OpenAI API创建文本补全
每当用户提出问题时,我们需要为其问题创建嵌入向量,执行相似性搜索,然后将查询和上下文内容合并成一个提示语,发送给OpenAI API进行文本补全请求。
所有这些功能都集成在一个Vercel边缘函数中,相关代码可在GitHub上找到。
为问题创建嵌入向量
为了执行相似性搜索,我们需要将问题转换为嵌入向量。
12345678910111213141516171819const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', { method: 'POST', headers: { Authorization: `Bearer ${openAiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'text-embedding-ada-002', input: sanitizedQuery.replaceAll('\n', ' '), }),})if (embeddingResponse.status !== 200) { throw new ApplicationError('Failed to create embedding for question', embeddingResponse)}const { data: [{ embedding }],} = await embeddingResponse.json()
执行相似性搜索
使用embeddingResponse
,我们现在可以通过远程过程调用(RPC)执行相似性搜索,调用之前创建的数据库函数。
123456789const { error: matchError, data: pageSections } = await supabaseClient.rpc( 'match_page_sections', { embedding, match_threshold: 0.78, match_count: 10, min_content_length: 50, })
执行文本补全请求
在确定与用户问题相关的内容后,我们现在可以构建提示语并通过OpenAI API发起文本补全请求。
如果成功,OpenAI API将返回一个text/event-stream
响应,我们可以将其转发给客户端,在那里处理事件流以流畅地向用户显示答案。
12345678910111213141516171819202122232425262728293031323334353637383940414243444546const prompt = codeBlock` ${oneLine` 您是一位非常热情的Supabase代表,热爱帮助他人!根据以下来自Supabase 文档的章节内容,仅使用这些信息回答问题,并以markdown格式输出。 如果不确定且答案未明确写在文档中,请说 "抱歉,我不知道如何帮助解决这个问题。" `} 上下文章节: ${contextText} 问题:""" ${sanitizedQuery} """ 以markdown格式回答(如适用请包含相关代码片段):`const completionOptions: CreateCompletionRequest = { model: 'gpt-3.5-turbo-instruct', prompt, max_tokens: 512, temperature: 0, stream: true,}const response = await fetch('https://api.openai.com/v1/completions', { method: 'POST', headers: { Authorization: `Bearer ${openAiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(completionOptions),})if (!response.ok) { const error = await response.json() throw new ApplicationError('Failed to generate completion', error)}// 代理OpenAI的流式SSE响应return new Response(response.body, { headers: { 'Content-Type': 'text/event-stream', },})
在前端展示答案
最后一步,我们需要处理来自 OpenAI API 的事件流并将答案展示给用户。完整代码可以在 GitHub 上找到。
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162const handleConfirm = React.useCallback( async (query: string) => { setAnswer(undefined) setQuestion(query) setSearch('') dispatchPromptData({ index: promptIndex, answer: undefined, query }) setHasError(false) setIsLoading(true) const eventSource = new SSE(`api/vector-search`, { headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, 'Content-Type': 'application/json', }, payload: JSON.stringify({ query }), }) function handleError<T>(err: T) { setIsLoading(false) setHasError(true) console.error(err) } eventSource.addEventListener('error', handleError) eventSource.addEventListener('message', (e: any) => { try { setIsLoading(false) if (e.data === '[DONE]') { setPromptIndex((x) => { return x + 1 }) return } const completionResponse: CreateCompletionResponse = JSON.parse(e.data) const text = completionResponse.choices[0].text setAnswer((answer) => { const currentAnswer = answer ?? '' dispatchPromptData({ index: promptIndex, answer: currentAnswer + text, }) return (answer ?? '') + text }) } catch (err) { handleError(err) } }) eventSource.stream() eventSourceRef.current = eventSource setIsLoading(true) }, [promptIndex, promptData])
了解更多
想深入了解支撑这一切的强大技术吗?
- 阅读我们如何构建Supabase文档的ChatGPT
- 查阅pgvector文档了解嵌入和向量相似度
- 观看Greg的完整解析视频: