使用Node.js和Langchain的语义搜索
#ai #node #nextjs #langchain

Langchain是一个创新的图书馆,最近突然进入了AI场景。它可用于JavaScript和Python,是一种多功能工具,旨在简化大型语言模型(LLMS)与hnswlib等其他实用程序的集成,从而促进了您的AI应用程序的迅速开发。

>

在今天的博客文章中,我们将利用Langchain的力量来构建紧凑的电影搜索应用程序。

此应用程序的美在于它使用HNSWLIB矢量商店中存储的OpenAI嵌入。将嵌入式与兰班链进行比较变得轻而易举,我们将与Next.js构建动态的,互动的前端。

如果您认为这很多,请坚持下去,我们将经历所有事情,您会意识到构建自己的语义搜索引擎是多么容易。

所以让我们开始,您将看到Langchain如何使用很少的代码来增强AI应用程序开发旅程。

什么是语义搜索?

在我们深入研究实施语义搜索的复杂性之前,至关重要的是,我们首先了解其在数据检索领域的本质和意义。

语义搜索之前

在语义前搜索时代,我们的搜索功能仅限于确切的匹配或正则表达式的利用。

用户进行搜索时,我们的系统返回的结果与输入的短语非常匹配。

但是,这种方法的一个重大缺点是缺乏理解。我们的服务器不理于单词的语义,纯粹是基于文本相似的搜索。

这意味着即使是较小的拼写错误也可能使整个搜索过程脱轨,从而产生无关的结果,或者更糟糕的是,根本没有。

语义搜索

随着语义搜索的出现,好像我们的服务器已经获得了新发现的理解。

他们不再简单地基于文本字符匹配数据,而是理解单词含义及其与其他单词在上下文中的联系。

搜索结果现在根据搜索查询背后的目的过滤。

我们将在此博客文章中深入研究的信息检索方法。

我们将探索语义搜索的力量,并演示如何通过一系列简单的步骤实现此高级工具。

什么是嵌入?

在机器学习的背景下,嵌入是一种分类数据的表示形式,例如单词或短语,以保留它们之间的语义关系的方式。

它们是每个点对应于特定类别或单词的向量(或数字数组)。这些向量位于高维空间中,向量之间的“距离”和“方向”捕获了相应的单词或类别之间的关系。

在自然语言处理方面,嵌入使我们可以将单词或短语转换为机器学习算法可以理解的形式。

嵌入的美丽是它们捕获的不仅仅是单词的独立含义 - 它们还封装了不同单词之间存在的上下文和语义关系。

这使得它们在处理语义搜索之类的任务时,理解上下文和关系至关重要。

为了我们的电影搜索应用程序,我们将利用Openai的嵌入 - 特别是Ada-2 model

由于Openai的效率和成本效益,该模型强烈推荐。它使我们能够将搜索查询和电影标题转换为有意义的向量,以捕获所涉及的单词的语义。

一旦我们有了这些嵌入,我们将将它们存储在Hnswlib Vector Store-一个用于最近邻居搜索的高性能库。

使用Langchain库简单地制作此任务,该库提供了LLMS与HNSWLIB(例如HNSWLIB)之间的无缝集成。这种强大的技术组合为我们应用程序的语义搜索功能奠定了基础。

什么是hnswlib?

hnswlib或层次可导航的小世界图库,是一个开源,高效的近似近似邻居(ANN)搜索库,编写了C ++。

它实现了层次可通航的小世界(HNSW)算法,该算法以其在高维空间中的出色速度和准确性而闻名。

Ann Search位于HNSW算法的核心,是在数据集中找到最接近给定点的数据点的问题。

执行最近的邻居搜索的传统方法在计算上可能很昂贵,尤其是在处理高维数据集时。

这是近似方法(例如Hnswlib中实现的方法)进来 - 它们在速度,准确性和内存使用之间提供了良好的权衡。

在我们的电影搜索应用程序中,我们将利用Hnswlib作为矢量商店。这意味着我们将在HNSWLIB索引中存储OpenAI嵌入(本质上是代表搜索查询和电影标题的高维矢量)。

