IlmHamroh
JavaScript Full-stack/31-qism. Prodakshn backend va AI integratsiya17/34-dars30 daqiqa
Mundarija (34)

Embedding va semantik qidiruv: matnni vektorga aylantirib, ma'no bo'yicha topish

Qisqacha: Embedding — matnning ma'nosini ifodalovchi sonlar ro'yxati (vektor). Ma'nosi yaqin matnlarning vektorlari ham yaqin bo'ladi, shuning uchun "doktor" so'rovi "Shifokor qabuliga yozilish" vazifasini topadi — umumiy so'z bo'lmasa ham. Yaqinlik kosinus o'xshashlik bilan o'lchanadi. Kanon o'lchovida 8 ta o'zbekcha savolda embedding kerakli vazifani 11/11 marta topdi, so'z mosligi esa 0/11. Loyihada vektorlar pgvector jadvalida saqlanadi va faqat matn o'zgarganda qayta hisoblanadi.

Bu darsda

  • Embedding nima ekanini va kosinus o'xshashlik qanday hisoblanishini brauzerda ko'rasiz.
  • O'lcham, normallashtirish va "hujjat/so'rov" turlari nega muhimligini bilasiz.
  • Kanon o'lchovida embedding va so'z bo'yicha qidiruvni o'zbekcha, kirillcha va inglizcha savollarda solishtirasiz.
  • Embedding'larni qachon qayta hisoblash kerakligini (matn xeshi, model nomi) SQL bilan aniqlaysiz.
  • vazifalar-api ga vazifa_embeddinglar jadvali va GET /v1/qidiruv ni qo'shasiz.

Oldin bilishingiz kerak: Vektor bazalar va pgvector, Postgres'da full-text search, LLM API asoslari, Vercel AI SDK va provayderlarni almashtirish.

O'tgan darsda zaxira model qo'shdik. Bu dars — AI'ning boshqa turi: matn yaratish emas, matnni tushunib qidirish.

1. Nega bu kerak?

Aziz ilovaga "Shifokor qabuliga yozilish" deb yozib qo'ygan. Bir haftadan keyin qidiruvga "doktor" deb yozadi. Natija — bo'sh. Sabab oddiy: full-text search so'zlarni solishtiradi, "doktor" va "shifokor" esa boshqa so'zlar.

Kanon buni o'lchadi. 20 ta vazifa va 8 ta savol: sinonim ("doktor"), umumlashtirish ("oziq-ovqat xaridi" → "Non va sut olish"), kirill yozuv va inglizcha savol. So'z bo'yicha qidiruv 11 ta kerakli javobdan birortasini ham topmadi. Embedding — 11 tasining hammasini.

O'xshatish: kutubxonachi. Yangi kelgan yordamchi kitobni faqat sarlavhadagi so'z bo'yicha qidiradi: "doktor" so'zi yo'q — kitob yo'q. Tajribali kutubxonachi esa "doktor" deganingizda tibbiyot javonini ko'rsatadi. U so'zni emas, ma'noni biladi. Embedding — kompyuterga shu "javon xaritasini" beradi.

2. Embedding nima

2.1 Ta'rif

Embedding (vektor ko'rinishi) — maxsus model matnni o'qib qaytaradigan, uzunligi doim bir xil sonlar ro'yxati. Kanon modeli gemini-embedding-2 har matnga 768 ta son qaytaradi. Bunday ro'yxat — vektor, sonlar soni — o'lcham (dimension).

Har son alohida biror narsani anglatmaydi, uni o'qib bo'lmaydi. Muhimi — vektorlar orasidagi masofa. Model shunday o'rgatilganki, ma'nosi yaqin matnlar fazoda yaqin joylashadi. Xuddi xaritadagi shaharlar kabi: Toshkent va Chirchiq yaqin, Toshkent va Nukus uzoq. Faqat bu xarita 2 o'lchamli emas, 768 o'lchamli.

Semantik qidiruv (semantic search) — so'rov matnining embedding'ini olib, unga eng yaqin hujjat vektorlarini topish. "Semantik" — "ma'noga oid".

2.2 Kosinus o'xshashlik

Ikki vektor qanchalik yaqin? Eng ko'p ishlatiladigan o'lchov — kosinus o'xshashlik (cosine similarity): vektorlar orasidagi burchakning kosinusi. Bir yo'nalishda — 1, perpendikulyar — 0. Formula: skalyar ko'paytma (mos sonlar ko'paytmasining yig'indisi) bo'lingan ikki uzunlik ko'paytmasiga.

Tushunish uchun o'yinchoq 3 o'lchamli vektorlar olamiz. O'qlarni o'zimiz nomladik: ovqat, salomatlik, transport. Haqiqiy embedding'da o'qlarning bunday nomi yo'q.

js
// o'qlar: [ovqat, salomatlik, transport] — o'yinchoq vektorlar
const vazifalar = {
  "Non va sut olish": [0.9, 0.1, 0],
  "Shifokor qabuliga yozilish": [0.05, 0.95, 0.1],
  "Mashinani ko'rikdan o'tkazish": [0, 0.1, 0.95],
};
const savol = [0.1, 0.9, 0.05]; // "doktor"

const skalyar = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);
const uzunlik = (a) => Math.sqrt(skalyar(a, a));
const kosinus = (a, b) => skalyar(a, b) / (uzunlik(a) * uzunlik(b));

for (const [matn, v] of Object.entries(vazifalar)) {
  console.log(kosinus(savol, v).toFixed(3), matn);
}

const birlik = (a) => a.map((x) => x / uzunlik(a));
const s1 = birlik(savol);
const v1 = birlik(vazifalar["Shifokor qabuliga yozilish"]);
console.log("uzunlik:", uzunlik(s1).toFixed(4));
console.log("skalyar:", skalyar(s1, v1).toFixed(3));

Konsolda:

text
0.219 Non va sut olish
0.997 Shifokor qabuliga yozilish
0.159 Mashinani ko'rikdan o'tkazish
uzunlik: 1.0000
skalyar: 0.997

"doktor" vektori "Shifokor" vektori bilan deyarli bir yo'nalishda — 0.997. Qolgan ikkitasi bilan — 0.2 atrofida. reduce — massivning HOF metodlari darsidan.

Oxirgi ikki qator muhim. birlik() vektorni uzunligiga bo'ladi — normallashtirish (normalization). Natijada uzunlik 1 bo'ladi. Ikki birlik vektorning oddiy skalyar ko'paytmasi kosinusning o'zi 0.997-bob. Shuning uchun vektorlar oldindan normallashtirilsa, qidiruvda bo'lish amali kerak emas.

2.3 O'lcham va normallashtirish

Gemini embedding API'siga outputDimensionality: 768 beriladi (to'liq o'lcham — 3 072). Kanon kodidagi izoh: "o'lcham kichik bo'lsa, Gemini vektorni normallashtirmasdan qaytaradi". Hujjatni tekshirdik (2026-11-03): bu eski gemini-embedding-001 uchun to'g'ri. gemini-embedding-2 esa kesilgan o'lchamni o'zi normallashtiradi. Kanon baribir har vektorni o'zi normallashtiradi — zarari yo'q va model almashsa ham to'g'ri ishlaydi:

ts
export function normallashtir(v: number[]): number[] {
  const u = Math.hypot(...v);
  return u === 0 ? v : v.map((x) => x / u);
}

Math.hypot(...v) — hamma sonlar kvadratlari yig'indisining ildizi, ya'ni uzunlik. Kanon tajribasi chiqargan |v| = 1.0000 — shu funksiyadan keyingi uzunlik. U kodimiz ishlaganini ko'rsatadi, Gemini xom vektorini emas.

O'lcham tanlovi — narx va sifat o'rtasidagi kelishuv. 768 son vector(768) ustunida ~3 KB joy oladi (har son 4 bayt). O'lcham ikki baravar kattalashsa, joy va qidiruv vaqti ham o'sadi. pgvector darsida halfvec (16-bit) bilan indeks ikki baravar kichrayganini ko'rgansiz: 117 MB → 59 MB.

2.4 Hujjat va so'rov

Qidiruvda ikki xil matn bor: hujjat (vazifa matni — saqlanadi) va so'rov (foydalanuvchi savoli — qisqa, bir martalik). Ba'zi modellar ularni farqlaydi. Kanon buning uchun Gemini'ning taskType maydonini yuboradi: RETRIEVAL_DOCUMENT va RETRIEVAL_QUERY. Kanon provayder shartnomasida bu tur: "hujjat" | "sorov":

ts
const taskType = tur === "hujjat"
  ? "RETRIEVAL_DOCUMENT" : "RETRIEVAL_QUERY";
const r = await this.#post(`${model}:batchEmbedContents`, {
  requests: matnlar.map((text) => ({
    model, content: { parts: [{ text }] }, taskType,
    outputDimensionality: this.#s.embeddingOlcham,
  })),
}, s);

