使用Chatgpt和EdgedB构建自己的文档聊天机器人
#javascript #教程 #ai #database

有关其他上下文,请查看 our blog post about why and how we use ChatGPT via embeddings 创建我们的询问ai bot,以回答与EdgedB文档相关的问题。

在本教程中,我们将使用Next.jsOpenAIEdgeDB构建文档聊天机器人。

警告:这个项目呼吁Openai的API。在出版时,在注册后的前三个月内将获得新用户的API信用额为5美元。根据电话号码进行试验,而不是每个帐户。如果您耗尽了试验学分或三个月的失误,则需要切换到付费帐户才能建立教程项目。

它是如何工作的

tl; dr-训练语言模型很难,但是使用嵌入使其访问信息超出其接受培训的信息很容易……所以我们会这样做!现在, skip ahead to get started building 或继续阅读以获取更多详细信息。

我们的聊天机器人由OpenAI’s ChatGPT支持。 CHATGPT是一种高级大语模型(LLM),它使用机器学习算法根据给出的输入来生成类似人类的响应。

一般整合chatgpt和语言模型时,有两个选项:微调模型或使用embeddings。微调会产生最好的结果,但需要更多的一切:更多的钱,更多的时间,更多的资源和更多的培训数据。这就是为什么许多人和企业使用嵌入方式而不是为现有语言模型提供其他上下文。

嵌入是一种将单词,短语或其他类型的数据转换为计算机可以使用数学的数值形式的方法。所有这些都建立在自然语言处理(NLP)基础之上,该加工允许计算机伪造对人类语言的理解。在NLP的背景下,单词嵌入用于将单词转化为向量。这些向量定义了一个在空间中的单词位置,在该空间中,计算机根据其句法和语义相似性对其进行分类。例如,同义词彼此接近,经常出现在类似上下文中的单词被分组在一起。

使用嵌入时,我们没有训练语言模型。取而代之的是,我们为每个文档创建嵌入向量,后来将帮助我们找到哪些文档可能回答用户的问题。当用户提出问题时,我们为该问题创建了一个新的嵌入,并将其与文档生成的嵌入方式进行比较,以找到最相似的嵌入。答案是使用与这些类似嵌入的内容生成的。

以此为止,让我们走过碎片如何结合在一起。

实施概述

广泛地,该应用程序做了两件事:它生成文档中的嵌入,并使用这些嵌入来回答用户问题。第一个是在此实现中手动触发的。每当文档更新时,我们都想触发它。当用户提出问题时,第二个是自动触发的。

嵌入生成需要两个步骤:

  1. 使用OpenAI’s embeddings API为每个部分创建嵌入
  2. 使用PGVECTOR将嵌入数据存储在边缘B中

用户每次提出问题时,我们的应用都会:

  1. 查询数据库的文档部分,使用相似性函数与问题最相关
  2. 将这些部分作为上下文注入提示 - 与用户的问题一起,并向OpenAI提交此请求
  3. 实时将OpenAI响应传输回用户

先决条件

本教程假设您已安装了Node.js。如果您不这样做,请在继续之前进行安装。

构建也需要其他软件,但是我们将帮助您作为教程的一部分安装它。

初始设置

让我们的应用程序使用next.js create-next-app工具开始。在您想为此项目创建新目录的任何地方运行此操作。

npx create-next-app --typescript docs-chatbot
Need to install the following packages:
  create-next-app@13.4.12
Ok to proceed? (y) y
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
Creating a new Next.js app in /<path>/<to>/<project>/docs-chatbot.

选择除了所有问题外,您想使用src/\目录吗?

一旦完成,您应该会看到一条成功消息:

Success! Created docs-chatbot at /<path>/<to>/<project>/docs-chatbot

更改为新目录,以便我们开始!

cd docs-chatbot

让我们对create-next-app生成的tsconfig.json进行了两个更改。将target更改为"es6",因为我们将使用仅在ES6中可用的一些数据结构。通过使用"baseUrl": "."baseUrl属性设置为根来更新compilerOptions对象。稍后,当我们将模块添加到项目的根部时,这将使他们更容易导入它们。

{
  "compilerOptions": {
    "target": "es6",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    },
    "baseUrl": "."
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

现在,我们将为我们的项目创建一个EdgedB的实例,但是首先,我们需要安装EdgedB!

安装边缘Cliâ

如果您已经安装了EdgedB,则可以跳过创建一个实例。

在我们可以为项目创建实例之前,我们需要安装EdgedB CLI。在Linux或MacOS上,在终端中运行以下内容,并按照屏幕上的说明进行操作:

curl --proto '=https' --tlsv1.2 -sSf https://sh.edgedb.com | sh

Windows PowerShell用户可以使用此命令:

iwr https://ps1.edgedb.com -useb | iex

有关其他安装方案,请参见our “Install” page的其他安装方法。

创建本地edgedb实例

为了创建我们的实例,让我们将项目初始化为一个EdgedB项目。在项目的根部运行以下内容:

edgedb project init
No `edgedb.toml` found in `/<path>/<to>/<project>/docs-chatbot` or above

Do you want to initialize a new project? [Y/n]
> Y

Specify the name of EdgeDB instance to use with this project [default: docs_chatbot]:
> docs_chatbot

Checking EdgeDB versions...
Specify the version of EdgeDB to use with this project
[default: 3.2]:
> 3.2

CLI应在该实例中设置一个EdgedB项目,实例和数据库。

  • 通过检查项目根中的edgedb.toml文件和dbschema目录来确认项目创建。

  • 确认实例正在使用edgedb instance list命令运行。搜索您刚创建的实例名称(如果遵循的话,docs_chatbot)并检查状态。 (不担心状态是否不活动;当您连接到实例时,状态会自动运行。)

  • 确认您可以通过在终端中运行edgedb来连接到创建的实例,以通过repl reams或运行edgedb ui连接到它。

配置环境

在您的新next.js项目的根部创建一个.env.local文件。

touch .env.local

我们将在该文件中添加几个变量,以配置EdgedB客户端。我们需要在新实例上运行一个命令,以获取其中一个的值。由于我们将在下一个项目中使用Edge runtime。实例。为此,运行此命令:

edgedb instance credentials --insecure-dsn

复制其注销的内容。打开文本编辑器中的.env.local文件,然后将其添加到它:

EDGEDB_DSN=<your-dsn>
EDGEDB_CLIENT_TLS_SECURITY="insecure"

用您之前复制的值替换<your-dsn>

我们将稍后使用EdgedB HTTP客户端来连接到我们的数据库,但是它需要可信赖的TLS/SSL证书。本地开发实例使用自签名的证书,将HTTP与这些证书一起使用将导致错误。要解决此错误,我们允许客户端通过将EDGEDB_CLIENT_TLS_SECURITY变量设置为"insecure"来忽略TLS。请记住,这仅用于本地发展,您应该始终在生产中使用TLS。

我们需要设置一个另一个环境变量,但首先我们必须获得一个API密钥。

准备OpenAI API客户端

我们需要一个来自OpenAI的API密钥,以便进行使该应用程序工作所需的呼叫。要得到一个:

  1. 登录或注册到OpenAI platform
  2. Create new secret key.

警告:如果您没有任何API免费试验学分,您可能需要启动付费帐户。

复制新密钥。重新打开您的.env.local文件,然后添加以下添加:

EDGEDB_DSN=<your-dsn>
EDGEDB_CLIENT_TLS_SECURITY="insecure"
OPENAI_API_KEY="<your-openai-api-key>"

而不是<your-openai-api-key>,粘贴在您刚创建的密钥中。

当我们在这里时,让我们准备好使用该钥匙。我们将打电话给OpenAI API。我们将创建一个utils模块,并从中导出一个函数,以初始化OpenAI API客户端。我们可以导入并调用该函数,以创建一个新客户端,我们需要在我们需要进行OpenAi API调用的任何地方。

// utils.ts in the project root
import OpenAI from "openai";

export function initOpenAIClient() {
  if (!process.env.OPENAI_API_KEY)
    throw new Error("Missing environment variable OPENAI_API_KEY");

  return new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });
}

