전문 검색(역색인)
전문 검색(full-text search)은 자연어로 쓴 문서 가운데 질의에 맞는 문서를 찾고, 필요하면 관련도 순으로 정렬하는 기능입니다. 글자를 그대로 비교하는 대신 문서를 단어 단위로 나누고 정규화한 뒤, 단어에서 그 단어가 든 문서를 바로 찾을 수 있게 색인을 만들어 둡니다.
이 색인이 역색인(inverted index)입니다. 문서에서 단어를 찾는 방향을 뒤집어 단어를 키로 하고 그 단어가 나오는 문서의 목록을 값으로 저장합니다. LIKE '%단어%'는 pg_trgm 같은 별도 인덱스가 없으면 문서를 하나씩 검사해야 하지만 역색인은 목록을 바로 얻습니다. 이 토픽에서는 역색인이 만들어지는 과정, LIKE와의 차이, 텍스트가 tsvector로 바뀌는 과정을 시각화로 확인합니다.
핵심 개념
역색인
단어를 키로, 그 단어가 나오는 문서의 목록을 값으로 저장한 구조입니다. 책 뒤의 찾아보기와 비슷합니다.
핵심 개념
토큰과 불용어
문장을 단어(토큰)로 나누고, a, on, it처럼 검색에 도움이 되지 않는 흔한 단어(불용어)는 검색 설정에 따라 색인에서 뺄 수 있습니다.
핵심 개념
어간 추출
rats를 rat으로 바꾸듯 단어를 기본형에 가깝게 맞춥니다. 사전이 인식하는 단어라면 형태가 달라도 같은 단어로 검색할 수 있습니다.
핵심 개념
tsvector
PostgreSQL에서 문서를 정규화한 단어와 그 위치의 목록으로 저장하는 타입입니다. to_tsvector 함수가 만듭니다.
핵심 개념
tsquery와 @@
검색어를 정규화한 질의 타입입니다. tsvector @@ tsquery가 일치 여부를 판단하고, &는 두 단어가 모두 있어야 한다는 뜻입니다.
핵심 개념
GIN 인덱스
tsvector 열에 만드는 역색인 형태의 인덱스입니다. PostgreSQL 문서는 전문 검색에 GIN 인덱스를 우선 권장합니다.
전문 검색의 배경
문서에서 단어로 문서를 찾는 문제는 정보 검색(information retrieval) 분야의 오래된 주제이고, 역색인은 그 기본 자료구조로 교재에서 가장 먼저 다루는 구조 가운데 하나입니다. 교재에서는 단어 사전(dictionary)과 단어마다 문서 번호를 모은 포스팅 목록(postings list)으로 색인을 설명하고, 목록의 항목 하나(단어가 문서에 나타났다는 기록)를 포스팅(posting)이라 부릅니다.
관계형 데이터베이스의 일반 인덱스(B-Tree 등)는 값 비교와 범위 검색에 맞춰져 있어서, 글 속의 단어는 LIKE나 정규식으로 찾는 경우가 많았습니다. PostgreSQL 문서는 이 방법이 언어 지식이 없고, 결과를 순위로 정렬하지 못하며, 인덱스 지원이 없어 느릴 수 있다고 설명합니다. 이런 한계 때문에 PostgreSQL을 비롯한 여러 데이터베이스는 전문 검색 기능을 직접 제공하고, 그렇지 않은 환경에서는 별도의 검색 엔진을 함께 두기도 합니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
역색인이란 무엇인가요?
단어를 키로 하고 그 단어가 나오는 문서의 목록을 값으로 저장한 구조입니다. 단어로 문서를 바로 찾을 수 있어 문서를 하나씩 훑지 않아도 됩니다.
면접 질문
LIKE '%단어%'가 느린 이유는?
B-Tree 인덱스는 값을 정렬해 두기 때문에 앞부분이 정해진 패턴은 쓸 수 있지만, %로 시작하는 패턴은 정렬 순서를 쓰지 못해 별도 인덱스가 없으면 행을 하나씩 검사하게 됩니다.
면접 질문
tsvector와 tsquery는 무엇인가요?
tsvector는 문서를 정규화한 단어와 위치의 목록이고, tsquery는 정규화한 검색 조건입니다. @@ 연산자로 일치 여부를 판단합니다.
면접 질문
전문 검색에 GIN 인덱스를 쓰는 이유는?
GIN은 키(단어)마다 그 키가 든 행의 목록(posting list)을 저장하는 역색인 형태라, 한 문서에 여러 단어가 들어 있는 tsvector에서 단어로 문서를 찾는 데 맞습니다.
활용 분야
함께 보면 좋은 토픽
참고 자료
SQL — PostgreSQL의 전문 검색
-- 텍스트를 tsvector로 바꾼다 (PostgreSQL 문서의 예) SELECT to_tsvector('english', 'a fat cat sat on a mat - it ate a fat rats'); -- 'ate':9 'cat':3 'fat':2,11 'mat':7 'rat':12 'sat':4 -- tsquery로 검색: @@ 연산자 (결과 t) SELECT to_tsvector('english', 'a fat cat sat on a mat - it ate a fat rats') @@ to_tsquery('english', 'fat & rat'); -- tsvector 생성 열과 GIN 인덱스 CREATE TABLE docs ( id int PRIMARY KEY, body text, tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED ); CREATE INDEX docs_tsv_idx ON docs USING GIN (tsv); SELECT id FROM docs WHERE tsv @@ to_tsquery('english', 'cat'); -- 관련도 순으로 정렬 SELECT id, ts_rank(tsv, query) AS rank FROM docs, to_tsquery('english', 'fat & cat') AS query WHERE tsv @@ query ORDER BY rank DESC; -- LIKE '%word%' 같은 부분 문자열 검색은 pg_trgm 확장의 인덱스 CREATE EXTENSION pg_trgm; CREATE INDEX docs_body_trgm ON docs USING GIN (body gin_trgm_ops);
to_tsvector는 텍스트를 토큰으로 나누고 불용어를 빼고 어간을 추출해 tsvector로 바꿉니다. 첫 줄의 결과는 PostgreSQL 문서에 실린 예와 같은 형태이고, 위치 번호는 불용어를 뺀 뒤에도 원래 단어의 위치를 유지합니다. @@ 연산자는 tsvector가 tsquery와 일치하는지 판단하며, fat & rat은 두 단어가 모두 있어야 일치합니다. 시각화의 토큰과 정규화 탭이 보여 주는 순서입니다. tsvector를 생성 열에 저장하고 GIN 인덱스를 만들면 단어에서 문서를 빠르게 찾을 수 있습니다. GIN(Generalized Inverted Index)은 키마다 그 키가 든 행의 목록(posting list)을 저장하는 역색인 형태의 인덱스입니다. 생성 열은 PostgreSQL 12 이상에서 쓸 수 있습니다. ts_rank는 관련도 점수를 계산합니다. 부분 문자열 검색을 빠르게 하려면 pg_trgm 확장의 gin_trgm_ops 인덱스를 씁니다. 결과는 텍스트 검색 설정(여기서는 english)에 따라 달라집니다.
Python — 역색인 만들기와 LIKE 비교
docs = {1: 'fat cat sat', 2: 'fat rat ran', 3: 'cat ate rat', 4: 'big fat cat',
5: 'dog ran fast', 6: 'big dog sat', 7: 'cat ran fast', 8: 'fast dog'}
index = {}
for doc_id, text in docs.items():
for word in text.split():
index.setdefault(word, []).append(doc_id) # 단어 -> 문서 목록(posting list)
for word in sorted(index):
print(word, index[word])
def search_and(*words):
result = set(index.get(words[0], []))
for w in words[1:]:
result &= set(index.get(w, [])) # 문서 목록의 교집합
return sorted(result)
print('cat:', search_and('cat'))
print('fat & cat:', search_and('fat', 'cat'))
scanned = 0
like = []
for doc_id, text in docs.items(): # LIKE '%cat%' : 문서를 하나씩 검사
scanned += 1
if 'cat' in text:
like.append(doc_id)
print('LIKE:', like, '검사한 문서', scanned)
# ate [3] big [4, 6] cat [1, 3, 4, 7] dog [5, 6, 8] fast [5, 7, 8]
# fat [1, 2, 4] ran [2, 5, 7] rat [2, 3] sat [1, 6] (한 줄에 하나씩 출력)
# cat: [1, 3, 4, 7]
# fat & cat: [1, 4]
# LIKE: [1, 3, 4, 7] 검사한 문서 8
문서 8개로 만든 장난감 역색인입니다. 문서를 하나씩 읽으며 단어마다 문서 번호를 목록에 추가하고, 두 단어 검색은 두 목록의 교집합으로 계산합니다. cat은 D1, D3, D4, D7이고 fat & cat은 D1, D4입니다. 마지막 반복문은 별도 인덱스가 없는 LIKE처럼 문서를 하나씩 검사해 같은 결과를 얻지만 검사한 문서가 8개입니다. 시각화의 역색인 탭과 LIKE와 전문 검색 탭이 보여 주는 결과입니다. 실제 검색 엔진은 어간 추출, 단어 위치, 순위, 디스크 저장 형식까지 다루므로 훨씬 복잡합니다.
Java — TreeMap과 TreeSet으로 만든 역색인
Map<Integer, String> docs = new TreeMap<>(); docs.put(1, "fat cat sat"); docs.put(2, "fat rat ran"); docs.put(3, "cat ate rat"); docs.put(4, "big fat cat"); docs.put(5, "dog ran fast"); docs.put(6, "big dog sat"); docs.put(7, "cat ran fast"); docs.put(8, "fast dog"); Map<String, TreeSet<Integer>> index = new TreeMap<>(); // 단어는 정렬된 순서로 보관 for (Map.Entry<Integer, String> e : docs.entrySet()) for (String w : e.getValue().split(" ")) index.computeIfAbsent(w, k -> new TreeSet<>()).add(e.getKey()); System.out.println(index); TreeSet<Integer> both = new TreeSet<>(index.get("fat")); both.retainAll(index.get("cat")); // 문서 목록의 교집합 System.out.println("fat & cat: " + both); int scanned = 0; List<Integer> like = new ArrayList<>(); for (Map.Entry<Integer, String> e : docs.entrySet()) { // LIKE '%cat%' : 문서를 하나씩 검사 scanned++; if (e.getValue().contains("cat")) like.add(e.getKey()); } System.out.println("LIKE: " + like + " 검사한 문서 " + scanned); // {ate=[3], big=[4, 6], cat=[1, 3, 4, 7], dog=[5, 6, 8], fast=[5, 7, 8], fat=[1, 2, 4], ran=[2, 5, 7], rat=[2, 3], sat=[1, 6]} // fat & cat: [1, 4] // LIKE: [1, 3, 4, 7] 검사한 문서 8
같은 모델을 자바로 옮겼습니다. TreeMap은 단어를 정렬된 순서로 보관해 색인이 정렬된 단어 목록처럼 출력되고, TreeSet은 문서 번호를 정렬해 담아 retainAll로 교집합을 간단히 구합니다. fat & cat은 D1, D4입니다. 마지막 반복문은 LIKE처럼 모든 문서를 검사하는 방법으로, 검사한 문서가 8개입니다. 색인은 문서를 한 번 훑어 미리 만들어 두고 이후 검색은 색인에서 단어를 찾는 방식입니다. 개념 설명용 코드입니다.
Q1
역색인(Inverted Index)이란?
Q2
LIKE '%검색어%' 조건이 일반 B-Tree 인덱스로 빨라지지 않는 이유는?
Q3
PostgreSQL 전문 검색에서 문서를 검색 가능한 토큰 형태로 변환한 값을 저장하는 데이터 타입은?