跳转到主要内容
Chinese, Simplified

2020-09-08 更新:使用一个 GIN 索引而不是两个,websearch_to_tsquery,添加 LIMIT,并将 TSVECTOR 存储为单独的列。 更多细节在文章末尾。

我最近开始研究全文搜索选项。 用例是实时搜索键值对,其中键是字符串,值是字符串、数字或日期。 它应该启用对键和值的全文搜索以及对数字和日期的范围查询,并将 150 万个唯一键值对作为预期的最大搜索索引大小。 搜索公司 Algolia 建议端到端延迟预算不超过 50 毫秒,因此我们将使用它作为我们的阈值。

PostgreSQL


我已经熟悉 PostgreSQL,所以让我们看看它是否满足这些要求。 首先,创建一个表,

CREATE TABLE IF NOT EXISTS search_idx
(
    id       BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    key_str  TEXT NOT NULL,
    val_str  TEXT NOT NULL,
    val_int  INT,
    val_date TIMESTAMPTZ
);

接下来,用半真实的数据播种它。 以下可以将〜20,000行/秒插入到没有索引的表中,

const pgp = require("pg-promise")();
const faker = require("faker");

const Iterations = 150;

const seedDb = async () => {
  const db = pgp({
    database: process.env.DATABASE_NAME,
    user: process.env.DATABASE_USER,
    password: process.env.DATABASE_PASSWORD,
    max: 30,
  });
  const columns = new pgp.helpers.ColumnSet(
    ["key_str", "val_str", "val_int", "val_date"],
    { table: "search_idx" }
  );

  const getNextData = (_, pageIdx) =>
    Promise.resolve(
      pageIdx > Iterations - 1
        ? null
        : Array.from(Array(10000)).map(() => ({
            key_str: `${faker.lorem.word()} ${faker.lorem.word()}`,
            val_str: faker.lorem.words(),
            val_int: Math.floor(faker.random.float()),
            val_date: faker.date.past(),
          }))
    );

  console.debug(
    await db.tx("seed-db", (t) =>
      t.sequence((idx) =>
        getNextData(t, idx).then((data) => {
          if (data) return t.none(pgp.helpers.insert(data, columns));
        })
      )
    )
  );
};

seedDb();

现在已经加载了 150 万行,添加全文搜索 GIN 索引(详细信息),

CREATE INDEX search_idx_key_str_idx ON search_idx
    USING GIN (to_tsvector('english'::regconfig, key_str));
CREATE INDEX search_idx_val_str_idx ON search_idx
    USING GIN (to_tsvector('english'::regconfig, val_str));

注意:如果在创建索引后向表中添加更多数据,VACUUM ANALYZE search_idx; 更新表统计信息并改进查询计划。

是时候测试以下查询的性能了,

-- Prefix query across FTS columns
SELECT *
FROM search_idx
WHERE to_tsvector('english'::regconfig, key_str) 
@@ to_tsquery('english'::regconfig, 'qui:*')
  OR to_tsvector('english'::regconfig, val_str) 
@@ to_tsquery('english'::regconfig, 'qui:*');

-- Wildcard query on key (not supported by GIN index)
SELECT *
FROM search_idx
WHERE key_str ILIKE '%quis%';

-- Specific key and value(s) query
SELECT *
FROM search_idx
WHERE to_tsvector('english'::regconfig, key_str) 
@@ to_tsquery('english'::regconfig, 'quis')
  AND (to_tsvector('english'::regconfig, val_str) 
@@ to_tsquery('english'::regconfig, 'nulla')
    OR (to_tsvector('english'::regconfig, val_str) 
@@ to_tsquery('english'::regconfig, 'velit')));

-- Contrived range query, one field wouldn't have both val_int and val_date populated
SELECT *
FROM search_idx
WHERE to_tsvector('english'::regconfig, key_str) 
@@ to_tsquery('english'::regconfig, 'quis')
  AND val_int > 1000
  AND val_date > '2020-01-01';

在任何查询前添加 EXPLAIN 以确保它使用索引而不是进行全表扫描。 在前面添加 EXPLAIN ANALYZE 将提供时间信息,但是,如文档中所述,这会增加开销,并且有时会比正常执行查询花费更长的时间。

在我的 MacBook Pro(2.4 GHz 8 核 i9,32 GB RAM)上,我总是在第一个可能是最常见的查询上得到大约 100 毫秒。 这远远超过了 50 毫秒的阈值。

Elasticsearch


接下来,让我们试试 Elasticsearch。 启动它并确保状态为绿色,如下所示,

docker run -d -p 9200:9200 -p 9300:9300 
-e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.9.0

curl -sX GET "localhost:9200/_cat/health?v&pretty" -H "Accept: application/json"

