전체 텍스트 검색 전투: PostgreSQL 대 Elasticsearch

2020-09-08 업데이트: 두 개 대신 하나의 GIN 인덱스를 사용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 requestsusing 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로 줄입니다.

추가 최적화에 대해 알고 있는 사람이 있으면 대략적인 수치와 코드 조각을 업데이트하여 포함하도록 하겠습니다.

좋은 웹페이지 즐겨찾기