当用户执行搜索时,我们可以快速找到其嵌入到搜索查询嵌入的电影 - 因此,有效地实现语义搜索。

此外,Hnswlib与Langchain库的集成使我们能够以有效且精简的方式处理嵌入的存储和检索,为有效且响应的语义搜索铺平了道路。

虽然Hnswlib是存储和检索嵌入的有力选择,但在使用Langchain Library时,这并不是您可以使用的唯一选择。

Langchain提供了许多矢量商店选项的无缝集成,每个选项都具有独特的优势,使您可以选择最适合您特定需求的媒介。

这是您可能考虑的一些替代方案:

  • 内存向量存储
  • Chroma
  • Elasticsearch
  • FAISS
  • lancedb
  • 风筝
  • mongodb atlas

语义搜索如何工作?

语义搜索表示信息检索的范式转移。它旨在通过了解搜索者的意图和术语中出现在可搜索数据台式中的术语的上下文含义来提高搜索准确性。

要了解语义搜索的运作方式,让我们以电影搜索应用程序为例。

首先,我们在数据库中为每个电影标题生成嵌入式。这些嵌入本质上是电影标题的数值表示和与同一电影有关的其他数据。

例如:电影中会发生什么?还是主角成功做什么?您提供的有关生成嵌入的信息越多,语义搜索的效率就越有效,用户可以搜索具有非常不同的查询的同一电影。

例如,如果您将整个电影故事嵌入嵌入,那么人们就可以通过描述故事的某些部分并获得他们试图找到的确切电影来轻松搜索电影。

由于嵌入是高维向量的,因此它们往往很大,并且传统的处理此类数据的方法(例如,通过典型的关系数据库(例如PostgreSQL)或像MongoDB这样的文档数据库进行搜索,阅读和循环)可以是资源 - 密集型。

相反,我们将这些嵌入在Hnswlib等专业矢量商店中存储。向量存储旨在有效处理高维数据。他们利用大约最近的邻居(ANN)算法,这使我们能够快速,准确地搜索这些嵌入。

一旦我们为数据库中的每个电影标题生成并存储了嵌入,下一步就是处理用户的搜索查询。

当用户输入搜索词时,我们以与电影标题相同的方式生成了这个术语的嵌入。然后将此嵌入再次捕获用户查询的语义,然后将其与存储的电影标题嵌入进行比较。

通过将用户的搜索查询嵌入与电影标题嵌入进行比较,我们可以评估它们的相似性。

此比较不仅涉及与确切的短语匹配,而且还考虑了单词的含义和上下文。然后,系统将最相似的电影标题与搜索结果返回。

本质上,我们的服务器正在理解用户的意图,而不仅仅是匹配字符字符串。

通过此过程,语义搜索提供了一种更细微,准确,有效的信息检索方法。它通过解释用户的意图和搜索查询背后的上下文提供了更丰富,更直观的搜索体验。

入门

在我们开始使用Node.js和Langchain进行语义搜索之前,请务必确保正确设置开发环境。

首先,应该在PC上安装node.js。如果您还没有安装它,则可以从官方 Node.js website

下载它。

安装了node.js后,我们将设置一个新的next.js应用程序。为此,打开一个终端并运行以下命令:

npx create-next-app@latest

这将触发带有几个选项的提示。为了我们的教程,我们将使用应用程序路由器和启用打字稿,以及具有ESLINT功能的parwindcss。根据您的首选项选择选项。

接下来,我们需要为我们的项目安装必要的库。在您的next.js应用程序的根目录中打开一个终端,并使用以下命令安装每个库:

yarn add langchain hnswlib-node gpt-3-encoder

注意:如果您使用的是Windows PC,则可能需要安装Visual Studio,然后才能正确构建 hnswlib-node 软件包。这是由于软件包中包含的本机代码。

通过上述步骤,您将在安装所有必需的库中设置并准备就绪。在以下各节中,我们将深入研究如何利用这些工具来构建我们的语义搜索应用程序。

数据库

对于我们的教程中的数据库,我们将为简单起见使用JSON文件。让我们称此文件 movies.json 。我从this open-source repository获得了电影数据(感谢它们),并根据我们的需求进行了重组。

