博客

正确使用 JavaScript 的 Elasticsearch,第一部分

讲解如何用 JavaScript 创建可投入生产的 Elasticsearch 后端。 探索如何使用 JavaScript 与 Elasticsearch,遵循客户端/服务器最佳实践,搭建包含多个搜索端点的服务器,用于查询 Elasticsearch 文档。

本文是系列文章的第一篇,介绍如何使用 JavaScript 使用 Elasticsearch。在本系列中,您将学习如何在 JavaScript 环境中使用 Elasticsearch 的基础知识,并回顾创建搜索应用程序的最相关功能和最佳实践。最后,您将了解使用 JavaScript 运行 Elasticsearch 所需的一切。

在第一部分中,我们将回顾

您可以 在这里查看示例的源代码

什么是 Elasticsearch Node.js 客户端?

Elasticsearch Node.js 客户端是一个 JavaScript 库,它将 Elasticsearch API 的 HTTP REST 调用放到了 JavaScript 中。这样就能更轻松地处理和使用帮助程序,简化批量编制文档索引等任务。

环境

前端、后端还是无服务器?

要使用 JavaScript 客户端创建搜索应用程序,我们至少需要两个组件:Elasticsearch 集群和运行客户端的 JavaScript 运行时。

JavaScript 客户端支持所有 Elasticsearch 解决方案(云、on-prem 和 Serverless),它们之间没有重大区别,因为客户端内部会处理所有变化,所以你不必担心使用哪一种。

不过,JavaScript 运行时必须从服务器运行,而不能直接从浏览器运行。

在 JavaScript 环境中使用 Elasticsearch。

这是因为从浏览器调用 Elasticsearch 时,用户可能会获得敏感信息,如集群 API 密钥、主机或查询本身。Elasticsearch 建议永远不要将集群直接暴露在互联网上 ,而是使用一个中间层来抽象所有这些信息,这样用户只能看到参数。您可以在这里了解更多相关信息。

我们建议使用这样的模式:

设置 Elasticsearch Node.js 客户端。

在这种情况下,客户端只向服务器发送搜索条件和验证密钥,而服务器则完全控制查询和与 Elasticsearch 的通信。

连接客户端

首先,按照以下步骤创建一个 API 密钥。

按照前面的示例,我们将创建一个简单的 Express 服务器,并使用 Node.JS 服务器的客户端连接到该服务器。

我们将使用 NPM 初始化项目,并安装 Elasticsearch 客户端和Express。后者是一个在 Node.js 中调用服务器的库。使用 Express,我们可以通过 HTTP 与后端交互。

让我们初始化项目:

npm init -y

安装依赖项:

npm install @elastic/elasticsearch express split2 dotenv

让我来为你分析一下:

  • @elastic/elasticsearch:它是 Node.js 的官方客户端

  • 快递:它将使我们能够运行一个轻量级的 nodejs 服务器,以暴露 Elasticsearch

  • split2: 将文本行分割成数据流。每次处理一行 ndjson 文件时非常有用

  • dotenv:允许我们使用 .env 管理环境变量文件

创建 .env文件,并添加以下几行:

ELASTICSEARCH_ENDPOINT="Your Elasticsearch endpoint"
ELASTICSEARCH_API_KEY="Your Elasticssearch API"

这样,我们就可以使用dotenv 软件包导入这些变量。

创建server.js 文件:

const express = require("express");
const bodyParser = require("body-parser");
const { Client } = require("@elastic/elasticsearch");
 
require("dotenv").config(); //environment variables setup

const ELASTICSEARCH_ENDPOINT = process.env.ELASTICSEARCH_ENDPOINT;
const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY;
const PORT = 3000;


const app = express();

app.listen(PORT, () => {
  console.log("Server running on port", PORT);
});
app.use(bodyParser.json());


let esClient = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: { apiKey: ELASTICSEARCH_API_KEY },  
});

app.get("/ping", async (req, res) => {
  try {
    const result = await esClient.info();

    res.status(200).json({
      success: true,
      clusterInfo: result,
    });
  } catch (error) {
    console.error("Error getting Elasticsearch info:", error);

    res.status(500).json({
      success: false,
      clusterInfo: null,
      error: error.message,
    });
  }
});

这段代码设置了一个基本的 Express.js 服务器,该服务器监听端口 3000,并使用 API 密钥进行身份验证,连接到 Elasticsearch 集群。它包括一个 /ping 端点,通过 GET 请求访问时,可使用 Elasticsearch 客户端的.info() 方法查询 Elasticsearch 集群的基本信息。

