PostgreSQL 전문 검색
고친 사람 github-actions[bot]
PostgreSQL 전문 검색은 PostgreSQL 에 저장한 글을 본문 속 낱말로 찾아 줍니다. 글을 미리 낱말 목록으로 바꿔 둡니다. 검색어도 같은 방식으로 바꿔 서로 맞춰 봅니다. 검색 서버를 따로 띄울 필요 없이 데이터베이스 질의 안에서 씁니다.
쉽고 빠른 이해
게시글 테이블에서 본문에 「jump」가 든 글을 찾아 주는 기능입니다. 「jumped」로 쓴 글도 「jumping」으로 찾으면 나옵니다.
왜 이렇게 하나. 본문을 글자 그대로 훑어 찾으면 모든 행을 읽어야 해서 글이 늘수록 느려집니다. 낱말의 모양이 조금만 달라도 못 찾고, 어느 글이 더 잘 맞는지도 모릅니다.
어떻게 도나.
- 글을 낱말로 쪼갭니다. 낱말마다 끝을 잘라 뿌리만 남긴 목록을 만듭니다.
- 낱말마다 그 낱말이 든 행을 적은 인덱스를 둡니다.
- 검색어도 같은 방식으로 바꿔 인덱스에서 행을 꺼냅니다. 꺼낸 행은 잘 맞는 순으로 줄 세웁니다.
대가. 흔한 낱말과 드문 낱말을 가려 점수를 주지 않아 순위가 거칩니다. 한국어를 낱말로 쪼개는 방법이 기본으로 들어 있지 않습니다. 검색이 데이터베이스 서버의 힘을 같이 씁니다.
언제 쓰나. 글이 이미 PostgreSQL 에 있을 때 씁니다. 검색 서버를 하나 더 굴리지 않아도 됩니다. 검색 품질이 서비스의 중심이면 검색 서버를 따로 둡니다.
상세
PostgreSQL 전문 검색은 PostgreSQL 에 처음부터 들어 있는 전문 검색 기능입니다. 전문 검색은 글의 본문 전체를 대상으로 낱말을 찾는 검색입니다. 따로 설치하는 제품이 아니라 몇 가지 자료형과 함수, 연산자의 묶음입니다. 이것들을 SQL(Structured Query Language, 데이터베이스에 묻는 질의 언어) 문장 안에서 부릅니다.
이 절은 짧은 글 한 줄을 낱말 목록으로 바꾸고, 모양이 다른 낱말로 그 글을 찾는 장면을 따라갑니다. 그 장면 위에서 두 자료형, 설정, 인덱스, 점수를 차례로 봅니다. 마지막에 이 기능이 내려놓은 것과 쓰는 경우를 봅니다.
LIKE 로 찾을 때 곤란한 점
데이터베이스에서 글 속 낱말을 찾을 때 흔히 LIKE '%jump%' 를 씁니다. 이 조건은 jump 앞에 무엇이든 올 수 있다는 뜻이라 일반 인덱스를 타지 못합니다. 그래서 행을 처음부터 끝까지 읽는 풀 테이블 스캔이 일어납니다.
속도 말고도 곤란한 점이 셋 있습니다. jumped 로 쓴 글을 jumping 으로 찾으면 안 나옵니다. 대소문자가 다르면 놓칩니다. 찾은 글 가운데 어느 것이 더 잘 맞는지도 알려 주지 않습니다. PostgreSQL 전문 검색은 글을 넣을 때 미리 정리해 두는 방식으로 이 넷을 풉니다.
tsvector
글을 정리해 둔 모양이 tsvector 자료형입니다. tsvector 는 글에 나온 낱말의 끝을 잘라 뿌리만 남기고 중복 없이 늘어놓은 목록입니다. 목록의 낱말 하나하나를 어휘소라고 부릅니다.
뿌리만 남기는 까닭은 모양만 다른 낱말을 한 줄에 모으기 위해서입니다. jumped 와 jumping 이 둘 다 jump 가 되면 어느 쪽으로 찾아도 서로를 찾습니다.
낱말 끝을 잘라 뿌리만 남기는 이 일을 어간 추출이라고 합니다. 남은 뿌리는 사람이 읽는 낱말과 다를 수 있습니다. 영어 규칙은 lazy 를 lazi 로 줄입니다.
글을 tsvector 로 바꾸는 함수는 to_tsvector 입니다.
SELECT to_tsvector(
'english',
'The foxes jumped' -- 'fox':2 'jump':3
);
결과에서 네 가지를 볼 수 있습니다. The 는 사라졌습니다. 어느 글에나 나와 찾는 데 쓸모없는 낱말이라 버린 것입니다. 이런 낱말을 불용어라고 합니다.
foxes 는 fox 로, jumped 는 jump 로 줄었습니다. 어간 추출을 거친 것입니다. 첫 인자 'english' 가 영어 규칙으로 줄이라는 지시입니다. 이 인자는 아래 「텍스트 검색 설정」 소절에서 다시 봅니다.
어휘소 뒤의 숫자는 그 낱말이 글에서 몇 번째에 나왔는지입니다. The 가 1번째를 차지했으므로 fox 는 2, jump 는 3 입니다. 이 번호가 있어야 「fox 바로 뒤에 jump」 같은 순서 조건으로 찾을 수 있습니다.
마지막으로 어휘소는 모두 소문자입니다. Jumped 로 쓴 글도 jump 가 되므로 대소문자가 달라도 만납니다.
tsquery 와 @@ 연산자
찾을 조건도 어휘소로 바꿔 둡니다. 그 모양이 tsquery 자료형입니다. tsquery 는 어휘소 여러 개를 논리 연산자로 묶은 조건입니다.
| 연산자 | 뜻 | 예 |
|---|---|---|
& |
둘 다 든 글 | fox & jump |
| |
둘 중 하나라도 든 글 | fox | dog |
! |
이 낱말이 없는 글 | fox & !dog |
<-> |
앞 낱말 바로 뒤에 뒤 낱말이 오는 글 | quick <-> fox |
표의 마지막 줄이 앞 소절의 위치 번호를 쓰는 조건입니다. 낱말 여럿이 붙어 나오는 구절을 찾는 이 방식을 구문 검색이라고 합니다.
tsvector 와 tsquery 를 맞춰 보는 연산자는 @@ 입니다. 글이 조건에 맞으면 참을 돌려줍니다.
SELECT
to_tsvector('english', 'jumped')
@@ to_tsquery('english', 'jumping'); -- t
결과 t 는 참입니다. 검색어 jumping 도 to_tsquery 를 거치며 jump 가 됐기 때문입니다. 글 쪽과 검색어 쪽이 같은 규칙으로 줄었으니 모양이 달라도 만납니다.
flowchart TD
S["같은 규칙 'english'"]
subgraph DOC["저장할 글"]
A["원문"] --> B["to_tsvector"]
B --> C["tsvector"]
end
subgraph QRY["검색어"]
D["입력한 검색어"] --> E["to_tsquery"]
E --> F["tsquery"]
end
S --> B
S --> E
C --> G["@@ 로 맞춰 봄"]
F --> G
그림은 두 줄기가 같은 'english' 규칙을 거쳐 같은 모양의 어휘소로 바뀐 뒤에야 만난다는 것을 보여 줍니다. 한쪽만 어간 추출을 거치면 둘은 만나지 못합니다.
to_tsquery 는 & 같은 연산자를 쓴 문자열을 받습니다. 사용자가 검색창에 친 글을 그대로 넘기면 연산자 문법이 안 맞아 오류가 날 수 있습니다. 그래서 사용자 입력은 평범한 글을 받아 알아서 tsquery 로 바꾸는 함수로 받습니다.
그런 함수가 둘입니다. plainto_tsquery 는 낱말 사이에 전부 & 를 넣습니다. websearch_to_tsquery 는 검색 사이트처럼 따옴표로 묶은 구절, or, 낱말 앞의 - 를 알아듣습니다.
텍스트 검색 설정
앞의 'english' 는 텍스트 검색 설정의 이름입니다. 텍스트 검색 설정은 글을 어휘소로 바꾸는 규칙 묶음입니다. 언어마다 불용어와 어간 추출 규칙이 다르므로 설정을 언어별로 둡니다.
설정 안에서는 두 단계가 돕니다. 먼저 파서가 글을 토큰으로 자릅니다. 토큰은 잘라 낸 조각 하나입니다.
다음 단계는 사전입니다. PostgreSQL 이 말하는 사전은 낱말 목록이 아니라, 토큰 하나를 받아 어휘소로 바꾸거나 버리는 처리 단계입니다. 불용어는 여기서 버려집니다. 나머지 토큰은 어간 추출을 거쳐 어휘소가 됩니다.
글 쪽과 검색어 쪽은 같은 설정을 써야 합니다. 글은 'english' 로 줄였다고 합시다. 검색어를 다른 설정으로 줄이면 jump 와 jumping 이 다시 어긋납니다.
'simple' 이라는 설정도 있습니다. 불용어를 버리지 않고 어간 추출도 하지 않습니다. 소문자로만 맞춥니다. 사람 이름이나 제품 코드처럼 줄이면 안 되는 글에 씁니다.
한국어
기본으로 딸려 오는 설정 목록에 한국어는 없습니다. 한국어 글에 'simple' 을 쓰면 띄어쓰기 단위로 자릅니다. 그러면 검색을 과 검색이 가 서로 다른 어휘소가 되어 서로를 못 찾습니다.
한국어는 조사와 어미를 떼어 내야 검색을 과 검색이 가 검색 으로 모입니다. 이 일을 형태소 분석이라고 합니다. PostgreSQL 에는 한국어 형태소 분석이 기본으로 들어 있지 않습니다.
그래서 한국어 검색은 확장을 붙여 풉니다. 확장은 PostgreSQL 에 기능을 더하는 추가 모듈입니다. 어떤 확장은 PostgreSQL 과 함께 배포되어 켜기만 하면 됩니다. 어떤 확장은 따로 내려받아 설치해야 합니다.
pg_trgm 은 PostgreSQL 과 함께 배포되는 확장입니다. 글을 세 글자씩 잘라 두 글이 얼마나 겹치는지 잽니다. 낱말을 쪼개지 않아도 되므로 언어를 가리지 않습니다. tsvector 와 @@ 를 거치지 않는 별개의 길입니다.
PGroonga 는 따로 설치하는 확장입니다. 한국어를 포함한 여러 언어의 글을 검색하는 기능을 자기 안에 갖고 있습니다.
GIN 인덱스
@@ 만 쓰면 여전히 느립니다. 행마다 to_tsvector 를 계산해 맞춰 봐야 하므로 결국 모든 행을 읽습니다. 이를 막는 것이 GIN(Generalized Inverted Index, 일반화 역색인) 인덱스입니다.
GIN 은 역색인입니다. 역색인은 낱말마다 그 낱말이 든 행을 적어 둔 목록입니다. 행에서 낱말을 찾는 대신 낱말에서 행을 찾도록 방향을 뒤집어 둔 것입니다.
책 맨 뒤의 찾아보기를 떠올리면 됩니다. 찾아보기에서 낱말 하나를 찾으면 그 낱말이 나오는 쪽 번호가 적혀 있습니다. 책을 첫 쪽부터 넘길 필요가 없습니다.
행 세 개가 있다고 해 봅시다. 1번은 The foxes jumped, 2번은 A lazy dog, 3번은 The dog jumps 입니다. 영어 설정으로 줄이면 인덱스는 아래와 같습니다.
| 어휘소 | 든 행 |
|---|---|
| dog | 2, 3 |
| fox | 1 |
| jump | 1, 3 |
| lazi | 2 |
jump 를 찾으면 한 줄만 읽고 1번과 3번을 얻습니다. 모든 행을 읽지 않으므로 행이 늘어도 찾는 일이 크게 무거워지지 않습니다.
lazi 는 앞에서 본 대로 영어 어간 추출이 만든 모양입니다. 어휘소는 사람이 읽을 낱말이 아니라 맞춰 보기 위한 열쇠입니다. 검색어 lazy 도 같은 규칙으로 lazi 가 되므로 둘은 만납니다.
인덱스는 이렇게 만들고 씁니다.
CREATE INDEX posts_body_fts ON posts
USING GIN (to_tsvector('english', body));
SELECT id FROM posts
WHERE to_tsvector('english', body)
@@ websearch_to_tsquery('english', 'jump');
첫 문장은 body 열을 tsvector 로 바꾼 값에 GIN 인덱스를 겁니다. 이처럼 열 값 대신 식의 결과에 거는 인덱스를 표현식 인덱스라고 합니다. 둘째 문장의 WHERE 가 인덱스와 똑같은 식을 써야 인덱스를 탑니다.
인덱스를 만들 때는 설정 이름을 꼭 적습니다. 설정 이름을 빼면 서버의 기본 설정을 따릅니다. 이 기본값은 나중에 바뀔 수 있습니다. 바뀌면 같은 글이 다른 어휘소가 되므로 PostgreSQL 은 설정 이름이 없는 식에는 인덱스를 못 걸게 합니다.
식을 인덱스에 거는 대신 tsvector 를 열로 저장해 두는 방법도 있습니다. 원문 열이 바뀌면 PostgreSQL 이 이 열을 자동으로 다시 계산하게 둡니다. 이런 열을 생성 열이라고 합니다. 저장 공간을 더 쓰는 대신, 질의가 tsvector 를 쓸 때마다 to_tsvector 를 다시 계산하지 않고 저장해 둔 값을 읽습니다.
아래는 tsv 라는 생성 열을 더하고 그 열에 GIN 인덱스를 거는 모습입니다.
ALTER TABLE posts ADD COLUMN tsv tsvector
GENERATED ALWAYS AS
(to_tsvector('english', body)) STORED;
CREATE INDEX ON posts USING GIN (tsv);
STORED 는 계산한 값을 디스크에 저장해 두라는 표시입니다. 이제 질의는 식을 다시 적지 않고 tsv @@ ... 처럼 열 이름만 씁니다.
같은 트랜잭션 안의 인덱스
GIN 인덱스는 PostgreSQL 의 다른 인덱스와 똑같이 트랜잭션을 따릅니다. 트랜잭션은 여러 변경을 한 덩어리로 묶어 전부 반영하거나 전부 취소하는 단위입니다. 글을 넣는 트랜잭션이 커밋되면 인덱스에도 그 순간 들어가 있습니다. 취소하면 인덱스에서도 빠집니다.
Elasticsearch 같은 검색 서버를 데이터베이스 밖에 따로 두면 사정이 다릅니다. 데이터베이스에 넣은 글을 검색 서버로 옮기는 일을 따로 해야 합니다. 그 사이에는 방금 넣은 글이 검색에 안 잡히거나, 지운 글이 계속 잡힙니다. PostgreSQL 전문 검색에는 이 틈이 없습니다.
점수 매기기
맞는 행이 여럿이면 어느 것을 먼저 보여 줄지 정해야 합니다. 글이 검색어와 얼마나 잘 맞는지를 관련도라고 합니다. PostgreSQL 은 관련도 점수를 계산하는 함수 둘을 줍니다.
| 함수 | 점수를 올리는 것 |
|---|---|
| ts_rank | 찾는 어휘소가 그 글에 자주 나올수록 |
| ts_rank_cd | 찾는 어휘소들이 글 안에서 가까이 모여 있을수록 |
제목에 나온 낱말을 본문보다 무겁게 칠 수도 있습니다. setweight 함수로 어휘소에 A 부터 D 까지 네 등급 가운데 하나를 붙입니다. 제목과 본문을 각각 tsvector 로 바꿔 제목에 A, 본문에 B 를 붙입니다. 둘을 || 로 이어 tsvector 하나로 만들면 점수 함수가 A 등급 어휘소에 더 큰 무게를 줍니다.
SELECT id, ts_rank(tsv, q) AS rank
FROM posts,
websearch_to_tsquery('english', 'jump') q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 10;
FROM 에 붙은 q 는 검색어를 tsquery 로 한 번 바꿔 둔 값입니다. WHERE 와 ts_rank 가 같이 쓰도록 FROM 에 올려 이름을 붙였습니다.
WHERE 가 인덱스로 맞는 행을 고릅니다. ts_rank 는 고른 행마다 tsvector 를 받아 점수를 매깁니다. 여기서 넘기는 tsv 는 앞에서 만든 생성 열이라 to_tsvector 를 다시 계산하지 않습니다.
ORDER BY 는 점수 높은 순으로 줄 세웁니다. LIMIT 은 앞의 10건만 남깁니다.
포기한 것
점수 계산은 한 행 안만 봅니다. 그 낱말이 테이블 전체에서 얼마나 흔한지는 보지 않습니다. 수많은 행에 나오는 낱말과 몇 행에만 나오는 낱말이 같은 무게를 받는다는 뜻입니다.
흔한 낱말은 글을 가려내지 못합니다. 그래서 검색 서버는 드문 낱말에 더 큰 무게를 줍니다. BM25(Best Matching 25)가 그렇게 계산하는 식입니다.
인덱스는 맞는 행을 찾아 줄 뿐 점수 순서는 모릅니다. 그래서 점수를 매기려면 맞은 행을 전부 꺼내 하나씩 계산한 뒤 정렬합니다. 맞는 행이 몇 건이면 가볍습니다. 흔한 낱말로 찾아 수십만 건이 맞으면 앞의 10건만 보여 주려 해도 그만큼을 다 계산합니다.
오타를 봐주지 않습니다. jump 를 jupm 으로 치면 다른 어휘소가 되어 안 나옵니다.
오타는 안 되지만 앞부분만 맞춰 보는 접두사 검색은 됩니다. to_tsquery('english', 'jum:*') 처럼 검색어 뒤에 :* 를 붙입니다. 그러면 jum 으로 시작하는 어휘소를 전부 찾습니다.
검색이 데이터베이스 서버 안에서 돕니다. 검색이 많아지면 주문 처리 같은 다른 질의와 같은 서버의 계산 자원과 메모리를 나눠 씁니다. 검색 인덱스만 떼어 여러 서버로 나눠 담는 기능은 없습니다.
쓰는 곳과 안 쓰는 곳
원본 데이터가 이미 PostgreSQL 에 있고, 검색 서버를 하나 더 굴리고 싶지 않을 때 씁니다. 게시판, 사내 문서, 상품 설명처럼 글이 테이블 하나에 모여 있는 경우입니다. 글을 넣은 트랜잭션이 끝나면 바로 검색에 잡혀야 하는 경우에도 맞습니다.
검색 품질 자체가 서비스의 중심이면 검색 서버를 따로 둡니다. 흔한 낱말과 드문 낱말을 가리는 점수, 오타를 봐주는 검색, 한국어 형태소 분석을 기본으로 갖췄기 때문입니다. 그 대신 데이터베이스와 검색 서버 사이에서 데이터를 옮기는 일을 떠안습니다.
관련 항목
PostgreSQL 전문 검색을 이루는 자료형과 함수
tsvector · tsquery · 어휘소 · to_tsvector · to_tsquery · plainto_tsquery · websearch_to_tsquery · ts_headline
글을 어휘소로 바꾸는 처리 단계
텍스트 검색 설정 · 파서 · 토큰 · 토큰화 · 텍스트 검색 사전 · 어간 추출 · 불용어 · 형태소 분석 · Snowball
PostgreSQL 전문 검색을 빠르게 하는 인덱스
GIN · GiST · 역색인 · 포스팅 리스트 · 표현식 인덱스 · 생성 열
PostgreSQL 전문 검색이 순위를 매기는 계산 방식
관련도 · ts_rank · ts_rank_cd · setweight · BM25 · TF-IDF · 랭킹
PostgreSQL 전문 검색이 받는 질의의 종류
불리언 검색 · 구문 검색 · 접두사 검색 · 퍼지 검색
한국어 검색에 덧붙이는 확장
PostgreSQL 확장 · pg_trgm · 트라이그램 · PGroonga · pg_bigm
PostgreSQL 전문 검색과 같은 일을 두고 겨루는 검색 수단
Elasticsearch · OpenSearch · Apache Solr · Apache Lucene · Meilisearch · FTS5
PostgreSQL 전문 검색과 견주는 데이터베이스 기본 검색
LIKE · 풀 테이블 스캔 · B-tree · 인덱스
PostgreSQL 전문 검색을 받쳐 주는 데이터베이스 성질
PostgreSQL 전문 검색이 속하는 상위 분류
전문 검색 · 검색 엔진 · 검색 · PostgreSQL · 관계형 데이터베이스 · SQL
다른 이름: PostgreSQL full-text search · PostgreSQL Full Text Search · Postgres 전문 검색 · PostgreSQL 텍스트 검색