movies.json文件将存储一系列电影,每个电影由对象表示。每个电影对象的结构如下:

[
  {
    "id": "a8c1479b-dd6b-488f-970b-ec395cf1798b",
    "name": "The Wizard of Oz",
    "year": 1925,
    "actors": [
      "Dorothy Dwan",
      "Mary Carr",
      "Virginia Pearson"
    ],
    "storyline": "Dorothy, heir to the Oz throne, must take it back from the wicked Prime Minister Kruel with the help of three farmhands."
  },
]

您可以将任意多的电影对象添加到 movies.json 文件中。只需确保它们遵循与上面示例相同的结构。

在进行过程中,我们将在我们的 movies.json 数据库中生成并存储每个电影属性的嵌入。这些嵌入将使我们能够实施我们的语义搜索。

生成嵌入

在我们开始在应用程序中提供搜索请求之前,我们需要为数据库中的每个电影生成嵌入式,并将其保存到矢量存储中。这些嵌入将为我们的语义搜索功能提供基础。

估计OpenAI嵌入价格

如果您想知道生成嵌入的成本是多少,那么这里是一小部分代码来计算它。

请记住,仅对ADA-2模型进行计算,如果您使用其他模型,请更改第二个参数的值:

import { encode } from "gpt-3-encoder"

/**
 * @param text
 * @param pricePerToken Price per token (For ada v2 it is $0.0001/1k tokens = 0.0001/1000)
 * @returns Price in usd
 */
const estimatePrice = (text: string, pricePerToken = 0.0001 / 1000) => {
  const encoded = encode(text)

  const price = encoded.length * pricePerToken

  return price
}

export default estimatePrice

现在,我们有一个功能来估计编码给定文本的价格,让我们将其应用于我们的电影数据。

这是您可以使用 estimatePrice 功能来计算所有电影生成嵌入的总成本:

import movies from "../data/movies.json"
import estimatePrice from "./estimatePrice"

const run = async () => {
  const textsToEmbed = movies.map(
    (movie) =>
      `Title:${movie.name}\n\nyear: ${
        movie.year
      }\n\nactors: ${movie.actors.join(", ")}\n\nstoryline: ${
        movie.storyline
      }\n\n`
  )

  const price = estimatePrice(textsToEmbed.join("\n\n"))

  console.log(price)
}

run()

运行此脚本为我们提供了0.0040587的结果,该脚本不到1%。这种低成本使我们的应用程序使用OpenAI的服务非常负担得起。

让我们回到生成嵌入,这是您可以生成电影数据的嵌入方式:

// ./functions/generateEmbeddings.ts
require("dotenv").config()
import { HNSWLib } from "langchain/vectorstores/hnswlib"
import { OpenAIEmbeddings } from "langchain/embeddings/openai"
import movies from "../data/movies.json"

const generateEmbeddings = async () => {
  try {
    const start = performance.now() / 1000

    const textsToEmbed = movies.map(
      (movie) =>
        `Title:${movie.name}\n\nyear: ${
          movie.year
        }\n\nactors: ${movie.actors.join(", ")}\n\nstoryline: ${
          movie.storyline
        }\n\n`
    )

    const metadata = movies.map((movie) => ({ id: movie.id }))

    const embeddings = new OpenAIEmbeddings()

    const vectorStore = await HNSWLib.fromTexts(
      textsToEmbed,
      metadata,
      embeddings
    )

        // saves the embeddings in the ./movies directory in the root directory
    await vectorStore.save("movies")

    const end = performance.now() / 1000

    console.log(`Took ${(end - start).toFixed(2)}s`)
  } catch (error) {
    console.error(error)
  }
}

generateEmbeddings()

此脚本执行以下操作:

  • 它读取我们 movies.json 文件的电影列表。
  • 对于每部电影,它会产生一个组合标题,年份,演员和故事情节的单字符串。
  • 它创建了这些字符串的数组( textsToEmbed )和相关的元数据阵列( metadata )。元数据阵列中的每个元素都是一个对象, id 字段对应于电影的 id
  • 它创建了 OpenAIEmbeddings 的新实例,我们将使用它将文本转换为嵌入。
  • 它使用 fromTexts HNSWLib 类的方法来为 textsToEmbed 中的每个字符串生成嵌入,并将它们存储在新的矢量存储中。
  • 最后,它保存在“电影”目录中。