如果查询成功,会以 JSON 格式返回群集信息;否则会返回错误信息。服务器还使用 body-parser 中间件来处理 JSON 请求体。

运行文件,启动服务器:

node server.js

答案应该是这样的

Server running on port 3000

现在,让我们查阅端点/ping ,检查 Elasticsearch 集群的状态。

curl http://localhost:3000/ping
{
    "success": true,
    "clusterInfo": {
        "name": "instance-0000000000",
        "cluster_name": "61b7e19eec204d59855f5e019acd2689",
        "cluster_uuid": "BIfvfLM0RJWRK_bDCY5ldg",
        "version": {
            "number": "9.0.0",
            "build_flavor": "default",
            "build_type": "docker",
            "build_hash": "112859b85d50de2a7e63f73c8fc70b99eea24291",
            "build_date": "2025-04-08T15:13:46.049795831Z",
            "build_snapshot": false,
            "lucene_version": "10.1.0",
            "minimum_wire_compatibility_version": "8.18.0",
            "minimum_index_compatibility_version": "8.0.0"
        },
        "tagline": "You Know, for Search"
    }
}

编制文件索引

一旦连接起来,我们就可以使用语义_文本(用于语义搜索)和文本(用于全文查询)等映射对文档进行索引。有了这两种字段类型,我们还可以进行混合搜索

我们将创建一个新的load.js 文件来生成映射并上传文件。

Elasticsearch 客户端

我们首先需要对客户端进行实例化和身份验证:

const { Client } = require("@elastic/elasticsearch");

const ELASTICSEARCH_ENDPOINT = "cluster/project_endpoint";
const ELASTICSEARCH_API_KEY = "apiKey";

const esClient = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: { apiKey: ELASTICSEARCH_API_KEY },
});

语义映射

我们将创建一个包含兽医院数据的索引。我们将保存主人、宠物和访问详情的信息。

我们要进行全文搜索的数据,如名称和描述,将以文本形式存储。类别中的数据,如动物的种类或品种,将以关键字的形式存储。

此外,我们还将把所有字段的值复制到一个 semantic_text 字段中,以便也能针对这些信息运行语义搜索。

const INDEX_NAME = "vet-visits";

const createMappings = async (indexName, mapping) => {
  try {
    const body = await esClient.indices.create({
      index: indexName,
      body: {
        mappings: mapping,
      },
    });

    console.log("Index created successfully:", body);
  } catch (error) {
    console.error("Error creating mapping:", error);
  }
};

await createMappings(INDEX_NAME, {
  properties: {
    owner_name: {
      type: "text",
      copy_to: "semantic_field",
    },
    pet_name: {
      type: "text",
      copy_to: "semantic_field",
    },
    species: {
      type: "keyword",
      copy_to: "semantic_field",
    },
    breed: {
      type: "keyword",
      copy_to: "semantic_field",
    },
    vaccination_history: {
      type: "keyword",
      copy_to: "semantic_field",
    },
    visit_details: {
      type: "text",
      copy_to: "semantic_field",
    },
    semantic_field: {
      type: "semantic_text",
    },
  },
});

批量助手

客户端的另一个优势是,我们可以使用批量助手来分批建立索引。通过批量辅助器,我们可以轻松处理并发、重试等问题,以及如何处理通过函数成功或失败的每个文档。

该助手的一个吸引人的特点是可以使用数据流。该功能允许您逐行发送文件,而不是将整个文件存储在内存中并一次性发送到 Elasticsearch。

要将数据上传到 Elasticsearch,请在项目根目录下创建名为 data.ndjson 的文件,并添加以下信息(也可以从此处下载包含数据集的文件):

