AI 与向量

使用Next.js和OpenAI实现向量搜索

学习如何基于Next.js、OpenAI和Supabase构建类似ChatGPT的文档搜索功能


虽然我们的无头向量搜索提供了生成式问答工具包,但在本教程中,我们将更深入地使用Next.js从头开始构建一个类似ChatGPT的自定义搜索体验。您将:

  1. 使用OpenAI将您的Markdown文档转换为嵌入向量
  2. 通过pgvector将嵌入向量存储到Postgres中
  3. 部署一个用于回答用户问题的函数

您可以阅读我们的Supabase Clippy博客文章查看完整示例。

我们假设您已有一个Next.js项目,其中包含嵌套在pages目录中的.mdx文件集合。我们将首先使用Supabase CLI进行本地开发,然后将本地数据库变更推送到托管的Supabase项目中。您可以在GitHub上找到完整的Next.js示例

创建项目

  1. Supabase控制面板中创建新项目
  2. 输入项目详细信息
  3. 等待新数据库启动完成

准备数据库

让我们来准备数据库架构。您可以使用 SQL 编辑器中的"OpenAI 向量搜索"快速入门,或者直接复制/粘贴下面的 SQL 语句自行运行。

  1. 进入仪表盘中的 SQL 编辑器页面
  2. 点击 OpenAI 向量搜索
  3. 点击 运行

在构建时预处理知识库

数据库设置完成后,我们需要处理并存储 pages 目录中的所有 .mdx 文件。您可以在这里找到完整脚本,或按照以下步骤操作:

1

生成嵌入向量

新建文件 lib/generate-embeddings.ts 并从 GitHub 复制代码。

1
2
3
curl \https://raw.githubusercontent.com/supabase-community/nextjs-openai-doc-search/main/lib/generate-embeddings.ts \-o "lib/generate-embeddings.ts"
2

设置环境变量

运行脚本需要一些环境变量。将它们添加到您的 .env 文件中,并确保 .env 文件不会被提交到源代码管理! 您可以通过运行 supabase status 获取本地 Supabase 凭据。

1
2
3
4
5
6
NEXT_PUBLIC_SUPABASE_URL=NEXT_PUBLIC_SUPABASE_ANON_KEY=SUPABASE_SERVICE_ROLE_KEY=# 在 https://platform.openai.com/account/api-keys 获取您的密钥OPENAI_API_KEY=
3

在构建时运行脚本

将脚本包含在您的 package.json 脚本命令中,使 Vercel 能够在构建时自动运行它。

1
2
3
4
5
6
"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上找到。

1

为问题创建嵌入向量

为了执行相似性搜索,我们需要将问题转换为嵌入向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const 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()
2

执行相似性搜索

使用embeddingResponse,我们现在可以通过远程过程调用(RPC)执行相似性搜索,调用之前创建的数据库函数。

1
2
3
4
5
6
7
8
9
const { error: matchError, data: pageSections } = await supabaseClient.rpc( 'match_page_sections', { embedding, match_threshold: 0.78, match_count: 10, min_content_length: 50, })
3

执行文本补全请求

在确定与用户问题相关的内容后,我们现在可以构建提示语并通过OpenAI API发起文本补全请求。

如果成功,OpenAI API将返回一个text/event-stream响应,我们可以将其转发给客户端,在那里处理事件流以流畅地向用户显示答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const 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 上找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const 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])

了解更多

想深入了解支撑这一切的强大技术吗?