这很简单。它确保在环境变量中提供了API密钥,并返回使用该密钥初始化的新API客户端。

现在,如果这些API调用出错,我们将创建错误消息,我们将在几个地方使用。创建一个文件app/constants.ts并填写以下内容:

export const errors = {
  flagged: `OpenAI has declined to answer your question due to their usage-policies. Please try another question.`,
  default: "There was an error processing your request. Please try again.",
};

这将带有几个错误消息的对象errors

现在,让我们准备好文档!

放置文档

对于此项目,我们将使用文档作为Markdown文件,因为它们可以直接使用OpenAI的语言模型。

在项目的根部创建一个docs文件夹。在这里,我们将放置我们的Markdown文档文件。您可以获取我们从the example project’s GitHub repo中使用的文件或添加自己的文件。 (如果您使用自己的使用,则可能还需要调整我们以后发送给OpenAI的系统消息。)

注意:使用Markdown以外的格式

我们可以选择使用其他简单格式,例如纯文本文件或更复杂的文件(例如HTML)。由于更复杂的格式可以包含超出我们想要的语言模型消耗的其他数据(例如HTML的标签及其属性),因此我们可能首先要清洁这些文件并在将内容发送到OpenAI之前提取这些文件。 (我们可以为此编写自己的逻辑或使用可以在线转换的库,例如。)

可以使用更复杂的格式而无需清洁它们,但随后我们要为额外的代币付费,以改善我们的聊天机器人会给用户提供的答案。

注意:在更长的文档部分

在这个教程项目中,我们的文档页面很短,但是实际上,由于LLM的标记限制,文档文件可能会变很长,并且可能需要分为多个部分。 LLMS将文本分为令牌。对于英语文本,1个令牌约为4个字符或0.75个字。 LLMS对他们可以接收并发送回的令牌数量有限制。

减轻这种情况的一种方法是分析您的文档文件并在每次遇到标题时创建新部分。如果您使用这种方法,请在编写文档时考虑截面长度。如果您发现一个部分太长,请考虑使用其他标题将其分解的方法。这可能会使您的用户也更容易阅读!

要生成嵌入,我们将使用text-embedding-ada-002模型。其输入令牌限制为8,191个令牌。后来,回答用户的问题时,我们将使用chat completions型号pt-3.5-turbo。它的令牌限制为4,096个令牌。该极限不仅涵盖了我们的输入,还涵盖了API的响应。

稍后,当我们发送用户的问题时,我们还将从文档中发送相关部分,作为聊天完成API的输入的一部分。这就是为什么保持我们的部分很重要的原因:我们想为答案留出足够的空间。

如果相关的部分太长,并且与用户的问题一起超过了4,096个令牌限制,我们将从OpenAI中恢复错误。如果问题和相关部分的长度太接近令牌限制,而不是超过令牌,则API会发送答案,但是当达到限制时,答案将被切断。

我们希望通过确保我们始终为所有输入和LLM的响应提供足够的象征性净空来避免这些结果中的任何一个。这就是为什么我们以后将1,500个令牌设置为我们将用于相关部分的最大令牌数量的原因,这也是为什么部分相对较短的原因。

如果您的应用程序具有较长的文档文件,请确保在生成嵌入之前弄清楚分解这些文件的策略。

创建模式以存储嵌入

要能够将数据存储在数据库中,我们必须首先创建其架构。我们希望使模式尽可能简单,并仅存储相关数据。我们需要存储部分的嵌入,内容和令牌数量。嵌入使我们能够将内容与问题匹配。内容为我们提供了馈送LLM的上下文。在计算迅速上下文中有多少相关部分时,我们将需要稍后的令牌计数,同时留在模型的代币限制下。

