IlmHamroh
JavaScript Full-stack/27-qism. Ma'lumotlar bazasi chuqur: Postgres, ORM, Mongo, Redis7/65-dars23 daqiqa
Mundarija (36)

PostgreSQL full-text search: tsvector, websearch va o'zbekcha qidiruv

Qisqacha: Full-text search matnni so'zlar ro'yxatiga (tsvector) aylantirib, so'rovni (tsquery) shu ro'yxat bilan solishtiradi — ILIKE '%…%' dan farqli ravishda indeksdan foydalanadi va natijani ts_rank bilan saralaydi. O'zbek tili uchun Postgres'da lug'at yo'q, shuning uchun 'simple' konfiguratsiya va o'zimizning normallashtirish (o' → oʻ, kirill → lotin) kerak. Kanonda 500 000 vazifada: ILIKE 157 ms, trigram indeks 12 ms, full-text 1.8 ms.

Bu darsda

  • tsvector, tsquery va @@ operatori qanday ishlashini tushuntirasiz.
  • to_tsquery, plainto_tsquery, websearch_to_tsquery farqini va qaysi biri foydalanuvchi matni uchun xavfsiz ekanini bilasiz.
  • O'zbekcha matndagi apostrof va kirill muammosini ko'rib, normallashtirish funksiyasini o'qiysiz.
  • Generated tsvector ustun, GIN indeks va ts_rank bilan tez, saralangan qidiruv qurasiz.
  • pg_trgm bilan xatoli yozilgan so'zni topasiz.

Oldin bilishingiz kerak: Indeks turlari: GIN, GiST, BRIN va pg_trgm, Ilg'or constraint'lar va generated ustunlar, Funksiyalar va PL/pgSQL.

1. Nega bu kerak?

vazifalar-api da ?q= qidiruvi bor. Hozir u shunday ishlaydi: matn ichida qidirilgan bo'lak bormi (strpos(lower(matn), …)). Bu kichik ro'yxatda yetadi. Lekin uch muammo bor:

  1. Sekin. '%so'z%' ko'rinishidagi qidiruv B-tree indeksni ishlata olmaydi — har qator o'qiladi.
  2. Tartib yo'q. Natijalar "qanchalik mos" bo'yicha saralanmaydi.
  3. O'zbek tili. Foydalanuvchi o'qish, oʻqish, o‘qish yoki kirillcha ўқиш deb yozishi mumkin — to'rt xil yozuv, bitta so'z.

