使用Supabase、Next.js和OpenAI实现语义搜索:教程

1,457 阅读9分钟

简介

随着网上可用的信息量以前所未有的速度增长,传统的搜索引擎正在努力跟上。这就是语义搜索的用武之地--一种旨在理解用户查询背后的意图并提供更准确结果的技术。在本教程中,我们将探讨如何使用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的回答请求超载,后者将解释这些信息。

这个过程如下:

  1. 我们将文档文本解析为每个约200个小块。
  2. 我们通过OpenAI的嵌入API运行每个块,以获得语义向量
  3. 我们将该文本(包括PG表中的矢量)存储在我们的PG表中。

这都是提前完成的。连续的过程如下:

  1. 用户在前端表单上提出一个问题
  2. 该问题通过OpenAI即时转换为一个矢量
  3. 我们在数据库中运行一个向量搜索查询,得到前3个结果
  4. 我们把这些结果串联起来,然后把它们和原来的问题反馈给ChatGPT
  5. ChatGPT给我们答案

设置Supabase

  1. 首先,我们需要创建一个Supabase项目和数据库。如果你还没有这样做,请注册一个免费的Supabase账户并创建一个新项目。
  2. 一旦你创建了你的项目,导航到SQL编辑器并连接到你的数据库。你可以通过点击左侧边栏的 "SQL "按钮,然后点击 "连接到数据库 "来完成。
  3. 在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);
    }
})();

进入全屏模式 退出全屏模式

  1. 我们从之前创建的文件中加载我们的JSON数据
  2. 我们使用text-embedding-ada-002 OpenAI端点来获得我们的矢量
  3. 我们使用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 函数需要两个参数:systempromptsystem 是来自系统的消息,prompt 是来自用户的提示。
  • 该函数向OpenAI API发送一个POST请求,其中包含指定的systemprompt 消息。
  • 如果响应状态不是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进行语义搜索,并根据你的具体用例进行调整。

这些法律硕士的力量将以一种前所未有的方式向人们解锁知识。世界将因这项技术而永远改变。

链接

  1. GitHub Repo
  2. 演示页面
  3. pdf-scraper

感谢你花时间阅读我的文章,我希望你觉得它很有用(或者至少是温和的娱乐性)。更多关于网络开发、系统管理和云计算的精彩信息,请阅读Designly博客。另外,请留下您的评论!我喜欢听我的读者的想法。

我使用Hostinger来托管我客户的网站。你可以得到一个可以托管100个网站的商业账户,价格为3.99美元/月,你可以锁定48个月!这是城里最好的交易。服务包括PHP主机(带扩展)、MySQL、Wordpress和电子邮件服务。

想找一个网站开发人员?我是可以被雇佣的!如需咨询,请填写联系表