打开当我们初始化EdgedB项目(位于项目目录的dbschema/default.esdl)时生成的空架构文件。我们将一步一步走进我们添加的内容。首先,将其添加在文件的顶部(module default {上方):

# dbschema/default.esdl

using extension pgvector;
module default {
  # Schema will go here
}

由于pgvector扩展,我们能够存储嵌入并在EdgedB数据库中找到类似的嵌入。为了在我们的架构中使用它,我们必须在模式文件的开头激活使用using extension pgvectorext::pgvector模块。该模块使我们可以访问ext::pgvector::vector数据类型,以及少数相似性功能和索引,我们以后可以使用以检索嵌入。阅读我们的pgvector documentation有关扩展的更多详细信息。

在此下方,我们可以通过创建新的标量类型开始构建模块。

# dbschema/default.esdl

using extension pgvector;
module default {
  scalar type OpenAIEmbedding extending
    ext::pgvector::vector<1536>;

  type Section {
    # We will build this out next
  }
}

使用Extension Active,我们现在可以使用随附的ext::pgvector::vector数据类型将属性添加到我们的对象类型中。但是,为了能够使用索引,所讨论的向量必须是固定长度的a。这可以通过创建扩展向量并指定所需长度的自定义标量来实现。 OpenAI嵌入的长度为1,536,因此我们在该自定义标量的模式中使用的是

现在,Section类型:

# dbschema/default.esdl

using extension pgvector;
module default {
  scalar type OpenAIEmbedding extending
    ext::pgvector::vector<1536>;

  type Section {
    required content: str;
    required tokens: int16;
    required embedding: OpenAIEmbedding;

    index ext::pgvector::ivfflat_cosine(lists := 1)
      on (.embedding);
  }
}

Section包含用于存储内容,代币计数和嵌入的属性,即我们在上一步中创建的自定义标量类型的属性。

我们还在Section类型中添加了一个索引,以加快查询。为了使其正常工作,该索引应与cosine_similarity功能相对应,我们将用于查找与用户问题相关的部分。相应的索引是ivfflat_cosine

我们将值1用于lists参数,因为确切的数据库中的三个项目很少。最佳实践是将多达1,000,000个对象除以1,000的对象数量。

在我们的情况下,索引不会产生太大影响,但是如果您打算存储和查询大量条目,则会通过添加此索引来查看性能提高。

将所有内容放在一起,您的整个模式文件应该看起来像这样:

# dbschema/default.esdl

using extension pgvector;

module default {
  scalar type OpenAIEmbedding extending
    ext::pgvector::vector<1536>;

  type Section {
    required content: str;
    required tokens: int16;
    required embedding: OpenAIEmbedding;

    index ext::pgvector::ivfflat_cosine(lists := 1)
      on (.embedding);
  }
}

我们通过创建和运行迁移来应用此模式。

edgedb migration create
edgedb migrate

注意:在本教程中,每次运行嵌入式生成脚本,擦拭所有数据并为所有文档保存新的Section对象时,我们都会再生所有嵌入式。如果您没有太多文档,这可能是一种合理的方法,但是如果您有很多文档,则可能需要一种更复杂的方法,仅在已更改的文档部分上运行。

您可以通过保存内容校验和为每个部分的唯一标识符来实现这一目标 - 在我们的生产实现中,我们将部分路径用作Section对象的一部分。下次运行生成时,将部分的当前校验和比较您存储在数据库中的校验和通过其唯一标识符找到它。您不需要生成嵌入并更新给定部分的数据库,除非两次校验和不同表明已更改的东西。

如果您决定走这条路线,则可以修改模式以支持这一点:

# dbschema/default.esdl
# This is only necessary to support partial regeneration
# We will not use it for this tutorial.

type Section {
  required path: str {
    constraint exclusive;
  }
  required checksum: str;
  # The rest of the Section type
}

您还需要根据这些比较的结果来存储唯一的标识符,计算和比较校验和对象。

创建和存储嵌入

在我们脚本创建嵌入式之前,我们需要安装一些可以帮助我们的库。

npm install openai edgedb
npm install \
  @edgedb/generate \
  gpt-tokenizer \
  dotenv \
  tsx \
  --save-dev

@edgedb/generate软件包提供了一组代码生成工具,这些工具在使用TypeScript/JavaScript开发EdgedB支持的应用程序时很有用。我们要使用我们的query builder来编写查询,但是在可以的之前,我们需要运行查询构建器生成器。

npx @edgedb/generate edgeql-js

当被问及将查询构建器添加到.gitignore时。

此生成器为我们提供了一种用打字稿编写完全符合的edgeql查询的代码优先方法。运行发电机后,您应该在dbschema中看到一个新的edgeql-js文件夹。

最后,我们准备为所有部分创建嵌入式,并将它们存储在我们之前创建的数据库中。让我们在项目根部内制作一个generate-embeddings.ts文件。

touch generate-embeddings.ts

让我们看脚本的骨架,并了解我们需要执行的任务流程。

,您可能只想阅读以了解所有代码,而不是尝试逐步构建这个。我们将整个脚本放在本节的末尾,您可以将其复制/粘贴到您的文件中。

// generate-embeddings.ts

import { promises as fs } from "fs";
import { join } from "path";
import dotenv from "dotenv";
import { encode } from "gpt-tokenizer";
import * as edgedb from "edgedb";
import e from "dbschema/edgeql-js";
import { initOpenAIClient } from "./utils";

dotenv.config({ path: ".env.local" });

const openai = initOpenAIClient();

interface Section {
  id?: string;
  tokens: number;
  content: string;
  embedding: number[];
}

async function walk(dir: string): Promise<string[]> {
  // …
}

async function prepareSectionsData(
  sectionPaths: string[]
): Promise<Section[]> {
  // …
}

async function storeEmbeddings() {
  // …
}

(async function main() {
  await storeEmbeddings();
})();

顶部都是我们在整个文件中都需要的所有导入。最后一次导入是我们之前生成的查询构建器,最后一个是初始化OpenAi API客户端的函数。

导入后,我们使用dotenv库从.env.local文件导入环境变量。

然后,我们通过致电initOpenAIClient初始化我们的OpenAI API客户端。

接下来,我们定义一个Section打字稿接口,该接口对应于我们在架构中定义的Section类型。

然后我们有一些函数定义:

  • walkprepareSectionsData将从storeEmbeddings内部调用。 walk返回与项目根相对于项目根的所有文档页面路径的数组。 prepareSectionsData照顾我们将插入数据库中的Section对象,并将其返回为数组。
  • storeEmbeddings协调一切。

要完成脚本,我们等待着我们的协调功能的电话,该功能可以根据需要启动其他所有内容。

获取部分路径

为了获取部分内容,我们首先需要知道需要读取文件的位置。 walk功能为我们找到它们,并返回所有路径。它构建了相对于项目根的所有路径的数组。

// generate-embeddings.ts

// …
async function walk(dir: string): Promise<string[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });

  return (
    await Promise.all(
      entries.map((entry) => {
        const path = join(dir, entry.name);
        if (entry.isFile()) return [path];
        else if (entry.isDirectory()) return walk(path);
        return [];
      })
    )
  ).flat();
}
// …

它产生的输出看起来像这样:

[
  'docs/edgeql/design-goals.md',
  'docs/edgeql/overview.md',
  'docs/edgeql/try-edgeql.md',
]

准备Section对象

此功能将负责收集我们将存储的每个Section对象所需的数据,包括进行OpenAI API调用以生成嵌入。让我们一次穿过它。

// generate-embeddings.ts

// …
async function prepareSectionsData(
  sectionPaths: string[]
): Promise<Section[]> {
  const contents: string[] = [];
  const sections: Section[] = [];

  for (const path of sectionPaths) {
    const content = await fs.readFile(path, "utf8");
    // OpenAI recommends replacing newlines with spaces for best results
    // when generating embeddings
    const contentTrimmed = content.replace(/\n/g, " ");
    contents.push(contentTrimmed);
    sections.push({
      content,
      tokens: encode(content).length
      embedding: [],
    });
  }
  // The rest of the function
}
// …

我们以一个参数开头:部分路径的数组。我们创建了几个空数组来存储有关我们的部分的信息(后来将成为数据库中的Section对象)及其内容。我们迭代路径,加载每个文件以获取其内容。

在数据库中,我们将按原样保存内容,但是在调用嵌入API时,OpenAI建议应将所有新线替换为单个空间以获得最佳结果。 contentTrimmed是更换新线的内容。我们将其推向contents阵列,并将未修剪的内容以及一个令牌计数以及一个令牌计数(通过调用从gpt-tokenizer导入的encode函数获得)和一个空数组,然后我们稍后将其替换为实际嵌入。

>

>

>

>

到下一个点!

// generate-embeddings.ts

// …
async function prepareSectionsData(
  sectionPaths: string[]
): Promise<Section[]> {
  // Part we just talked about

  const embeddingResponse = await openai.embeddings.create({
    model: "text-embedding-ada-002",
    input: contents,
  });

  // The rest
}
// …

现在,我们从内容中生成嵌入。我们需要谨慎时要谨慎使用API​​调用以生成嵌入,因为它们可能会对生成多长时间的影响产生重大影响,尤其是随着您的文档的增长。最简单的解决方案是为每个部分向API提出单个请求,但是在有大约3,000页的EdgedB的文档中,这将需要大约半小时。

由于Openai的嵌入式API不仅可以服用 字符串,还可以使用字符串的 array ,我们可以利用它来批量所有内容并生成带有单个请求的嵌入!当我们将embeddingResponse设置为拨打openai.embeddings.create的结果时,您可以看到单个API调用,指定模型并传递整个内容。

这种单发嵌入生成方法的一个缺点是,我们 not 恢复了令牌计数,结果我们 将仅对单个字符串生成嵌入式。令牌计数很重要,因为它们确定我们可以发送多少相关部分以及我们对聊天完成的输入API的输入 - 回答用户问题并且仍然在模型的限制范围内。 。要保持在极限之内,我们需要知道每个部分有多少个令牌。由于我们没有将它们带回批处理的嵌入一代,因此我们早些时候使用了gpt-tokenizer库的encode功能来自己计算它们。

现在是时候通过迭代响应数据将这些嵌入到我们的部分对象中了。

// generate-embeddings.ts

// …
async function prepareSectionsData(
  sectionPaths: string[]
): Promise<Section[]> {
  // The stuff we already talked about

  embeddingResponse.data.forEach((item, i) => {
    sections[i].embedding = item.embedding;
  });

  return sections;
}
// …

我们迭代我们回来的所有嵌入,将嵌入到各自的部分中。最终的数据使该部分已准备好存储在数据库中,因此我们现在可以从功能中返回完整的部分。

这里组装了整个功能:

// generate-embeddings.ts