{"owner_name":"Alice Johnson","pet_name":"Buddy","species":"Dog","breed":"Golden Retriever","vaccination_history":["Rabies","Parvovirus","Distemper"],"visit_details":"Annual check-up and nail trimming. Healthy and active."}
{"owner_name":"Marco Rivera","pet_name":"Milo","species":"Cat","breed":"Siamese","vaccination_history":["Rabies","Feline Leukemia"],"visit_details":"Slight eye irritation, prescribed eye drops."}
{"owner_name":"Sandra Lee","pet_name":"Pickles","species":"Guinea Pig","breed":"Mixed","vaccination_history":[],"visit_details":"Loss of appetite, recommended dietary changes."}
{"owner_name":"Jake Thompson","pet_name":"Luna","species":"Dog","breed":"Labrador Mix","vaccination_history":["Rabies","Bordetella"],"visit_details":"Mild ear infection, cleaning and antibiotics given."}
{"owner_name":"Emily Chen","pet_name":"Ziggy","species":"Cat","breed":"Mixed","vaccination_history":["Rabies","Feline Calicivirus"],"visit_details":"Vaccination update and routine physical."}
{"owner_name":"Tomás Herrera","pet_name":"Rex","species":"Dog","breed":"German Shepherd","vaccination_history":["Rabies","Parvovirus","Leptospirosis"],"visit_details":"Follow-up for previous leg strain, improving well."}
{"owner_name":"Nina Park","pet_name":"Coco","species":"Ferret","breed":"Mixed","vaccination_history":["Rabies"],"visit_details":"Slight weight loss; advised new diet."}
{"owner_name":"Leo Martínez","pet_name":"Simba","species":"Cat","breed":"Maine Coon","vaccination_history":["Rabies","Feline Panleukopenia"],"visit_details":"Dental cleaning. Minor tartar buildup removed."}
{"owner_name":"Rachel Green","pet_name":"Rocky","species":"Dog","breed":"Bulldog Mix","vaccination_history":["Rabies","Parvovirus"],"visit_details":"Skin rash, antihistamines prescribed."}
{"owner_name":"Daniel Kim","pet_name":"Mochi","species":"Rabbit","breed":"Mixed","vaccination_history":[],"visit_details":"Nail trimming and general health check. No issues."}

我们使用 split2 对文件行进行流式处理,而批量助手则将它们发送到 Elasticsearch。

const { createReadStream } = require("fs");
const split = require("split2");
 
const indexData = async (filePath, indexName) => {
  try {
    console.log(`Indexing data from ${filePath} into ${indexName}...`);

    const result = await esClient.helpers.bulk({
      datasource: createReadStream(filePath).pipe(split()),

      onDocument: () => {
        return {
          index: { _index: indexName },
        };
      },
      onDrop(doc) {
        console.error("Error processing document:", doc);
      },
    });

    console.log("Bulk indexing successful elements:", result.items.length);
  } catch (error) {
    console.error("Error indexing data:", error);
    throw error;
  }
};

await indexData("./data.ndjson", INDEX_NAME);

上面的代码读取 .ndjson文件,并使用helpers.bulk 方法将每个 JSON 对象批量索引到指定的 Elasticsearch 索引中。它使用createReadStreamsplit2 对文件进行流式处理,为每个文件设置索引元数据,并记录处理失败的文件。完成后,它会记录成功索引的项目数。

indexData 功能外,您还可以使用 Kibana 直接通过用户界面上传文件,并使用上传数据文件用户界面。

我们运行文件,将文件上传到 Elasticsearch 集群。

node load.js

Creating mappings for index vet-visits...
Index created successfully: { acknowledged: true, shards_acknowledged: true, index: 'vet-visits' }
Indexing data from ./data.ndjson into vet-visits...
Bulk indexing completed. Total documents: 10, Failed: 0

在 Elasticsearch 中搜索数据

回到server.js 文件,我们将创建不同的端点来执行词法、语义或混合搜索。

简而言之,这些类型的搜索并不相互排斥,而是取决于您需要回答的问题类型。

查询类型

用例

问题示例

词法查询

问题中的单词或词根很可能出现在索引文件中。问题与文件之间的标记相似性。

我在找一件蓝色运动 T 恤。

语义查询

问题中的词语不可能出现在文件中。问题与文件之间的概念相似性。

我在寻找适合寒冷天气穿的衣服。

混合搜索

问题包含词汇和/或语义成分。问题与文档之间的标记和语义相似性。

我想为海滩婚礼找一件 S 码的礼服。

问题的词汇 部分很可能是标题和说明的一部分,或者是类别名称,而语义 部分则是与这些领域相关的概念。蓝色可能是一个类别名称或描述的一部分,海滩婚礼不太可能是,但可以与亚麻服装在语义上相关。

词法查询 (/search/lexic?q=<query_term>)

词法搜索也称全文搜索,是指基于标记的相似性进行搜索;也就是说,经过分析后,将返回包含搜索标记的文档。

您可以点击此处查看我们的词法搜索实践教程。

