Skip to content
AI & Vectors

使用 Next.js 和 OpenAI 进行向量搜索

Learn how to build a ChatGPT-style doc search powered by Next.js, OpenAI, and Supabase.

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

🌐 While our Headless Vector search provides a toolkit for generative Q&A, in this tutorial we'll go more in-depth, build a custom ChatGPT-like search experience from the ground-up using Next.js. You will:

  1. 使用 OpenAI 将你的 Markdown 转换成嵌入。
  2. 使用 pgvector 把你的嵌入存储到 Postgres。
  3. 部署一个功能来回答你用户的问题。

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

🌐 You can read our Supabase Clippy blog post for a full example.

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

🌐 We assume that you have a Next.js project with a collection of .mdx files nested inside your pages directory. We will start developing locally with the Supabase CLI and then push our local database changes to our hosted Supabase project. You can find the full Next.js example on GitHub.

创建一个项目 #

🌐 Create a project

  1. 在 Supabase 仪表板中创建一个新项目
  2. 输入你的项目详情。
  3. 等新数据库上线。

准备数据库 #

🌐 Prepare the database

准备数据库架构。我们可以在 SQL 编辑器 中使用“OpenAI 向量搜索”快速入门,或者你也可以复制粘贴下面的 SQL 自己运行。

🌐 Prepare the database schema. We can use the "OpenAI Vector Search" quickstart in the SQL Editor, or you can copy/paste the SQL below and run it yourself.

  1. 在仪表板中转到SQL 编辑器页面。
  2. 点击 OpenAI 向量搜索
  3. 点击运行

在构建时预处理知识库 #

🌐 Pre-process the knowledge base at build time

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

🌐 With our database set up, we need to process and store all .mdx files in the pages directory. You can find the full script here, or follow the steps below:

1
Generate Embeddings

创建一个新文件 lib/generate-embeddings.ts,然后把代码从 GitHub 复制过来。

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

我们需要一些环境变量来运行脚本。把它们添加到你的 .env 文件,并确保你的 .env 文件没有被提交到版本控制! 你可以通过运行 supabase status 来获取你本地的 Supabase 凭证。

1
NEXT_PUBLIC_SUPABASE_URL=
2
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
3
SUPABASE_SECRET_KEY=
4
5
# Get your key at https://platform.openai.com/account/api-keys
6
OPENAI_API_KEY=
3
Run script at build time

在你的 package.json 脚本命令中加入这个脚本,这样 Vercel 就能在构建时自动运行它。

1
"scripts": {
2
"dev": "next dev",
3
"build": "pnpm run embeddings && next build",
4
"start": "next start",
5
"embeddings": "tsx lib/generate-embeddings.ts"
6
},

使用 OpenAI API 创建文本补全 #

🌐 Create text completion with OpenAI API

每当用户提出问题时,我们需要为他们的问题创建一个嵌入,进行相似度搜索,然后向 OpenAI API 发送一个文本补全请求,把查询和上下文内容合并成一个提示。

🌐 Anytime a user asks a question, we need to create an embedding for their question, perform a similarity search, and then send a text completion request to the OpenAI API with the query and then context content merged together into a prompt.

所有这些都被整合在一个Vercel Edge Function中,相关代码可以在GitHub上找到。

🌐 All of this is glued together in a Vercel Edge Function, the code for which can be found on GitHub.

1
Create Embedding for Question

为了进行相似性搜索,我们需要把问题转化成向量表示。

1
const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', {
2
method: 'POST',
3
headers: {
4
Authorization: `Bearer ${openAiKey}`,
5
'Content-Type': 'application/json',
6
},
7
body: JSON.stringify({
8
model: 'text-embedding-ada-002',
9
input: sanitizedQuery.replaceAll('\n', ' '),
10
}),
11
})
12
13
if (embeddingResponse.status !== 200) {
14
throw new ApplicationError('Failed to create embedding for question', embeddingResponse)
15
}
16
17
const {
18
data: [{ embedding }],
19
} = await embeddingResponse.json()
2
Perform similarity search

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

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

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

如果成功,OpenAI API 会返回一个 text/event-stream 响应,我们可以把它转发给客户端,然后处理事件流来顺畅地把答案显示给用户。

1
const prompt = codeBlock`
2
${oneLine`
3
You are a very enthusiastic Supabase representative who loves
4
to help people! Given the following sections from the Supabase
5
documentation, answer the question using only that information,
6
outputted in markdown format. If you are unsure and the answer
7
is not explicitly written in the documentation, say
8
"Sorry, I don't know how to help with that."
9
`}
10
11
Context sections:
12
${contextText}
13
14
Question: """
15
${sanitizedQuery}
16
"""
17
18
Answer as markdown (including related code snippets if available):
19
`
20
21
const completionOptions: CreateCompletionRequest = {
22
model: 'gpt-3.5-turbo-instruct',
23
prompt,
24
max_tokens: 512,
25
temperature: 0,
26
stream: true,
27
}
28
29
const response = await fetch('https://api.openai.com/v1/completions', {
30
method: 'POST',
31
headers: {
32
Authorization: `Bearer ${openAiKey}`,
33
'Content-Type': 'application/json',
34
},
35
body: JSON.stringify(completionOptions),
36
})
37
38
if (!response.ok) {
39
const error = await response.json()
40
throw new ApplicationError('Failed to generate completion', error)
41
}
42
43
// Proxy the streamed SSE response from OpenAI
44
return new Response(response.body, {
45
headers: {
46
'Content-Type': 'text/event-stream',
47
},
48
})

在前端显示答案 #

🌐 Display the answer on the frontend

最后一步,我们需要处理来自 OpenAI API 的事件流,并把答案打印给用户。完整代码可以在 GitHub 上找到。

🌐 In a last step, we need to process the event stream from the OpenAI API and print the answer to the user. The full code for this can be found on GitHub.

1
const handleConfirm = React.useCallback(
2
async (query: string) => {
3
setAnswer(undefined)
4
setQuestion(query)
5
setSearch('')
6
dispatchPromptData({ index: promptIndex, answer: undefined, query })
7
setHasError(false)
8
setIsLoading(true)
9
10
const eventSource = new SSE(`api/vector-search`, {
11
headers: {
12
apikey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? '',
13
Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}`,
14
'Content-Type': 'application/json',
15
},
16
payload: JSON.stringify({ query }),
17
})
18
19
function handleError<T>(err: T) {
20
setIsLoading(false)
21
setHasError(true)
22
console.error(err)
23
}
24
25
eventSource.addEventListener('error', handleError)
26
eventSource.addEventListener('message', (e: any) => {
27
try {
28
setIsLoading(false)
29
30
if (e.data === '[DONE]') {
31
setPromptIndex((x) => {
32
return x + 1
33
})
34
return
35
}
36
37
const completionResponse: CreateCompletionResponse = JSON.parse(e.data)
38
const text = completionResponse.choices[0].text
39
40
setAnswer((answer) => {
41
const currentAnswer = answer ?? ''
42
43
dispatchPromptData({
44
index: promptIndex,
45
answer: currentAnswer + text,
46
})
47
48
return (answer ?? '') + text
49
})
50
} catch (err) {
51
handleError(err)
52
}
53
})
54
55
eventSource.stream()
56
57
eventSourceRef.current = eventSource
58
59
setIsLoading(true)
60
},
61
[promptIndex, promptData]
62
)

了解更多 #

🌐 Learn more

想了解更多支持这个的超棒技术吗?

🌐 Want to learn more about the awesome tech that is powering this?