// …
async function prepareSectionsData(
  sectionPaths: string[]
): Promise<Section[]> {
  const contents: string[] = [];
  const sections: Section[] = [];

  for (const path of sectionPaths) {
    const content = await fs.readFile(path, "utf8");
    // OpenAI recommends replacing newlines with spaces for best results
    // when generating embeddings
    const contentTrimmed = content.replace(/\n/g, " ");
    contents.push(contentTrimmed);
    sections.push({
      content,
      tokens: encode(content).length
      embedding: [],
    });
  }

  const embeddingResponse = await openai.embeddings.create({
    model: "text-embedding-ada-002",
    input: contents,
  });

  embeddingResponse.data.forEach((item, i) => {
    sections[i].embedding = item.embedding;
  });

  return sections;
}
// …

这不是跟踪令牌的唯一方法。我们可以选择而不是在数据库中保存令牌计数,而是在我们找到相关部分后稍后在客户端上进行计数。

现在,我们已经准备存储在数据库中的部分,让我们将所有内容与storeEmbeddings函数联系在一起。

存储Section对象

再次,我们将破坏storeEmbeddings的功能,然后浏览它。

// generate-embeddings.ts

// …
async function storeEmbeddings() {
  const client = edgedb.createClient();

  const sectionPaths = await walk("docs");

  console.log(`Discovered ${sectionPaths.length} sections`);

  const sections = await prepareSectionsData(sectionPaths);

  // The rest of the function
}
// …

我们创建了我们的EdgedB客户端,并通过调用walk来获取文档路径。我们还记录了一些调试信息,以显示发现了多少个部分。然后,我们通过调用我们刚刚穿过并通过文档路径的prepareSectionsData函数来准备我们的Section对象。

接下来,我们将存储此数据。

// generate-embeddings.ts

// …
async function storeEmbeddings() {
  // The parts we just talked about

  // Delete old data from the DB.
  await e.delete(e.Section).run(client);

  // Bulk-insert all data into EdgeDB database.
  const query = e.params({ sections: e.json }, ({ sections }) => {
    return e.for(e.json_array_unpack(sections), (section) => {
      return e.insert(e.Section, {
        content: e.cast(e.str, section.content),
        tokens: e.cast(e.int16, section.tokens),
        embedding: e.cast(e.OpenAIEmbedding, section.embedding),
      });
    });
  });

  await query.run(client, { sections });
  console.log("Embedding generation complete");
}
// …

评论在这里解释很好,但是让我们详细介绍一下。首先,我们构建并运行一个查询,该查询删除当前数据库中当前的所有Section对象。然后,我们构建另一个查询,它将插入我们刚刚准备的新的Section数据。我们等待着一个询问该查询的run方法,通过我们刚刚准备的部分。

这是整个功能的样子:

// generate-embeddings.ts

// …
async function storeEmbeddings() {
  const client = edgedb.createClient();

  const sectionPaths = await walk("docs");

  console.log(`Discovered ${sectionPaths.length} sections`);

  const sections = await prepareSectionsData(sectionPaths);

  // Delete old data from the DB.
  await e.delete(e.Section).run(client);

  // Bulk-insert all data into EdgeDB database.
  const query = e.params({ sections: e.json }, ({ sections }) => {
    return e.for(e.json_array_unpack(sections), (section) => {
      return e.insert(e.Section, {
        content: e.cast(e.str, section.content),
        tokens: e.cast(e.int16, section.tokens),
        embedding: e.cast(e.OpenAIEmbedding, section.embedding),
      });
    });
  });

  await query.run(client, { sections });
  console.log("Embedding generation complete");
}
// …

将它们放在一起

这是整个嵌入式生成脚本。复制并将整个内容粘贴到您的generate-embeddings.ts文件中。

// generate-embeddings.ts

import { promises as fs } from "fs";
import { join } from "path";
import dotenv from "dotenv";
import { encode } from "gpt-tokenizer";
import * as edgedb from "edgedb";
import e from "dbschema/edgeql-js";
import { initOpenAIClient } from "@/utils";

dotenv.config({ path: ".env.local" });

const openai = initOpenAIClient();

interface Section {
  id?: string;
  tokens: number;
  content: string;
  embedding: number[];
}

async function walk(dir: string): Promise<string[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });

  return (
    await Promise.all(
      entries.map((entry) => {
        const path = join(dir, entry.name);
        if (entry.isFile()) return [path];
        else if (entry.isDirectory()) return walk(path);
        return [];
      })
    )
  ).flat();
}

async function prepareSectionsData(
  sectionPaths: string[]
): Promise<Section[]> {
  const contents: string[] = [];
  const sections: Section[] = [];

  for (const path of sectionPaths) {
    const content = await fs.readFile(path, "utf8");
    // OpenAI recommends replacing newlines with spaces for best results
    // when generating embeddings
    const contentTrimmed = content.replace(/\n/g, " ");
    contents.push(contentTrimmed);
    sections.push({
      content,
      tokens: encode(content).length,
      embedding: [],
    });
  }

  const embeddingResponse = await openai.embeddings.create({
    model: "text-embedding-ada-002",
    input: contents,
  });

  embeddingResponse.data.forEach((item, i) => {
    sections[i].embedding = item.embedding;
  });

  return sections;
}

async function storeEmbeddings() {
  const client = edgedb.createClient();

  const sectionPaths = await walk("docs");

  console.log(`Discovered ${sectionPaths.length} sections`);

  const sections = await prepareSectionsData(sectionPaths);

  // Delete old data from the DB.
  await e.delete(e.Section).run(client);

  // Bulk-insert all data into EdgeDB database.
  const query = e.params({ sections: e.json }, ({ sections }) => {
    return e.for(e.json_array_unpack(sections), (section) => {
      return e.insert(e.Section, {
        content: e.cast(e.str, section.content),
        tokens: e.cast(e.int16, section.tokens),
        embedding: e.cast(e.OpenAIEmbedding, section.embedding),
      });
    });
  });

  await query.run(client, { sections });
  console.log("Embedding generation complete");
}

(async function main() {
  await storeEmbeddings();
})();

运行脚本

让我们将脚本添加到package.json中,该脚本将调用并执行generate-embeddings.ts。在下面添加scripts.embeddings

{
  "name": "docs-chatbot",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
    "lint": "next lint",
    "embeddings": "tsx generate-embeddings.ts"
  },
  "dependencies": {
    "edgedb": "^1.3.5",
    "next": "^13.4.19",
    "openai": "^4.0.1",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "5.1.6"
  },
  "devDependencies": {
    "@edgedb/generate": "^0.3.3",
    "@types/node": "20.4.8",
    "@types/react": "18.2.18",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.14",
    "dotenv": "^16.3.1",
    "eslint": "8.46.0",
    "eslint-config-next": "13.4.13",
    "gpt-tokenizer": "^2.1.1",
    "postcss": "8.4.27",
    "tailwindcss": "3.3.3",
    "tsx": "^3.12.7"
  }
}

现在,我们可以使用一个简单命令从终端调用generate-embeddings.ts脚本:

npm run embeddings

脚本完成后,打开EdgedB UI。

edgedb ui

打开您的EdgedB数据库,然后切换到数据资源管理器选项卡。您应该看到数据库已通过嵌入和其他相关数据进行了更新。

回答用户问题

现在我们已经存储了内容的嵌入式,我们可以开始在处理程序上处理用户问题。用户将向我们的服务器提出一个问题,处理程序将向他们发送答案。我们将为此任务定义路由和HTTP请求处理程序。多亏了下一步的力量。