记住在运行此脚本之前,请在 .env 文件中设置OpenAI API密钥。钥匙应精确命名为“ OpenAi_Api_Key”,Langchain将自动拾取它。

通过遵循以下步骤,您可以确保数据库中的每个电影标题都有存储在矢量商店中的关联嵌入,可以在语义搜索中使用。

现在,我们将矢量商店保存在./movies目录中,并准备用于搜索电影。

创建搜索功能

使用 ./movies 目录中存储的嵌入式,我们现在准备在应用程序中创建语义搜索功能。

Langchain提供了一种在我们生成的嵌入式上执行相似性搜索的方便方法。

similaritySearch HNSWLib 类中的方法允许我们比较我们生成的嵌入量,并根据给定文本找到最相似的条目。

这个功能强大的功能构成了我们语义搜索引擎的骨干。

这是您可以创建执行相似性搜索的函数的方式:

require("dotenv").config()
import { OpenAIEmbeddings } from "langchain/embeddings/openai"
import { HNSWLib } from "langchain/vectorstores/hnswlib"

const search = async (text: string) => {
  try {
    const vectorStore = await HNSWLib.load("movies", new OpenAIEmbeddings())

    const results = await vectorStore.similaritySearch(text, 2) // returns only 2 entries

    results.forEach((r) => {
      console.log(r.pageContent.match(/Title:(.*)/)?.[0]) // Use regex to extract the title from the result text
    })
  } catch (error) {
    console.error(error)
  }
}

search("a tom cruise movie")

此功能将一串文本作为输入,从 ./movies 目录中加载我们存储的嵌入式,并在嵌入式上执行相似性搜索。

similaritySearch 方法的第二个参数指示我们想要找到的类似条目的数量 - 在这种情况下,我们要求提供前两个最相似的条目。

然后,我们记录了发现与输入文本最相似的电影的标题。在此示例中,输入文本是“汤姆·克鲁斯电影”。

运行此功能将输出:

Title:Mission: Impossible
Title:Mission Impossible III

这正是我们想要的!因此,我们在应用程序中成功实施了语义搜索。

搜索功能现在了解搜索查询的含义并找到最相关的结果,而不仅仅是在文本中寻找精确的匹配。

构建API

创建了语义搜索功能,我们现在准备实现一个允许客户使用此功能的API。我们将使用Next.js的路由处理程序构建此API。

要这样做,在 ./api/search/route.ts 中创建一个文件,然后添加以下代码:

import { OpenAIEmbeddings } from "langchain/embeddings/openai"
import { HNSWLib } from "langchain/vectorstores/hnswlib"
import { NextResponse } from "next/server"
import movies from "@/data/movies.json"

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)

  const q = searchParams.get("q")

  if (!q) {
    return new NextResponse(JSON.stringify({ message: "Missing query" }), {
      status: 400,
    })
  }

  const vectorStore = await HNSWLib.load("movies", new OpenAIEmbeddings())

  const searchResultIds = searchResult.map((r) => r.metadata.id)

  let results = movies.filter((movie) => searchResultIds.includes(movie.id))

  return NextResponse.json({ results })
}

这是代码的作用:

  • 我们定义了 GET 功能,该函数将获取请求到API。
  • 该功能从请求的URL中提取查询字符串 q
  • 该功能加载了我们存储的嵌入,并使用提供的查询字符串对其进行相似搜索。
  • 然后它过滤电影列表,仅包括与搜索结果元数据匹配的ID的电影。
  • 最后,该功能返回包含这些过滤电影的JSON响应。

此功能构成了我们API的核心。它允许客户根据提供的查询字符串的语义含义搜索电影。我们将结果数限制为前5个最相似的条目,以使我们的API响应可管理。

这是一个示例回应:

