简介
随着网上可用的信息量以前所未有的速度增长,传统的搜索引擎正在努力跟上。这就是语义搜索的用武之地--一种旨在理解用户查询背后的意图并提供更准确结果的技术。在本教程中,我们将探讨如何使用Supabase和PostgreSQL作为数据库,Next.js作为前端,以及OpenAI的GPT-3作为自然语言处理来实现语义搜索。在本教程结束时,您将拥有一个功能齐全的语义搜索引擎,可以为您的用户提供相关的、准确的结果。
实施语义搜索的关键组成部分之一,是能够以一种允许进行有意义的比较和分析的方式来表示文本数据。这就是矢量表示的作用--它们允许我们将单词或短语转化为高维矢量,以捕捉其含义和背景。
在PostgreSQL中,pg-vector 模块提供了一种在数据库中存储和操作矢量数据的方法。通过使用pg-vector ,我们可以将我们的文本数据表示为向量,并执行向量操作,比如余弦相似度,以比较不同文档或搜索查询的相似度。
为了用Supabase和PostgreSQL实现语义搜索,我们可以首先创建一个表来存储我们的文档及其使用pg-vector 数据类型的向量表示。然后,当用户提交搜索查询时,我们可以使用与文档相同的方法将查询转换为矢量表示,并执行余弦相似度搜索,以找到最相关的结果。
通过将pg-vector 与Supabase和Next.js结合使用,我们可以创建一个强大的语义搜索引擎,为用户提供准确而相关的结果。在处理大量的文本数据时,这种方法特别有用,因为传统的基于关键词的搜索引擎可能难以提供有意义的结果。
本教程将使用来自我的投资组合网站(这是一个公共资源库)的代码片段。链接在底部。
过程
使其发挥作用的秘密是OpenAI的GPT-3嵌入API,它可以处理将文本片段转换为一个非常大的数字数组,称为向量(准确地说,是1536)。通过使用200个标记的块大小,它给嵌入API提供了足够的信息来匹配查询的相关结果,但又没有太多的信息使我们对ChatGPT的回答请求超载,后者将解释这些信息。
这个过程如下:
- 我们将文档文本解析为每个约200个小块。
- 我们通过OpenAI的嵌入API运行每个块,以获得语义向量
- 我们将该文本(包括PG表中的矢量)存储在我们的PG表中。
这都是提前完成的。连续的过程如下:
- 用户在前端表单上提出一个问题
- 该问题通过OpenAI即时转换为一个矢量
- 我们在数据库中运行一个向量搜索查询,得到前3个结果
- 我们把这些结果串联起来,然后把它们和原来的问题反馈给ChatGPT
- ChatGPT给我们答案
设置Supabase
- 首先,我们需要创建一个Supabase项目和数据库。如果你还没有这样做,请注册一个免费的Supabase账户并创建一个新项目。
- 一旦你创建了你的项目,导航到SQL编辑器并连接到你的数据库。你可以通过点击左侧边栏的 "SQL "按钮,然后点击 "连接到数据库 "来完成。
- 在SQL编辑器中,创建一个新表来存储你的文件。在这个例子中,我们将创建一个名为 "文档 "的表,其中有以下几列:
-- RUN 1st
create extension vector;
-- RUN 2nd
create table vsearch (
id bigserial primary key,
document_title text,
page_no int2,
content text,
content_length bigint,
content_tokens bigint,
embedding vector (1536)
);
-- RUN 3rd after running the scripts
create or replace function vector_search (
query_embedding vector(1536),
similarity_threshold float,
match_count int
)
returns table (
id bigint,
document_title text,
page_no int2,
content text,
content_length bigint,
content_tokens bigint,
similarity float
)
language plpgsql
as $$
begin
return query
select
vsearch.id,
vsearch.document_title,
vsearch.page_no,
vsearch.content,
vsearch.content_length,
vsearch.content_tokens,
1 - (vsearch.embedding <=> query_embedding) as similarity
from vsearch
where 1 - (vsearch.embedding <=> query_embedding) > similarity_threshold
order by vsearch.embedding <=> query_embedding
limit match_count;
end;
$$;
-- RUN 4th
create index on vsearch
using ivfflat (embedding vector_cosine_ops)
with (lists = 100);
进入全屏模式 退出全屏模式
下面是代码中每个部分的具体内容:
create extension vector;正在创建vector扩展,为PostgreSQL中的矢量数据类型和操作提供支持。create table vsearch ...正在创建一个名为vsearch的新表,该表有文件标题、页码、内容、内容长度、内容令牌和嵌入矢量等列。create or replace function vector_search ...正在定义一个名为vector_search的新函数,该函数接收一个查询嵌入向量、一个相似度阈值和一个匹配计数,并返回一个带有相似度分数的文档信息表。这个函数是用PL/pgSQL编写的,这是PostgreSQL的一种程序语言。create index on vsearch ...正在使用IVFFLAT方法在embedding列上创建一个索引,有100个列表。这个索引通过将向量聚类,减少寻找最相似向量所需的比较次数,从而加快了相似性搜索。
将一个文档解析到PG-Vector表中
好了,希望一切都按计划进行,你已经创建了你的表和RPC函数。如果没有,就问问ChatGPT,他知道该怎么做。🤣
在我的实验中,我使用了一个PDF文件,这是一本关于早期IBM程序员的短书,名为《真正的黑客》,作为我的信息主体。我写了几个脚本,从我的开发环境中运行,对信息进行预处理,然后插入到表中。
下面是解析PDF的脚本:
import fs from 'fs'
import { encode } from 'gpt-3-encoder';
import PDF from 'pdf-scraper';
const inFile = 'data/hackers.pdf';
const outFile = 'data/hackers.json';
const dataBuffer = fs.readFileSync(inFile);
const tokensPerChunk = 200;
const documentTitle = "True Hackers"
PDF(dataBuffer).then(function (data) {
console.log(`Successfully parsed ${data.numpages} pages from ${inFile}`);
// Iterate over PDF pages
let chunkIndex = 0;
let currentChunk = '';
let currentChunkWords = 0;
const output = [];
let content = '';
for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) {
console.log(`Parsing page #${pageIndex}...`);
const pushChunk = (chunk) => {
const contentLength = encode(chunk).length;
console.log('Creating chunk of token length:', contentLength);
output.push({
documentTitle: documentTitle,
pageNo: pageIndex + 1,
tokens: contentLength,
content: chunk.trim()
});
}
// Normalize all whitespace to a single space
content = data.pages[pageIndex].replace(/\s+/g, ' ');
// If page content is longer than tokens limit, parse into sentences
if (encode(content).length > tokensPerChunk) {
// Split content into sentences
let sentences = content.split('. ');
let chunk = '';
for (let i = 0; i < sentences.length; i++) {
const sentence = sentences[i];
const sentenceTokenLength = encode(sentence).length;
const chunkTokenLength = encode(chunk).length;
// If our chunk has grown to exceed the tokens limit, append to output buffer
if (chunkTokenLength + sentenceTokenLength > tokensPerChunk) {
pushChunk(chunk);
chunk = '';
}
// If current sentence ends with a character, append a period, otherwise a space
if (sentence && sentence[sentence.length - 1].match(/[a-z0-9]/i)) {
chunk += sentence + ". ";
} else {
chunk += sentence + " ";
}
}
// Append the remaining text
pushChunk(chunk);
} else {
pushChunk(content);
}
}
fs.writeFileSync(outFile, JSON.stringify(output));
});
进入全屏模式 退出全屏模式
该代码首先导入了必要的模块,如'fs'、'gpt-3-encoder'和'pdf-scraper'。
然后,它定义了输入和输出文件的路径,并使用fs.readFileSync() ,将输入的PDF文件的内容读入一个缓冲区对象。
接下来,PDF内容通过pdf-scraper 模块,使用PDF(dataBuffer) 。pdf-scraper 模块从PDF文件中提取文本内容,并返回一个Promise,在回调函数中处理。文本内容被存储在data.pages ,数组中的每个元素代表PDF的一个页面。
然后脚本使用一个for 循环遍历PDF的各个页面。对于每一页,它检查页面内容的长度是否超过了tokensPerChunk 所指定的最大标记长度。如果超过了,则将内容分割成句子,每个句子被串联成一个文本块,其最大标记长度为tokensPerChunk 。如果一个句子超过了这个限制,则将其分割成多个块。
gpt-3-encoder 模块用于将文本块编码为可作为OpenAI GPT-3语言模型输入的格式。编码后的文本被存储在output 数组中,该数组是一个对象的集合,包含了文档的标题、页码、标记的数量和内容块本身。
最后,output 数组被写入一个JSON文件中,使用fs.writeFileSync() ,输出文件路径由outFile 指定。
接下来,这里是生成嵌入物并插入我们的PG表的代码。请确保首先检查JSON输出的有效性。这就是为什么我把这些功能分成两个脚本:
import { loadEnvConfig } from "@next/env";
import { createClient } from "@supabase/supabase-js";
import fs from "fs";
import { Configuration, OpenAIApi } from "openai";
import { encode } from "gpt-3-encoder";
loadEnvConfig("");
(async function () {
try {
const configuration = new Configuration({ apiKey: process.env.OPENAI_KEY });
const openai = new OpenAIApi(configuration);
const supabase = createClient(process.env.NEXT_PUBLIC_SB_URL, process.env.SB_SERVICE_KEY);
const inFile = 'data/hackers.json';
const dataBuffer = fs.readFileSync(inFile);
const data = JSON.parse(dataBuffer);
data.forEach(async item => {
// Generate embedding via GPT model ada-002
const aiRes = await openai.createEmbedding({
model: "text-embedding-ada-002",
input: item.content
});
const [{ embedding }] = aiRes.data.data;
// Insert data and embedding into PG table
const { data, error } = await supabase
.from("vsearch")
.insert({
document_title: item.documentTitle,
page_no: item.pageNo,
embedding,
content: item.content,
content_length: item.content.length,
content_tokens: encode(item.content).length
})
.select("*");
return;
});
} catch (err) {
console.error(err.message, err.stack);
}
})();
进入全屏模式 退出全屏模式
- 我们从之前创建的文件中加载我们的JSON数据
- 我们使用
text-embedding-ada-002OpenAI端点来获得我们的矢量 - 我们使用Supabase SDK将我们的片段插入到我们的搜索表中。
将其付诸实践
我们将把后端过程分为两个独立的控制器。第一个控制器将查询我们的PG表并获得3个最相关的结果。第二个控制器将向ChatGPT提示所提取的数据和原始查询。
这里是第一个:
import { createClient } from "@supabase/supabase-js";
import readRequestBody from "@/lib/api/readRequestBody";
import checkTurnstileToken from "@/lib/api/checkTurnstileToken";
// Use Next.js edge runtime
export const config = {
runtime: 'edge',
}
export default async function handler(request) {
const MATCHES = 3; // max matches to return
const THRESHOLD = 0.01; // similarity threshold
try {
const requestData = await readRequestBody(request);
// Validate CAPTCHA response
if (!await checkTurnstileToken(requestData.token)) {
throw new Error('Captcha verification failed');
}
const supabase = createClient(process.env.NEXT_PUBLIC_SB_URL, process.env.SB_SERVICE_KEY);
// Get embedding vector for search term via OpenAI
const input = requestData.searchTerm.replace(/\n/g, " ");
const result = await fetch("https://api.openai.com/v1/embeddings", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_KEY}`
},
method: "POST",
body: JSON.stringify({
model: "text-embedding-ada-002",
input
})
});
if (!result.ok) {
const mess = await result.text();
throw new Error(mess)
}
const json = await result.json();
const embedding = json.data[0].embedding;
// Perform cosine similarity search via RPC call
const { data: chunks, error } = await supabase.rpc("vector_search", {
query_embedding: embedding,
similarity_threshold: THRESHOLD,
match_count: MATCHES
});
if (error) {
console.error(error);
throw new Error(error);
}
return new Response(JSON.stringify(chunks), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
} catch (err) {
return new Response(`Server error: ${err.message}`, { status: 500 });
}
}
进入全屏模式 退出全屏模式
下面是它的作用:
config对象指定了这是一个Next.js边缘运行时的函数。边缘运行时允许函数在离用户更近的地方运行,减少延迟。handler函数是主函数,它接收一个request对象并返回一个Response对象。MATCHES变量决定了要返回的最大匹配数。THRESHOLD变量指定了相似度阈值。相似性分数低于该阈值的匹配将不被返回。readRequestBody函数将请求主体读成一个字符串。checkTurnstileToken函数通过将 CAPTCHA 响应发送到 Supabase 来验证它。createClient函数用 Supabase 的 URL 和服务密钥创建一个 Supabase 客户端。fetch函数向OpenAI API发送一个请求,以获得搜索词的嵌入向量。rpc函数向Supabase服务器发送一个远程过程调用以执行余弦相似度搜索。Response对象会以JSON格式返回匹配的块。
接下来是一个向OpenAI API发送提示的函数,并返回一个实时接收响应的ReadableStream :
import { createParser } from "eventsource-parser";
const openAiStream = async (system, prompt) => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const res = await fetch("https://api.openai.com/v1/chat/completions", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_KEY}`
},
method: "POST",
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [
{
role: "system",
content: system
},
{
role: "user",
content: prompt
}
],
max_tokens: 150,
temperature: 0.0,
stream: true
})
});
if (res.status !== 200) {
const mess = await res.text();
throw new Error(mess);
}
const stream = new ReadableStream({
async start(controller) {
const onParse = (event) => {
if (event.type === "event") {
const data = event.data;
if (data === "[DONE]") {
controller.close();
return;
}
try {
const json = JSON.parse(data);
const text = json.choices[0].delta.content;
const queue = encoder.encode(text);
controller.enqueue(queue);
} catch (e) {
controller.error(e);
}
}
};
const parser = createParser(onParse);
for await (const chunk of res.body) {
parser.feed(decoder.decode(chunk));
}
}
});
return stream;
};
export default openAiStream;
进入全屏模式 退出全屏模式
createParser函数创建了一个新的Parser对象,解析了一个EventSource流。openAiStream函数需要两个参数:system和prompt。system是来自系统的消息,prompt是来自用户的提示。- 该函数向OpenAI API发送一个POST请求,其中包含指定的
system和prompt消息。 - 如果响应状态不是200,就会抛出一个错误。
ReadableStream是用一个async start方法创建的,该方法接收一个controller对象。controller是用来管理流的。- 一个回调函数
onParse,用来处理来自Parser的解析的事件。 - 在
start方法中,一个for await...of循环被用来迭代响应体块。 - 使用
decoder.decode(chunk),将每个块从二进制解码为UTF-8。 - 解码后的块被送入分析器,使用
parser.feed()。 - 如果解析器遇到一个 "事件 "类型,
data字段被解析为JSON,响应文本被编码并排入controller对象。 - 如果解析器遇到一个"[DONE]"信息,
controller被关闭,start方法退出。 - 如果对响应的解析有错误,就会用
controller.error()抛出。
最后,这里有一个处理程序,它将把所有的事情放在一起:
import readRequestBody from "@/lib/api/readRequestBody";
import endent from "endent";
import openAiStream from "@/lib/openAi/openAiStream";
// Use Next.js edge runtime
export const config = {
runtime: 'edge',
}
export default async function handler(request) {
try {
const requestData = await readRequestBody(request);
const query = requestData.searchTerm.replace(/\n/g, " ");
const system = endent`
You are a helpful assistant that answers questions based on a provided body of text.
Use only the provided text to answer the question. Try not to copy the text word-for-word.
If you are not certain of the answer, then answer: "Sorry, I can't help you with that."
`
const textBody = requestData.chunks.map(c => {
return c.content + ' ';
});
const prompt = `Please answer this query: ${query}\n\n`
+ `Use only the following information:\n\n${textBody}`;
const stream = await openAiStream(system, prompt);
return new Response(stream);
} catch (err) {
return new Response(`Server error: ${err.message}`, { status: 500 });
}
}
进入全屏模式 退出全屏模式
第一部分是系统信息,它告诉ChatGPT应该如何处理后续的查询。第二部分是实际的查询,包含用户的搜索词和从数据库中提取的数据。然后我们把它输入到我们的openAiStream 函数,然后把数据流返回给客户端。
创建一个客户端
欢迎你查看我在我的投资组合网站上创建的客户端(见下文),但是这篇文章越来越长了,所以我不会把全部代码放在这里,因为其中大部分对实现这个工作并不重要。不过,这里有一些好东西:
const embedSearchTerm = async () => {
const body = new FormData();
body.append('searchTerm', searchTerm);
body.append('token', token);
const result = await fetch('/api/vsearch/embed', {
method: 'POST',
body
});
if (!result.ok) {
const mess = await result.text();
throw new Error(mess);
}
const chunks = await result.json();
if (!chunks.length) {
setErrorMess('Sorry, I was unable to find a match');
return;
}
setPageNumbers(chunks.map(c => (c.page_no)));
return chunks;
}
const getAnswer = async (chunks) => {
const body = {
searchTerm,
chunks
};
const response = await fetch('/api/vsearch/answer', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const message = await response.text();
throw new Error(message);
}
const stream = response.body;
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = new TextDecoder('utf-8').decode(value);
setAnswer((prev) => prev + chunk);
}
};
const handleSearch = async (e) => {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
setSearchErrorMess('');
if (!searchTerm.length) {
setSearchErrorMess('Please fill this out');
return;
}
setIsLoading(true);
try {
const chunks = await embedSearchTerm();
if (chunks.length) {
await getAnswer(chunks);
}
} catch (err) {
setErrorMess(err.message);
captcha.reset();
} finally {
setIsLoading(false);
}
}
进入全屏模式 退出全屏模式
总结
在本教程中,我们已经探讨了如何使用Supabase/PostgreSQL、Next.js和OpenAI来实现语义搜索。我们在Supabase中设置了一个数据库表,以存储我们的文档及其矢量表示,并定义了一个函数,用于针对这些矢量进行语义搜索。我们还利用了PostgreSQL中的pg-vector扩展来实现矢量操作,并在嵌入列上设置了一个索引,以加快相似性搜索的速度。
语义搜索是提高搜索准确性和相关性的强大工具,尤其是在处理大量非结构化数据的时候。随着自然语言处理和机器学习的兴起,在我们自己的项目中实施语义搜索也变得越来越可行。通过遵循本教程中概述的步骤,你可以开始使用Supabase/PostgreSQL、Next.js和OpenAI进行语义搜索,并根据你的具体用例进行调整。
这些法律硕士的力量将以一种前所未有的方式向人们解锁知识。世界将因这项技术而永远改变。
链接
感谢你花时间阅读我的文章,我希望你觉得它很有用(或者至少是温和的娱乐性)。更多关于网络开发、系统管理和云计算的精彩信息,请阅读Designly博客。另外,请留下您的评论!我喜欢听我的读者的想法。
我使用Hostinger来托管我客户的网站。你可以得到一个可以托管100个网站的商业账户,价格为3.99美元/月,你可以锁定48个月!这是城里最好的交易。服务包括PHP主机(带扩展)、MySQL、Wordpress和电子邮件服务。
想找一个网站开发人员?我是可以被雇佣的!如需咨询,请填写联系表。