当我们撰写处理程序时,一个重要的考虑因素是答案可能很长。我们可以在服务器端等待从OpenAI获得整个答案,然后将其发送给客户端,但这对用户来说会很慢。 OpenAI支持流式传输,因此,我们可以将答案发送到服务器时,将答案发送给客户端。使用这种方法,用户不必等待整个响应才能开始获得反馈,而我们的API似乎更快。

为了流式响应,我们将使用浏览器的server-sent events (SSE) API。服务器范围的事件使客户端可以通过HTTP连接从服务器接收自动更新,并描述服务器在建立初始客户端连接后如何维护向客户端的数据传输。客户端发送请求,随着该请求,​​启动了与服务器的连接。然后,服务器将数据发送回客户端,直到发送所有数据为止,此时它关闭了连接。

Next.js路线处理程序

使用Next.js’s App Router时,应在app/api文件夹中写入路由处理程序。每个路线都应在其中都有自己的文件夹,并且应在路由文件夹内的route.ts文件中定义处理程序。

让我们为app/api内的答案生成路线创建一个新文件夹。

mkdir app/api && cd app/api
mkdir generate-answer && touch generate-answer/route.ts

我们还需要安装common-tags NPM软件包(及其相应类型的软件包),该软件包为我们提供了一些有用的模板标签,当我们从用户的问题和相关部分创建提示时,我们将在以后使用。

npm install common-tags
npm install @types/common-tags --save-dev

让我们简要讨论跑步时间。在Next.js的上下文中,运行时指执行过程中代码可用的一组库,API和一般功能。 Next.js支持Node.js and Edge runtimes。 (边缘运行时是偶然的,但与EdgedB无关。)

在两个运行时间内都支持流,但是使用Edge时实现更简单,因此我们将在此处使用。边缘运行时基于Web API。由于资源的最小使用,它的延迟非常低,但不利的一面是它不支持本机node.js apis。

我们将从导入管理器中需要的模块并编写一些配置开始。

像以前一样,您可能需要阅读以了解和复制/粘贴本节末尾的完整路线。

// app/api/generate-answer/route.ts

import { stripIndents, oneLineTrim } from "common-tags";
import * as edgedb from "edgedb";
import e from "dbschema/edgeql-js";
import { errors } from "../../constants";
import { initOpenAIClient } from "@/utils";

export const runtime = "edge";

const openai = initOpenAIClient();

const client = edgedb.createHttpClient();

export async function POST(req: Request) {
    // …
}

// other functions that are called inside POST handler

第一个导入是我们之前安装的common-tags库中的模板。然后,我们导入EdgedB绑定。第三个导入是我们先前描述的查询构建器。我们还导入错误和OpenAI API客户端初始化功能。

通过导出runtime,我们覆盖了该处理程序的下一个默认值。

我们现在准备为HTTP POST请求编写处理程序功能。要在Next.js中执行此操作,您导出一个以您要处理的请求方法命名的函数。

我们的帖子处理程序调用了我们还无法定义的其他功能,但是我们以后会回到他们。

// app/api/generate-answer/route.ts

// …

