콘텐츠로 이동

(KO) PostgreSQL 전문 검색 — tsvector, tsquery, 사전 체인, GIN

목차

**전문 검색(full-text search)**은 컬럼 값이 특정 문자열과 같은 문서가 아니라 특정 단어를 포함하는 문서를 찾는 문제다. 두 문제는 겉보기엔 비슷하지만 데이터베이스 엔진을 서로 다른 방향으로 끌어당긴다. 동등 비교는 정확하고, 순서를 보존하며, B-트리에 어울린다. “굴절형을 모두 포함하고, 대소문자를 무시하고, 불용어를 제거한 채로 running이라는 단어를 포함하는 문서를 찾아 용어의 현저도 순으로 정렬하라”는 요구는 전혀 다른 세 층의 기계를 요구한다.

  1. 토큰화와 정규화. 원시 텍스트는 구조 없는 바이트 스트림이다. 검색하려면 먼저 스트림을 토큰(단어, 숫자, URL, 이메일 주소, 호스트명)으로 잘라낸 뒤 각 토큰을 *렉셈(lexeme)*으로 접어야 한다. 소문자 변환, 굴절 어미 제거(running → run), 변별력이 없는 불용어(stop word) 제거(the, a, and), 동의어나 시소러스 구문 확장이 여기에 해당한다. 이 단계는 본질적으로 언어 종속적이어서 i18n 서브시스템이 소유한다.

  2. 반전 가능한 문서 표현. 정규화된 문서는 *렉셈의 가방(또는 시퀀스)*이 된다. 정보 검색(IR) 이론의 표준 표현은 **역 인덱스(inverted index)**다(Salton & McGill, Introduction to Modern Information Retrieval, 1983; Zobel & Moffat, “Inverted Files for Text Search Engines”, ACM Computing Surveys, 2006). 역 인덱스는 각 렉셈을 그것이 등장하는 문서(와 문서 내 위치) 목록으로 매핑한다. 단일 문서 쪽의 절반 — 한 문서의 렉셈 정렬 집합과 위치 정보 — 이 PostgreSQL에서는 퍼스트클래스 값인 tsvector로 구체화된다.

  3. 질의 언어와 랭킹 함수. 검색 요청은 단일 값이 아니라 렉셈에 대한 불리언 식이다(run & (fast | quick) & !slow). 구문(phrase) 제약도 가능하다(quick <-> brown은 “quick 직후에 brown”). 매칭은 비교가 아니라 트리 평가다. 많은 문서가 매칭되므로 엔진은 랭킹을 수행해 연관성 점수를 부여해야 한다. 고전 IR은 tf-idf(Okapi BM25, Robertson & Spärck Jones 계열)로 랭킹한다. PostgreSQL은 용어 가중치와 *커버 밀도(cover density)*를 기반으로 더 단순하되 위치 인식적인 방식을 사용한다(Clarke, Cormack & Tudhope, “Relevance Ranking for One- to Three-Term Queries”, Information Processing & Management, 2000).

Database System Concepts(Silberschatz et al.)는 연관성 랭킹·키워드 검색 모델이 관계형 정확 매칭 핵심 바깥에 있다고 판단해 정보 검색을 별도 챕터로 다룬다. Database Internals(Petrov)는 역 인덱스를 B-트리 리프 체인과는 다른 저장·병합 전략을 요구하는 보조 인덱스 패밀리의 일원으로 분류한다.

PostgreSQL이 이 모든 것을 관계형 엔진에 통합하는 설계 통찰은 하나다. PostgreSQL은 별도의 검색 엔진을 측면에 볼트로 조이는 방식을 택하지 않는다. tsvectortsquery를 평범한 사용자 가시 데이터타입으로 노출하고, to_tsvector(text)를 표현식 인덱스에 넣을 수 있는 평범한 함수로 만들며, 배열 포함과 JSONB도 인덱싱하는 GIN(generalized inverted index) 접근 방법을 재사용해 @@를 가속한다. 전문 검색은 특별한 케이스가 아니라 기존 확장성 포인트(커스텀 타입, 커스텀 연산자, 커스텀 GIN opclass, 커스텀 파서/사전 플러그인)의 *합성(composition)*이다.

전문 검색을 제공하는 거의 모든 엔진이 비슷한 파이프라인에 수렴한다. 차이는 각 단계가 어디에 위치하고 얼마나 플러그인 가능한가에 있다. 공통 형태를 먼저 이름 붙이면 PostgreSQL의 선택들을 알려진 설계 공간 안의 결정으로 읽을 수 있다.

분석 파이프라인: 토큰화 → 필터 → 정규화

섹션 제목: “분석 파이프라인: 토큰화 → 필터 → 정규화”

Lucene/Elasticsearch는 analyzer, Oracle Text는 lexer + stoplist + stemmer, PostgreSQL은 parser + dictionaries라고 부른다. 형태는 동일하다. **토크나이저(tokenizer)**가 바이트 스트림을 타입 있는 토큰으로 자르고, 필터 체인이 각 토큰을 변환한다 — 소문자 변환, 불용어 제거, 어간 추출, 동의어 확장 — 해서 0개 이상의 정규화된 용어를 만들어낸다. “0개”가 중요하다. 불용어는 용어를 만들지 않지만 *위치(position)*를 소비해야 하므로, 하위 구문 질의가 거리를 올바르게 측정할 수 있다. 필터 체인이 토큰 타입별(PostgreSQL 방식)인지 전역적(Lucene 방식)인지는 엔진마다 다르다.

문서 벡터와 역 인덱스는 하나의 사물의 두 시점

섹션 제목: “문서 벡터와 역 인덱스는 하나의 사물의 두 시점”

모든 엔진은 문서마다 (렉셈 → 위치) 형태의 압축 정렬 구조를 저장한다. PostgreSQL은 이것을 테이블 컬럼에 저장된 tsvector 으로 구체화한다. Lucene은 인덱스 세그먼트 내부에만 저장한다. 문서별 벡터를 퍼스트클래스 컬럼으로 두는 데 따르는 결과가 있다. SELECT로 꺼내 볼 수 있고, 디버그할 수 있으며, 생성 컬럼에 to_tsvector(body)를 미리 계산해 둘 수 있다. GIN 인덱스는 그 컬럼을 단순히 *반전(invert)*한다. 즉, 각 렉셈을 행 포인터(TID) 포스팅 리스트로 매핑한다. 질의 용어 조회는 인덱스 프로브 한 번으로 포스팅 리스트를 가져오고, 불리언 질의는 포스팅 리스트를 교집합·합집합한다.

