Full-text search¶
Full-text search is one API on every dialect with it; each dialect writes the SQL its own way. ClickHouse
has none through hare: __search and every search expression raise UnSupportedError there before any
SQL is sent.
| PostgreSQL | SQLite | |
|---|---|---|
| Engine | tsvector and tsquery | FTS5 |
| What is searched | any text column or expression | the fields of a FullTextIndex the model declares |
__search |
TO_TSVECTOR(column) @@ PLAINTO_TSQUERY(text), or the column as is for a TSVectorField |
rowid IN (SELECT rowid FROM index WHERE index MATCH query) |
SearchRank |
TS_RANK() / TS_RANK_CD() |
bm25() with the sign flipped, NULL for a row the query doesn't match |
SearchHeadline |
TS_HEADLINE() |
highlight(), or snippet() with max_words — NULL for a row the query doesn't match |
| Configurations, lexemes, label weights | yes | no |
| Weights by field | through SearchVector(weight=...) |
SearchRank(weights={"title": 10}) |
What only one dialect runs is declared by two Features flags, and a call that needs a flag the
connection hasn't raises UnSupportedError before any SQL is sent:
supports_text_search_configurations(PostgreSQL) — a text search configuration (config=), aSearchVectoras a value of its own (an annotation,+, a weight), lexemes,SearchRank's label weights,normalizationandcover_density, andSearchHeadline'sconfig,min_words,short_word,highlight_allandmax_fragments;supports_full_text_index(SQLite) — aFullTextIndexandSearchRank's weights by field.
from hare.search import SearchHeadline, SearchQuery, SearchRank, SearchType, SearchVector
await Book.objects.filter(description__search="white whale")
await Book.objects.filter(description__search=SearchQuery("white whale", search_type=SearchType.PHRASE))
await (
Book.objects.filter(description__search="white whale")
.annotate(rank=SearchRank("description", "white whale"))
.order_by("-rank")
)
await Book.objects.annotate(excerpt=SearchHeadline("description", "white whale", max_words=20))
On SQLite the model declares the index the search reads:
from hare.dialects.sqlite.indexes import FullTextIndex
class Book(Model):
title = fields.CharField(max_length=200)
description = fields.TextField()
class Meta:
indexes = (FullTextIndex(fields=("title", "description"), tokenizer="porter unicode61"),)
__search¶
field__search=text matches the rows whose text holds every word of text (SearchType.PLAIN); text
can be a SearchQuery too.
- PostgreSQL vectorizes the column with
TO_TSVECTOR(); aTSVectorFieldis used as it is, with its ownconfig. On a plain text column,field__search=SearchQuery(..., config="russian")vectorizes the column with the query'sconfig. - SQLite matches through the model's
FullTextIndexcovering the field, restricted to the field's column. A field noFullTextIndexcovers, or an annotation other than a plain field, raisesUnSupportedErrorbefore any SQL.
SearchQuery¶
| Argument | Meaning |
|---|---|
value |
The search text, an expression giving it, or PostgreSQL's lexemes (read as RAW) |
config |
The text search configuration ("english") — PostgreSQL |
search_type |
How the text is read — a SearchType; anything else raises ConfigurationError |
invert |
Negate the query |
SearchType:
| Value | Reads | PostgreSQL | SQLite |
|---|---|---|---|
PLAIN |
every word, anywhere | PLAINTO_TSQUERY |
each word as a quoted FTS5 term |
PHRASE |
the words next to each other, in order | PHRASETO_TSQUERY |
one FTS5 phrase |
WEBSEARCH |
words, "quoted phrases", or between alternatives, -word for a word that must be missing |
WEBSEARCH_TO_TSQUERY |
the same, as an FTS5 query; an alternative of only -words (-python, hare or -python) raises UnSupportedError — FTS5 excludes words only after a word the rows hold |
RAW |
the database's own syntax, as it is | TO_TSQUERY |
FTS5's query syntax |
On SQLite, except for RAW, the text is bound as a parameter and turned into an FTS5 query by a function
hare registers on its connections — none of it is read as FTS5 syntax. Text without a word matches no
row.
Queries combine with & (both match), | (either matches) and ~ (negation):
PostgreSQL negates any query (!!). FTS5 negates a query only as the right side of & — SQLite writes
query & ~query as query NOT query; ~query on its own, inside | or on the left of & raises
UnSupportedError before any SQL.
a & b and a | b give a CombinedSearchQuery joining the two with a SearchOperator (AND, OR);
SearchQueryCombinable is the base giving both query classes the operators. A query in PostgreSQL's own
lexeme syntax is a RawSearchQueryText, which a SearchQuery reads as RAW. All of them come from
hare.search.
SearchVector¶
The searchable text of one or more fields or expressions, concatenated with a space between them.
- As the vector of
SearchRank, aSearchVectorof plain field names names the ranked fields on every dialect. - As a value of its own it is PostgreSQL's tsvector (
TO_TSVECTOR(...),SETWEIGHT(...)withweight="A"..."D"— another letter or value raisesQueryError— or an expression giving the letter), concatenated with+. A source that isn't text (a number, JSON) is cast to text, andCOALESCEkeeps a NULL source from making the vector NULL. An encrypted field raises. a + bof two vectors is aCombinedSearchVector;SearchVectorCombinableis the base giving both vector classes the+.
SearchRank¶
How well each row matches, higher for a better match — for an annotation to filter and order by.
| Argument | Meaning |
|---|---|
vector |
The ranked field, a tuple of fields, a SearchVector, or (PostgreSQL) a tsvector expression — a TSVectorField is ranked as it is |
query |
The search text (PLAIN) or a SearchQuery |
weights |
A dict of weights by field — a match in a heavier field ranks higher, 1 for a field left out, each a finite number from 0 to 1000000 (SQLite); or PostgreSQL's weights of the labels D, C, B, A as a sequence of four numbers or an expression — another sequence raises ConfigurationError |
normalization |
PostgreSQL's length normalization bitmask — an integer of 32 bits or an expression; another value raises ConfigurationError |
cover_density |
TS_RANK_CD() instead of TS_RANK() — PostgreSQL |
On SQLite the fields have to be covered by one FullTextIndex of the model, a weight has to name one of
its fields (ConfigurationError otherwise), and a row the query doesn't match ranks NULL — FTS5 ranks
only the rows it matches. On PostgreSQL
TS_RANK() gives such a row 0 for a query of one word, but a rank a little above 0 (1e-20) for one of
several, so rank__gt=0 keeps it — filter by __search to keep only the matching rows.
SearchHeadline¶
SearchHeadline(
expression, query, config=None, start_sel=None, stop_sel=None, max_words=None, min_words=None,
short_word=None, highlight_all=None, max_fragments=None, fragment_delimiter=None,
)
The text with the matched words marked, for an annotation.
| Argument | Meaning |
|---|---|
expression |
The field whose text is marked; on PostgreSQL also an expression |
query |
The search text (PLAIN) or a SearchQuery |
start_sel, stop_sel |
The text before and after a matched word — PostgreSQL's <b> and </b> by default; on SQLite required, FTS5 having no marker of its own; at most 1000 characters |
max_words |
The most words of a fragment — from 1; on SQLite at most 64, and it switches to FTS5's snippet(); on PostgreSQL above min_words, 15 unless given — max_words=3 needs a lower min_words, else PostgreSQL raises OperationalError |
fragment_delimiter |
The text between fragments — PostgreSQL's " ... " by default; on SQLite required with max_words |
config, min_words, short_word, highlight_all, max_fragments |
TS_HEADLINE()'s own options — PostgreSQL |
On SQLite a row the query doesn't match gets NULL, and a SearchHeadline without start_sel and
stop_sel, or with max_words but no fragment_delimiter, raises QueryError before any SQL is sent. A
count out of range or a marker that isn't text raises ConfigurationError.
Lexemes (PostgreSQL)¶
from hare.dialects.postgresql.search import Lexeme
SearchQuery(Lexeme("cat", prefix=True, weight="A") & ~Lexeme("dog"))
Lexeme(value, invert=False, prefix=False, weight=None) builds a raw tsquery from lexemes combined with
&, | and ~; the value is bound as a parameter. A SearchQuery of lexemes is read as RAW.
FullTextIndex (SQLite)¶
class FullTextIndex(SeparateTableIndex):
def __init__(
self,
*,
fields: Sequence[str],
name: str | None = None,
tokenizer: str | None = None,
) -> None: ...
An FTS5 table of its own (CREATE VIRTUAL TABLE ... USING fts5(...)) reading its text from the model's
table (content=), keyed by the model's integer primary key. Triggers on insert, update and delete keep
it in step with the table — a row written by any client, not only hare, is indexed — and it is filled
from the rows already there when it is created. Migrations create and drop it (AddIndex, RemoveIndex)
with its triggers; changing the fields or the tokenizer replaces it.
| Argument | Meaning |
|---|---|
fields |
The indexed text fields — a non-empty list |
name |
The FTS5 table's name — generated from the table and fields by default |
tokenizer |
FTS5's tokenize option: "porter unicode61" (English stems), "trigram" (substrings), ...; FTS5's own unicode61 when None |
The model needs an integer primary key (FTS5's rowid); a model without one, no fields, a descending key
or an empty tokenizer raise ConfigurationError. On a connection without
Features.supports_full_text_index a model declaring the index raises ConfigurationError in
Hare.init(), and a migration creating or dropping it raises UnSupportedError before any SQL.
Query plans¶
Every search expression describes its plan: a later query of the same structure runs on the plan with its own search text bound. Field names, the search type, field weights and the headline's options are part of the structure.