app.get("/search/lexic", async (req, res) => {
  const { q } = req.query;

  const INDEX_NAME = "vet-visits";

  try {
    const result = await esClient.search({
      index: INDEX_NAME,
      size: 5,
      body: {
        query: {
          multi_match: {
            query: q,
            fields: ["owner_name", "pet_name", "visit_details"],
          },
        },
      },
    });

    res.status(200).json({
      success: true,
      results: result.hits.hits
    });
  } catch (error) {
    console.error("Error performing search:", error);

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});

我们测试:修剪指甲

curl http://localhost:3000/search/lexic?q=nail%20trimming

请回答:

{
    "success": true,
    "results": [
        {
            "_index": "vet-visits",
            "_id": "-RY6RJYBLe2GoFQ6-9n9",
            "_score": 2.7075968,
            "_source": {
                "pet_name": "Mochi",
                "owner_name": "Daniel Kim",
                "species": "Rabbit",
                "visit_details": "Nail trimming and general health check. No issues.",
                "breed": "Mixed",
                "vaccination_history": []
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8BY6RJYBLe2GoFQ6-9n9",
            "_score": 2.560356,
            "_source": {
                "pet_name": "Buddy",
                "owner_name": "Alice Johnson",
                "species": "Dog",
                "visit_details": "Annual check-up and nail trimming. Healthy and active.",
                "breed": "Golden Retriever",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Distemper"
                ]
            }
        }
    ]
}

语义查询 (/search/semantic?q=<query_term>)

语义搜索与词汇搜索不同,它通过矢量搜索找到与搜索词含义相似的结果。

您可以点击这里查看我们的语义搜索实践教程。

app.get("/search/semantic", async (req, res) => {
  const { q } = req.query;

  const INDEX_NAME = "vet-visits";

  try {
    const result = await esClient.search({
      index: INDEX_NAME,
      size: 5,
      body: {
        query: {
          semantic: {
            field: "semantic_field",
            query: q
          },
        },
      },
    });

    res.status(200).json({
      success: true,
      results: result.hits.hits,
    });
  } catch (error) {
    console.error("Error performing search:", error);

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});

我们进行测试:谁做了修脚?

curl http://localhost:3000/search/semantic?q=Who%20got%20a%20pedicure?

请回答:

{
    "success": true,
    "results": [
        {
            "_index": "vet-visits",
            "_id": "-RY6RJYBLe2GoFQ6-9n9",
            "_score": 4.861466,
            "_source": {
                "owner_name": "Daniel Kim",
                "pet_name": "Mochi",
                "species": "Rabbit",
                "breed": "Mixed",
                "vaccination_history": [],
                "visit_details": "Nail trimming and general health check. No issues."
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8BY6RJYBLe2GoFQ6-9n9",
            "_score": 4.7152824,
            "_source": {
                "pet_name": "Buddy",
                "owner_name": "Alice Johnson",
                "species": "Dog",
                "visit_details": "Annual check-up and nail trimming. Healthy and active.",
                "breed": "Golden Retriever",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Distemper"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "9RY6RJYBLe2GoFQ6-9n9",
            "_score": 1.6717153,
            "_source": {
                "pet_name": "Rex",
                "owner_name": "Tomás Herrera",
                "species": "Dog",
                "visit_details": "Follow-up for previous leg strain, improving well.",
                "breed": "German Shepherd",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Leptospirosis"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "9xY6RJYBLe2GoFQ6-9n9",
            "_score": 1.5600781,
            "_source": {
                "pet_name": "Simba",
                "owner_name": "Leo Martínez",
                "species": "Cat",
                "visit_details": "Dental cleaning. Minor tartar buildup removed.",
                "breed": "Maine Coon",
                "vaccination_history": [
                    "Rabies",
                    "Feline Panleukopenia"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "-BY6RJYBLe2GoFQ6-9n9",
            "_score": 1.2696637,
            "_source": {
                "pet_name": "Rocky",
                "owner_name": "Rachel Green",
                "species": "Dog",
                "visit_details": "Skin rash, antihistamines prescribed.",
                "breed": "Bulldog Mix",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus"
                ]
            }
        }
    ]
}

混合查询 (/search/hybrid?q=<query_term>)

混合搜索允许我们将语义搜索和词法搜索结合起来,从而获得两全其美的效果:既能获得标记搜索的精确性,又能获得语义搜索的意义接近性。

app.get("/search/hybrid", async (req, res) => {
  const { q } = req.query;

  const INDEX_NAME = "vet-visits";

  try {
    const result = await esClient.search({
      index: INDEX_NAME,
      body: {
        retriever: {
          rrf: {
            retrievers: [
              {
                standard: {
                  query: {
                    bool: {
                      must: {
                         multi_match: {
             query: q,
            fields: ["owner_name", "pet_name", "visit_details"],
          },
                      },
                    },
                  },
                },
              },
              {
                standard: {
                  query: {
                    bool: {
                      must: {
                        semantic: {
                          field: "semantic_field",
                          query: q,
                        },
                      },
                    },
                  },
                },
              },
            ],
          },
        },
        size: 5,
      },
    });

    res.status(200).json({
      success: true,
      results: result.hits.hits,
    });
  } catch (error) {
    console.error("Error performing search:", error);

    res.status(500).json({
      success: false,
      results: null,
      error: error.message,
    });
  }
});

我们以 "谁做了修脚或牙科治疗?"

curl http://localhost:3000/search/hybrid?q=who%20got%20a%20pedicure%20or%20dental%20treatment

响应:

{
    "success": true,
    "results": [
        {
            "_index": "vet-visits",
            "_id": "9xY6RJYBLe2GoFQ6-9n9",
            "_score": 0.032522473,
            "_source": {
                "pet_name": "Simba",
                "owner_name": "Leo Martínez",
                "species": "Cat",
                "visit_details": "Dental cleaning. Minor tartar buildup removed.",
                "breed": "Maine Coon",
                "vaccination_history": [
                    "Rabies",
                    "Feline Panleukopenia"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "-RY6RJYBLe2GoFQ6-9n9",
            "_score": 0.016393442,
            "_source": {
                "pet_name": "Mochi",
                "owner_name": "Daniel Kim",
                "species": "Rabbit",
                "visit_details": "Nail trimming and general health check. No issues.",
                "breed": "Mixed",
                "vaccination_history": []
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8BY6RJYBLe2GoFQ6-9n9",
            "_score": 0.015873017,
            "_source": {
                "pet_name": "Buddy",
                "owner_name": "Alice Johnson",
                "species": "Dog",
                "visit_details": "Annual check-up and nail trimming. Healthy and active.",
                "breed": "Golden Retriever",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Distemper"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "9RY6RJYBLe2GoFQ6-9n9",
            "_score": 0.015625,
            "_source": {
                "pet_name": "Rex",
                "owner_name": "Tomás Herrera",
                "species": "Dog",
                "visit_details": "Follow-up for previous leg strain, improving well.",
                "breed": "German Shepherd",
                "vaccination_history": [
                    "Rabies",
                    "Parvovirus",
                    "Leptospirosis"
                ]
            }
        },
        {
            "_index": "vet-visits",
            "_id": "8xY6RJYBLe2GoFQ6-9n9",
            "_score": 0.015384615,
            "_source": {
                "pet_name": "Luna",
                "owner_name": "Jake Thompson",
                "species": "Dog",
                "visit_details": "Mild ear infection, cleaning and antibiotics given.",
                "breed": "Labrador Mix",
                "vaccination_history": [
                    "Rabies",
                    "Bordetella"
                ]
            }
        }
    ]
}

结论

在本系列的第一部分中,我们介绍了如何按照客户端/服务器最佳实践设置环境并创建带有不同搜索端点的服务器,以查询 Elasticsearch 文档。查看我们系列的第二部分,您将了解生产最佳实践以及如何在无服务器环境中运行 Elasticsearch Node.js 客户端。

常见问题

什么是 Node.js 客户端?

Node.js 客户端是一个 JavaScript 库,用于将 Elasticsearch API 的 HTTP REST 调用封装成 JavaScript 接口。它还提供了一些辅助方法,可简化批量索引文档等任务。

为何应使用服务器端的 Node.js 环境,而不是在前端直接调用 Elasticsearch?

安全性是其主要优势。在后端环境(例如使用 Express 的 Node.js)中运行客户端,可避免集群 API 密钥、端点 URL 及内部查询逻辑等敏感信息暴露在浏览器端。

在 Node.js 中使用 Elasticsearch “Bulk Helper” 有哪些优势?

在 Node.js 中使用 Elasticsearch “Bulk Helper” 有哪些优势? 批量索引:它自动处理按组而非逐个对文档进行索引的复杂性。 流式支持:借助 split2 等工具,您可以对 .ndjson 等文件进行流式读取并按行处理。。这样一来,无需将整个数据集加载到服务器内存即可处理海量文件。

相关内容

正确使用 JavaScript 的 Elasticsearch,第二部分

Jeffrey Rengifo

使用 JavaScript、Mastra 和 Elasticsearch 构建代理 RAG 助手

JD Armada

准备好打造最先进的搜索体验了吗?

足够先进的搜索不是一个人的努力就能实现的。Elasticsearch 由数据科学家、ML 操作员、工程师以及更多和您一样对搜索充满热情的人提供支持。让我们联系起来,共同打造神奇的搜索体验,让您获得想要的结果。

亲自试用