불리언 질의 트리와 연관성 랭킹은 분리된 단계

섹션 제목: “불리언 질의 트리와 연관성 랭킹은 분리된 단계”

매칭은 어느 문서가 조건을 충족하는지 답하고, 랭킹은 각 문서가 얼마나 좋은지 답한다. 두 단계는 의도적으로 분리된다. 인덱스는 후보 집합을 저렴하게(종종 손실적으로 — 재검사로 걸러낼 거짓 양성을 포함할 수 있다) 공급하고, 랭킹은 생존한 행에 대해서만 실행되며 전체 문서 벡터를 읽어 근접도·빈도 인식 점수를 계산한다. 분리 덕분에 인덱스는 작게(렉셈 존재 여부만) 유지하면서 랭킹은 정확하게(저장된 tsvector에서 위치를 다시 읽어) 유지할 수 있다.

핵심 패턴이 있다. 인덱스는 가능한 매칭을 반환하는 것이 허용된다. 가중치 제약(run:A — “run, 단 가중치 A인 위치에서만”)과 구문 거리(<->)는 포스팅 리스트 멤버십만으로 항상 결정할 수 없다. GIN 포스팅 리스트는 어느 행이 렉셈을 포함하는지를 기록하지, 어느 가중치 위치에서인지는 아니기 때문이다. 따라서 인덱스는 TRUE 또는 MAYBE를 반환하고, recheck 플래그가 실행기에게 실제 힙 튜플에서 @@를 재평가하도록 지시한다. 이 3진 논리(yes / no / maybe)가 존재 여부만 아는 인덱스가 위치·가중치 민감한 질의를 처리할 수 있게 해 주는 핵심이다.

flowchart LR
  doc["raw document text"] --> P["text parser<br/>(tokenize)"]
  P --> T1["token: asciiword 'Running'"]
  P --> T2["token: blank ' '"]
  P --> T3["token: asciiword 'fast'"]
  T1 --> D["dictionary chain<br/>(lexize: lowercase,<br/>stopword, stem)"]
  T3 --> D
  D --> V["tsvector<br/>'fast':2 'run':1"]
  V --> G["GIN inverted index<br/>(lexeme to row TIDs)"]
  q["query text"] --> QP["to_tsquery<br/>(parse + lexize)"]
  QP --> Q["tsquery<br/>'run' & 'fast'"]
  Q --> G
  G --> C["candidate rows<br/>(TRUE / MAYBE)"]
  C --> R["@@ recheck +<br/>ts_rank scoring"]
  R --> out["ranked results"]

PostgreSQL 전문 검색은 다섯 개의 협력 부품으로 조립된다. 각각 src/backend/tsearch/(언어 엔진)와 src/backend/utils/adt/(타입 연산자) 아래 별도 컴파일 단위다.

  1. 텍스트 파서 (wparser_def.c) — 입력을 23종 토큰 타입(asciiword, word, numword, email, url, host, version, tag 등)으로 분류하는 수작업 유한 상태 기계(FSM). 기본 파서는 pg_catalog.default로 등록된다.

  2. 사전(dictionaries) (dict.c, dict_simple.c, dict_synonym.c, dict_thesaurus.c, dict_ispell.c, spell.c) — 각각 initlexize 함수를 노출하는 플러그인. 사전은 토큰을 0개, 1개, 또는 여러 출력 렉셈으로 매핑하거나, NULL을 반환해 체인의 다음 사전에게 넘긴다.

  3. 렉사이즈 드라이버 (ts_parse.c) — parsetext()가 파서를 실행하고, 토큰마다 설정의 토큰-타입별 사전 목록을 순회하며 어떤 사전이 수락할 때까지 lexize를 호출한다. 상태형 LexizeData 기계는 뒤따르는 토큰을 엿봐야 하는 멀티워드 사전(시소러스)도 지원한다.

  4. 데이터타입과 연산자 (to_tsany.c, tsvector_op.c, tsrank.c) — to_tsvector / to_tsquerytsvector / tsquery 값을 구성하고, ts_match_vq@@를 구현하며, ts_rank / ts_rank_cd가 매칭을 점수화한다.

  5. GIN 브리지 (tsginidx.c) — gin_extract_tsvector, gin_extract_tsquery, gin_tsquery_consistent@@를 역 인덱스에 연결한다.

tsvector: 정렬된 렉셈 + 위치 저장소

섹션 제목: “tsvector: 정렬된 렉셈 + 위치 저장소”

tsvector는 varlena 블롭이다. 카운트, WordEntry 헤더 배열(오프셋 + 길이 + haspos 비트), 그다음 렉셈 바이트와 선택적 위치 배열을 담은 패킹된 문자열 영역으로 구성된다. 헤더 레이아웃은 WordEntry당 4바이트로 고정된다.

// WordEntry — src/include/utils/tsvector.h
typedef struct
{
uint32 haspos:1,
len:11, /* MAX 2Kb */
pos:20; /* MAX 1Mb */
} WordEntry;

위치는 16비트이고, 상위 2비트는 가중치(weight)(A/B/C/D)를 담으며 하위 14비트는 위치 서수를 담는다. MAXENTRYPOS = (1<<14), MAXNUMPOS = 256이 된다.

// position limits — src/include/utils/tsvector.h
#define MAXENTRYPOS (1<<14)
#define MAXNUMPOS (256)
#define LIMITPOS(x) ( ( (x) >= MAXENTRYPOS ) ? (MAXENTRYPOS-1) : (x) )

렉셈 배열은 (길이, 바이트) 기준으로 정렬된 상태를 유지한다. @@ 매칭과 랭킹이 이진 탐색할 수 있기 위해서다. 정렬과 중복 제거는 make_tsvectoruniqueWORD에서 이루어진다. 반복된 렉셈을 합치고 위치 목록을 병합한다.

// make_tsvector — src/backend/tsearch/to_tsany.c
TSVector
make_tsvector(ParsedText *prs)
{
/* Merge duplicate words */
if (prs->curwords > 0)
prs->curwords = uniqueWORD(prs->words, prs->curwords);
/* ... compute lenstr, palloc0(totallen), SET_VARSIZE ... */
in->size = prs->curwords;
/* ... copy each lexeme's bytes + position array into the blob ... */
return in;
}

uniqueWORD는 먼저 compareWORD 비교자(tsCompareString 호출)로 qsort한 뒤, 런을 순회하며 같은 단어를 병합하고 위치를 MAXNUMPOS 상한까지 추가한다.