Bu — kanon gemini.ts dan parcha (qatorlar bo'lingan). batchEmbedContents — batch embedding: bitta HTTP so'rovda ko'p matn. Kanon bitta so'rovga ko'pi bilan 100 ta matn qo'yadi. 20 vazifa uchun 20 ta so'rov o'rniga bittasi — tezroq, limitga ham kamroq uriladi.

Diqqat: Gemini hujjatiga ko'ra (2026-11-03), taskType faqat gemini-embedding-001 uchun. gemini-embedding-2 da bu maydon qo'llanmaydi: vazifa matnning o'ziga yoziladi — so'rov task: search result | query: …, hujjat title: none | text: … ko'rinishida. Kanon taskType ni yuborgan, API xato qaytarmagan va natija 11/11 chiqqan. Lekin maydon e'tiborga olinganmi — kanon o'lchamagan. Bu ochiq xato: prefiks bilan ham o'lchab ko'rish kerak.

flowchart LR
  subgraph Saqlash
    V["Vazifa matni"] --> E1["Embedding: hujjat"]
    E1 --> DB[("vazifa_embeddinglar")]
  end
  subgraph Qidiruv
    S["Savol"] --> E2["Embedding: so'rov"]
    E2 --> K["Eng yaqin k ta"]
    DB --> K
  end
  K --> N["Natija"]

Diagrammada ikki yo'l bor. Yuqorida vazifa matni bir marta vektorga aylanadi va saqlanadi. Pastda har qidiruvda faqat savol vektorga aylanadi va saqlangan vektorlar bilan solishtiriladi. Ikkala yo'lda bitta model ishlatilishi shart.

Tekshirib ko'ring: Vazifalar gemini-embedding-2 bilan saqlangan. Savolni boshqa embedding modeli bilan vektorga aylantirsak nima bo'ladi?

Javob

Natija ma'nosiz bo'ladi. Har model o'z "xaritasini" quradi: bir modelning vektori boshqa model fazosida hech narsani anglatmaydi. O'lchamlar teng bo'lsa ham, xato jim o'tadi — qidiruv shunchaki noto'g'ri vazifalarni qaytaradi. Shuning uchun kanon jadvalda model ustunini saqlaydi va zaxira modelda embedding'ni almashtirmaydi.

3. AI SDK da embedding

Vercel AI SDK darsidagi kutubxonada ham embedding bor: embed (bitta matn), embedMany (ko'p matn) va cosineSimilarity. Biz AI SDK ning soxta embedding modeli bilan sinab ko'rdik (tarmoqsiz). Model bitta chaqiruvda ko'pi bilan 2 ta matn qabul qiladi (maxEmbeddingsPerCall: 2):

js
import { embedMany, cosineSimilarity } from "ai";

const { embeddings, usage } = await embedMany({
  model, // soxta: 3 o'lchamli vektor qaytaradi
  values: [
    "Non va sut olish", "Shifokor qabuliga yozilish", "doktor",
  ],
});
console.log("chaqiruvlar:", chaqiruv, "| tokenlar:", usage.tokens);
const [non, shifokor, doktor] = embeddings;
console.log(cosineSimilarity(doktor, shifokor).toFixed(3));
console.log(cosineSimilarity(doktor, non).toFixed(3));
text
chaqiruvlar: 2 | tokenlar: 12
0.994
0.061

embedMany 3 ta matnni o'zi 2 + 1 ga bo'lib yubordi — 2 chaqiruv (biz ishga tushirdik, ai 7.0.153). chaqiruv — soxta modeldagi hisoblagich. Kanon esa bo'lishni o'zi qiladi (PAKET = 100) — 0 paket qarori bo'yicha.

4. Kanon o'lchovi: embedding va so'z mosligi

4.1 Natijalar

Kanon 20 ta sintetik vazifani va 8 ta savolni haqiqiy gemini-embedding-2 ga yubordi (2 so'rov: hujjatlar va savollar paketda). Xom chiqishdan bitta savol va yakuniy qator:

text
"doktor": embedding top3 = Shifokor qabuliga yozilish 0.612-bob | Tish shifokoriga borish 0.571-bob | Hisobot yozish #ish 0.497-bob  →  2/2;  so'z mosligi → 0/2 (hech narsa)
recall@3: embedding 11/11 (100 %), so'z mosligi (FTS'ga yaqin) 0/11 (0 %)

Qavs ichida — kosinus o'xshashlik. Qolgan savollar uchun birinchi o'rindagi vazifa va uning bali:

Savol 1-o'rin Ball
oziq-ovqat xaridi Bozordan meva va sabzavot olish 0.788
kommunal to'lovlar Elektr to'lovini amalga oshirish 0.831
ish qidirish Rezyumeni yangilash va vakansiyalarga yuborish 0.789
ingliz tili sertifikati IELTS imtihoniga ro'yxatdan o'tish 0.817
"mashinani ko'rikdan o'tkazish" (kirill yozuvda) Mashinani texnik ko'rikdan o'tkazish 0.891
birthday gift for mom Onamga tug'ilgan kun sovg'asi tanlash 0.681
avtomobil Mashinani texnik ko'rikdan o'tkazish 0.704

recall@3 — "kerakli vazifalarning qanchasi birinchi 3 tada bor". Embedding — 11/11. Ikki savolning kerakli javobi ikkitadan edi: "oziq-ovqat xaridi" (non-sut va bozor) hamda "doktor" va "kommunal to'lovlar".

4.2 Natijani halol o'qish

Raqamlar yaxshi, lekin to'rt narsaga e'tibor bering.

Birinchi: ballar mutlaq emas. "doktor" uchun eng yaxshi natija atigi 0.612, uchinchisi — mutlaqo bog'liq bo'lmagan "Hisobot yozish" — 0.497. Farq kichik. "Ball 0.5 dan yuqori — mos" degan qoida yo'q. Har model va til uchun chegarani o'lchab topish kerak.

Ikkinchi: embedding ham adashadi. "avtomobil" uchun ikkinchi o'rinda "Bolalarni bog'chaga olib borish" — 0.619. Ehtimol, "olib borish" ma'nosi mashinaga yaqin. Top-1 to'g'ri, top-2 esa shovqin.

Uchinchi: ko'p tillilik ishlaydi. Kirillcha savol lotincha vazifani 0.891 bilan topdi — eng yuqori ball. Inglizcha "birthday gift for mom" — 0.681. O'zbek foydalanuvchisi ikki yozuvda yozadi — bu katta yutuq.

To'rtinchi: "so'z mosligi" ustuni — kanon skriptidagi JS taxmini. U kirillni lotinga o'girmaydi. Haqiqiy loyiha qidiruvi esa full-text search darsidagi uz_normal() bilan kirillni lotinga o'giradi. Demak 0/11 — pessimistik raqam. Buni o'zimiz tekshirdik.

4.3 Haqiqiy FTS bilan tekshiruv

Xuddi shu 20 vazifa va 8 savolni loyihaning haqiqiy FTS'i bilan solishtirdik: to_tsvector('simple', uz_normal(matn)) va so'zlar OR bilan bog'langan so'rov. Qatorlar telefon uchun qisqartirildi, vazifalar — id bilan (biz ishga tushirdik, PostgreSQL 17.6):

sql
CREATE TEMP TABLE v20 (id int, matn text);
INSERT INTO v20 VALUES
 (1, 'Non va sut sotib olish'), (2, 'Hisobot yozish #ish'),
 (3, 'IELTS imtihoniga ro''yxatdan o''tish'),
 (4, 'Bozordan meva va sabzavot olish'),
 (5, 'Shifokor qabuliga yozilish'),
 (6, 'Mashinani texnik ko''rikdan o''tkazish'),
 (7, 'Onamga tug''ilgan kun sovg''asi tanlash'),
 (8, 'Kvartira ijara haqini to''lash'),
 (9, 'JavaScript kursining 26-qismini o''qish'),
 (10, 'Sport zaliga borish'), (11, 'Jamoa bilan haftalik yig''ilish'),
 (12, 'Pasportni almashtirish uchun hujjat topshirish'),
 (13, 'Kir yuvish mashinasini ta''mirlatish'),
 (14, 'Bolalarni bog''chaga olib borish'),
 (15, 'Elektr to''lovini amalga oshirish'),
 (16, 'Kitob o''qish: Atomic Habits'),
 (17, 'Velosiped g''ildiragini almashtirish'),
 (18, 'Do''stlar bilan osh tayyorlash'),
 (19, 'Rezyumeni yangilash va vakansiyalarga yuborish'),
 (20, 'Tish shifokoriga borish');
SELECT q AS savol,
  coalesce(string_agg(v.id::text, ',' ORDER BY v.id), '—') AS topildi
FROM unnest(ARRAY['oziq-ovqat xaridi', 'kommunal to''lovlar',
  'doktor', 'ish qidirish', 'ingliz tili sertifikati',
  'машинани кўрикдан ўтказиш', 'birthday gift for mom',
  'avtomobil']) WITH ORDINALITY AS s(q, n)
LEFT JOIN v20 v ON to_tsvector('simple', uz_normal(v.matn)) @@
  nullif(replace(plainto_tsquery('simple', uz_normal(s.q))::text,
    '&', '|'), '')::tsquery
GROUP BY s.n, s.q ORDER BY s.n;
text
CREATE TABLE
INSERT 0 20
           savol           | topildi 
---------------------------+---------
 oziq-ovqat xaridi         | —
 kommunal to'lovlar        | —
 doktor                    | —
 ish qidirish              | 2
 ingliz tili sertifikati   | —
 машинани кўрикдан ўтказиш | 6
 birthday gift for mom     | —
 avtomobil                 | —
(8 rows)

Haqiqiy FTS kirillcha savolni topdi: uz_normal kirillcha so'zni lotincha "mashinani" ga aylantirdi va 6-vazifa mos keldi. Demak haqiqiy FTS natijasi — 1/11, 0/11 emas. "ish qidirish" esa 2-vazifani ("Hisobot yozish #ish") topdi — noto'g'risini: "ish" so'zi teg sifatida bor, ma'no esa boshqa.

Kerakli vazifa birinchi 3 tada (recall@3), 11 ta kutilgan javob
  • embedding11/11100 %
  • haqiqiy FTS (uz_normal)1/119 %
  • so'z mosligi (JS taxmini)0/110 %

Manba: IlmHamroh kanon o'lchovi, 2026-11-03 (gemini-embedding-2, 768; JS taxmini); haqiqiy FTS — biz ishga tushirdik, PostgreSQL 17.6, uz_normal

Grafikning xulosasi: bu turdagi savollarda (sinonim, umumlashtirish, boshqa til) so'z bo'yicha qidiruv deyarli ojiz. Lekin FTS ning o'z kuchli tomoni bor — aniq so'z, nom, raqam, teg. "26-qism" yoki "#ta'mir" ni FTS aniq topadi, embedding esa "yaqin" narsalarni ham aralashtiradi. Ikkalasini birlashtirish — gibrid qidiruv — keyingi darsda.

Tekshirib ko'ring: Nega haqiqiy FTS "doktor" ni topmadi, kirillcha savolni esa topdi?

Javob

FTS so'z shaklini solishtiradi. Kirillcha so'z uz_normal dan keyin "mashinani" bo'ladi — aynan vazifadagi so'z. "doktor" esa normallashtirishdan keyin ham "doktor" — vazifada bunday so'z yo'q, "shifokor" bor. Sinonimni faqat ma'noni biladigan usul topadi.

5. Vektorlarni saqlash

5.1 Alohida jadval

Embedding'ni vazifalar jadvaliga ustun qilib qo'shsa bo'lardi. Kanon alohida jadval tanladi — vazifa_embeddinglar. Sabablar migratsiya izohida:

  • Model yoki o'lcham almashsa, jadval qayta to'ldiriladi — asosiy jadval yozuvlariga tegilmaydi.
  • vazifalar qatorlari og'irlashmaydi: har vektor ~3 KB, ro'yxat so'rovlari esa uni o'qimasligi kerak.
  • matn_hash — "matn o'zgarganmi?" savoliga javob.

Indeks — HNSW, vector_cosine_ops (kosinus masofa). pgvector darsida HNSW va IVFFlat solishtirilgan edi. Kosinus masofa <=> — 1 minus o'xshashlik. Shuning uchun kanon so'rovida ball 1 - (e.embedding <=> $2) bo'ladi.

5.2 Filtr tuzog'i

pgvector darsidagi topilmani eslang: HNSW avval ~40 ta eng yaqin nomzodni oladi, WHERE egasi_id = 7 filtri esa keyin qo'llanadi. Kanon o'lchovida 300 egadan biri uchun natija — 0 ta (10 kutilgan). hnsw.iterative_scan = relaxed_order bilan — 10 ta. Bizning jadvalda ham har foydalanuvchi faqat o'z vazifalarini qidiradi. Shuning uchun kanon har vektor so'rovidan oldin buni yoqadi:

sql
SET LOCAL hnsw.iterative_scan = relaxed_order;

SET LOCAL — faqat shu tranzaksiya uchun. Boshqa so'rovlarga ta'sir qilmaydi.

6. Qayta hisoblash strategiyasi

6.1 Uch variant

Vazifa matni o'zgardi. Embedding qachon yangilanadi?

Variant Qachon Afzalligi Kamchiligi
Darhol POST/PATCH ichida doim yangi har yozuv LLM'ni kutadi
Fon navbati yozuvdan keyin ishchi yozish tez navbat va ishchi kerak
Dangasa (lazy) qidiruvdan oldin oddiy, faqat kerak bo'lsa birinchi qidiruv sekin

Kanon dangasa variantni tanladi. Qidiruvdan oldin yangila() shu foydalanuvchining eskirgan vektorlarini bitta-ikki paketda hisoblaydi. Qidiruvdan foydalanmagan foydalanuvchi uchun embedding umuman hisoblanmaydi — pul tejaladi. Narxi jonli o'lchovda ko'rindi: birinchi qidiruv 1.58 s (12 ta embedding hisoblandi), keyingisi 0.46 s.

BullMQ bilan fon navbati — katta hajmli loyihalar uchun yaxshiroq: har yozuvdan keyin ishchi embedding'ni hisoblaydi.

6.2 Nimani qayta hisoblash kerak

Uch holat: vazifa yangi (embedding yo'q), matn o'zgargan, model almashgan. Matn o'zgarganini bilish uchun matnning o'zini saqlash shart emas — uning xeshi yetarli (crypto: hash). Kanon so'rovining g'oyasini kichik jadvallarda sinaymiz (biz ishga tushirdik, PostgreSQL 17.6):

sql
CREATE TEMP TABLE vaz (id int, matn text);
CREATE TEMP TABLE emb (vazifa_id int, model text, matn_hash bytea);
INSERT INTO vaz VALUES (1, 'Non olish'), (2, 'Hisobot yozish'),
  (3, 'Dori olish'), (4, 'Sport zaliga borish');
-- 1: o'zgarmagan; 2: matni o'zgargan; 3: eski model; 4: hali yo'q
INSERT INTO emb VALUES
  (1, 'gemini-embedding-2', sha256(convert_to('Non olish', 'UTF8'))),
  (2, 'gemini-embedding-2', sha256(convert_to('Hisobot', 'UTF8'))),
  (3, 'eski-model', sha256(convert_to('Dori olish', 'UTF8')));
SELECT v.id, v.matn,
  CASE WHEN e.vazifa_id IS NULL THEN 'yangi'
       WHEN e.model <> 'gemini-embedding-2' THEN 'model almashgan'
       ELSE 'matn o''zgargan' END AS sabab
FROM vaz v LEFT JOIN emb e ON e.vazifa_id = v.id
WHERE e.vazifa_id IS NULL
   OR e.matn_hash <> sha256(convert_to(v.matn, 'UTF8'))
   OR e.model <> 'gemini-embedding-2'
ORDER BY v.id;
text
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 3
 id |        matn         |      sabab      
----+---------------------+-----------------
  2 | Hisobot yozish      | matn o'zgargan
  3 | Dori olish          | model almashgan
  4 | Sport zaliga borish | yangi
(3 rows)

1-vazifa ro'yxatda yo'q — uning matni ham, modeli ham o'zgarmagan, qayta so'rash shart emas. convert_to(matn, 'UTF8') — matnni baytlarga aylantiradi, sha256 esa baytlardan 32 baytlik xesh oladi. Bitta bo'shliq ham xeshni butunlay o'zgartiradi. Kanonning yangila() so'rovi aynan shu WHERE shartidan foydalanadi.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
Vektor yozishda o'lcham xatosi model o'lchami vector(768) dan farqli outputDimensionality yoki migratsiya
Qidiruv natijalari ma'nosiz saqlash va qidiruv — boshqa modellar model ustuni, model almashsa — qayta hisoblash
Filtrli qidiruvda bo'sh natija HNSW nomzodlari filtrdan keyin hnsw.iterative_scan
Embedding hisobi katta har qidiruvda hamma vektor qayta matn_hash bilan faqat o'zgarganlar
Ballarni boshqa modelniki bilan solishtirish ball modelga bog'liq chegarani har model uchun o'lchang

Birinchi qatordagi xatoning aniq matnini bermadik: bizning lokal bazada pgvector yo'q, kanon esa bu holatni sinamagan. O'z bazangizda bir marta ataylab sinab ko'ring — xatoni tanib olasiz.

8. Mashqlar

Mashqlar kurs/mashqlar/31/17-embedding/ da. 2- va 3-mashq yechimlari Node 24.21.0 da tekshirilgan (biz ishga tushirdik).

1-mashq (oson): bo'shliqlarni to'ldiring

  • Kanon embedding modeli har matnga nechta son qaytaradi? —
  • Ikki birlik vektorning skalyar ko'paytmasi nimaga teng? —
  • pgvector'da kosinus masofa operatori —
  • Gemini'da savol uchun taskType — RETRIEVAL_
  • Kanon o'lchovida embedding recall@3 —
Yechim
  • 768 — outputDimensionality, jadvalda vector(768).
  • kosinus — uzunliklar 1 bo'lgani uchun bo'lish shart emas.
  • <=> — masofa; o'xshashlik = 1 − masofa.
  • QUERY — hujjat uchun RETRIEVAL_DOCUMENT.
  • 11/11 — so'z mosligi 0/11, haqiqiy FTS 1/11.

2-mashq (o'rta): eng yaqin k ta

engYaqin(savol, hujjatlar, k) ni yozing: har hujjat { matn, v }, natija — kosinus bo'yicha kamayish tartibida birinchi k ta, "0.997 Matn" ko'rinishida. Ishora: map → toSorted (toSorted) → slice.

Yechim
js
const kosinus = (a, b) => {
  let s = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    s += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  return s / Math.sqrt(na * nb);
};

function engYaqin(savol, hujjatlar, k) {
  return hujjatlar
    .map((h) => ({ matn: h.matn, ball: kosinus(savol, h.v) }))
    .toSorted((a, b) => b.ball - a.ball)
    .slice(0, k)
    .map((x) => `${x.ball.toFixed(3)} ${x.matn}`);
}

const hujjatlar = [
  { matn: "Non va sut olish", v: [0.9, 0.1, 0] },
  { matn: "Tish shifokoriga borish", v: [0.1, 0.9, 0.2] },
  { matn: "Shifokor qabuliga yozilish", v: [0.05, 0.95, 0.1] },
  { matn: "Velosipedni ta'mirlash", v: [0, 0.05, 0.9] },
];
console.log(engYaqin([0.1, 0.9, 0.05], hujjatlar, 2));
console.log(engYaqin([0.2, 0, 0.9], hujjatlar, 1));

Konsolda:

text
[ '0.997 Shifokor qabuliga yozilish', '0.987 Tish shifokoriga borish' ]
[ "0.975 Velosipedni ta'mirlash" ]

Ikkinchi natija qo'shtirnoqda — Node apostrofli satrni shunday ko'rsatadi. Bu "aniq qidiruv" (hamma hujjat bilan solishtirish) — pgvector'dagi indekssiz Seq Scan bilan bir xil g'oya. Katta jadvalda HNSW kerak.

3-mashq (qiyin): nimani qayta hisoblash kerak

qaytaHisobla(vazifalar, embeddinglar, model) ni yozing. embeddinglar — Map: id → { model, hash }. Natija — "2: matn o'zgargan" kabi satrlar ro'yxati (yangi, model almashgan, matn o'zgargan). Xesh — SHA-256 hex, crypto.subtle.digest bilan (crypto: hash). Oxirida "Non olish" xeshining birinchi 16 belgisini chiqaring — SQL'dagi sha256 bilan solishtirish uchun.

Yechim
js
async function sha256(matn) {
  const b = new TextEncoder().encode(matn);
  const h = await crypto.subtle.digest("SHA-256", b);
  return [...new Uint8Array(h)]
    .map((x) => x.toString(16).padStart(2, "0")).join("");
}

async function qaytaHisobla(vazifalar, embeddinglar, model) {
  const natija = [];
  for (const v of vazifalar) {
    const e = embeddinglar.get(v.id);
    const h = await sha256(v.matn);
    if (e === undefined) natija.push(`${v.id}: yangi`);
    else if (e.model !== model) {
      natija.push(`${v.id}: model almashgan`);
    }
    else if (e.hash !== h) natija.push(`${v.id}: matn o'zgargan`);
  }
  return natija;
}

const M = "gemini-embedding-2";
const embeddinglar = new Map([
  [1, { model: M, hash: await sha256("Non olish") }],
  [2, { model: M, hash: await sha256("Hisobot") }],
  [3, { model: "eski-model", hash: await sha256("Dori olish") }],
]);
const vazifalar = [
  { id: 1, matn: "Non olish" },
  { id: 2, matn: "Hisobot yozish" },
  { id: 3, matn: "Dori olish" },
  { id: 4, matn: "Sport zaliga borish" },
];
console.log(await qaytaHisobla(vazifalar, embeddinglar, M));
console.log((await sha256("Non olish")).slice(0, 16));

Konsolda:

text
[ "2: matn o'zgargan", '3: model almashgan', '4: yangi' ]
ddd29c86f89263f0

Natija SQL bilan bir xil. Xeshni Postgres'da ham oldik: encode(sha256(convert_to('Non olish', 'UTF8')), 'hex') ham ddd29c86f89263f0… bilan boshlandi — ikkalasi ham UTF-8 baytlardan SHA-256.

9. Loyiha qadami: embedding'lar va GET /v1/qidiruv

Bu bosqichda loyihaga pgvector kiradi. pgvector darsida "loyihaga qismning AI bo'limida qo'shamiz" degan edik — o'sha va'da shu darsda bajariladi; bu dars va keyingisi bitta bosqich. Hali commit qilmang — commit keyingi darsda.

Kanon bazasi — pgvector/pgvector:pg18 image'idagi PostgreSQL 18.6 + pgvector 0.8.7. Bizning lokal Postgres'da pgvector yo'q edi, shuning uchun vektorli SQL natijalari — kanon chiqishi. Docker Compose'ni 33-qismda o'rganamiz.

9.1 Migratsiya: sql/303-embeddinglar.sql

Yangi fayl sql/303-embeddinglar.sql va aynan shu matn bilan Prisma migratsiyasi prisma/migrations/20261102120000_embeddinglar/migration.sql. Izohlarni qisqartirdik, qatorlarni bo'ldik:

sql
-- 303-embeddinglar.sql — vazifa matnining embedding'i. Alohida
-- jadval: model yoki o'lcham almashsa — qayta to'ldiriladi;
-- matn_hash — matn o'zgarganini bilish. CREATE EXTENSION —
-- superuser/egasi huquqi: migratsiya roli bilan, API roli bilan EMAS.
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE vazifa_embeddinglar (
  vazifa_id        bigint      NOT NULL,
  egasi_id         bigint      NOT NULL,
  model            text        NOT NULL,
  matn_hash        bytea       NOT NULL, -- sha256(matn)
  embedding        vector(768) NOT NULL, -- normallashtirilgan
  yangilangan_vaqt timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT vazifa_embeddinglar_pkey PRIMARY KEY (vazifa_id),
  CONSTRAINT vazifa_embeddinglar_vazifa_id_fkey
    FOREIGN KEY (vazifa_id)
    REFERENCES vazifalar (id) ON DELETE CASCADE,
  CONSTRAINT vazifa_embeddinglar_egasi_id_fkey
    FOREIGN KEY (egasi_id)
    REFERENCES foydalanuvchilar (id) ON DELETE CASCADE
);
-- HNSW — cosine masofa (<=>); vektorlar normallashtirilgan —
-- inner product (<#>) ham bir xil tartib beradi
CREATE INDEX vazifa_embeddinglar_hnsw ON vazifa_embeddinglar
  USING hnsw (embedding vector_cosine_ops);
CREATE INDEX vazifa_embeddinglar_egasi_id_idx
  ON vazifa_embeddinglar (egasi_id);

GRANT SELECT, INSERT, UPDATE, DELETE ON vazifa_embeddinglar
  TO vazifalar_ilova;
ALTER TABLE vazifa_embeddinglar ENABLE ROW LEVEL SECURITY;
CREATE POLICY vazifa_embeddinglar_egasi ON vazifa_embeddinglar
  TO vazifalar_ilova
  USING (egasi_id = joriy_foydalanuvchi())
  WITH CHECK (egasi_id = joriy_foydalanuvchi());

COMMENT ON TABLE vazifa_embeddinglar
  IS 'vazifa embeddinglari (pgvector, HNSW cosine) — qidiruv va RAG';

Uchta qaror: ON DELETE CASCADE — vazifa o'chsa, vektori ham ketadi; Row Level Security — har foydalanuvchi faqat o'z vektorlarini ko'radi (joriy_foydalanuvchi() — o'sha darsdan); CREATE EXTENSION — migratsiya roli bilan, chunki API roli eng kam huquqli. Managed Postgres'da pgvector odatda provayder ro'yxatidan yoqiladi.

9.2 prisma.config.ts

Prisma vector(768) turini bilmaydi va migrate diff jadvalni "ortiqcha" deb DROP qilmoqchi bo'ladi — xuddi Partitioning darsidagi bo'limli jadval kabi. Yechim ham o'sha: jadval "tashqi" ro'yxatga qo'shiladi.

ts
  experimental: { externalTables: true },
  tables: {
    external: [
      "public.vazifa_hodisalari",
      // vector(768) + HNSW — Prisma bu turni bilmaydi
      "public.vazifa_embeddinglar",
    ],
  },

Kanonda bu bir qator edi, biz bo'ldik. Loyiha nusxasida npx prisma validate — "The schema at prisma\schema.prisma is valid".

9.3 src/ai/qidiruv.ts

Yangi fayl — qidiruvning butun mantig'i. Kanon qatorlari telefon uchun bo'lingan, SQL matnida faqat qator bo'linishi o'zgargan:

ts
// qidiruv.ts — semantik qidiruv va RAG uchun topish (retrieval).
//   embedding'lar — dangasa (lazy) yangilanadi: qidiruvdan oldin shu
//   foydalanuvchining yangi/o'zgargan (matn_hash) vazifalari paketlab
//   hisoblanadi. semantik — pgvector HNSW (cosine); gibrid — FTS
//   (`qidiruv` tsvector, uz_normal) + vektor, Reciprocal Rank Fusion
//   (RRF, k = 60).
import type { Pool, PoolClient } from "pg";
/** pgvector matn shakli `[0.1,0.2,…]` — JSON massivi bilan bir xil
 *  (pgvector npm paketi kerak emas) */
const vektorSql = (v: number[]): string => JSON.stringify(v);
import { egasiSifatida } from "../umumiy/rls.ts";
import type { LlmProvayder } from "./llm.ts";

export type Topilgan = {
  id: number; matn: string; bajarildi: boolean;
  muddat: string | null; ball: number;
};
export type QidiruvSozlamasi = {
  pool: Pool; provayder: LlmProvayder; model: string; tz: string;
  paket?: number;
};

/** bitta embedding so'rovida ko'pi bilan nechta matn
 *  (Gemini batchEmbedContents — 100) */
const PAKET = 100;
const RRF_K = 60;

export class Qidiruv {
  constructor(private readonly s: QidiruvSozlamasi) {}

  /** yangi va matni o'zgargan vazifalar uchun embedding (bir
   *  chaqiruvda ≤ 100 ta); qaytaradi — nechta hisoblandi */
  async yangila(
    egasi: number, signal?: AbortSignal,
  ): Promise<number> {
    const paket = this.s.paket ?? PAKET;
    let jami = 0;
    for (;;) {
      type Eski = { id: string; matn: string; h: Buffer };
      const eskilar = await egasiSifatida(this.s.pool, egasi,
        async (c) => (await c.query<Eski>(
          `SELECT v.id, v.matn,
                  sha256(convert_to(v.matn, 'UTF8')) AS h
             FROM vazifalar v
             LEFT JOIN vazifa_embeddinglar e ON e.vazifa_id = v.id
            WHERE v.egasi_id = $1 AND v.ochirilgan_vaqt IS NULL
              AND (e.vazifa_id IS NULL
                OR e.matn_hash <> sha256(convert_to(v.matn, 'UTF8'))
                OR e.model <> $2)
            ORDER BY v.id LIMIT $3`,
          [egasi, this.s.model, paket])).rows);
      if (eskilar.length === 0) return jami;
      const vektorlar = await this.s.provayder.embedding(
        eskilar.map((q) => q.matn), "hujjat", signal);
      await egasiSifatida(this.s.pool, egasi, async (c) => {
        await c.query(
          `INSERT INTO vazifa_embeddinglar
             (vazifa_id, egasi_id, model, matn_hash, embedding)
           SELECT * FROM unnest($1::bigint[], $2::bigint[],
             $3::text[], $4::bytea[], $5::vector[])
           ON CONFLICT (vazifa_id) DO UPDATE SET
             model = EXCLUDED.model,
             matn_hash = EXCLUDED.matn_hash,
             embedding = EXCLUDED.embedding,
             yangilangan_vaqt = now()`,
          [eskilar.map((q) => q.id), eskilar.map(() => egasi),
            eskilar.map(() => this.s.model), eskilar.map((q) => q.h),
            vektorlar.map((v) => vektorSql(v))]);
      });
      jami += eskilar.length;
      if (eskilar.length < paket) return jami;
    }
  }

  async #vektorbilan(
    c: PoolClient, egasi: number, v: number[], k: number,
  ): Promise<Topilgan[]> {
    // pgvector 0.8: filtr (egasi) HNSW natijasidan KEYIN qo'llanadi —
    // ef_search (40) ta yaqindan boshqalarniki chiqib ketsa, natija
    // kam bo'ladi. iterative_scan — kerakli son topilguncha indeksni
    // davom ettiradi
    await c.query("SET LOCAL hnsw.iterative_scan = relaxed_order");
    const { rows } = await c.query<Topilgan & { id: string }>(
      `SELECT v.id, v.matn, v.bajarildi,
              to_char(v.muddat AT TIME ZONE $4, 'YYYY-MM-DD HH24:MI')
                AS muddat,
              1 - (e.embedding <=> $2) AS ball
         FROM vazifa_embeddinglar e
         JOIN vazifalar v ON v.id = e.vazifa_id
        WHERE e.egasi_id = $1 AND v.ochirilgan_vaqt IS NULL
        ORDER BY e.embedding <=> $2 LIMIT $3`,
      [egasi, vektorSql(v), k, this.s.tz]);
    // float8 — pg o'zi number qiladi; bigint id — satr
    return rows.map((r) => ({ ...r, id: Number(r.id) }));
  }

  async semantik(
    egasi: number, savol: string, k: number, signal?: AbortSignal,
  ): Promise<Topilgan[]> {
    await this.yangila(egasi, signal);
    const [v] = await this.s.provayder.embedding(
      [savol], "sorov", signal);
    if (v === undefined) return [];
    return egasiSifatida(this.s.pool, egasi,
      (c) => this.#vektorbilan(c, egasi, v, k));
  }

  /** gibrid: FTS (so'z mosligi — nom, raqam, teg) + vektor (ma'no) →
   *  RRF: ball = Σ 1/(k + o'rin) */
  async gibrid(
    egasi: number, savol: string, k: number, signal?: AbortSignal,
  ): Promise<Topilgan[]> {
    await this.yangila(egasi, signal);
    const [v] = await this.s.provayder.embedding(
      [savol], "sorov", signal);
    return egasiSifatida(this.s.pool, egasi, async (c) => {
      const vektor = v === undefined
        ? [] : await this.#vektorbilan(c, egasi, v, k * 2);
      // OR semantikasi: plainto_tsquery so'zlarni AND bilan
      // bog'laydi — savolda ortiqcha so'z bo'lsa hech narsa
      // topilmaydi
      const { rows } = await c.query<Topilgan & { id: string }>(
        `WITH s AS (SELECT nullif(replace(plainto_tsquery('simple',
            uz_normal($2))::text, '&', '|'), '')::tsquery AS q)
         SELECT v.id, v.matn, v.bajarildi,
                to_char(v.muddat AT TIME ZONE $4,
                  'YYYY-MM-DD HH24:MI') AS muddat,
                ts_rank(v.qidiruv, s.q) AS ball
           FROM vazifalar v, s
          WHERE v.egasi_id = $1 AND v.ochirilgan_vaqt IS NULL
            AND s.q IS NOT NULL AND v.qidiruv @@ s.q
          ORDER BY ball DESC, v.id LIMIT $3`,
        [egasi, savol, k * 2, this.s.tz]);
      const fts = rows.map((r) => ({ ...r, id: Number(r.id) }));
      const ballar = new Map<number, Topilgan>();
      for (const royxat of [vektor, fts]) {
        royxat.forEach((t, i) => {
          const b = ballar.get(t.id) ?? { ...t, ball: 0 };
          b.ball += 1 / (RRF_K + i + 1);
          ballar.set(t.id, b);
        });
      }
      return [...ballar.values()]
        .sort((a, b) => b.ball - a.ball || a.id - b.id)
        .slice(0, k);
    });
  }
}

Bu darsga tegishli qismlar:

  • yangila() — «Nimani qayta hisoblash kerak» bo'limidagi WHERE sharti, faqat LIMIT $3 (100) bilan paketlab. Paket to'lsa, sikl yana aylanadi.
  • unnest(...) bilan bitta INSERT — 100 ta qator uchun 100 ta so'rov emas, bitta. Har ustun alohida massiv bo'lib keladi, unnest ularni qatorlarga "ochadi". ON CONFLICT — UPSERT: bor bo'lsa yangilaydi.
  • vektorSql — pgvector vektorni [0.1,0.2] matn shaklida qabul qiladi, bu JSON massivi bilan bir xil. Shuning uchun pgvector npm paketi kerak bo'lmadi.
  • egasiSifatida — loyihaning RLS yordamchisi: so'rov shu foydalanuvchi kontekstida bajariladi.
  • gibrid() — so'z (FTS) va ma'no (vektor) natijalarini birlashtiradi. Uni keyingi darsda batafsil ko'ramiz.

9.4 src/ai/ai.module.ts

Modulga Qidiruv qo'shiladi. xizmatlar() endi qidiruv obyektini ham yasaydi. Embedding modeli nomi config'dan (AI_EMBEDDING_MODEL, sukut gemini-embedding-2):

ts
import {
  AiVazifaController, QidiruvController,
} from "./ai.controller.ts";
import { Qidiruv } from "./qidiruv.ts";

export type AiBogliqliklari = {
  pool: Pool; provayder: LlmProvayder; tz?: string;
  /** embedding jadvalidagi model (o'zgarsa — qayta hisoblanadi) */
  embeddingModel?: string;
};
export type AiXizmatlari = {
  provayder: LlmProvayder; qadamlar: QadamlarXizmati;
  qidiruv: Qidiruv; log: Log;
};

function xizmatlar(b: AiBogliqliklari, log: Log): AiXizmatlari {
  const tz = b.tz ?? "Asia/Tashkent";
  const qidiruv = new Qidiruv({
    pool: b.pool, provayder: b.provayder, tz,
    model: b.embeddingModel ?? `${b.provayder.nom}-embedding`,
  });
  return {
    provayder: b.provayder, log, qidiruv,
    qadamlar: new QadamlarXizmati({
      pool: b.pool, provayder: b.provayder, tz,
    }),
  };
}

forRoot ichida ikki joy: controller ro'yxatiga QidiruvController, config rejimidagi xizmatlar({ … }) chaqiruviga embeddingModel: c.AI_EMBEDDING_MODEL:

ts
    if (o !== undefined || configdan) {
      controllers.push(AiVazifaController, QidiruvController);
    }
    // … config rejimida:
          return xizmatlar({
            pool: s.pool, provayder, tz: c.APP_TZ,
            embeddingModel: c.AI_EMBEDDING_MODEL,
          }, log);

9.5 GET /v1/qidiruv

src/ai/ai.controller.ts — @nestjs/common importiga Get va Query, yangi importlar z (zod) va tekshir, fayl oxiriga yangi controller:

ts
import {
  Controller, Get, HttpCode, Inject, Param, Post, Query, Req, Res,
} from "@nestjs/common";
import { z } from "zod";
import { tekshir } from "../umumiy/tekshir.ts";

const QidiruvSorovi = z.strictObject({
  q: z.string().trim().min(2).max(200),
  rejim: z.enum(["semantik", "gibrid"]).default("gibrid"),
  limit: z.coerce.number().int().min(1).max(20).default(10),
});

/** GET /v1/qidiruv?q=…&rejim=semantik|gibrid — ma'no bo'yicha qidiruv
 *  (embedding'lar shu so'rovda dangasa yangilanadi) */
@Controller({ path: "qidiruv", version: "1" })
@Cheklov(API_CHEKLOVI)
export class QidiruvController {
  constructor(@Inject(AI) private readonly ai: AiXizmatlari) {}

  @Get()
  async qidir(
    @Egasi() egasi: number, @Query() q: unknown,
    @Req() req: Request, @Res({ passthrough: true }) res: Response,
  ): Promise<object> {
    const s = tekshir(QidiruvSorovi, q, "query");
    const signal = uzilishSignali(req, res);
    try {
      const topilgan = s.rejim === "semantik"
        ? await this.ai.qidiruv.semantik(egasi, s.q, s.limit, signal)
        : await this.ai.qidiruv.gibrid(egasi, s.q, s.limit, signal);
      return {
        rejim: s.rejim,
        vazifalar: topilgan.map((t) => ({
          ...t, ball: Math.round(t.ball * 10_000) / 10_000,
        })),
      };
    } catch (x) {
      throw aiXatosi(x);
    }
  }
}

Kanonda uzilishSignali(req, res) har tarmoqda alohida chaqiriladi. Biz qatorlarni qisqartirish uchun uni bitta o'zgaruvchiga oldik — ma'no bir xil (faqat bitta tarmoq bajariladi).

Nega yo'l /v1/qidiruv, /v1/vazifalar/semantik emas? Vazifalar controller'ida /:id yo'li bor. "semantik" ham :id ga mos kelib, yutib yuborilardi — 30-qismdagi marshrut tartibi tuzog'i. Alohida resurs — toza yechim. Sukut rejimi — gibrid.

9.6 Test

test/ai-qidiruv.test.ts ga bazali blok qo'shiladi (zaxira testidan keyin). Kanon testi — ikki test, qatorlari bo'lingan:

ts
const baza = await authBaza("ai_qidiruv");
if (typeof baza === "string") {
  test.skip(`AI qidiruv va RAG (Postgres + pgvector) — ${baza}`,
    () => {});
} else {
  const pool = baza;
  afterAll(() => pool.end());
  beforeEach(() => tozala(pool));
  const VAZIFALAR = [
    "Non va sut sotib olish", "Hisobot yozish #ish",
    "IELTS imtihoniga tayyorlanish", "Bozorga borib meva olish",
    "Sut mahsulotlari narxini solishtirish",
  ];
  const ilova = async (p: SoxtaProvayder) => {
    const d = authBogliqliklari(pool);
    const app = await expressIlova({
      ombor: (e) => new PgOmbori(pool, e), auth: d,
      ai: { pool, provayder: p, embeddingModel: "soxta-emb" },
    });
    const aziz = await hisobOch(app, d, "aziz@example.com");
    const dil = await hisobOch(app, d, "dilnoza@example.com");
    const idlar: number[] = [];
    for (const m of VAZIFALAR) {
      const r = await request(app).post("/v1/vazifalar")
        .set("Cookie", aziz).send({ matn: m }).expect(201);
      idlar.push((r.body as { id: number }).id);
    }
    await request(app).post("/v1/vazifalar").set("Cookie", dil)
      .send({ matn: "Sut va qatiq olish (Dilnoza)" }).expect(201);
    return { app, aziz, dil, idlar };
  };
  type Javob = {
    rejim: string;
    vazifalar: { id: number; matn: string; ball: number }[];
  };

  test("#17 semantik: embedding'lar dangasa (bir paketda), takror "
    + "so'rovda qayta hisoblanmaydi, matn o'zgarsa — faqat o'sha; "
    + "begona vazifalar yo'q", async () => {
    const p = new SoxtaProvayder();
    const { app, aziz, idlar } = await ilova(p);
    const r = (await request(app)
      .get("/v1/qidiruv?q=sut olish&rejim=semantik&limit=3")
      .set("Cookie", aziz).expect(200)).body as Javob;
    assert.equal(r.vazifalar[0]?.matn, "Non va sut sotib olish");
    assert.ok(r.vazifalar.every((v) => !v.matn.includes("Dilnoza")));
    assert.equal(p.embeddingChaqiruvlar, 2); // 1 paket + savol
    await request(app).get("/v1/qidiruv?q=hisobot&rejim=semantik")
      .set("Cookie", aziz).expect(200);
    assert.equal(p.embeddingChaqiruvlar, 3); // faqat savol
    await request(app).patch(`/v1/vazifalar/${String(idlar[1])}`)
      .set("Cookie", aziz)
      .set("Content-Type", "application/merge-patch+json")
      .send({ matn: "Choraklik hisobot yozish #ish" }).expect(200);
    await request(app).get("/v1/qidiruv?q=hisobot&rejim=semantik")
      .set("Cookie", aziz).expect(200);
    assert.equal(p.embeddingChaqiruvlar, 5); // o'zgargan 1 + savol
    const { rows } = await pool.query<{ n: number }>(
      "SELECT count(*)::int AS n FROM vazifa_embeddinglar");
    assert.equal(rows[0]?.n, 5); // Dilnozaniki hali hisoblanmagan
    await request(app).get("/v1/qidiruv?q=a")
      .set("Cookie", aziz).expect(400);
    await request(app).get("/v1/qidiruv?q=sut").expect(401);
  });

  test("#17 gibrid: FTS (so'z) + vektor → RRF; o'chirilgan vazifa "
    + "chiqmaydi", async () => {
    const { app, aziz, idlar } = await ilova(new SoxtaProvayder());
    await request(app).delete(`/v1/vazifalar/${String(idlar[0])}`)
      .set("Cookie", aziz).expect(204);
    const r = (await request(app).get("/v1/qidiruv?q=sut")
      .set("Cookie", aziz).expect(200)).body as Javob;
    assert.equal(r.rejim, "gibrid");
    assert.equal(r.vazifalar[0]?.matn,
      "Sut mahsulotlari narxini solishtirish");
    assert.ok(!r.vazifalar.some((v) => v.id === idlar[0]));
  });
}

Fayl boshidagi importlarga afterAll, beforeEach, request, expressIlova, PgOmbori va auth-yordam.ts dagi yordamchilar qo'shiladi. Soxta provayderning embedding'i — "so'zlar sumkasi" xeshi: ma'noni bilmaydi, lekin bir xil so'zli matnlar yaqin chiqadi. Pgvector va RLS ni sinash uchun shu yetarli.

Birinchi test dangasa yangilashning uch qoidasini sanaydi. 5 vazifa — bitta paket va savol: 2 chaqiruv. Takror qidiruv — faqat savol: 3. Bitta matn o'zgardi — o'sha vazifa va savol: 5. Dilnozaning vazifasi hisoblanmagan — u hali qidirmagan.

9.7 Tekshirish

bash
npm run tip
npm run lint
npm test

Biz loyiha nusxasida (Node 24.21.0, bazasiz) tsc --noEmit, eslint va prisma validate ni yurgizdik — 0 xato. Bazasiz testlar:

text
 ✓ |asosiy| test/ai-qidiruv.test.ts (2 tests | 1 skipped) 9ms
 ✓ |asosiy| test/ai.test.ts (4 tests | 1 skipped) 16ms

 Test Files  2 passed (2)
      Tests  4 passed | 2 skipped (6)

1 skipped — bazali blok: pgvector bilan Postgres kerak. Kanon tekshiruvi pgvector'li bazada bu testlarni o'tkazdi. Kanon jonli serverida (haqiqiy rollar, namuna bazasi, haqiqiy Gemini) birinchi qidiruv shunday javob berdi:

text
--- GET /v1/qidiruv?q=dasturlash darslari (gibrid; birinchi so'rov embedding'larni hisoblaydi)
{"rejim":"gibrid","vazifalar":[{"id":1,"matn":"JavaScript kursining 26-qismini boshlash #o'qish","bajarildi":false,"muddat":"2026-10-31 23:59","ball":0.0164},{"id":34,"matn":"SQL JOIN mashqlarini yechish #o'qish","bajarildi":true,"muddat":"2026-10-25 21:00","ball":0.0161},{"id":17,"matn":"Portfolio saytiga blog yozish #portfolio","bajarildi":false,"muddat":"2026-11-15 18:00","ball":0.0159}]}
HTTP 200, 1.581261 s

"dasturlash darslari" so'zlari vazifalarning birortasida yo'q, lekin JavaScript kursi va SQL mashqlari birinchi chiqdi. 1.58 s ichida 12 ta vazifa embedding'i hisoblandi — bazada keyin vazifa_embeddinglar: 12 ta, gemini-embedding-2, 768. Ballar nega 0.0164 kabi kichik — keyingi darsning RRF bo'limida.

10. Real ishda

  • Qayerda uchraydi: do'kon qidiruvi ("qishki oyoq kiyim" → "etik"), yordam markazidagi savol-javob, o'xshash e'lonlar, takror xabarlarni topish. Embedding'ning o'zi tasniflash va guruhlash uchun ham ishlatiladi.
  • Vektor bazalar: pgvector — mavjud Postgres ichida. Qdrant, Pinecone, Weaviate kabi alohida xizmatlar ham bor (pgvector darsida taqqoslangan).
  • Intervyu savollari: "Embedding nima va kosinus o'xshashlik qanday hisoblanadi?", "Model almashsa nima qilasiz?", "Semantik qidiruv qachon FTS'dan yomon ishlaydi?"
  • Kod review'da: "embedding har qidiruvda qayta hisoblanyapti", "model ustuni yo'q — almashtirsak bilmaymiz", "filtrli HNSW so'rovida iterative_scan yo'q".

Xulosa

  • Embedding — ma'noni ifodalovchi bir xil uzunlikdagi vektor; ma'nosi yaqin matnlar vektorlari yaqin.
  • Kosinus o'xshashlik — vektorlar burchagi; normallashtirilgan vektorlarda — oddiy skalyar ko'paytma.
  • Saqlash va qidiruvda bitta model; hujjat va so'rov farqlanadi (modelga qarab taskType yoki matn prefiksi); ko'p matn — batch.
  • Kanon o'lchovi: embedding 11/11, so'z mosligi 0/11, haqiqiy FTS 1/11 — lekin FTS aniq so'z, nom va raqamda kuchli.
  • Loyihada: vazifa_embeddinglar (pgvector, HNSW, RLS), dangasa yangilash matn_hash va model bilan, GET /v1/qidiruv.

Keyingi dars: RAG asoslari — topilgan vazifalarni LLM'ga kontekst qilib beramiz: "Pul bilan bog'liq vazifam bormi?" savoliga manbali javob.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Embedding va semantik qidiruv: matnni vektorga aylantirib, ma'no bo'yicha topish — IlmHamroh