接下来,为索引播种。 第一个脚本创建一个文件,每行一个半真实的文档,

const faker = require("faker");
const { writeFileSync } = require("fs");

const Iterations = 1_500_000;

writeFileSync(
  "./dataset.ndjson",
  Array.from(Array(Iterations))
    .map(() =>
      JSON.stringify({
        key: `${faker.lorem.word()} ${faker.lorem.word()}`,
        val: faker.lorem.words(),
        valInt: Math.floor(faker.random.float()),
        valDate: faker.date.past(),
      })
    )
    .join("\n")
);

第二个脚本将 ~150 MB 文件中的每个文档添加到索引中,

const { createReadStream } = require("fs");
const split = require("split2");
const { Client } = require("@elastic/elasticsearch");

const Index = "search-idx";

const seedIndex = async () => {
  const client = new Client({ node: "http://localhost:9200" });
  console.debug(
    await client.helpers.bulk({
      datasource: createReadStream("./dataset.ndjson").pipe(split()),
      onDocument(doc) {
        return { index: { _index: Index } };
      },
      onDrop(doc) {
        b.abort();
      },
    })
  );
};

seedIndex();

注意:此脚本可用于测试目的,但在生产中,请遵循有关调整批量请求大小和使用多线程的最佳实践。

现在,运行一些查询,

curl -sX GET "localhost:9200/search-idx/_search?pretty" \
-H 'Content-Type: application/json' \
-d'
{
  "query": {
    "simple_query_string" : {
      "query": "\"repellat sunt\" -quis",
      "fields": ["key", "val"],
      "default_operator": "and"
    }
  }
}
'

我在低端聚集的 136 个匹配结果上得到 5-24 毫秒,运行查询的次数越多。 这比 PostgreSQL 快约 5 倍。 因此,为了获得我所追求的性能,维护 Elasticsearch 集群的额外开销似乎是值得的。

2020-09-08 更新


在@samokhvalov 的帮助下,我创建了一个 GIN 索引而不是使用两个,

CREATE INDEX search_idx_key_str_idx ON search_idx
    USING GIN ((setweight(to_tsvector('english'::regconfig, key_str), 'A') ||
                setweight(to_tsvector('english'::regconfig, val_str), 'B')));

但是,我在生产中使用 PostgreSQL 10,因为我使用的是最新版本的 Elasticsearch,所以拉取最新版本的 PostgreSQL 是公平的(在撰写本文时,postgres:12.4-alpine)。 然后我更新了查询以使用新索引 websearch_to_tsquery,并添加了 Elasticsearch 使用的相同默认 LIMIT,

SELECT *
FROM search_idx
WHERE (setweight(to_tsvector('english'::regconfig, key_str), 'A') ||
       setweight(to_tsvector('english'::regconfig, val_str), 'B')) @@
      websearch_to_tsquery('english'::regconfig, '"repellat sunt" -quis')
LIMIT 10000;

这更像是一个苹果与苹果的比较,并在我的 MacBook Pro 上大幅减少了 172 个匹配结果的查询时间,从 ~100 毫秒到 ~13-16 毫秒!

作为最后的测试,我创建了一个独立的列来保存 TSVECTOR,如文档中所述。 它通过触发器保持最新,

CREATE TABLE IF NOT EXISTS search_idx
(
    id       BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    key_str  TEXT NOT NULL,
    val_str  TEXT NOT NULL,
    val_int  INT,
    val_date TIMESTAMPTZ,
    fts      TSVECTOR
);

CREATE FUNCTION fts_trigger() RETURNS trigger AS
$$
BEGIN
    new.fts :=
      setweight(to_tsvector('pg_catalog.english', new.key_str), 'A') ||
      setweight(to_tsvector('pg_catalog.english', new.val_str), 'B');
    return new;
END
$$ LANGUAGE plpgsql;

CREATE TRIGGER tgr_search_idx_fts_update
    BEFORE INSERT OR UPDATE
    ON search_idx
    FOR EACH ROW
EXECUTE FUNCTION fts_trigger();

CREATE INDEX search_idx_fts_idx ON search_idx USING GIN (fts);

SELECT *
FROM search_idx
WHERE fts @@ websearch_to_tsquery('english'::regconfig, '"repellat sunt" -quis')
LIMIT 10000;

在 psql 中测量时,这会将查询缩短到 6-10 毫秒,实际上与针对此特定查询的 Elasticsearch 相同

如果有人知道进一步的优化,我会更新我的粗略数字和代码片段以包含它们。

 

原文:https://www.rocky.dev/full-text-search

本文:https://jiagoushi.pro/node/2018

Article
知识星球
 
微信公众号
 
视频号