{
  "results": [
    {
      "id": "a203e314-7ce0-429e-945e-81c1ea905d9f",
      "name": "Mad Max 3: Beyond Thunderdome",
      "year": 1985,
      "actors": [
        "Mel Gibson",
        "Tina Turner"
      ],
      "storyline": "Max is exiled into the desert by the corrupt ruler of Bartertown, Aunty Entity, and there encounters an isolated cargo cult centered on a crashed Boeing 747 and its deceased captain."
    },
    ]
}

前端

Next.js和Tailwind CSS非常适合快速开发时尚和现代的Web应用程序。我们的电影搜索应用程序的用户界面(UI)很简单:我们需要一个搜索输入来输入查询,以及一个显示结果电影的空间。

首先,安装react-use库,该库提供各种有用的挂钩,但我们只需要useAsyncFn来处理异步功能。

您可以使用以下命令使用纱线安装它:

yarn add react-use

现在,让我们构建界面。这是一个基本的前端设置,它连接到我们以前创建的API,从 useAsyncFn 挂钩中的 react-use 软件包来处理异步API请求。这是设置的方法:

"use client"
import { useState } from "react"
import { useAsyncFn } from "react-use"

interface SearchResults {
  results: {
    id: string
    name: string
    year: number
    actors: string[]
    storyline: string
  }[]
}

export default function Home() {
  const [query, setQuery] = useState("")

  const [{ value, loading }, search] = useAsyncFn<() => Promise<SearchResults>>(
    async () => {
      const response = await fetch("/api/search?q=" + query);
      const data = await response.json();
      return data;
    },
    [query]
  )

  return (
    <main className="flex min-h-screen flex-col items-center p-5 lg:p-24 w-full mx-auto">
      <h1 className="text-4xl font-bold text-center">Search Movies</h1>

      <form
        onSubmit={(e) => {
          e.preventDefault()
          search()
        }}
      >
        <input
          type="text"
          name="search"
          className="border-2 border-gray-300 bg-black mt-3 h-10 px-5 pr-16 rounded-lg text-sm focus:outline-none"
          placeholder="Search anything"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
        />
      </form>

      <div className="mt-10">
        {loading ? (
          <div>Loading...</div>
        ) : (
          <div className="flex flex-wrap gap-5">
            {value?.results.map((movie) => (
              <div
                key={movie.id}
                className="flex flex-col bg-gray-800 rounded-lg shadow-lg p-5 w-full max-w-sm"
              >
                <h2 className="text-xl font-bold">{movie.name}</h2>
                <p className="text-sm">{movie.year}</p>
                <p className="text-sm">{movie.actors.join(", ")}</p>
                <p className="text-sm">{movie.storyline}</p>
              </div>
            ))}
          </div>
        )}
      </div>
    </main>
  )
}

此UI组件包括一个搜索栏和结果列表。当用户在搜索栏中键入查询并提交该查询时,向我们的API提出了请求,结果将显示在屏幕上。

重要的是要注意, useAsyncFn 挂钩自动为我们处理加载状态,显示一个加载消息,直到获取结果并准备好显示。

semantic search

您可以看到,现在我们的前端与API搭配得很好,我们的语义搜索功能已完全设置并且很好。

结论

在本教程中,我们探索了一种现代化的方法来构建有效和经济的语义搜索引擎。

我们使用Langchain和OpenAI嵌入式,以及Hnswlib存储嵌入,使我们能够为电影集创建语义搜索引擎。

这种方法展示了如何利用语言模型以实惠的价格提供强大的功能,这要归功于OpenAI的ADA V2模型的效率和Langchain库的便利性。

语义搜索的使用提供了更直观和用户友好的搜索体验,即使没有使用确切的措辞或关键字,也可以帮助用户找到更多相关的结果。

,但我们只是刮擦了可能的表面。存储数据的媒介商店有很多,每个矢量商店都提供了自己独特的功能和权衡。

例如,

elasticsearch是一个选项,可以启用全文搜索功能和复杂的查询选项。

随着AI技术的继续发展和发展,我们可以实现的可能性也是如此。无论是构建语义搜索引擎,推荐系统还是其他应用程序,潜力仅受我们的想象力限制。