Full-text search (to'liq matnli qidiruv) — matnni oldindan so'zlarga ajratib, so'z ro'yxati bo'yicha qidirish. O'xshatish: kitob oxiridagi ko'rsatkich (indeks sahifasi). "Bozor — 12, 45, 101-betlar". Butun kitobni varaqlamaysiz — ko'rsatkichdan so'zni topib, kerakli betga o'tasiz.

Bu darsdagi so'rovlarni biz ishga tushirdik: PostgreSQL 17.6, kanon holat 07 sxemasi. Kanon (IlmHamroh, 2026-10, PostgreSQL 18.6) bilan chiqish bir xil; farqli joy alohida aytilgan.

2. tsvector va tsquery

2.1 Matn → so'zlar ro'yxati

to_tsvector matnni leksemalar (lexeme — so'zning qidiruv uchun normallashgan shakli) ro'yxatiga aylantiradi. Birinchi argument — konfiguratsiya: qaysi til qoidalari bilan.

sql
SELECT to_tsvector('english', 'Running books are running') AS en,
       to_tsvector('simple', 'Running books are running') AS oddiy;
text
         en         |              oddiy
--------------------+---------------------------------
 'book':2 'run':1,4 | 'are':3 'books':2 'running':1,4
(1 row)

Ikki ustunni solishtiring:

  • english: running → run, books → book — bu stemming (so'zni o'zagiga keltirish). are yo'qoldi — u stop-so'z (har matnda uchraydigan, qidiruvga foydasiz so'z).
  • simple: faqat kichik harfga o'tkazdi. So'z o'zgarmadi, stop-so'z yo'q.

Raqamlar (:1,4) — so'zning matndagi o'rni. Ular ibora qidiruvi va saralash uchun kerak.

Rus tili uchun ham lug'at bor:

sql
SELECT to_tsvector('russian', 'Книги о кошках') AS ru;
text
        ru
-------------------
 'книг':1 'кошк':3
(1 row)

O'zbek tili uchun esa Postgres'da konfiguratsiya yo'q. english ni o'zbekcha matnga qo'llasak — foyda yo'q, faqat zarar:

sql
SELECT to_tsvector('english', 'kitoblar o''qiyman') AS en_uz;
text
             en_uz
-------------------------------
 'kitoblar':1 'o':2 'qiyman':3
(1 row)

kitoblar o'zgarmadi (ingliz qoidasi o'zbek qo'shimchasini bilmaydi), o'qiyman esa ikkiga bo'lindi. Shuning uchun o'zbekcha matn uchun — simple va o'zimizning normallashtirish.

2.2 So'rov va @@

tsquery — qidiruv sharti: so'zlar va mantiqiy amallar (& va, | yoki, ! emas). @@ — "mos keladimi?" operatori. Muhim farq LIKE dan:

sql
SELECT 'bozor' LIKE '%bo%' AS like_, to_tsvector('simple', 'bozor')
  @@ to_tsquery('simple', 'bo') AS fts;
text
 like_ | fts
-------+-----
 t     | f
(1 row)

LIKE harflar bo'lagini qidiradi, full-text esa butun so'zni. bo — boshqa so'z. Bo'lak bo'yicha qidiruv kerak bo'lsa — prefiks (bo:*) yoki trigram (pastda).

Tekshirib ko'ring: Nega english konfiguratsiyasida are yo'qoldi, simple da esa qoldi? O'zbekcha matnda va, bilan so'zlari bilan nima bo'ladi?

Javob

english ning stop-so'zlar ro'yxati bor (are, the, a …) — ular tashlanadi. simple da ro'yxat yo'q, shuning uchun o'zbekcha va, bilan ham so'z sifatida qoladi. Bu hajmni biroz oshiradi va "va" deb qidirsa hamma narsa topiladi — lekin o'zbek tili uchun tayyor ro'yxat yo'q; kerak bo'lsa o'z lug'atingizni yaratish mumkin (hujjatdagi "Simple Dictionary" + StopWords).

3. O'zbek tili: apostrof va kirill

3.1 Muammo: apostrof so'zni bo'ladi

Kanonning asosiy topilmasi. simple ajratuvchisi (parser) oddiy apostrofni ' va egri ’, ‘ ni so'z ajratuvchi deb biladi:

sql
SELECT to_tsvector('simple',
  'Kitob o''qish, G‘ayrat bilan ko’rishish') AS xom;
text
                                  xom
-----------------------------------------------------------------------
 'ayrat':5 'bilan':6 'g':4 'kitob':1 'ko':7 'o':2 'qish':3 'rishish':8
(1 row)

o'qish → o va qish; G‘ayrat → g va ayrat. "qish" deb qidirsangiz — "o'qish" topiladi; "o'qish" deb qidirsangiz — ikki so'zli shartga aylanadi. Qaysi belgi harf hisoblanadi? ts_debug har bo'lakni ko'rsatadi:

sql
SELECT alias, token
FROM ts_debug('simple', 'o''qish oʻqish o’qish')
WHERE alias <> 'blank';
text
   alias   | token
-----------+--------
 asciiword | o
 asciiword | qish
 word      | oʻqish
 asciiword | o
 asciiword | qish
(5 rows)

O'rtadagi oʻqish — bitta so'z. Unda U+02BB belgisi (ʻ, "modifier letter turned comma") — Unicode'da u harf hisoblanadi. O'zbek lotin alifbosining rasmiy belgisi aynan shu. Demak yechim: matndagi hamma apostrof shakllarini ʻ ga keltirish.

3.2 Yechim: uz_normal funksiyasi

Kanonning 104-qidiruv.sql migratsiyasidagi funksiya (to'liq matni «Loyiha qadami» bo'limida) uch ish qiladi, aynan shu tartibda:

  1. kirill → lotin (ў → oʻ, ш → sh, ғ → gʻ …);
  2. unaccent — urg'u belgilarini olib tashlash (é → e), Extension'lar darsidagi kengaytma;
  3. oxirida hamma apostrof shakllari (', ’, ‘, `, ʼ, ´) → ʻ.

Natija:

sql
SELECT x AS asl, uz_normal(x) AS normal,
       to_tsvector('simple', uz_normal(x)) AS tsv
FROM (VALUES ('Kitob o''qish, G‘ayrat bilan ko’rishish'),
             ('Ўқиш: китоб, шаҳар Тошкент, Ғайрат, маъно'),
             ('Café oʻqish a`lo')) v(x);
text
                    asl                    |                     normal                     |                                tsv
-------------------------------------------+------------------------------------------------+--------------------------------------------------------------------
 Kitob o'qish, G‘ayrat bilan ko’rishish    | kitob oʻqish, gʻayrat bilan koʻrishish         | 'bilan':4 'gʻayrat':3 'kitob':1 'koʻrishish':5 'oʻqish':2
 Ўқиш: китоб, шаҳар Тошкент, Ғайрат, маъно | oʻqish: kitob, shahar toshkent, gʻayrat, maʻno | 'gʻayrat':5 'kitob':2 'maʻno':6 'oʻqish':1 'shahar':3 'toshkent':4
 Café oʻqish a`lo                          | cafe oʻqish aʻlo                               | 'aʻlo':3 'cafe':1 'oʻqish':2
(3 rows)

Uch xil yozuv — bir xil leksemalar. Endi lotin va kirill matn bir-birini topadi.

Nega tartib muhim? Kanon buni o'z xatosidan o'rgandi. Birinchi variantda unaccent oxirida turgan edi. unaccent esa ʻ ni oddiy ' ga aylantiradi — va normallashtirish o'z ishini o'zi buzdi: 'o' 'qish' qaytib keldi. Eng yomoni — xato yashirin edi: qidiruv baribir "topardi", chunki so'rov ham xuddi shunday ikki so'zga bo'linardi. Xato fayl bazaga qo'llanishidan oldin ts_debug bilan topilgan va tuzatilgan.

3.3 Cheklovlar — ochiq aytamiz

sql
SELECT uz_normal('Ёзиш'), uz_normal('O‘zbekiston'), uz_normal('ЕР');
text
 uz_normal |  uz_normal  | uz_normal
-----------+-------------+-----------
 yozish    | oʻzbekiston | er
(1 row)

ер → er, lekin to'g'risi yer. So'z boshidagi е → ye qoidasi translate bilan qilinmaydi (buning uchun so'z chegarasini bilish kerak). Ikkinchi cheklov — stemming yo'q: o'zbekcha qo'shimchalarni (-lar, -ni, -dan) olib tashlaydigan lug'at yo'q. Buni pastda prefiks qidiruvi bilan qisman yechamiz.

3.4 IMMUTABLE va'dasi

Funksiyalar va PL/pgSQL darsida "yolg'on IMMUTABLE — xavfli" degandik. uz_normal aynan shunday ongli istisno:

sql
SELECT proname, provolatile FROM pg_proc
WHERE proname IN ('unaccent', 'uz_normal') ORDER BY proname;
text
  proname  | provolatile
-----------+-------------
 unaccent  | s
 unaccent  | s
 uz_normal | i
(3 rows)

unaccent — STABLE: uning lug'ati (fayl) o'zgarishi mumkin. Generated ustun va ifoda indeksi esa faqat IMMUTABLE funksiyani qabul qiladi. Kanon unaccent ni lug'at nomini aniq ko'rsatib chaqiradi va o'zini IMMUTABLE deb e'lon qiladi. Narxi migratsiya izohida yozilgan: unaccent lug'ati yangilansa — indeks va ustunni qayta hisoblash (REINDEX) kerak. Yolg'on va'da qachon kechiriladi? Faqat oqibati hujjatlangan va boshqariladigan bo'lsa.

4. So'rov turlari va foydalanuvchi matni

Postgres'da tsquery yasashning to'rt yo'li bor:

sql
SELECT to_tsquery('simple', 'bozor & !non') AS a,
       plainto_tsquery('simple', 'bozor non') AS b,
       websearch_to_tsquery('simple',
         '"kitob qaytarish" -kutubxona OR ingliz') AS c,
       phraseto_tsquery('simple', 'kitob qaytarish') AS d;
text
        a         |        b        |                         c                         |            d
------------------+-----------------+---------------------------------------------------+-------------------------
 'bozor' & !'non' | 'bozor' & 'non' | 'kitob' <-> 'qaytarish' & !'kutubxona' | 'ingliz' | 'kitob' <-> 'qaytarish'
(1 row)
Funksiya Kirish Kimga
to_tsquery operatorlar bilan: bozor & !non dasturchi yozgan shart
plainto_tsquery oddiy matn, hamma so'z & oddiy qidiruv maydoni
websearch_to_tsquery Google uslubi: "ibora", -so'z, OR foydalanuvchi qidiruv maydoni
phraseto_tsquery so'zlar ketma-ket (<-> — "keyingi so'z") aniq ibora

4.1 Hujumchi nigohi: foydalanuvchi matni to_tsquery da

Qidiruv maydoniga foydalanuvchi ikki so'z yozdi:

sql
SELECT to_tsquery('simple', 'non olish');
text
ERROR:  syntax error in tsquery: "non olish"

to_tsquery operatorsiz ikki so'zni tushunmaydi. Ilova bu xatoni ushlamasa — har shunday so'rov 500 javob. Hujumchi esa ((( yoki a & | b yuborib, log'ni xatolar bilan to'ldiradi va server xatosi matnida nimadir sizib chiqishini kutadi. websearch_to_tsquery esa hech qachon sintaksis xatosi bermaydi — tushunmaganini tashlab yuboradi:

sql
SELECT to_tsquery('simple', 'osh:*') AS a,
       websearch_to_tsquery('simple', 'osh & ') AS b,
       websearch_to_tsquery('simple', '(((') AS c;
text
NOTICE:  text-search query contains only stop words or doesn't contain lexemes, ignored
    a    |   b   | c
---------+-------+---
 'osh':* | 'osh' |
(1 row)

((( — bo'sh so'rov va ogohlantirish, xato emas. Qoida: foydalanuvchi matni — faqat websearch_to_tsquery (yoki plainto_), to_tsquery — faqat siz yig'gan satr uchun. Va albatta parametr bilan ($1), satr ulab emas (Parametrli so'rovlar va SQL injection).

Ikkinchi tuzoq — LIKE ga foydalanuvchi matni. % va _ — LIKE ning maxsus belgilari: foydalanuvchi % yozsa, hamma qator topiladi. Kanon shu qismdagi ORM tajribasida aynan buni topdi: Prisma (Prisma: filtrlash, pagination va agregatlar darsida o'rganiladigan ORM) contains filtrida % va _ ni ekranlamaydi. Full-text'da bu muammo yo'q — % u yerda shunchaki belgi.

Tekshirib ko'ring: Foydalanuvchi -bozor deb yozdi. websearch_to_tsquery qanday shart yasaydi va nima topiladi?

Javob

!'bozor' — "bozor so'zi YO'Q". Bunday shart deyarli hamma qatorga mos keladi va GIN indeks undan foyda ko'rmaydi (inkorni indeksdan qidirib bo'lmaydi). Amalda ilovalar faqat inkordan iborat so'rovni rad etadi yoki uni boshqa shart bilan birga talab qiladi.

5. Qidiruv ustuni, GIN va saralash

5.1 Generated tsvector ustun

Har so'rovda to_tsvector(…) ni hisoblash — sekin (pastda o'lchov bor). Shuning uchun natija saqlanadi — Ilg'or constraint'lar va generated ustunlar darsidagi STORED generated ustun bilan. Kanon 104 dan:

sql
ALTER TABLE vazifalar ADD COLUMN qidiruv tsvector
  GENERATED ALWAYS AS
    (to_tsvector('simple'::regconfig, uz_normal(matn))) STORED;

CREATE INDEX vazifalar_qidiruv_idx ON vazifalar USING gin (qidiruv);

'simple'::regconfig — konfiguratsiya nomi aniq turda. Bu ham IMMUTABLE talabidan: to_tsvector(matn) (konfiguratsiyasiz) sukut sozlamaga (default_text_search_config) bog'liq — generated ustunda ishlamaydi. GIN indeks (Generalized Inverted Index) — aynan "so'z → qatorlar ro'yxati" ko'rsatkichi (Indeks turlari darsida JSONB va massiv uchun ko'rgansiz).

Matn o'zgarsa — ustun o'zi yangilanadi, trigger kerak emas:

sql
BEGIN;
UPDATE vazifalar SET matn = 'Ўқитувчига хат ёзиш' WHERE id = 1
RETURNING matn, qidiruv;
ROLLBACK;
text
BEGIN
        matn         |               qidiruv
---------------------+-------------------------------------
 Ўқитувчига хат ёзиш | 'oʻqituvchiga':1 'xat':2 'yozish':3
(1 row)

UPDATE 1
ROLLBACK

5.2 Qidiruv va saralash

So'rov ham xuddi shu uz_normal dan o'tadi — matn va so'rov bir xil qoidada bo'lishi shart:

sql
SELECT id, matn, ts_rank(qidiruv, q) AS rank
FROM vazifalar,
     websearch_to_tsquery('simple', uz_normal('o''qish')) q
WHERE qidiruv @@ q ORDER BY rank DESC, id;
text
 id |                       matn                       |    rank
----+--------------------------------------------------+------------
  1 | JavaScript kursining 26-qismini boshlash #o'qish | 0.06079271
  2 | Postgres'ni Docker'da ko'tarish #o'qish #docker  | 0.06079271
  5 | Ingliz tili: 20 ta yangi so'z #o'qish #ingliz    | 0.06079271
  7 | Kurs ishi: ma'lumotlar bazasi bo'limi #o'qish    | 0.06079271
 13 | Kutubxonaga kitob qaytarish #o'qish              | 0.06079271
 25 | Algoritmlar: 5 ta masala #o'qish                 | 0.06079271
 31 | Python laboratoriya ishi #o'qish                 | 0.06079271
 34 | SQL JOIN mashqlarini yechish #o'qish             | 0.06079271
(8 rows)

8 ta vazifa. #o'qish tegidagi # tashlandi — teg ham so'z sifatida topildi. Endi xuddi shu so'rov kirillcha:

sql
SELECT id, matn FROM vazifalar
WHERE qidiruv @@ websearch_to_tsquery('simple', uz_normal('ўқиш'))
ORDER BY id;
text
 id |                       matn
----+--------------------------------------------------
  1 | JavaScript kursining 26-qismini boshlash #o'qish
  2 | Postgres'ni Docker'da ko'tarish #o'qish #docker
  5 | Ingliz tili: 20 ta yangi so'z #o'qish #ingliz
  7 | Kurs ishi: ma'lumotlar bazasi bo'limi #o'qish
 13 | Kutubxonaga kitob qaytarish #o'qish
 25 | Algoritmlar: 5 ta masala #o'qish
 31 | Python laboratoriya ishi #o'qish
 34 | SQL JOIN mashqlarini yechish #o'qish
(8 rows)

Aynan o'sha 8 ta. Telefonida kirill klaviatura qolib ketgan foydalanuvchi ham natija oladi.

5.3 Vazn bilan saralash

Yuqorida hamma rank bir xil — har matnda so'z bir marta. Real ilovada sarlavhadagi so'z izohdagidan muhimroq. setweight har qismga vazn beradi: A (eng muhim) … D:

sql
SELECT nom, round(ts_rank(
         setweight(to_tsvector('simple', nom), 'A')
         || setweight(to_tsvector('simple', tavsif), 'D'),
         to_tsquery('simple', 'osh'))::numeric, 4) AS rank
FROM (VALUES ('Osh', 'guruch va sabzi'),
             ('Palov', 'osh uchun guruch')) AS t(nom, tavsif)
ORDER BY rank DESC;
text
  nom  |  rank
-------+--------
 Osh   | 0.6079
 Palov | 0.0608
(2 rows)

Nomida "osh" bor taom 10 barobar yuqori. || — ikki tsvector ni birlashtiradi.

5.4 Prefiks, natijani belgilash

Yozish davomida qidirish ("bozo…") — prefiks :*:

sql
SELECT id, matn FROM vazifalar
WHERE qidiruv @@ to_tsquery('simple', 'bozo:*') ORDER BY id;
text
 id |                   matn
----+------------------------------------------
  8 | Bozordan kartoshka va piyoz olish #bozor
 16 | Bozordan meva olish #bozor
 22 | Un va yog' xarid qilish #bozor #oshxona
 48 | Bozordan kartoshka va piyoz olish #bozor
(4 rows)

Prefiks stemming yo'qligini qisman to'ldiradi:

sql
SELECT count(*) FILTER (WHERE qidiruv @@
         to_tsquery('simple', 'kitoblar')) AS kitoblar,
       count(*) FILTER (WHERE qidiruv @@
         to_tsquery('simple', 'kitob')) AS kitob,
       count(*) FILTER (WHERE qidiruv @@
         to_tsquery('simple', 'kitob:*')) AS kitob_prefiks
FROM vazifalar;
text
 kitoblar | kitob | kitob_prefiks
----------+-------+---------------
        0 |     1 |             1
(1 row)

"kitoblar" deb qidirgan foydalanuvchi "kitob" ni topmaydi; ilova oxirgi so'zga :* qo'shsa — "kitob" bilan boshlangan hamma so'z. Teskari holat ("kitob" deb qidirib "kitoblar" ni topish) prefiks bilan ishlaydi.

ts_headline — natijada topilgan so'zni belgilaydi (saytda <mark> qilish uchun):

sql
SELECT ts_headline('simple', matn,
         to_tsquery('simple', 'bozordan'),
         'StartSel=[, StopSel=]')
FROM vazifalar
WHERE qidiruv @@ to_tsquery('simple', 'bozordan') ORDER BY id;
text
                ts_headline
--------------------------------------------
 [Bozordan] kartoshka va piyoz olish #bozor
 [Bozordan] meva olish #bozor
 [Bozordan] kartoshka va piyoz olish #bozor
(3 rows)

ts_headline asl matn bilan ishlaydi (indekssiz) — uni faqat sahifadagi 20 ta natija uchun chaqiring, hamma qator uchun emas. Natijani HTML'ga qo'yishdan oldin matnni ekranlang (XSS va DOM xavfsizligi darsidagi qoida — [ ] o'rniga <mark> qo'ysangiz, matndagi <script> ham birga keladi).

Tekshirib ko'ring: Nega ORDER BY rank DESC, id da id ham bor?

Javob

Rank'lar teng bo'lishi mumkin (yuqorida 8 tasi bir xil). Teng qatorlar tartibi kafolatlanmagan — sahifalashda bir qator ikki sahifada chiqishi yoki tushib qolishi mumkin. id tartibni barqaror qiladi (Keyset pagination va katta OFFSET darsidagi qoida).

6. pg_trgm: xatoli yozuv va so'z bo'lagi

Full-text butun so'zni qidiradi. Foydalanuvchi "kartoska" deb xato yozsa — hech narsa topmaydi. Bu yerda trigramlar yordam beradi: matn uch harfli bo'laklarga ajratiladi va o'xshashlik o'lchanadi (Indeks turlari: GIN, GiST, BRIN va pg_trgm darsida ko'rgansiz):

sql
SELECT show_trgm('non');
text
        show_trgm
-------------------------
 {"  n"," no",non,"on "}
(1 row)

word_similarity — so'rov matndagi eng o'xshash so'zga qanchalik yaqin (0…1). <% — "o'xshashlik chegaradan (sukut 0.6) yuqori":

sql
SELECT id, matn, round(word_similarity(uz_normal('kartoska'),
         uz_normal(matn))::numeric, 2) AS xato_bilan
FROM vazifalar WHERE uz_normal('kartoska') <% uz_normal(matn)
ORDER BY 3 DESC, id;
text
 id |                   matn                   | xato_bilan
----+------------------------------------------+------------
  8 | Bozordan kartoshka va piyoz olish #bozor |       0.67
 48 | Bozordan kartoshka va piyoz olish #bozor |       0.67
(2 rows)

Bitta harf tushib qolgan — baribir topildi (to'g'ri yozilganda 1.00). Trigram indeks so'z bo'lagini (LIKE '%…%') ham tezlashtiradi:

sql
SELECT id, matn FROM vazifalar
WHERE uz_normal(matn) LIKE '%' || uz_normal('to''lov') || '%'
ORDER BY id;
text
 id |                     matn
----+----------------------------------------------
 18 | Yotoqxona to'lovi #to'lov
 19 | Universitet kontrakti #to'lov
 28 | Elektr to'lovini Click orqali qilish #to'lov
 29 | Gaz to'lovi #to'lov
 32 | Soliq hisobotini topshirish #ish #to'lov
(5 rows)

to'lovi, to'lovini — full-text bularni to'lov deb bilmaydi (stemming yo'q), bo'lak qidiruvi esa topdi. Amalda ikkalasi birga ishlatiladi: avval full-text, natija bo'lmasa — trigram ("Balki … ni nazarda tutdingiz?").

7. Indekslar ishlayaptimi va qancha tez

7.1 EXPLAIN: ifoda aynan bir xil bo'lsin

Kichik jadvalda planner Seq Scan tanlaydi, shuning uchun uni vaqtincha o'chiramiz:

sql
SET enable_seqscan = off;
EXPLAIN (COSTS OFF) SELECT id FROM vazifalar
WHERE qidiruv @@ websearch_to_tsquery('simple', 'bozor');
EXPLAIN (COSTS OFF) SELECT id FROM vazifalar
WHERE uz_normal(matn) LIKE '%lov%';
EXPLAIN (COSTS OFF) SELECT id FROM vazifalar
WHERE matn ILIKE '%lov%';
text
SET
                      QUERY PLAN
-------------------------------------------------------
 Bitmap Heap Scan on vazifalar
   Recheck Cond: (qidiruv @@ '''bozor'''::tsquery)
   ->  Bitmap Index Scan on vazifalar_qidiruv_idx
         Index Cond: (qidiruv @@ '''bozor'''::tsquery)
(4 rows)

                       QUERY PLAN
--------------------------------------------------------
 Bitmap Heap Scan on vazifalar
   Recheck Cond: (uz_normal(matn) ~~ '%lov%'::text)
   ->  Bitmap Index Scan on vazifalar_matn_trgm_idx
         Index Cond: (uz_normal(matn) ~~ '%lov%'::text)
(4 rows)

             QUERY PLAN
------------------------------------
 Seq Scan on vazifalar
   Filter: (matn ~~* '%lov%'::text)
(2 rows)

Uchinchi so'rovga qarang: trigram indeks uz_normal(matn) ifodasi ustiga qurilgan. matn ILIKE — boshqa ifoda, indeks ishlamaydi, Seq Scan taqiqlangan bo'lsa ham. Ifoda indeksi faqat so'rovda aynan o'sha ifoda yozilganda ishlaydi (Partial, expression va covering indekslar). PostgreSQL 18 bu holda rejaga Disabled: true qatorini ham qo'shadi (kanon chiqishida bor) — "taqiqlangan usulni majburan tanladim" degani.

7.2 Kanon o'lchovi: 500 000 vazifa

Kanon katta to'plamda (500 000 vazifa, issiq kesh, PostgreSQL 18.6) bir xil qidiruvni turli usulda o'lchagan:

500 000 vazifada qidiruv vaqti (ms)
  • to_tsvector har so'rovdagenerated ustunsiz2 284 ms
  • ILIKE indekssizParallel Seq Scan157 ms
  • trigram indeks12 ms
  • full-text GIN1,8 ms

Manba: IlmHamroh kanon o'lchovi, 2026-10-24: PostgreSQL 18.6 (Docker), 500 000 vazifa, issiq kesh

Nimaga qarash kerak:

  1. Full-text + GIN — 1.8 ms, ILIKE dan 87 barobar tez.
  2. Generated ustunsiz to_tsvector(…) @@ … — 2 284 ms: har so'rovda 500 000 matnni qayta tahlil qiladi. Saqlangan ustun shuning uchun.
  3. Ko'p natijali so'z boshqacha: "kitob o'qish" 49 499 qatorga mos keldi va top-20 ts_rank bilan 69–83 ms oldi — ts_rank hamma mos qatorni baholaydi, indeks saralashga yordam bermaydi.
  4. Xatoli so'rov (<% trigram bilan) — 22 ms.

Narxi ham bor: trigram indeksi 39 MB, tsvector GIN 33 MB (jadvalning o'zi 115 MB). 10 000 ta INSERT ikkala GIN bilan 1 376 ms, ularsiz 1 073 ms — yozish 28 % sekin.

8. Postgres FTS yoki alohida qidiruv tizimi?

Elasticsearch, Meilisearch, Typesense — alohida qidiruv dvigatellari. Ular haqida kursda alohida dars bor (Qidiruv tizimlari qanday ishlaydi); hozir bilish shart emas. Qisqacha qaror:

Postgres FTS yetadi Alohida dvigatel kerak
Millionlab hujjatgacha, oddiy qidiruv Yuz millionlab hujjat, murakkab relevantlik
Ma'lumot bilan bitta tranzaksiyada, sinxronlash yo'q Facet'lar, sinonimlar, xatoga chidamlilik "qutidan"
Qo'shimcha server yo'q Qidiruvga alohida jamoa va infratuzilma bor

Ko'p loyihalar Postgres bilan boshlaydi va faqat o'lchov "yetmayapti" desa ko'chadi.

9. Ko'p uchraydigan xatolar

9.1 Foydalanuvchi matnini to_tsquery ga berish

syntax error in tsquery. websearch_to_tsquery.

9.2 Matn va so'rovni turli qoidada normallashtirish

Ustun uz_normal(matn) dan, so'rov esa xom — o'qish hech narsa topmaydi. Ikkala tomonga bir xil funksiya.

9.3 Konfiguratsiyasiz to_tsvector(matn)

Natija server sozlamasiga bog'liq; generated ustun va indeks qabul qilmaydi. to_tsvector('simple', …) — aniq konfiguratsiya.

9.4 Ifoda indeksi bor, so'rovda boshqa ifoda

matn ILIKE — trigram indeks uz_normal(matn) ustida, ishlamaydi. So'rovda indeksdagi ifodaning o'zi.

9.5 unaccent lug'ati yangilangach indeksni unutish

IMMUTABLE va'dasi buzildi — eski qatorlar eski qoidada. Lug'at o'zgarsa REINDEX va ustunni qayta hisoblash (migratsiya izohida yozing).

10. Mashqlar

Mashqlar kurs/mashqlar/27/07-fts/ da. Ular o'z jadvallaringiz bilan, istalgan bo'sh bazada bajariladi; yechimlar tekshirilgan (biz ishga tushirdik, PostgreSQL 17.6).

1-mashq (oson): Ikki konfiguratsiya

'Kitoblar va daftarlar' matni uchun english va simple konfiguratsiyasidagi to_tsvector ni chiqaring. Farq bormi? Nega?

Yechim
sql
SELECT to_tsvector('english', 'Kitoblar va daftarlar');
SELECT to_tsvector('simple', 'Kitoblar va daftarlar');

Biz ishga tushirdik (psql -X -q -A -t, PostgreSQL 17.6):

text
'daftarlar':3 'kitoblar':1 'va':2
'daftarlar':3 'kitoblar':1 'va':2

Farq yo'q: ingliz stemmeri o'zbekcha -lar ni bilmaydi, va esa uning stop-so'zlar ro'yxatida yo'q. O'zbekcha matnga english hech narsa bermaydi — simple aniqroq niyatni bildiradi.

2-mashq (o'rta): Menyu qidiruvi

m07_menyu (id int, nom text, tavsif text) jadvalini yarating. Taomlar:

  • osh — "guruch, sabzi, go'sht";
  • manti — "xamir, go'sht, piyoz";
  • lag'mon — "cho'zma xamir, go'sht";
  • ko'k choy — "choy bargi".

qidiruv — generated tsvector ustun (nom — A, tavsif — D vazni, apostrofni ʻ ga almashtirish uchun replace(matn, '''', 'ʻ')), GIN indeks. websearch_to_tsquery bilan foydalanuvchining go'sht so'rovini (so'rov ham replace dan o'tsin) bajaring va natijani ts_rank kamayishi, keyin id bo'yicha chiqaring. Ishora: generated ifodada 'simple'::regconfig yozing (aks holda generation expression is not immutable).

Yechim
sql
CREATE TABLE m07_menyu (
  id int PRIMARY KEY, nom text, tavsif text,
  qidiruv tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('simple'::regconfig,
      replace(nom, '''', 'ʻ')), 'A')
    || setweight(to_tsvector('simple'::regconfig,
      replace(tavsif, '''', 'ʻ')), 'D')) STORED
);
CREATE INDEX m07_menyu_qidiruv_idx ON m07_menyu USING gin (qidiruv);
INSERT INTO m07_menyu (id, nom, tavsif) VALUES
  (1, 'osh', 'guruch, sabzi, go''sht'),
  (2, 'manti', 'xamir, go''sht, piyoz'),
  (3, 'lag''mon', 'cho''zma xamir, go''sht'),
  (4, 'ko''k choy', 'choy bargi');
SELECT nom FROM m07_menyu,
  websearch_to_tsquery('simple', replace('go''sht', '''', 'ʻ')) q
WHERE qidiruv @@ q ORDER BY ts_rank(qidiruv, q) DESC, id;
SELECT nom FROM m07_menyu
WHERE qidiruv @@ websearch_to_tsquery('simple', 'choy');
DROP TABLE m07_menyu;

Biz ishga tushirdik (psql -X -q -A -t, PostgreSQL 17.6):

text
osh
manti
lag'mon
ko'k choy

Birinchi so'rov uchta taom (hammasida goʻsht bir marta, D vaznida — rank teng, tartib id bo'yicha). Ikkinchi so'rov — "choy" nomida ham (A), tavsifda ham bor bitta taom. replace siz go'sht → go + sht bo'lib, "go" so'zi bor har qatorni topardi.

3-mashq (qiyin): Xatoga chidamli qidiruv

m07_shahar (nom text) ga Toshkent, Samarqand, Buxoro, Xiva, Namangan, Andijon ni qo'shing; pg_trgm kengaytmasini yoqing (CREATE EXTENSION IF NOT EXISTS pg_trgm) va lower(nom) ustiga gin_trgm_ops indeks quring. Foydalanuvchi "samarkand" va "toshkend" deb yozdi. Har biri uchun eng o'xshash shaharni similarity bilan (2 xonali) chiqaring. Ishora: ORDER BY similarity(…) DESC LIMIT 1 va lower.

Yechim
sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE m07_shahar (nom text);
INSERT INTO m07_shahar VALUES ('Toshkent'), ('Samarqand'),
  ('Buxoro'), ('Xiva'), ('Namangan'), ('Andijon');
CREATE INDEX m07_shahar_trgm_idx
  ON m07_shahar USING gin (lower(nom) gin_trgm_ops);
SELECT s.soz || ' -> ' || t.nom || ' '
       || round(similarity(lower(t.nom), s.soz)::numeric, 2)
FROM (VALUES ('samarkand'), ('toshkend')) AS s(soz)
CROSS JOIN LATERAL (
  SELECT nom FROM m07_shahar
  ORDER BY similarity(lower(nom), s.soz) DESC LIMIT 1) t;
DROP TABLE m07_shahar;

Biz ishga tushirdik (psql -X -q -A -t, PostgreSQL 17.6):

text
samarkand -> Samarqand 0.54
toshkend -> Toshkent 0.64

LATERAL — har so'z uchun alohida ichki so'rov (Subquery va LATERAL). Bu yerda chegara emas, "eng yaqini" olindi. Chegara bilan qidirsangiz, % operatorining sukut chegarasi 0.3 — ikkala so'z ham o'tadi. Katta jadvalda ORDER BY lower(nom) <-> s.soz (masofa operatori) GiST indeks bilan tezroq.

11. Loyiha qadami: qidiruv migratsiyasi (holat 07)

Holat 04 → holat 07. Bitta fayl — sql/104-qidiruv.sql. Kanon fayli to'liq; uzun qatorlar telefon uchun bo'lindi, mazmuni aynan (faqat bitta izohdagi ichki o'lchov fayli nomi tushirildi):

sql
-- 104-qidiruv.sql — 27-qism #07 (DB-68, holat 07): vazifalar bo'yicha
-- qidiruv — full-text (tsvector) + pg_trgm (xatoli yozuv).
-- O'zbek tili uchun Postgres'da lug'at (stemming) YO'Q → 'simple'
-- konfiguratsiya (so'zni o'zgartirmaydi, faqat kichik harf).
-- Muammo (o'lchandi): 'simple' parser oddiy
-- apostrofni (') va ’ ni so'z ajratuvchi deb biladi:
--   o'qish → 'o' + 'qish'; ʻ (U+02BB) esa harf → 'oʻqish' bitta so'z.
--   Yechim — normallashtirish: hamma apostrof shakllari → ʻ (U+02BB),
--   kirill → lotin (foydalanuvchi "ўқиш" deb qidirsa ham topilsin),
--   unaccent (é → e). Shu funksiya ham matnga, ham so'rovga
--   qo'llanadi.

CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- IMMUTABLE va'dasi: unaccent() o'zi STABLE (lug'at o'zgarishi
-- mumkin) — lug'atni aniq ko'rsatib o'rab IMMUTABLE deymiz
-- (generated ustun va indeks uchun shart). unaccent lug'ati
-- yangilansa — REINDEX/qayta hisoblash kerak (izohda ochiq aytilgan).
CREATE FUNCTION uz_normal(t text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
-- tartib muhim (o'lchandi): unaccent ʻ ni ASCII ' ga aylantiradi va
-- ё → е qiladi — shuning uchun 1) kirill → lotin, 2) unaccent,
-- 3) OXIRIDA hamma apostrof shakllari → ʻ
RETURN translate(
  public.unaccent('public.unaccent'::regdictionary,
    translate(
      replace(replace(replace(replace(replace(replace(
      replace(replace(replace(replace(replace(replace(
        lower(t),
        'ў', 'oʻ'), 'ғ', 'gʻ'), 'ш', 'sh'), 'ч', 'ch'),
        'ё', 'yo'), 'ю', 'yu'), 'я', 'ya'), 'ц', 'ts'),
        'щ', 'sh'), 'қ', 'q'), 'ҳ', 'h'), 'ъ', 'ʻ'),
      'абвгдежзийклмнопрстуфхэь',
      'abvgdejziyklmnoprstufxe')),
  'ʼ''’‘`´', 'ʻʻʻʻʻʻ');

ALTER TABLE vazifalar ADD COLUMN qidiruv tsvector
  GENERATED ALWAYS AS
    (to_tsvector('simple'::regconfig, uz_normal(matn))) STORED;

CREATE INDEX vazifalar_qidiruv_idx ON vazifalar USING gin (qidiruv);
-- Xatoli yozuv / so'z bo'lagi (ILIKE '%…%', similarity) uchun trigram
CREATE INDEX vazifalar_matn_trgm_idx
  ON vazifalar USING gin (uz_normal(matn) gin_trgm_ops);

-- SELECT * view yangi ustunni ko'rmaydi (26 #40 topilmasi)
-- — qayta yaratiladi
CREATE OR REPLACE VIEW faol_vazifalar AS
  SELECT * FROM vazifalar WHERE ochirilgan_vaqt IS NULL;

COMMENT ON FUNCTION uz_normal(text) IS
  'Qidiruv uchun: kichik harf, kirill→lotin, apostroflar→ʻ, '
  'unaccent. IMMUTABLE — unaccent lug''ati o''zgarsa REINDEX';
COMMENT ON COLUMN vazifalar.qidiruv IS
  'to_tsvector(simple, uz_normal(matn)) — '
  'STORED generated, GIN indeks';

Uch tafsilot:

  • translate(matn, 'абв…', 'abv…') — har harfni mos harfga almashtiradi; ikki harfli moslar (ш → sh) uchun undan oldin replace zanjiri.
  • '…' '…' (ikki satr orasida yangi qator) — SQL'da yonma-yon satrlar bitta satrga qo'shiladi; uzun izohni bo'lish uchun. Kanonda bu izoh bitta qatorda.
  • faol_vazifalar view qayta yaratiladi — SELECT * view yaratilgan paytdagi ustunlarni eslab qoladi.

Faylni avvalgi migratsiyalar kabi qo'llang va kanon tekshiruvi kabi holatni noldan qurib ko'ring (kanon: namunasiz va namuna bilan — ikkalasi exit 0). Commit:

bash
git add sql/104-qidiruv.sql
M="db: qidiruv — uz_normal (apostrof, kirill),"
M+=" generated tsvector + GIN, pg_trgm (104)"
git commit -m "$M"

Halol eslatma: vazifalar-api ning ?q= qidiruvi kanonda hali strpos(lower(matn), …) bilan ishlaydi — bazada qidiruv ustuni tayyor, lekin API'ni unga ulash kanon qadamlariga kirmagan. Ulash uchun so'rov sharti qidiruv @@ websearch_to_tsquery('simple', uz_normal($1)) bo'ladi; buni o'z loyihangizda sinab ko'rishingiz mumkin.

12. Real ishda

  • Onlayn do'kon, e'lonlar sayti, bilimlar bazasi — "tez va arzon qidiruv" ko'pincha aynan Postgres FTS + pg_trgm. O'zbek bozoridagi loyihalarda lotin/kirill aralashuvi — kundalik muammo; normallashtirish funksiyasi shu yerda pul tejaydi.
  • Supabase va boshqa Postgres platformalari qidiruvni shu tsvector + GIN orqali tavsiya qiladi.
  • AI qidiruv (ma'no bo'yicha) — vektor bazalar va pgvector; ko'pincha full-text bilan birga ("gibrid qidiruv"). Keyingi darsda kengaytma sifatida tilga olinadi.
  • Intervyu: "LIKE '%x%' nega sekin va nima bilan almashtirasiz?", "GIN indeks qanday ishlaydi?".

Xulosa

  • Full-text: matn → tsvector (leksemalar), so'rov → tsquery, @@ — moslik; butun so'z bo'yicha, LIKE kabi bo'lak bo'yicha emas.
  • O'zbek tili uchun lug'at yo'q: simple + uz_normal (kirill → lotin, unaccent, oxirida apostroflar → ʻ); tartib muhim.
  • Foydalanuvchi matni — websearch_to_tsquery (sintaksis xatosi bermaydi).
  • Generated STORED ustun + GIN: kanonda 1.8 ms; har so'rovda hisoblash — 2 284 ms.
  • ts_rank + setweight — saralash; :* — prefiks; ts_headline — belgilash.
  • pg_trgm — xatoli yozuv va so'z bo'lagi; ifoda indeksi so'rovda aynan o'sha ifoda bilan ishlaydi.

Keyingi dars: Extension'lar — unaccent va pg_trgm kabi kengaytmalar qanday o'rnatiladi, qaysilari bor va managed bazada nimaga ruxsat.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
PostgreSQL full-text search: tsvector, websearch va o'zbekcha qidiruv — IlmHamroh