// uniqueWORD — src/backend/tsearch/to_tsany.c
qsort(a, l, sizeof(ParsedWord), compareWORD);
/* ... */
while (ptr - a < l)
{
if (!(ptr->len == res->len &&
strncmp(ptr->word, res->word, res->len) == 0))
{
/* Got a new word, so put it in result */
res++;
/* ... start a fresh position array ... */
}
else
{
/* same word: append position if within MAXNUMPOS and distinct */
if (res->pos.apos[0] < MAXNUMPOS - 1 && ...)
res->pos.apos[res->pos.apos[0] + 1] = LIMITPOS(ptr->pos.pos);
}
ptr++;
}

to_tsvector: 파서 구동, 렉사이즈, 구체화

섹션 제목: “to_tsvector: 파서 구동, 렉사이즈, 구체화”

to_tsvector_byid가 핵심 구현체다. 단어 수를 추정하고 ParsedText를 할당한 뒤, parsetext()를 호출해 채우고(토큰화 + 렉사이즈), make_tsvector로 정렬된 블롭을 패킹한다.

// to_tsvector_byid — src/backend/tsearch/to_tsany.c
prs.lenwords = VARSIZE_ANY_EXHDR(in) / 6; /* estimate word count */
if (prs.lenwords < 2)
prs.lenwords = 2;
prs.curwords = 0;
prs.pos = 0;
prs.words = (ParsedWord *) palloc(sizeof(ParsedWord) * prs.lenwords);
parsetext(cfgId, &prs, VARDATA_ANY(in), VARSIZE_ANY_EXHDR(in));
out = make_tsvector(&prs);
PG_RETURN_TSVECTOR(out);

인수 없는 to_tsvector(text)는 현재 설정(default_text_search_config GUC)을 조회해 바로 위임한다.

// to_tsvector — src/backend/tsearch/to_tsany.c
cfgId = getTSCurrentConfig(true);
PG_RETURN_DATUM(DirectFunctionCall2(to_tsvector_byid,
ObjectIdGetDatum(cfgId),
PointerGetDatum(in)));

tsqueryQueryItem의 평탄화된 후위 트리다. QI_VAL 피연산자(렉셈, 선택적 접두사/가중치 플래그)이거나 QI_OPR 연산자다. 연산자 종류는 OP_NOT, OP_AND, OP_OR, OP_PHRASE(<-> / <N> 거리 연산자)이며, 마지막 것이 가장 높은 코드 값을 갖는다.

// operator codes — src/include/tsearch/ts_type.h
#define OP_NOT 1
#define OP_AND 2
#define OP_OR 3
#define OP_PHRASE 4 /* highest code, tsquery_cleanup.c */

to_tsqueryto_tsvector보다 미묘하다. 질의의 각 단어도 사전 체인을 통과해야 한다(to_tsquery('Running')이 벡터에 저장된 run과 매칭되어야 하므로). 단어 하나가 여러 변형 렉셈으로 렉사이즈될 수 있다. pushval_morph 콜백이 이를 처리한다. 같은 형태소 변형에서 온 단어들은 AND로 연결하고, 다른 변형들은 OR로 연결하며, 구문 중간에 제거된 불용어는 자리 표시자 위치를 남겨 구문 거리를 보정한다.

// pushval_morph — src/backend/tsearch/to_tsany.c
parsetext(data->cfg_id, &prs, strval, lenval);
/* ... for each output position ... */
/* Push all words belonging to the same variant */
pushValue(state, prs.words[count].word, prs.words[count].len,
weight, ((prs.words[count].flags & TSL_PREFIX) || prefix));
if (cnt)
pushOperator(state, OP_AND, 0); /* same variant: AND */
/* ... */
if (cntvar)
pushOperator(state, OP_OR, 0); /* different variant: OR */

공개 진입점들은 pushval_morph에 전달하는 qoperator와 파스 플래그에서만 차이가 난다.

  • to_tsquery_byid — 완전한 연산자 구문, 인접 형태소에 OP_PHRASE.
  • plainto_tsquery_byidP_TSQ_PLAIN, OP_AND: 입력 전체를 평문 단어로 AND 연결.
  • phraseto_tsquery_byidP_TSQ_PLAIN, OP_PHRASE: 단어들을 구문으로.
  • websearch_to_tsquery_byidP_TSQ_WEB: Google 스타일 구문(따옴표 구문, or, - 부정).
// plainto_tsquery_byid — src/backend/tsearch/to_tsany.c
data.qoperator = OP_AND;
query = parse_tsquery(text_to_cstring(in),
pushval_morph,
PointerGetDatum(&data),
P_TSQ_PLAIN,
NULL);

모든 변환은 텍스트 검색 설정(regconfig, 파서와 토큰-타입별 사전 맵을 묶는 객체)으로 매개변수화된다. getTSCurrentConfig가 세션 기본값을 읽고, lookup_ts_config_cache가 해결된 TSConfigCacheEntry를 캐시한다. 이 캐시 항목의 map 필드가 LexizeExec가 소비하는 토큰-타입 → 사전 목록 배열이다. get_current_ts_config SQL 함수는 전자를 노출한다.

// get_current_ts_config — src/backend/tsearch/to_tsany.c
Datum
get_current_ts_config(PG_FUNCTION_ARGS)
{
PG_RETURN_OID(getTSCurrentConfig(true));
}
flowchart TD
  txt["to_tsvector(cfg, text)"] --> pt["parsetext()"]
  pt --> cfg["lookup_ts_config_cache(cfgId)"]
  pt --> prs["lookup_ts_parser_cache(prsId)"]
  prs --> tok["prsstart / prstoken / prsend<br/>FSM emits typed tokens"]
  tok --> lz["LexizeAddLemm + LexizeExec"]
  lz --> map["cfg->map[token.type]<br/>dictionary list"]
  map --> dic["each dict->lexize until accept"]
  dic --> pw["ParsedWord[] (word, pos, flags)"]
  pw --> mv["make_tsvector: uniqueWORD + pack"]
  mv --> tsv["tsvector value"]

호출 흐름을 실행 순서대로 추적한다. 파서 → 사전 → 렉사이즈 드라이버 → 데이터타입 구성 → 매칭 → 랭킹 → 인덱스 순이다. 섹션 끝의 위치 힌트 테이블이 각 심볼과 updated: 리비전 기준 (파일, 줄 번호) 쌍을 제공한다.

기본 파서는 테이블 구동 FSM이다. 토큰 타입은 tok_alias[]에 이름이 있는 작은 정수로 표현된다. 타입 0(빈 문자열 / blank)은 특별 처리되며 렉사이즈 드라이버에서 건너뛴다. 인식하는 23종 타입은 다음과 같다.

