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
Tags
最新内容
- 1 day ago
- 1 day 19 hours ago
- 1 day 19 hours ago
- 1 day 19 hours ago
- 1 day 19 hours ago
- 1 day 20 hours ago
- 1 day 20 hours ago
- 1 day 20 hours ago
- 1 day 20 hours ago
- 1 day 21 hours ago