컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
db · 20

전문 검색(역색인)

검색엔진역색인전문검색

전문 검색(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 인덱스를 우선 권장합니다.

색인은 쓸 때 만들고 읽을 때 활용합니다 — 문서를 한 번 훑어 단어마다 문서 목록을 만들어 두면, 검색은 문서 전체를 읽지 않고 색인에서 단어를 찾는 일이 됩니다.
여러 단어는 목록을 합칩니다 — fat & cat처럼 두 단어가 모두 필요하면 두 문서 목록의 교집합을 구합니다. 목록이 정렬되어 있으면 합치기가 쉽습니다.
LIKE '%단어%'는 B-Tree 정렬 순서를 쓰지 못합니다 — B-Tree 인덱스는 값을 정렬해 두기 때문에 LIKE 'foo%'처럼 앞부분이 정해진 패턴은 쓸 수 있지만, %로 시작하는 패턴은 그 순서를 활용할 수 없습니다. 부분 문자열 검색용 인덱스가 필요하면 PostgreSQL의 pg_trgm 확장 같은 방법이 있습니다.
색인의 이점은 단어에 따라 다릅니다 — 일치하는 문서가 적은 단어는 짧은 목록만 읽으면 되지만, 거의 모든 문서에 나오는 흔한 단어는 목록이 길어 이점이 줄어듭니다. 불용어를 빼는 이유 중 하나입니다.
이 시각화에서 확인할 것 — "역색인" 탭에서 문서 8개로 색인이 만들어지고 cat은 D1, D3, D4, D7, fat & cat은 D1, D4가 되는 모습을, "LIKE와 전문 검색" 탭에서 LIKE는 문서 8개를 모두 검사하고 색인은 일치하는 4개만 접근하는 모습을, "토큰과 정규화" 탭에서 한 문장이 토큰, 불용어 제거, 어간 추출을 거쳐 tsvector가 되는 모습을 STEP으로 확인하세요.

전문 검색의 배경

문서에서 단어로 문서를 찾는 문제는 정보 검색(information retrieval) 분야의 오래된 주제이고, 역색인은 그 기본 자료구조로 교재에서 가장 먼저 다루는 구조 가운데 하나입니다. 교재에서는 단어 사전(dictionary)과 단어마다 문서 번호를 모은 포스팅 목록(postings list)으로 색인을 설명하고, 목록의 항목 하나(단어가 문서에 나타났다는 기록)를 포스팅(posting)이라 부릅니다.

관계형 데이터베이스의 일반 인덱스(B-Tree 등)는 값 비교와 범위 검색에 맞춰져 있어서, 글 속의 단어는 LIKE나 정규식으로 찾는 경우가 많았습니다. PostgreSQL 문서는 이 방법이 언어 지식이 없고, 결과를 순위로 정렬하지 못하며, 인덱스 지원이 없어 느릴 수 있다고 설명합니다. 이런 한계 때문에 PostgreSQL을 비롯한 여러 데이터베이스는 전문 검색 기능을 직접 제공하고, 그렇지 않은 환경에서는 별도의 검색 엔진을 함께 두기도 합니다.

숫자로 확인하기

문서 8개, 단어 9개 — 8개 문서에서 서로 다른 단어는 ate, big, cat, dog, fast, fat, ran, rat, sat의 9개이고, 각 단어마다 문서 목록이 하나씩 만들어집니다.
검사 8개 대 접근 4개 — cat이 든 문서를 찾을 때 별도 인덱스가 없는 LIKE는 8개를 모두 검사하고, 색인은 목록 D1, D3, D4, D7의 4개만 접근합니다.
교집합으로 합치기 — fat의 목록 1, 2, 4와 cat의 목록 1, 3, 4, 7에 모두 있는 문서는 D1과 D4입니다.
토큰 12개에서 단어 6개로 — 예문은 12개 토큰이고 불용어 5개를 빼고 어간을 추출하면 ate, cat, fat, mat, rat, sat의 6개가 되며, fat은 위치 2와 11에 두 번 나옵니다. 코드는 같은 색인을 파이썬과 자바로 만들어 cat은 D1, D3, D4, D7, 검사한 문서는 8개를 출력합니다.

자주 하는 오해

전문 검색은 LIKE를 빠르게 만든 것이다 — 둘은 찾는 단위가 다릅니다. LIKE는 글자의 부분 일치이고 전문 검색은 정규화한 단어 단위의 일치라, 단어의 중간 부분(부분 문자열) 검색을 그대로 대신하지는 못합니다.
색인만 만들면 어떤 검색도 빨라진다 — 색인은 만든 방식에 맞는 질의에만 쓰입니다. tsvector의 GIN 인덱스는 @@ 검색용이고, LIKE '%단어%'는 별도의 인덱스(pg_trgm)가 필요합니다.
검색어는 입력한 그대로 비교한다 — 검색어도 문서와 같은 방식으로 정규화해 tsquery로 바꿉니다. 그래서 rat을 검색해도 문서의 rats를 찾을 수 있습니다.
역색인은 검색 엔진에서만 쓴다 — 검색 엔진의 핵심 구조이기도 하지만, PostgreSQL처럼 관계형 데이터베이스도 tsvector와 GIN 인덱스로 같은 방식의 검색을 제공합니다.
english 설정으로 한국어도 잘 검색된다 — 어간 추출과 불용어는 텍스트 검색 설정(사전)에 따라 다르고 english 설정은 영어 규칙입니다. 한국어에는 맞는 설정이나 별도의 형태소 분석 도구가 필요할 수 있습니다.

면접에서 이렇게 물어요

면접 질문

역색인이란 무엇인가요?

단어를 키로 하고 그 단어가 나오는 문서의 목록을 값으로 저장한 구조입니다. 단어로 문서를 바로 찾을 수 있어 문서를 하나씩 훑지 않아도 됩니다.

면접 질문

LIKE '%단어%'가 느린 이유는?

B-Tree 인덱스는 값을 정렬해 두기 때문에 앞부분이 정해진 패턴은 쓸 수 있지만, %로 시작하는 패턴은 정렬 순서를 쓰지 못해 별도 인덱스가 없으면 행을 하나씩 검사하게 됩니다.

면접 질문

tsvector와 tsquery는 무엇인가요?

tsvector는 문서를 정규화한 단어와 위치의 목록이고, tsquery는 정규화한 검색 조건입니다. @@ 연산자로 일치 여부를 판단합니다.

면접 질문

전문 검색에 GIN 인덱스를 쓰는 이유는?

GIN은 키(단어)마다 그 키가 든 행의 목록(posting list)을 저장하는 역색인 형태라, 한 문서에 여러 단어가 들어 있는 tsvector에서 단어로 문서를 찾는 데 맞습니다.

활용 분야

게시글과 상품 검색 — 블로그, 게시판, 쇼핑몰의 제목과 본문에서 단어로 글을 찾는 기능에 씁니다.
문서와 로그 검색 — 문서 저장소나 로그에서 특정 단어가 든 항목을 찾습니다.
검색 엔진 — Elasticsearch 같은 검색 엔진은 역색인을 기반으로 대량의 텍스트를 검색하며, 데이터베이스의 전문 검색으로 부족할 때 함께 두기도 합니다.

함께 보면 좋은 토픽

인덱스 — B-Tree가 앞부분 기준 정렬이라 LIKE '%단어%'에 쓰이지 못하는 이유를 봅니다.
쿼리 실행 계획 — 순차 검색과 인덱스 검색이 실행 계획에서 어떻게 다른지 봅니다.
NoSQL — 관계형이 아닌 저장소의 종류와 특징을 봅니다.

참고 자료

공식 문서 — PostgreSQL: GIN Indexes
공식 문서 — PostgreSQL: pg_trgm
교재 — Manning, C. D., Raghavan, P., Schütze, H., "Introduction to Information Retrieval", Cambridge University Press, 2008

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 인덱스로 빨라지지 않는 이유는?

B-Tree는 "앞에서부터" 정렬된 구조입니다.
B-Tree 인덱스는 값의 앞부분 기준으로 정렬되어 있어, 앞이 %로 시작하는 패턴은 정렬을 활용할 수 없어 전체 테이블을 스캔합니다. 이런 검색에는 전문 검색 인덱스나 trigram 인덱스가 적합합니다.

Q3

PostgreSQL 전문 검색에서 문서를 검색 가능한 토큰 형태로 변환한 값을 저장하는 데이터 타입은?

검색 질의를 표현하는 타입은 tsquery입니다.
to_tsvector 함수로 텍스트를 정규화된 토큰 목록인 tsvector로 바꾸고, tsquery로 검색 조건을 표현해 @@ 연산자로 매칭합니다. tsvector 컬럼에 GIN 인덱스를 만들면 검색이 빨라집니다.