// tok_alias — src/backend/tsearch/wparser_def.c
static const char *const tok_alias[] = {
"", "asciiword", "word", "numword",
"email", "url", "host", "sfloat",
"version", "hword_numpart", "hword_part", "hword_asciipart",
"blank", "tag", "protocol", "numhword",
"asciihword","hword", "url_path", "file",
"float", "int", "uint", "entity"
};

FSM은 TParserStateActionItem 배열로 표현된다. 각 상태마다 (문자 클래스 술어, 플래그, 동작, 다음 상태, 방출 타입) 행 목록이 있다. ASCII 단어 안에 있을 때 단어가 아닌 문자나 EOF를 만나면 ASCIIWORD 토큰을 방출한다(A_BINGO).

// actionTPS_InAsciiWord — src/backend/tsearch/wparser_def.c
static const TParserStateActionItem actionTPS_InAsciiWord[] = {
{p_isEOF, 0, A_BINGO, TPS_Base, ASCIIWORD, NULL},
/* ... transitions to hword/url/host/email states ... */
{NULL, 0, A_BINGO, TPS_Base, ASCIIWORD, NULL}
};

파서는 prsstart, prstoken, prsend 세 개의 함수 관리자 콜백을 노출한다. 사전이나 설정을 알지 못한다.

2. 사전: 렉사이즈 플러그인 계약 (dict_simple.c, dict_synonym.c)

섹션 제목: “2. 사전: 렉사이즈 플러그인 계약 (dict_simple.c, dict_synonym.c)”

사전은 FunctionCall-able 프로시저 쌍이다. initCREATE TEXT SEARCH DICTIONARY 옵션을 파싱해 불투명 상태 블롭을 만들고, lexize(dictData, lemmaText, len, DictSubState*) 를 받아 NULL 종료 TSLexeme 배열을 반환한다. 출력 배열은 세 가지 결과를 인코딩한다.

  • NULL 반환 — “이 토큰을 모름” → 드라이버가 목록의 다음 사전을 시도.
  • 길이 0(빈 첫 렉셈) 배열 반환 — “불용어” → 토큰이 소비되고 렉셈은 나오지 않지만 위치는 전진.
  • 하나 이상의 렉셈 반환 — 수락 및 정규화 완료.

dsimple_lexize가 표준 예시다. 소문자 변환 후 불용어이면 빈 배열, 수락이면 렉셈 하나, 거절이면 NULL을 반환한다.

// dsimple_lexize — src/backend/tsearch/dict_simple.c
txt = str_tolower(in, len, DEFAULT_COLLATION_OID);
if (*txt == '\0' || searchstoplist(&(d->stoplist), txt))
{
pfree(txt);
res = palloc0(sizeof(TSLexeme) * 2); /* empty array = stop word */
PG_RETURN_POINTER(res);
}
else if (d->accept)
{
res = palloc0(sizeof(TSLexeme) * 2);
res[0].lexeme = txt; /* one lexeme = accept */
PG_RETURN_POINTER(res);
}
else
PG_RETURN_POINTER(NULL); /* NULL = decline */

TSLexeme는 드라이버를 조종하는 플래그 비트를 담는다.

// TSLexeme flags — src/include/tsearch/ts_public.h
#define TSL_ADDPOS 0x01 /* this lexeme advances the position counter */
#define TSL_PREFIX 0x02 /* lexeme is a prefix pattern (foo:*) */
#define TSL_FILTER 0x04 /* re-feed lexeme to the rest of the chain */

TSL_FILTER필터링 사전(예: unaccent)을 합성 가능하게 만드는 장치다. 필터 사전은 토큰을 재작성해 체인의 같은 위치로 돌려보내므로, 나중 단계의 어간 추출기가 여전히 실행될 수 있다. 동의어 사전(dsynonym_lexize)은 직접 테이블 조회를 수행하며 멀티워드 교체에 TSL_ADDPOS를 설정한다.

3. 렉사이즈 드라이버: parsetextLexizeExec (ts_parse.c)

섹션 제목: “3. 렉사이즈 드라이버: parsetext와 LexizeExec (ts_parse.c)”

parsetext는 파서와 사전을 묶는 루프다. 설정과 파서 캐시를 조회하고, 파서를 시작한 뒤, 반복해서 토큰을 당기고(prstoken), 렉사이즈 상태 기계에 먹이고, 나온 렉셈을 prs->words[]에 쏟아내며 prs->pos를 토큰마다, TSL_ADDPOS마다 한 번씩 올린다.

// parsetext — src/backend/tsearch/ts_parse.c
do {
type = DatumGetInt32(FunctionCall3(&(prsobj->prstoken),
PointerGetDatum(prsdata),
PointerGetDatum(&lemm),
PointerGetDatum(&lenlemm)));
/* ... IGNORE_LONGLEXEME guard for words >= MAXSTRLEN ... */
LexizeAddLemm(&ldata, type, lemm, lenlemm);
while ((norms = LexizeExec(&ldata, NULL)) != NULL)
{
prs->pos++; /* one position per token */
while (ptr->lexeme)
{
if (ptr->flags & TSL_ADDPOS)
prs->pos++;
prs->words[prs->curwords].word = ptr->lexeme;
prs->words[prs->curwords].pos.pos = LIMITPOS(prs->pos);
ptr++; prs->curwords++;
}
}
} while (type > 0);

LexizeExec가 체인 로직의 핵심이다. 일반 모드에서 cfg->map[type] 사전 목록을 순회하며 각 lexize를 호출한다. 빈 맵이거나 타입 0인 토큰은 건너뛴다.

// LexizeExec (normal mode) — src/backend/tsearch/ts_parse.c
map = ld->cfg->map + curVal->type;
if (curVal->type == 0 || curVal->type >= ld->cfg->lenmap || map->len == 0)
{
RemoveHead(ld); /* skip this token type */
continue;
}
for (i = ld->posDict; i < map->len; i++)
{
dict = lookup_ts_dictionary_cache(map->dictIds[i]);
res = (TSLexeme *) DatumGetPointer(FunctionCall4(&(dict->lexize), ...));
if (ld->dictState.getnext) { /* go multiword mode */ ... }
if (!res) continue; /* dict declined: next dict */
if (res->flags & TSL_FILTER) { /* rewrite & re-feed same chain */ ... }
RemoveHead(ld);
return res; /* accepted */
}

사전이 dictState.getnext를 설정하면(시소러스 스타일 멀티워드 매칭) LexizeExec멀티워드 모드로 전환된다. 사전 id를 기억하고, setNewTmpRes로 잠정 결과를 보관하며, 해당 사전이 커밋하거나 되돌아갈 때까지 뒤따르는 토큰을 같은 사전에 재귀적으로 먹인다. DictSubState 구조체가 이 핸드셰이크의 채널이다.

