/*------------------------------------------------------------------------- * * pg_fts_tsanalyze.c * Analyzer that reuses PostgreSQL's existing text-search pipeline. * * Stage 2 of pg_fts. Where pg_fts_analyze.c provides a minimal self-contained * tokenizer, this file makes the analyzer *pluggable* by binding an ftsdoc to * any installed text search configuration (pg_ts_config): the configured * parser and dictionary chain (snowball stemmers, ispell, synonyms, thesaurus, * stopwords) are run via parsetext(), and the resulting normalized lexemes are * folded into an ftsdoc. No tokenizer or dictionary code is reimplemented -- * this is the reuse the design calls for. * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * * IDENTIFICATION * pg_fts_tsanalyze.c * *------------------------------------------------------------------------- */ #include "postgres.h" #include "pg_fts.h" #include "catalog/pg_type.h" #include "mb/pg_wchar.h" #include "parser/scansup.h" #include "tsearch/ts_cache.h" #include "tsearch/ts_type.h" #include "tsearch/ts_utils.h" #include "utils/builtins.h" #include "utils/memutils.h" /* qsort_arg comparator: elements are indices into a ParsedWord array (arg). * Ties on (text, len) break by token position so a distinct-term run comes out * with its positions ascending -- phrase_step requires each term's position * list to be ascending (this mirrors cmp_rawterm in pg_fts_analyze.c). */ typedef struct WordSortArg { ParsedWord *words; const uint32 *pos; /* true 1-based token ordinal of each word */ } WordSortArg; static int cmp_word_idx(const void *a, const void *b, void *arg) { WordSortArg *sa = (WordSortArg *) arg; ParsedWord *words = sa->words; int ia = *(const int *) a, ib = *(const int *) b; ParsedWord *wa = &words[ia]; ParsedWord *wb = &words[ib]; int min = Min(wa->len, wb->len); int c = memcmp(wa->word, wb->word, min); if (c != 0) return c; if (wa->len != wb->len) return wa->len - wb->len; /* same term: order by position so positions come out ascending */ if (sa->pos[ia] < sa->pos[ib]) return -1; if (sa->pos[ia] > sa->pos[ib]) return 1; return 0; } /* * Build an ftsdoc from the words produced by parsetext(). The words are not * sorted and may contain duplicates and multiple variants per position, so we * sort, deduplicate and count term frequency, exactly like the simple * analyzer. doclen is the number of token positions, which parsetext tracks * in prs->pos. Per-token positions are stored (FTS_DOCF_POSITIONS) so phrase * and NEAR queries enforce adjacency on this path too; parsetext() fills each * ParsedWord.pos.pos with a 1-based token ordinal. */ static FtsDoc ftsdoc_from_parsed(ParsedText *prs, const uint32 *wpos, uint32 doclen, uint8 label) { WordSortArg sa; int nw = prs->curwords; ParsedWord *words = prs->words; int *order; int i; int ndistinct = 0; Size lexbytes = 0; FtsDoc doc; Size posbase; Size total; int npos; FtsTermEntry *entries; char *lexemes; uint32 *positions; uint32 off; uint32 pidx; if (nw == 0) { total = FTS_DOC_HDRSIZE; doc = (FtsDoc) palloc0(total); SET_VARSIZE(doc, total); doc->version = FTS_DOC_VERSION; doc->flags = 0; doc->nterms = 0; doc->doclen = 0; doc->lexbytes = 0; return doc; } /* index-sort words by (text, len, pos) without disturbing the array */ order = (int *) palloc(nw * sizeof(int)); for (i = 0; i < nw; i++) order[i] = i; sa.words = words; sa.pos = wpos; qsort_arg(order, nw, sizeof(int), cmp_word_idx, &sa); for (i = 0; i < nw;) { int run = 1; ParsedWord *w = &words[order[i]]; while (i + run < nw) { ParsedWord *n = &words[order[i + run]]; int min = Min(w->len, n->len); int c = memcmp(w->word, n->word, min); if (c == 0) c = w->len - n->len; if (c != 0) break; run++; } lexbytes += w->len; ndistinct++; i += run; } total = FTS_DOC_HDRSIZE + (Size) ndistinct * sizeof(FtsTermEntry) + lexbytes; /* one stored position per token; npos == nw (sum of tf over all terms) */ npos = nw; posbase = MAXALIGN(total); total = posbase + (Size) npos * sizeof(uint32); if (total > MaxAllocSize) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("ftsdoc document is too large"), errdetail("An ftsdoc value is limited to %zu bytes; this document needs %zu.", (Size) MaxAllocSize, total))); doc = (FtsDoc) palloc0(total); SET_VARSIZE(doc, total); doc->version = FTS_DOC_VERSION; doc->flags = FTS_DOCF_POSITIONS; doc->nterms = ndistinct; doc->doclen = doclen; doc->lexbytes = lexbytes; entries = FTS_DOC_ENTRIES(doc); lexemes = FTS_DOC_LEXEMES(doc); positions = FTS_DOC_POSITIONS(doc); off = 0; pidx = 0; ndistinct = 0; for (i = 0; i < nw;) { int run = 1; int k; ParsedWord *w = &words[order[i]]; while (i + run < nw) { ParsedWord *n = &words[order[i + run]]; int min = Min(w->len, n->len); int c = memcmp(w->word, n->word, min); if (c == 0) c = w->len - n->len; if (c != 0) break; run++; } entries[ndistinct].off = off; entries[ndistinct].len = w->len; entries[ndistinct].tf = run; entries[ndistinct].posoff = pidx; memcpy(lexemes + off, w->word, w->len); off += w->len; /* cmp_word_idx broke ties by pos, so this run is already ascending */ for (k = 0; k < run; k++) positions[pidx++] = FTS_POS_MAKE(wpos[order[i + k]], label); ndistinct++; i += run; } if (label != 0) doc->flags |= FTS_DOCF_WEIGHTS; return doc; } /* * fts_analyze_with_config -- analyze text using a specific TS configuration. * * Positions (CHANGELOG 1.15.0, ROADMAP I9): parsetext() stores each token's * ordinal in a 16-bit field capped at MAXENTRYPOS - 1 = 16,383, so through * 1.14.3 every token past the 16,383rd shared that position and a phrase * starting there could not match (the unconfigured analyzer was not capped). * Its own counter (prs.pos) is a full int32. * * The text is parsed whole, exactly as before. Only when that parse ran past * the cap is the result discarded and the text parsed again in chunks of at * most FTS_PARSE_CHUNK positions, each chunk's ordinals offset by the * positions of the chunks before it (a chunk that still overflows -- a * thesaurus adds positions -- is retried with half the budget). So a * document whose positions 1.14.3 got right is analyzed identically. Since 1.15.1 (CHANGELOG, ROADMAP I9a) a * chunk is bounded by positions -- only a token of a type the configuration * maps to dictionaries takes one -- and ends at a token boundary of the * configured parser verified to reproduce the whole text's raw tokens on both * sides (fts_cut_reproduces): 1.15.0 cut every 16,383 bytes after whitespace * and split tags, comments and