Търсене в текст
Накратко: Пълнотекстовото търсене намира думи в текста през обърнат индекс, който пази за всяка дума в кои редове се среща, вместо да чете всичко с LIKE.
Проблем
Полето за търсене
Сайтът получава поле за търсене в отзивите. Първата идея е LIKE:
Отзивът „Кафето беше с изтекъл срок.“ липсва. В SQLite LIKE не различава главни от малки букви само за латиницата, а К и к са различни. Друго търсене:
„нов“ хваща и „Обикновена“, защото LIKE търси парче от текста, а не дума. А % в началото не позволява индекс: при милион отзива базата чете милион текста за всяко търсене.
Идеята
Обърнатият индекс
Пълнотекстовото търсене прави обратното на таблицата. Таблицата казва „отзив 3 съдържа думите най-доброто, кафе, в, квартала“. Обърнатият индекс (inverted index) казва „думата кафе е в отзиви 3 и 6“. Търсенето на дума е едно четене в индекса, а не обхождане на текстовете.
Текстът първо се разбива на думи (tokenizer). unicode61 реже по интервали и препинателни знаци и прави всички букви малки, включително кирилицата. SQLite пази индекса във виртуална таблица с fts4:
docid е номерът на реда в индекса. Тук той е номерът на отзива, за да се свързва с reviews. MATCH е търсенето в индекса, а не = или LIKE.
Така изглеждат думите в индекса (fts4aux е таблица, която показва думите и в колко документа е всяка):
Идеята
Окончанията
В индекса „кафе“ и „кафето“ са две различни думи. Затова MATCH 'кафе' не намери отзив 13. Най-простата помощ е представка: кафе* значи всяка дума, която започва с „кафе“:
Представката е груба: кафе* би намерила и „кафене“. Истинските търсачки имат анализатори за всеки език, които свеждат думите до корена им (stemming): „кафето“ и „кафета“ стават „кафе“. SQLite има такъв само за английския (tokenize=porter), а Elasticsearch и OpenSearch имат и за българския.
Пробвай
Тиганът
Намери всички отзиви за тигана, в каквато и да е форма на думата (колони docid и body).
Решение
SELECT docid, body FROM review_search WHERE review_search MATCH 'тиган*';MATCH 'тиган*'. Само 'тиган' не намира нищо: в текстовете думата е „тиганът“. А LIKE '%тиган%' изпуска „Тиганът е добър“, защото започва с главно Т.
Идеята
Повече от една дума
Две думи една след друга значат И: отзивът трябва да има и двете. OR значи поне едната:
Фраза в кавички търси думите една до друга и в този ред. NEAR/3 търси двете думи на най-много 3 думи разстояние:
NOT маха документите с дадена дума: кафе* NOT срок.
По-точно, за любопитните
FTS4 има два синтаксиса на търсенето. В стандартния „без думата“ се пише с минус (кафе* -срок), а в разширения с NOT. sql.js, който пуска SQLite в браузъра, е с разширения, и там -срок е просто думата „срок“. Разширеният позволява и скоби: (ябълки OR домати) NOT скъсан. В Elasticsearch същото е заявка bool с must, should и must_not.
Пробвай
Кафе без оплаквания
Намери отзивите за кафе (в каквато и да е форма на думата), в които не се споменава срок (колони docid и body).
Решение
SELECT docid, body FROM review_search WHERE review_search MATCH 'кафе* NOT срок';MATCH 'кафе* NOT срок'. С -срок в sql.js се търсят отзивите, в които има и кафе, и срок, тоест точно обратното.
Идеята
Откъс и подреждане
Търсачката показва откъс с маркирана дума. snippet го прави сам:
Първите резултати трябва да са най-подходящите. Истинските търсачки ги подреждат по BM25: колкото по-рядка е думата във всички документи и колкото по-често е в този, толкова по-високо, а късият текст с думата бие дългия. Таблицата fts4aux от картата за индекса дава числата за това: в колко документа е всяка дума. FTS4 не подрежда сам (FTS5 и Elasticsearch имат BM25 наготово), затова тук редът идва от ORDER BY.
Упражнение
Търсене с продуктите
1. Индексът пази само текста. Името на продукта и оценката са в таблиците.
Намери отзивите за кафе (в каквато и да е форма на думата) с името на продукта и оценката (колони name, rating и body), от най-високата оценка надолу.
Решение
SELECT p.name, r.rating, r.body
FROM review_search s
JOIN reviews r ON r.id = s.docid
JOIN products p ON p.id = r.product_id
WHERE review_search MATCH 'кафе*'
ORDER BY r.rating DESC;JOIN reviews r ON r.id = s.docid, после JOIN products, и MATCH 'кафе*' в WHERE. Затова docid е номерът на отзива. С LIKE '%кафе%' отзивът с оценка 1 („Кафето беше…“) липсва, а той е най-важният за магазина.
2. Индексът е отделна таблица и не знае сам, когато reviews се промени. Тригер го обновява при всеки нов отзив:
Напиши тригер review_removed, който маха отзива от review_search, когато той се изтрие от reviews.
Решение
CREATE TRIGGER review_removed AFTER DELETE ON reviews
BEGIN
DELETE FROM review_search WHERE docid = old.id;
END;AFTER DELETE ON reviews и DELETE FROM review_search WHERE docid = old.id. При изтриване редът е само old: new има при INSERT и UPDATE. Проверката трие отзив 13 и търси пак: той не трябва да излиза. За пълнота трябва и тригер за UPDATE на текста.
Идеята
Кога отделна търсачка
За търсене в няколко хиляди или милиона текста пълнотекстовият индекс на базата стига: FTS в SQLite, tsvector и GIN индекс в PostgreSQL, FULLTEXT в MySQL. Отделна търсачка (Elasticsearch, OpenSearch, Meilisearch, Typesense) си струва, когато търсенето е главната работа на сайта: анализатори за много езици, поправка на правописни грешки („кафу“ → „кафе“), филтри по категории с броячи до тях, подсказки, докато пишеш, и много машини. Тогава данните се копират в нея, обикновено от поток със събития, а истината остава в базата.
По-точно, за любопитните
В PostgreSQL същото търсене е WHERE to_tsvector('simple', body) @@ to_tsquery('simple', 'кафе:*') с индекс CREATE INDEX ON reviews USING gin (to_tsvector('simple', body)). Конфигурация bulgarian PostgreSQL няма по подразбиране. В Elasticsearch полето получава "analyzer": "bulgarian" и търсенето е {"query": {"match": {"body": "кафе"}}}.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: INSERT: нови редове · DELETE: изтриване на редове · Тригери · Индекси
- Продължава в: Търсене по смисъл · Как се избира база
- Задачи по темата: Видове бази данни