// DictSubState — src/include/tsearch/ts_public.h
typedef struct
{
bool isend; /* in: text end reached */
bool getnext; /* out: dict wants next lexeme */
void *private_state; /* dict's cross-call scratch */
} DictSubState;

ts_lexize(dictid, word) SQL 함수는 하나의 사전 lexize를 직접 호출하는 디버깅용 단일 토큰 점검기다. getnext 두-번 호출 프로토콜을 처리하고 렉셈 배열을 text[]로 반환한다.

// ts_lexize — src/backend/tsearch/dict.c
res = (TSLexeme *) DatumGetPointer(FunctionCall4(&dict->lexize,
PointerGetDatum(dict->dictData),
PointerGetDatum(VARDATA_ANY(in)),
Int32GetDatum(VARSIZE_ANY_EXHDR(in)),
PointerGetDatum(&dstate)));
if (dstate.getnext) { dstate.isend = true; /* second call */ }

4. 매칭: @@, TS_execute, 구문 거리 (tsvector_op.c)

섹션 제목: “4. 매칭: @@, TS_execute, 구문 거리 (tsvector_op.c)”

@@ 연산자는 ts_match_vq다. tsvector의 정렬된 렉셈 배열을 가리키는 CHKVAL을 설정하고, checkcondition_str 콜백과 함께 3진 트리 평가자 TS_execute를 실행한다.

// ts_match_vq — src/backend/utils/adt/tsvector_op.c
chkval.arrb = ARRPTR(val);
chkval.arre = chkval.arrb + val->size;
chkval.values = STRPTR(val);
chkval.operand = GETOPERAND(query);
result = TS_execute(GETQUERY(query), &chkval,
TS_EXEC_EMPTY, checkcondition_str);
PG_RETURN_BOOL(result);

checkcondition_str은 질의 피연산자가 벡터에 존재하는지를 정렬된 WordEntry 배열에서 이진 탐색으로 확인한다(tsvector를 정렬 상태로 유지하는 이유의 보상이다). 이어 checkclass_str이 가중치 제약을 점검하고 구문 로직을 위한 위치 데이터를 채운다. 접두사 피연산자(foo:*)는 접두사를 공유하는 렉셈 연속 구간을 스캔한다.

// checkcondition_str — src/backend/utils/adt/tsvector_op.c
while (StopLow < StopHigh)
{
StopMiddle = StopLow + (StopHigh - StopLow) / 2;
difference = tsCompareString(chkval->operand + val->distance, val->length,
chkval->values + StopMiddle->pos,
StopMiddle->len, false);
if (difference == 0) { res = checkclass_str(chkval, StopMiddle, val, data); break; }
else if (difference > 0) StopLow = StopMiddle + 1;
else StopHigh = StopMiddle;
}

TS_executeTS_execute_recurse를 얇게 감싼다. TS_execute_recurse3진 값(TS_YES / TS_NO / TS_MAYBE)을 반환한다. MAYBE는 위치를 모르는 호출자(GIN 인덱스)가 가중치·구문 결정을 재검사로 미룰 수 있게 하는 장치다.

// TS_execute — src/backend/utils/adt/tsvector_op.c
bool
TS_execute(QueryItem *curitem, void *arg, uint32 flags,
TSExecuteCallback chkcond)
{
return TS_execute_recurse(curitem, arg, flags, chkcond) != TS_NO;
}

OP_PHRASE 케이스는 TS_phrase_execute로 특별 처리된다. 두 피연산자의 위치 목록을 순회하며 거리가 연산자의 distance 필드와 일치하는 매칭만 남긴다. quick <-> brown(거리 1)이나 a <3> b가 이렇게 집행된다. TS_phrase_execute는 위치 집합을 반환해 중첩된 구문 연산자가 합성될 수 있게 한다.

5. 랭킹: ts_rank와 커버 밀도 ts_rank_cd (tsrank.c)

섹션 제목: “5. 랭킹: ts_rank와 커버 밀도 ts_rank_cd (tsrank.c)”

calc_rank가 표준 ts_rank 핵심이다. 최상위 연산자에 따라 분기한다. AND/PHRASE 질의는 근접 인식형인 calc_rank_and, 나머지는 빈도 인식형인 calc_rank_or를 사용한 뒤 선택된 정규화 비트를 적용한다.

// calc_rank — src/backend/utils/adt/tsrank.c
res = (item->type == QI_OPR && (item->qoperator.oper == OP_AND ||
item->qoperator.oper == OP_PHRASE)) ?
calc_rank_and(w, t, q) :
calc_rank_or(w, t, q);
if (res < 0) res = 1e-20f;
if ((method & RANK_NORM_LOGLENGTH) && t->size > 0)
res /= log((double) (cnt_length(t) + 1)) / log(2.0);
if (method & RANK_NORM_LENGTH) { len = cnt_length(t); if (len > 0) res /= (float) len; }
if ((method & RANK_NORM_UNIQ) && t->size > 0) res /= (float) (t->size);
if (method & RANK_NORM_RDIVRPLUS1) res /= (res + 1);

calc_rank_and는 발생당 가중치에 거리 감쇠를 곱한다. 두 질의 용어가 가까울수록 기여도가 높아진다. 감쇠 곡선은 word_distance인데, 간격이 100 렉셈을 넘으면 1e-30으로 내려앉는 지수 곡선이다.

// word_distance — src/backend/utils/adt/tsrank.c
static float4
word_distance(int32 w)
{
if (w > 100)
return 1e-30f;
return 1.0 / (1.005 + 0.05 * exp(((float4) w) / 1.5 - 2));
}
// calc_rank_and (core product) — src/backend/utils/adt/tsrank.c
dist = abs((int) WEP_GETPOS(post[l]) - (int) WEP_GETPOS(ct[p]));
if (dist || (dist == 0 && (pos[i] == POSNULL || pos[k] == POSNULL)))
{
if (!dist) dist = MAXENTRYPOS;
curw = sqrt(wpos(post[l]) * wpos(ct[p]) * word_distance(dist));
res = (res < 0) ? curw : 1.0 - (1.0 - res) * (1.0 - curw);
}

calc_rank_or는 선언적·빈도 분기다. 가중치가 적용된 발생들을 1/i^2 할인을 적용해 합산한다. i번째 이후 추가 히트는 수익 체감 방식으로 기여하며, pi^2/6 급수 한계로 정규화된다.