export async function POST(req: Request) {
  try {
    const { query } = await req.json();
    const sanitizedQuery = query.trim();

    const flagged = await isQueryFlagged(query);

    if (flagged) throw new Error(errors.flagged);

    const embedding = await getEmbedding(query);

    const context = await getContext(embedding);

    const prompt = createFullPrompt(sanitizedQuery, context);

    const answer = await getOpenAiAnswer(prompt);

    return new Response(answer.body, {
      headers: {
        "Content-Type": "text/event-stream",
      },
    });
  } catch (error: any) {
    console.error(error);

    const uiError = error.message || errors.default;

    return new Response(uiError, {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
}

我们的处理程序将通过几个不同的步骤运行用户的问题。

  1. 我们检查查询是否符合Openai的usage policies,这意味着它不应包括任何可恨,骚扰或暴力内容。这是由我们的isQueryFlagged函数处理的。
  2. 如果查询失败,我们会投掷。如果它通过,我们使用OpenAI嵌入API生成嵌入式。这是由我们的getEmbedding函数处理的。
  3. 我们从EdgedB数据库中获取相关的文档部分。这是由getContext处理的。
  4. 我们通过组合问题,相关文档部分和系统消息来创建完整的提示作为对聊天完成API的输入。

系统消息是回答任何问题时应遵循的语言模型的一般指令。

使用已完全准备的输入,我们使用先前生成的提示符调用聊天完成API,然后将我们从OpenAI获得的响应传输到用户。为了使用流媒体,我们需要提供适当的content-type标头:"text/event-stream"。 (您可以在选项对象中看到传递给Response构造函数的对象。)

为了使事情变得简单,我们将其中的大多数包裹在一个try/catch块中。如果发生任何错误,我们会以状态500将错误消息发送给用户。实际上,您可能需要根据结果将其拆分并使用不同的状态代码响应。例如,如果调节请求返回错误,则可能需要发送回400响应状态(不良请求),而不是500(内部服务器错误)。

现在,您可以广泛地看到我们在此处理程序中所做的事情,让我们挖掘我们所调用的每个功能。

审核请求

让我们看一下我们的审核请求功能:isQueryFlagged。我们将使用openai.moderations.create方法。

// app/api/generate-answer/route.ts

async function isQueryFlagged(query: string) {
  const moderation = await openai.moderations.create({
    input: query,
  });

  const [{ flagged }] = moderation.results;

  return flagged;
}

该函数非常简单:它需要问题(query参数),向API发射审核请求,从结果中解开flagged,然后返回。

如果API找到了用户问题的问题,则响应将使flagged属性设置为true。在这种情况下,我们将在处理程序中将一般错误置于处理程序中,但是您还可以检查响应以查找哪些类别有问题,并在错误中包含更多信息。

如果问题通过了节制,那么我们可以生成问题的嵌入。

嵌入生成请求

对于嵌入式请求,我们将在称为getEmbedding的新功能中调用openai.embeddings.create方法。

// app/api/generate-answer/route.ts

async function getEmbedding(query: string) {
  const embeddingResponse = await openai.embeddings.create({
    model: "text-embedding-ada-002",
    input: query.replaceAll("\n", " "),
  });

  const [{ embedding }] = embeddingResponse.data;

  return embedding;
}

此新功能再次提出问题(作为query)。我们调用OpenAI库embeddings.create方法,指定用于生成的模型(选项传递给该方法的model属性)并传递输入(query,所有Newlines均由单个空间代替)。

)。

获取相关文档部分请求

让我们查看数据库查询,该查询将使我们在名为getSectionsQuery的变量中退回相关部分。

// app/api/generate-answer/route.ts

const getSectionsQuery = e.params(
    {
        target: e.OpenAIEmbedding,
        matchThreshold: e.float64,
        matchCount: e.int16,
        minContentLength: e.int16,
    },
    (params) => {
        return e.select(e.Section, (section) => {
        const dist = e.ext.pgvector.cosine_distance(
            section.embedding,
            params.target
        );
        return {
            content: true,
            tokens: true,
            dist,
            filter: e.op(
                e.op(
                  e.len(section.content),
                  ">",
                  params.minContentLength
                ),
                "and",
                e.op(dist, "<", params.matchThreshold)
            ),
            order_by: {
                expression: dist,
                empty: e.EMPTY_LAST,
            },
            limit: params.matchCount,
        };
        });
    }
);

在上面的代码中,我们使用EdgedB的打字稿查询构建器来创建查询。查询需要一些参数:

  • target:嵌入数组以比较以查找相关部分。在这种情况下,这些将是我们刚刚产生的问题的嵌入。
  • matchThreshold:相似性阈值。仅以低于此阈值的相似性得分匹配。这将是0.02.0之间的数字。接近0.0的值意味着文档部分必须与问题非常相似,而靠近2.0的值则允许更多差异。
  • matchCount:返回的最大部分
  • minContentLength:为了考虑该部分应具有的最小字符数量

我们通过调用e.select并将其传递给我们要选择的类型(e.Section)来编写选择查询。我们从该函数返回一个代表我们想要的形状的对象以及我们需要的任何其他条款:在这种情况下,滤波器,订购和限制子句。

我们使用cosine_distance函数来计算用户问题和我们的文档部分之间的相似性。我们可以通过EdgedB的PGVECTOR扩展来访问此功能。然后,我们通过将其在执行查询时将通过的matchThreshold值进行过滤。

我们希望恢复每个通过过滤器子句的相关部分的内容和代币数(即,比minContentLength代币还多,距离嵌入小于我们的matchThreshold的问题的距离)。我们希望通过它们与问题的关系(表示为dist),以升级顺序(默认为默认顺序)订购结果,并最多可以返回matchCount部分。

我们写了查询,但是它会帮助我们直到执行它。我们将在getContext函数中做到这一点。

// app/api/generate-answer/route.ts

async function getContext(embedding: number[]) {
    const sections = await getSectionsQuery.run(client, {
        target: embedding,
        matchThreshold: 0.3,
        matchCount: 8,
        minContentLength: 20,
    });

    let tokenCount = 0;
    let context = "";

    for (let i = 0; i < sections.length; i++) {
        const section = sections[i];
        const content = section.content;
        tokenCount += section.tokens;

        if (tokenCount >= 1500) {
            tokenCount -= section.tokens;
            break;
        }

        context += `${content.trim()}\n---\n`;
    }

    return context;
}

此函数将问题的嵌入(embedding参数)填充并返回相关文档部分。

我们首先运行查询并传递一些参数的值:

  • 传递给函数的问题嵌入
  • matchThreshold 0.3的值。如果您不喜欢结果,则可以修改。
  • a matchCount。我们在这里选择了8,代表我们将回来的最多部分。
  • 20个字符的minContentLength

然后,我们迭代返回的部分,以准备将它们发送到聊天完成API。这涉及将当前部分的令牌计数增加,确保整体代币计数不超过我们上下文的最大值(以LLM的代币限制),并且如果令牌计数不超过。 t超过了,将本节的修剪内容添加到context中,我们最终将返回。由于我们通过dist升序订购了此查询,并且较低的dist值表示更多相似的部分,因此我们一定会在达到令牌限制之前获得最相似的部分。

准备好上下文,是时候让我们的用户答案了。

聊天完成请求

在提出完成请求之前,我们将构建由用户问题,相关文档和系统消息组成的完整输入。系统消息应告诉语言模型在回答问题时要使用哪种音调以及有关其期望的一些一般说明。有了您,您可以给它一些个性,它将烘烤每个回应。我们将所有这些部分结合在称为createFullPrompt的功能中。

// app/api/generate-answer/route.ts

function createFullPrompt(query: string, context: string) {
    const systemMessage = `
        As an enthusiastic EdgeDB expert keen to assist,
        respond to questions referencing the given EdgeDB
        sections.

        If unable to help based on documentation, respond
        with: "Sorry, I don't know how to help with that."`;

    return stripIndents`
        ${oneLineTrim`${systemMessage}`}

        EdgeDB sections: """
        ${context}
        """

        Question: """
        ${query}
        """`;
}

此功能将问题(如query)和相关文档(如context)与系统消息相结合,并将其格式化为很好,以便在聊天完成API中轻松消费。

我们将通过该函数作为参数返回的提示到新功能(getOpenAiAnswer),该功能将从OpenAI中获得答案并返回。

// app/api/generate-answer/route.ts

async function getOpenAiAnswer(prompt: string) {
  const completion = await openai.chat.completions
    .create({
      model: "gpt-3.5-turbo",
      messages: [{ role: "user", content: prompt }],
      max_tokens: 1024,
      temperature: 0.1,
      stream: true,
    })
    .asResponse();

  return completion;
}

让我们看一下我们发送的选项:

  • model:我们希望聊天完成API在回答问题时使用的语言模型。 (如果您可以访问它,也可以使用gpt-4使用。)
  • messages:我们将提示作为消息属性的一部分发送。可以使用role: system在数组的第一个对象上发送系统消息,但是由于我们也将上下文部分作为输入的一部分,我们只会将所有内容发送给ware user
  • max_tokens:用于答案的最大令牌数量。
  • temperature:0到2之间的数字。从OpenAI’s create chat completion endpoint documentation:较高的值等值将使输出更随机,而较低的值等于0.2将使其更加集中和确定性。”
  • stream:将其设置为true将具有API流响应

完整的路线

现在,让我们看看整个事情。复制并将其粘贴到您的app/api/generate-answer/route.ts文件中。

// app/api/generate-answer/route.ts

import { stripIndents, oneLineTrim } from "common-tags";
import * as edgedb from "edgedb";
import e from "dbschema/edgeql-js";
import { errors } from "../../constants";
import { initOpenAIClient } from "@/utils";

export const runtime = "edge";

const openai = initOpenAIClient();

const client = edgedb.createHttpClient();

export async function POST(req: Request) {
  try {
    const { query } = await req.json();
    const sanitizedQuery = query.trim();

    const flagged = await isQueryFlagged(query);

    if (flagged) throw new Error(errors.flagged);

    const embedding = await getEmbedding(query);

    const context = await getContext(embedding);

    const prompt = createFullPrompt(sanitizedQuery, context);

    const answer = await getOpenAiAnswer(prompt);

    return new Response(answer.body, {
      headers: {
        "Content-Type": "text/event-stream",
      },
    });
  } catch (error: any) {
    console.error(error);

    const uiError = error.message || errors.default;

    return new Response(uiError, {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
}

async function isQueryFlagged(query: string) {
  const moderation = await openai.moderations.create({
    input: query,
  });

  const [{ flagged }] = moderation.results;

  return flagged;
}

async function getEmbedding(query: string) {
  const embeddingResponse = await openai.embeddings.create({
    model: "text-embedding-ada-002",
    input: query.replaceAll("\n", " "),
  });

  const [{ embedding }] = embeddingResponse.data;

  return embedding;
}

const getSectionsQuery = e.params(
  {
    target: e.OpenAIEmbedding,
    matchThreshold: e.float64,
    matchCount: e.int16,
    minContentLength: e.int16,
  },
  (params) => {
    return e.select(e.Section, (section) => {
      const dist = e.ext.pgvector.cosine_distance(
        section.embedding,
        params.target
      );
      return {
        content: true,
        tokens: true,
        dist,
        filter: e.op(
          e.op(
            e.len(section.content),
            ">",
            params.minContentLength
          ),
          "and",
          e.op(dist, "<", params.matchThreshold)
        ),
        order_by: {
          expression: dist,
          empty: e.EMPTY_LAST,
        },
        limit: params.matchCount,
      };
    });
  }
);

async function getContext(embedding: number[]) {
  const sections = await getSectionsQuery.run(client, {
    target: embedding,
    matchThreshold: 0.3,
    matchCount: 8,
    minContentLength: 20,
  });

  let tokenCount = 0;
  let context = "";

  for (let i = 0; i < sections.length; i++) {
    const section = sections[i];
    const content = section.content;
    tokenCount += section.tokens;

    if (tokenCount >= 1500) {
      tokenCount -= section.tokens;
      break;
    }

    context += `${content.trim()}\n---\n`;
  }

  return context;
}

function createFullPrompt(query: string, context: string) {
  const systemMessage = `
        As an enthusiastic EdgeDB expert keen to assist,
        respond to questions referencing the given EdgeDB
        sections.

        If unable to help based on documentation, respond
        with: "Sorry, I don't know how to help with that."`;

  return stripIndents`
        ${oneLineTrim`${systemMessage}`}

        EdgeDB sections: """
        ${context}
        """

        Question: """
        ${query}
        """`;
}

async function getOpenAiAnswer(prompt: string) {
  const completion = await openai.chat.completions
    .create({
      model: "gpt-3.5-turbo",
      messages: [{ role: "user", content: prompt }],
      max_tokens: 1024,
      temperature: 0.1,
      stream: true,
    })
    .asResponse();

  return completion;
}

路线完成,我们可以构建UI并将所有内容连接在一起。

构建UIâ

为了使事情尽可能简单,我们只会更新app/page.tsx文件中的Home组件。默认情况下,应用程序路由器中的所有组件都是服务器组件,但是我们希望具有客户端的交互性和动态更新。为此,我们必须为我们的Home组件使用客户端组件。实现这一目标的方法是将page.tsx文件转换为使用客户端组件。我们通过将use client指令添加到文件顶部来做到这一点。

遵循本节末尾的理解和复制/粘贴完整组件代码。

// app/page.tsx

"use client";

现在我们为聊天机器人构建一个简单的UI。

// app/page.tsx

import { useState } from "react";
import { errors } from "./constants";

export default function Home() {
    const [prompt, setPrompt] = useState("");
    const [question, setQuestion] = useState("");
    const [answer, setAnswer] = useState<string>("");
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<string | undefined>(undefined);

    const handleSubmit = () => {};

    return (
    <main className="w-screen h-screen flex items-center justify-center bg-[#2e2e2e]">
        <form className="bg-[#2e2e2e] w-[540px] relative">
        <input
            className={`py-5 pl-6 pr-[40px] rounded-md bg-[#1f1f1f] w-full
            outline-[#1f1f1f] focus:outline outline-offset-2 text-[#b3b3b3]
            mb-8 placeholder-[#4d4d4d]`}
            placeholder="Ask a question..."
            value={prompt}
            onChange={(e) => {
              setPrompt(e.target.value);
            }}
        ></input>
        <button
            onClick={handleSubmit}
            className="absolute top-[25px] right-4"
            disabled={!prompt}
        >
            <ReturnIcon
            className={`${!prompt ? "fill-[#4d4d4d]" : "fill-[#1b9873]"}`}
            />
        </button>
        <div className="h-96 px-6">
            {question && (
            <p className="text-[#b3b3b3] pb-4 mb-8 border-b border-[#525252] ">
                {question}
            </p>
            )}
            {(isLoading && <LoadingDots />) ||
            (error && <p className="text-[#b3b3b3]">{error}</p>) ||
            (answer && <p className="text-[#b3b3b3]">{answer}</p>)}
        </div>
        </form>
    </main>
    );
}

function ReturnIcon({ className }: { className?: string }) {
    return (
        <svg
            width="20"
            height="12"
            viewBox="0 0 20 12"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
            className={className}
        >
            <path
            fillRule="evenodd"
            clipRule="evenodd"
            d={`M12 0C11.4477 0 11 0.447715 11 1C11 1.55228 11.4477 2 12
            2H17C17.5523 2 18 2.44771 18 3V6C18 6.55229 17.5523 7 17
            7H3.41436L4.70726 5.70711C5.09778 5.31658 5.09778 4.68342 4.70726
            4.29289C4.31673 3.90237 3.68357 3.90237 3.29304 4.29289L0.306297
            7.27964L0.292893 7.2928C0.18663 7.39906 0.109281 7.52329 0.0608469
            7.65571C0.0214847 7.76305 0 7.87902 0 8C0 8.23166 0.078771 8.44492
            0.210989 8.61445C0.23874 8.65004 0.268845 8.68369 0.30107
            8.71519L3.29289 11.707C3.68342 12.0975 4.31658 12.0975 4.70711
            11.707C5.09763 11.3165 5.09763 10.6833 4.70711 10.2928L3.41431
            9H17C18.6568 9 20 7.65685 20 6V3C20 1.34315 18.6568 0 17 0H12Z`}
            />
        </svg>
    );
}

function LoadingDots() {
    return (
        <div className="grid gap-2">
            <div className="flex items-center space-x-2 animate-pulse">
            <div className="w-1 h-1 bg-[#b3b3b3] rounded-full"></div>
            <div className="w-1 h-1 bg-[#b3b3b3] rounded-full"></div>
            <div className="w-1 h-1 bg-[#b3b3b3] rounded-full"></div>
            </div>
        </div>
    );
}

我们创建了一个输入字段,用户可以在其中输入问题。输入字段中用户类型的文本被捕获为promptquestion是提交的提示,我们在用户提交问题时在输入下显示。我们清除输入并在用户提交时删除提示,但请保留question值,以便用户可以参考。

让我们看一下我们之前固定在的肉体表单提交处理程序功能:

// app/page.tsx

const handleSubmit = (
  e: KeyboardEvent | React.MouseEvent<HTMLButtonElement>
) => {
  e.preventDefault();

  setIsLoading(true);
  setQuestion(prompt);
  setAnswer("");
  setPrompt("");
  generateAnswer(prompt);
};

用户提交问题时,我们将isLoading状态设置为true并显示加载指示器。我们清除及时状态并设置问题状态。我们也清除了答案状态,因为答案可能会对上一个问题有一个答案,但我们想从空空心开始。

此时,我们要创建一个服务器量事件,并向我们的api/generate-answer路线发送请求。我们将在generateAnswer函数中执行此操作。

浏览器本地SSE API不允许客户端向服务器发送有效载荷;客户端只能通过GET请求打开与服务器的连接,以开始从中接收事件。为了使客户能够通过POST请求发送有效载荷以打开SSE连接,我们将使用sse.js软件包,所以让我们安装它。

npm install sse.js

此软件包没有相应的类型软件包,因此我们需要手动添加它们。让我们在项目root中创建一个名为types的新文件夹,其中一个sse.d.ts文件。

mkdir types && touch types/sse.d.ts

打开sse.d.ts并添加此代码:

// types/sse.d.ts

type SSEOptions = EventSourceInit & {
    payload?: string;
};

declare module "sse.js" {
    class SSE extends EventSource {
        constructor(url: string | URL, sseOptions?: SSEOptions);
        stream(): void;
    }
}

这通过向构造函数添加有效载荷来扩展本机EventStream。我们还向其添加了stream函数,用于激活sse.js库中的流。

这使我们可以在page.tsx中导入SSE,并使用它打开与处理程序路由的连接,同时也发送了用户的查询。

// app/page.tsx
"use client";

import { useState } from "react";
import { useState, useRef } from "react";
import { SSE } from "sse.js";
import { errors } from "./constants";

export default function Home() {
    const eventSourceRef = useRef<SSE>();

    const [prompt, setPrompt] = useState("");
    const [question, setQuestion] = useState("");
    const [answer, setAnswer] = useState<string>("");
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<string | undefined>(undefined);

    const handleSubmit = () => {};

    const generateAnswer = async (query: string) => {
        if (eventSourceRef.current) eventSourceRef.current.close();

        const eventSource = new SSE(`api/generate-answer`, {
            payload: JSON.stringify({ query }),
        });
        eventSourceRef.current = eventSource;

        eventSource.onerror = handleError;
        eventSource.onmessage = handleMessage;
        eventSource.stream();
    };

    handleError() { /* … */ }
    handleMessage() { /* … */ }
// …

请注意,我们保存对eventSource对象的引用。如果用户提交一个新问题,我们需要这一点,而回答上一个问题仍在客户端上。如果我们在打开新的连接之前没有关闭与服务器的现有连接,则可能会引起问题,因为两个连接将打开并尝试接收数据。

我们打开了与服务器的连接,现在我们准备从中接收事件。我们只需要为这些事件编写处理程序,以便UI知道该如何处理它们。我们将获得答案作为消息事件的一部分,如果返回错误,服务器将向客户端发送错误事件。

让他们分解这些处理程序。

// app/page.tsx

// …

function handleError(err: any) {
    setIsLoading(false);

    const errMessage =
    err.data === errors.flagged ? errors.flagged : errors.default;

    setError(errMessage);
}

function handleMessage(e: MessageEvent<any>) {
    try {
        setIsLoading(false);
        if (e.data === "[DONE]") return;

        const chunkResponse = JSON.parse(e.data);
        const chunk = chunkResponse.choices[0].delta?.content || "";
        setAnswer((answer) => answer + chunk);
    } catch (err) {
        handleError(err);
    }
}

当我们获得消息事件时,我们会从中提取数据,并将其添加到answer状态,直到我们收到所有块为止。当数据等于[DONE]时,这表明这是指收到的整个答案,并且将关闭服务器的连接。在这种情况下,没有数据要解析,因此我们返回而不是试图解析它。 (如果我们在这种情况下试图解析它,将丢弃错误。)

已完成的UIâ

将所有这些放在一起,然后您将其放在一起(可以复制/粘贴到app/page.tsx):

// app/page.tsx

"use client";

import { useState, useRef } from "react";
import { SSE } from "sse.js";
import { errors } from "./constants";

export default function Home() {
  const eventSourceRef = useRef<SSE>();

  const [prompt, setPrompt] = useState("");
  const [question, setQuestion] = useState("");
  const [answer, setAnswer] = useState<string>("");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);

  const handleSubmit = (
    e: KeyboardEvent | React.MouseEvent<HTMLButtonElement>
  ) => {
    e.preventDefault();

    setIsLoading(true);
    setQuestion(prompt);
    setAnswer("");
    setPrompt("");
    generateAnswer(prompt);
  };

  const generateAnswer = async (query: string) => {
    if (eventSourceRef.current) eventSourceRef.current.close();

    const eventSource = new SSE(`api/generate-answer`, {
      payload: JSON.stringify({ query }),
    });
    eventSourceRef.current = eventSource;

    eventSource.onerror = handleError;
    eventSource.onmessage = handleMessage;
    eventSource.stream();
  };

  function handleError(err: any) {
    setIsLoading(false);

    const errMessage =
      err.data === errors.flagged ? errors.flagged : errors.default;

    setError(errMessage);
  }

  function handleMessage(e: MessageEvent<any>) {
    try {
      setIsLoading(false);
      if (e.data === "[DONE]") return;

      const chunkResponse = JSON.parse(e.data);
      const chunk = chunkResponse.choices[0].delta?.content || "";
      setAnswer((answer) => answer + chunk);
    } catch (err) {
      handleError(err);
    }
  }

  return (
    <main className="w-screen h-screen flex items-center justify-center bg-[#2e2e2e]">
      <form className="bg-[#2e2e2e] w-[540px] relative">
        <input
          className={`py-5 pl-6 pr-[40px] rounded-md bg-[#1f1f1f] w-full
            outline-[#1f1f1f] focus:outline outline-offset-2 text-[#b3b3b3]
            mb-8 placeholder-[#4d4d4d]`}
          placeholder="Ask a question..."
          value={prompt}
          onChange={(e) => {
            setPrompt(e.target.value);
          }}
        ></input>
        <button
          onClick={handleSubmit}
          className="absolute top-[25px] right-4"
          disabled={!prompt}
        >
          <ReturnIcon
            className={`${!prompt ? "fill-[#4d4d4d]" : "fill-[#1b9873]"}`}
          />
        </button>
        <div className="h-96 px-6">
          {question && (
            <p className="text-[#b3b3b3] pb-4 mb-8 border-b border-[#525252] ">
              {question}
            </p>
          )}
          {(isLoading && <LoadingDots />) ||
            (error && <p className="text-[#b3b3b3]">{error}</p>) ||
            (answer && <p className="text-[#b3b3b3]">{answer}</p>)}
        </div>
      </form>
    </main>
  );
}

function ReturnIcon({ className }: { className?: string }) {
  return (
    <svg
      width="20"
      height="12"
      viewBox="0 0 20 12"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      className={className}
    >
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d={`M12 0C11.4477 0 11 0.447715 11 1C11 1.55228 11.4477 2 12
            2H17C17.5523 2 18 2.44771 18 3V6C18 6.55229 17.5523 7 17
            7H3.41436L4.70726 5.70711C5.09778 5.31658 5.09778 4.68342 4.70726
            4.29289C4.31673 3.90237 3.68357 3.90237 3.29304 4.29289L0.306297
            7.27964L0.292893 7.2928C0.18663 7.39906 0.109281 7.52329 0.0608469
            7.65571C0.0214847 7.76305 0 7.87902 0 8C0 8.23166 0.078771 8.44492
            0.210989 8.61445C0.23874 8.65004 0.268845 8.68369 0.30107
            8.71519L3.29289 11.707C3.68342 12.0975 4.31658 12.0975 4.70711
            11.707C5.09763 11.3165 5.09763 10.6833 4.70711 10.2928L3.41431
            9H17C18.6568 9 20 7.65685 20 6V3C20 1.34315 18.6568 0 17 0H12Z`}
      />
    </svg>
  );
}

function LoadingDots() {
  return (
    <div className="grid gap-2">
      <div className="flex items-center space-x-2 animate-pulse">
        <div className="w-1 h-1 bg-[#b3b3b3] rounded-full"></div>
        <div className="w-1 h-1 bg-[#b3b3b3] rounded-full"></div>
        <div className="w-1 h-1 bg-[#b3b3b3] rounded-full"></div>
      </div>
    </div>
  );
}

这样,UI现在可以从Next.js路由获得答案。构建已经完成,该尝试一下了!

测试它

您现在应该能够运行该项目进行测试。

npm run dev

如果您使用了我们的示例文档,则聊天机器人将了解有关EdgeQL的几件事以及训练的内容。

您可能会尝试的一些问题:

  • 是edgeql?
  • 谁是edgeql?
  • 我应该如何开始edgeql?

如果您不喜欢得到的回答,这是您可能尝试调整的几件事:

  • systemMessagecreateFullPrompt功能中
  • temperaturegetOpenAiAnswer中的app/api/generate-answer/route.ts
  • matchThreshold值从app/api/generate-answer/route.ts中的getContext函数传递给查询

您可以在our examples repo on GitHub中看到此构建的完成源代码。您可能还会发现我们的实际实施很有趣。您会在our website repo中找到它。密切注意嵌入一代发生的buildTools/gpt的内容,而components/gpt(包含我们聊天机器人的大部分UI)。

如果您在构建方面遇到麻烦或只是想与其他EdgedB用户一起出去玩,请加入our awesome community on Discord