전체 텍스트 검색 전투: PostgreSQL 대 Elasticsearch
30161 단어 searchpostgreselasticsearch
websearch_to_tsquery
, 추가LIMIT
및 저장TSVECTOR
을 별도의 열로 사용합니다. 자세한 내용은 기사 끝에 있습니다.최근에 전체 텍스트 검색 옵션을 조사하기 시작했습니다. 사용 사례는 키가 문자열이고 값이 문자열, 숫자 또는 날짜인 키-값 쌍에 대한 실시간 검색입니다. 예상되는 최대 검색 인덱스 크기로 150만 개의 고유 키-값 쌍을 사용하여 키 및 값에 대한 전체 텍스트 검색 및 숫자 및 날짜에 대한 범위 쿼리를 활성화해야 합니다. 검색 회사 Algolia는 50ms 이하의 end-to-end latency budget을 권장하므로 이를 임계값으로 사용합니다.
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.log(
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 인덱스( details )를 추가합니다.
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
를 추가하면 타이밍 정보가 제공되지만 as noted in the documentation , 이것은 오버헤드를 추가하고 때때로 쿼리를 정상적으로 실행하는 것보다 훨씬 더 오래 걸릴 수 있습니다.내 MacBook Pro(2.4GHz 8-Core i9, 32GB RAM)에서 가장 일반적인 첫 번째 쿼리에서 일관되게 ~120ms를 얻습니다. 이것은 50ms 임계값을 훨씬 초과합니다.
엘라스틱서치
다음으로 Elasticsearch를 사용해 보겠습니다. 그것을 시작하고 다음과 함께 상태가
green
인지 확인하십시오.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
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")
)
두 번째 스크립트는 ~150MB 파일의 각 문서를 인덱스에 추가합니다.
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.log(
await client.helpers.bulk({
datasource: createReadStream("./dataset.ndjson").pipe(split()),
onDocument(doc) {
return { index: { _index: Index } }
},
onDrop(doc) { b.abort() }
})
)
}
seedIndex()
참고: 이 스크립트는 테스트용으로 적합하지만 프로덕션에서는 sizing bulk requests 및 using multiple threads 의 모범 사례를 따르십시오.
이제 몇 가지 쿼리를 실행하고
curl -sX GET "localhost:9200/search-idx/_search?pretty" \
-H 'Content-Type: application/json' \
-d'
{
"query": {
"simple_query_string" : {
"query": "\"repellat sunt\" -quis",
"fields": ["valStr", "keyStr"],
"default_operator": "and"
}
}
}
'
인덱스가 따뜻해지면 PostgreSQL보다 ~8-20ms 또는 6배 빠릅니다. 따라서 Elasticsearch 클러스터를 유지 관리하는 데 추가되는 오버헤드는 내가 원하는 성능을 얻기 위해 그만한 가치가 있는 것 같습니다.
2020-09-08 업데이트
@samokhvalov's 에 대한 응답으로 나는 그의 권고를 따랐습니다. 먼저 두 개를 사용하는 대신 단일 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;
이것은 사과 대 사과 비교에 훨씬 더 가깝고 172개의 일치 결과에서 내 MacBook Pro의 쿼리 시간을 ~120ms에서 ~13-16ms로 대폭 줄였습니다!
마지막 테스트로 documentation 에 설명된 대로
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
에서 측정할 때 이 특정 쿼리에 대한 Elasticsearch와 동등한 쿼리를 6-10ms로 줄입니다.추가 최적화에 대해 알고 있는 사람이 있으면 대략적인 수치와 코드 조각을 업데이트하여 포함하도록 하겠습니다.
Reference
이 문제에 관하여(전체 텍스트 검색 전투: PostgreSQL 대 Elasticsearch), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://dev.to/therockstorm/full-text-search-battle-postgresql-vs-elasticsearch-3h29텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)