// calc_rank_or — src/backend/utils/adt/tsrank.c
for (j = 0; j < dimt; j++)
{
resj = resj + wpos(post[j]) / ((j + 1) * (j + 1));
if (wpos(post[j]) > wjm) { wjm = wpos(post[j]); jm = j; }
}
res = res + (wjm + resj - wjm / ((jm + 1) * (jm + 1))) / 1.64493406685;

ts_rank_cd(커버 밀도)는 근접 랭커다. get_docrep으로 DocRepresentation(문서 안 질의 용어 위치 배열)을 구성한 뒤, 반복해서 Cover를 호출해 모든 질의 용어를 포함하는 최소 커버(문서에서 가장 짧은 스팬)를 찾고, 커버 길이에 역비례해 점수를 매겨 합산한다. Cover 자체는 QueryRepresentation을 상대로 같은 TS_execute 매처를 재사용한다.

// Cover — src/backend/utils/adt/tsrank.c
while (ptr - doc < len)
{
fillQueryRepresentationData(qr, ptr);
if (TS_execute(GETQUERY(qr->query), qr,
TS_EXEC_EMPTY, checkcondition_QueryOperand))
{
if (WEP_GETPOS(ptr->pos) > ext->q)
{ ext->q = WEP_GETPOS(ptr->pos); ext->end = ptr; found = true; }
break;
}
ptr++;
}

tsvector 컬럼의 GIN 인덱스는 그것을 반전한다. gin_extract_tsvector는 렉셈마다 인덱스 키(key) 하나씩을 반환한다. GIN이 행의 TID별로 저장하는 포스팅 리스트 키 그대로다.

// gin_extract_tsvector — src/backend/utils/adt/tsginidx.c
*nentries = vector->size;
for (i = 0; i < vector->size; i++)
{
txt = cstring_to_text_with_len(STRPTR(vector) + we->pos, we->len);
entries[i] = PointerGetDatum(txt);
we++;
}

gin_extract_tsquery는 질의를 조회할 렉셈 키 집합으로 변환하고, 접두사 피연산자에 partialmatch를 설정하며, 탐색 모드를 선택한다. 필수 양성 용어가 없는 질의(예: !foo)는 전체 스캔(GIN_SEARCH_MODE_ALL)을 강제한다. 인덱스가 순수 부정을 만족할 수 없기 때문이다.

// gin_extract_tsquery — src/backend/utils/adt/tsginidx.c
if (tsquery_requires_match(item))
*searchMode = GIN_SEARCH_MODE_DEFAULT;
else
*searchMode = GIN_SEARCH_MODE_ALL;
/* ... emit one entry per QI_VAL, partialmatch[j] = val->prefix ... */

gin_tsquery_consistent는 손실 매칭이 구현되는 곳이다. GIN은 bool check[] 배열(질의 키마다 “이 렉셈이 후보 행에 있는가?” 플래그)을 전달하고, checkcondition_gin 콜백과 함께 TS_execute_ternary를 실행한다. 인덱스는 존재 여부는 알지만 가중치위치는 모르기 때문에, checkcondition_gin은 존재하지만 가중치 제약이 있는 매칭을 GIN_MAYBE로 강등하고, TS_MAYBE 결과는 *recheck를 설정한다.

// gin_tsquery_consistent — src/backend/utils/adt/tsginidx.c
gcv.first_item = GETQUERY(query);
gcv.check = (GinTernaryValue *) check;
gcv.map_item_operand = (int *) (extra_data[0]);
switch (TS_execute_ternary(GETQUERY(query), &gcv,
TS_EXEC_PHRASE_NO_POS, checkcondition_gin))
{
case TS_NO: res = false; break;
case TS_YES: res = true; break;
case TS_MAYBE: res = true; *recheck = true; break;
}
// checkcondition_gin — src/backend/utils/adt/tsginidx.c
result = gcv->check[j];
if (result == GIN_TRUE)
{
if (val->weight != 0 || data != NULL) /* weight/position needed */
result = GIN_MAYBE; /* force a recheck */
}
return (TSTernaryValue) result;

*recheck가 설정되면 실행기는 힙 튜플의 실제 tsvector에서 @@를 다시 평가한다(ts_match_vq 경유). 인덱스 프로브가 손실적이었어도 구문 거리와 가중치 필터가 정확하게 결정된다.

flowchart TD
  q["WHERE body_tsv @@ to_tsquery('run & fast:A')"] --> ext["gin_extract_tsquery<br/>keys: run, fast"]
  ext --> probe["GIN posting-list probe<br/>per key to TID sets"]
  probe --> check["bool check[] per row<br/>(present? per key)"]
  check --> cons["gin_tsquery_consistent<br/>TS_execute_ternary"]
  cons --> yes["TS_YES to emit row"]
  cons --> maybe["TS_MAYBE (weight A unknown)<br/>set recheck"]
  maybe --> rc["heap fetch + ts_match_vq<br/>exact recheck"]
  yes --> rank["ts_rank / ts_rank_cd"]
  rc --> rank
  rank --> out["ordered results"]

위치 힌트 (2026-06-05, REL_18 273fe94 기준)

섹션 제목: “위치 힌트 (2026-06-05, REL_18 273fe94 기준)”
심볼파일
to_tsvector_byidsrc/backend/tsearch/to_tsany.c243
to_tsvectorsrc/backend/tsearch/to_tsany.c270
make_tsvectorsrc/backend/tsearch/to_tsany.c165
uniqueWORDsrc/backend/tsearch/to_tsany.c77
pushval_morphsrc/backend/tsearch/to_tsany.c492
to_tsquery_byidsrc/backend/tsearch/to_tsany.c579
plainto_tsquery_byidsrc/backend/tsearch/to_tsany.c617
websearch_to_tsquery_byidsrc/backend/tsearch/to_tsany.c692
get_current_ts_configsrc/backend/tsearch/to_tsany.c47
parsetextsrc/backend/tsearch/ts_parse.c355
LexizeExecsrc/backend/tsearch/ts_parse.c173
hlparsetextsrc/backend/tsearch/ts_parse.c540
generateHeadlinesrc/backend/tsearch/ts_parse.c607
ts_lexizesrc/backend/tsearch/dict.c27
dsimple_lexizesrc/backend/tsearch/dict_simple.c76
dsynonym_lexizesrc/backend/tsearch/dict_synonym.c212
tok_aliassrc/backend/tsearch/wparser_def.c62
actionTPS_InAsciiWordsrc/backend/tsearch/wparser_def.c999
ts_match_vqsrc/backend/utils/adt/tsvector_op.c2187
checkcondition_strsrc/backend/utils/adt/tsvector_op.c1268
TS_executesrc/backend/utils/adt/tsvector_op.c1827
TS_phrase_executesrc/backend/utils/adt/tsvector_op.c1582
tsvector_bsearchsrc/backend/utils/adt/tsvector_op.c395
calc_ranksrc/backend/utils/adt/tsrank.c358
calc_rank_andsrc/backend/utils/adt/tsrank.c201
calc_rank_orsrc/backend/utils/adt/tsrank.c284
word_distancesrc/backend/utils/adt/tsrank.c45
calc_rank_cdsrc/backend/utils/adt/tsrank.c855
Coversrc/backend/utils/adt/tsrank.c651
get_docrepsrc/backend/utils/adt/tsrank.c732
gin_extract_tsvectorsrc/backend/utils/adt/tsginidx.c64
gin_extract_tsquerysrc/backend/utils/adt/tsginidx.c94
checkcondition_ginsrc/backend/utils/adt/tsginidx.c183
gin_tsquery_consistentsrc/backend/utils/adt/tsginidx.c214
getTSCurrentConfigsrc/backend/utils/cache/ts_cache.c556
lookup_ts_config_cachesrc/backend/utils/cache/ts_cache.c385

위에 나온 모든 심볼, 코드 발췌, 상수는 /data/hgryoo/references/postgres의 REL_18 작업 트리, 커밋 273fe94852b(2026-06-05)에서 직접 읽어낸 것이다. 검증 사항 및 주의점은 다음과 같다.

  • 커밋 / 브랜치. git log -1273fe94852b 2026-06-05를 보고한다. 모든 발췌는 압축되어 있지만(/* ... */로 생략 표시) 그 외에는 원문 그대로다. 모든 선행 // symbol — path 주석은 위치 힌트 테이블에 인용된 줄에 실제로 존재하는 함수를 명명한다.

  • 저작권 배너는 2025로 표기된다. 모든 파일 헤더가 “Portions Copyright (c) 1996-2025”라고 되어 있다. REL_18 브랜치의 트리 내 배너이며, 2026년자 커밋 날짜와 모순이 아니다. 배너 연도가 실제 커밋 날짜보다 뒤처진다. 향후 독자가 낡은 체크아웃으로 오해하지 않도록 기록해 둔다.

  • tsvector / tsquery 온디스크 레이아웃. WordEntry는 4바이트로 단언된다(tsvectorsend/recv의 어서션). 비트필드 너비(len:11, pos:20, haspos:1)와 MAXENTRYPOS (1<<14) / MAXNUMPOS (256) 상수는 src/include/utils/tsvector.h에서 읽었다. 연산자 코드 OP_NOT/AND/OR/PHRASE = 1/2/3/4src/include/tsearch/ts_type.h에서 읽었다.

  • 3진 매칭. TS_executebool을 반환하며(TS_MAYBE를 true로 붕괴), TS_execute_ternary(GIN consistent 함수가 사용)는 TS_MAYBE를 보존한다. 이 분리가 인덱스 재검사 경로의 메커니즘이며 tsvector_op.ctsginidx.c 양쪽에서 확인했다.

  • 범위 경계. 헤드라인 서브시스템(hlparsetext, generateHeadline, ts_headline)과 통계 경로(ts_typanalyze.c, ts_selfuncs.c)는 트리 내에 있으며 완전성을 위해 이름을 올려두지만, 내부는 이 문서의 범위 밖이다. spell.c ispell 엔진과 dict_thesaurus.c도 스케치 수준으로만 다룬다. GIN 접근 방법 자체(포스팅 트리 레이아웃, 빠른 삽입, 페이지 분할)는 postgres-gin.md로 위임한다. 큰 tsvector의 varlena / TOAST 저장 방식은 postgres-datatypes-adt.mdpostgres-toast.md로 위임한다.

  • contrib/는 범위 밖이다. pg_trgm, unaccent, dict_int / dict_xsyn 예제 사전은 contrib/에 있으며 예시로만 언급한다. 워크스루의 어떤 내용도 이들에 대한 사실을 단언하지 않는다. Snowball 어간 추출 사전(snowball/)은 코어 트리에 있지만 언어별 어간 추출 테이블은 여기서 검토하지 않는다.

PostgreSQL 너머 — 비교 설계와 연구 최전선

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 최전선”

PostgreSQL의 전문 검색은 전용 검색 엔진보다 의도적으로 범위를 좁힌 엔진 내 80% 솔루션이다. 비교 설계 지형에 놓아 보면 무엇을 얻고 무엇을 포기하는지 드러난다.

Lucene / Elasticsearch — 세그먼트 기반 역 인덱스

섹션 제목: “Lucene / Elasticsearch — 세그먼트 기반 역 인덱스”

Lucene은 “본격적인” 텍스트 검색의 기준점이다. PostgreSQL 모델과의 차이는 단순히 규모 차이가 아니라 설계 철학의 차이다.

  • 벡터가 어디에 사는가. Lucene은 문서별 벡터를 사용자가 볼 수 있는 값으로 구체화하지 않는다. 용어→포스팅 맵은 불변 온디스크 세그먼트 내부에만 존재한다. PostgreSQL은 tsvectorSELECT하고, 디버그하고, 생성 컬럼에 미리 계산해 둘 수 있는 퍼스트클래스 컬럼으로 만든다. 비용은 중복(컬럼과 GIN 인덱스 양쪽이 렉셈을 보유)이고, 이득은 투명성과 일반 MVCC·힙 기계 재사용이다.
  • 점수 산정. Lucene은 기본값으로 BM25(Robertson & Spärck Jones의 Okapi 계열)를 사용한다. 문서 길이 정규화와 k1/b 파라미터를 갖춘 포화형 tf-idf다. PostgreSQL의 ts_rankts_rank_cd(Clarke, Cormack & Tudhope의 커버 밀도 방식)는 위치·근접 랭커이며 코퍼스 단위 역문서빈도(idf)를 사용하지 않는다. @@ 인덱스 경로에는 용어의 테이블 전체 빈도를 알 수 있는 통계가 없으므로, 용어의 희귀성이 점수를 올려주지 않는다. 이 점이 PostgreSQL과 Lucene 사이의 가장 큰 의미론적 차이다.
  • 세그먼트 병합 vs. GIN 펜딩 리스트. Lucene은 새 세그먼트를 써서 인덱스 갱신을 상각하고 백그라운드에서 병합한다. GIN은 빠른 갱신 펜딩 리스트(미병합 항목을 일괄 플러시)로 상각한다. 자세한 내용은 postgres-gin.md를 참조한다.

Oracle Text, SQL Server, MySQL — 엔진 내 동류들

섹션 제목: “Oracle Text, SQL Server, MySQL — 엔진 내 동류들”

Oracle Text(CONTEXT 인덱스, CONTAINS() 연산자)는 구조적으로 가장 가까운 동류다. 렉서 + 불용어 + 어간 추출 파이프라인이 역 인덱스를 먹이고, SCORE()로 연관성을 제공한다. SQL Server의 FULLTEXT 인덱스(CONTAINS/FREETEXT)와 MySQL/InnoDB의 FULLTEXT(MATCH … AGAINST — BM25 유사 자연어 모드를 제공)도 같은 자리를 차지한다. 관계형 엔진에 텍스트 검색을 용접해 트랜잭션 내에서 동작하고 일반 컬럼과 조인할 수 있게 만드는 것이다. PostgreSQL의 독특한 점은 합성 정도다. 전체 서브시스템이 공개 확장성 포인트(커스텀 타입 쌍, 커스텀 연산자, GIN opclass, 플러그인 가능한 파서·사전 FMGR 프로시저)로 표현되므로, 코어 C를 건드리지 않고 CREATE TEXT SEARCH DICTIONARY로 새 사전을 등록할 수 있다.

인코어 설계의 두 가지 구조적 한계가 연구·확장 최전선을 정의한다.

  1. 인덱스 경로에 코퍼스 통계가 없다. gin_tsquery_consistent는 행별 bool check[](존재 여부)만 받고, ts_rank는 문서 하나의 tsvector만 본다. 진정한 tf-idf/BM25 랭킹을 하려면 렉셈별 문서빈도 카탈로그를 유지하거나 다른 접근 방법이 필요하다. pg_search/ParadeDB와 rum 확장(포스팅 리스트 안에 위치와 렉셈 가중치를 저장하는 GIN 사촌)이 정확히 이 문제를 공략한다.
  2. 가중치·구문 질의는 손실 재검사가 필수다. 6절에서 보았듯 checkcondition_gin은 가중치·위치 민감 피연산자를 GIN_MAYBE로 강등하여 힙 페치와 두 번째 ts_match_vq를 강제한다. 구문 중심이거나 가중치 필터가 많은 워크로드에서 이것이 비용을 지배할 수 있다. rum의 포스팅 리스트 내 위치 저장이 커뮤니티의 답이며, 동등한 기능을 코어 GIN에 넣는 것은 아직 열린 과제다.

벡터·의미 검색 — 직교하는 축

섹션 제목: “벡터·의미 검색 — 직교하는 축”

2023년 이후 급부상한 밀집 벡터 의미 검색(임베딩 + 근사 최근접 이웃, 예: pgvector의 HNSW/IVFFlat 인덱스)은 다른 검색 모델이지 대체재가 아니다. 렉셈 중첩이 아니라 임베딩 코사인 유사도로 랭킹하며, “이 단어를 포함”이 아니라 “의미적으로 유사”를 답한다. 최근 실용 시스템은 하이브리드 검색을 구성한다 — 렉시컬(tsvector/@@)과 벡터 ANN을 역순위 융합(reciprocal-rank fusion)으로 결합한다. PostgreSQL은 같은 테이블에 tsvector GIN 컬럼과 vector HNSW 컬럼을 함께 둘 수 있는데, 이 자체가 타입으로부터 합성하는 철학의 논거다. 두 인덱스 세계가 트랜잭션, 조인, WHERE 필터링을 공짜로 공유한다. pgvector 내부는 이 문서의 범위 밖이다.

이 설계는 *“관계형 코어와 합성되는 80% 솔루션을 내장”*하는 교과서적 사례다. “최고의 외부 엔진을 볼트로 조이는” 대신 그 자리를 선택했다. 이미 저장하고 조인 중인 행에 대한 트랜잭션적·중간 규모·언어 인식 포함+근접 랭킹 용도에서 tsvector/tsquery/GIN 삼위일체는 운영 단순성 면에서 필적하기 어렵다. 코퍼스 상대 연관성(BM25), 패싯, 분산 샤딩, 수십억 문서 규모에서는 비교 문헌이 Lucene 급 엔진이나 rum/ParadeDB 확장을 가리키며, 갈수록 하이브리드 렉시컬+벡터 파이프라인 쪽으로도 향한다.

소스 파일 (REL_18_STABLE, 커밋 273fe94)

  • src/backend/tsearch/to_tsany.cto_tsvector, to_tsquery, make_tsvector, uniqueWORD, pushval_morph
  • src/backend/tsearch/ts_parse.cparsetext, LexizeExec, LexizeAddLemm, hlparsetext
  • src/backend/tsearch/dict.cts_lexize
  • src/backend/tsearch/dict_simple.cdsimple_lexize
  • src/backend/tsearch/dict_synonym.cdsynonym_lexize
  • src/backend/tsearch/wparser_def.ctok_alias, FSM 상태 테이블
  • src/backend/utils/adt/tsvector_op.cts_match_vq, checkcondition_str, TS_execute, TS_phrase_execute
  • src/backend/utils/adt/tsrank.ccalc_rank, calc_rank_and, calc_rank_or, word_distance, calc_rank_cd, Cover, get_docrep
  • src/backend/utils/adt/tsginidx.cgin_extract_tsvector, gin_extract_tsquery, checkcondition_gin, gin_tsquery_consistent
  • src/backend/utils/cache/ts_cache.cgetTSCurrentConfig, lookup_ts_config_cache
  • src/include/tsearch/ts_public.hTSLexeme, TSL_* 플래그, DictSubState
  • src/include/utils/tsvector.hWordEntry, MAXENTRYPOS, MAXNUMPOS
  • src/include/tsearch/ts_type.hOP_NOT/AND/OR/PHRASE 코드

IR 이론 / 교과서

  • Salton & McGill, Introduction to Modern Information Retrieval, 1983
  • Zobel & Moffat, “Inverted Files for Text Search Engines”, ACM Computing Surveys, 2006
  • Clarke, Cormack & Tudhope, “Relevance Ranking for One- to Three-Term Queries”, Information Processing & Management, 2000
  • Silberschatz et al., Database System Concepts — 정보 검색 챕터
  • Petrov, Database Internals — 보조 인덱스 패밀리

연관 문서

  • postgres-gin.md — GIN 접근 방법 내부 (포스팅 트리, 빠른 삽입, 페이지 분할)
  • postgres-datatypes-adt.md — varlena 타입 시스템과 tsvector TOAST 경로
  • postgres-toast.md — TOAST 압축·저장 메커니즘
  • postgres-overview-i18n-text.md — i18n-text 서브카테고리 진입점