Asosiy qismga o'tish

Ma'lumot Yig'ish (Data Ingestion)

5-bob 30 kod bloki 4264 so'z

Umumiy ko'rinish

RAG tizimining sifati to'g'ridan-to'g'ri ma'lumot sifatiga bog'liq. "Garbage in, garbage out" -- agar chunklardagi matn shovqinli, takroriy yoki noto'g'ri bo'lsa, LLM ham noto'g'ri javob beradi.

Data ingestion pipeline -- bu xom manbadan qidiruvga tayyor chunklargacha bo'lgan barcha bosqichlar. Nihol loyihasida bu pipeline quyidagicha ishlaydi:

python2 qator
nihol.uz → Scrape (httpx) → Extract (BeautifulSoup/Trafilatura)
         → Normalize → Chunk → Embed → PostgreSQL

Keling, har bir bosqichni va mavjud vositalarni batafsil ko'rib chiqamiz.

Web scraping vositalari

BeautifulSoup

Python ning eng mashhur HTML parsing kutubxonasi. HTML tuzilmasini parse qiladi va CSS selectors yordamida kerakli elementlarni tanlash imkonini beradi.

python6 qator
from bs4 import BeautifulSoup

html = "<div class='content'><p>Nihol kompaniyasi</p></div>"
soup = BeautifulSoup(html, "lxml")
text = soup.select_one(".content p").get_text(strip=True)
# Natija: "Nihol kompaniyasi"

Bizning navigator.py da BeautifulSoup CSS selectors orqali sahifadagi havolalarni topadi:

python29 qator
# navigator.py dan olingan haqiqiy kod
def _extract_from_soup(soup, css_selector, base_url, page_url):
    base_domain = urlparse(base_url).netloc
    url_selector, name_selector = _parse_selector(css_selector)
    elements = soup.select(url_selector) if url_selector else soup.find_all("a", href=True)
    names = soup.select(name_selector) if name_selector else []

    results = []
    for i, el in enumerate(elements):
        href = el.get("href", "").strip()
        if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")):
            continue

        full_url = urljoin(page_url, href)
        parsed = urlparse(full_url)
        if parsed.netloc and parsed.netloc != base_domain:
            continue

        relative = parsed.path
        if not relative:
            continue

        if names and i < len(names):
            title = (names[i].get_text(strip=True) or relative)[:200]
        else:
            title = (el.get_text(strip=True) or relative)[:200]
        results.append((relative, title))

    return results

Bu funksiya nima qiladi: 1. CSS selector bo'yicha <a> elementlarni topadi. 2. href attributini oladi va to'liq URL ga aylantiradi (urljoin). 3. Tashqi havolalarni filtrlaydi (faqat nihol.uz domeni). 4. javascript:, mailto:, tel: havolalarni o'tkazib yuboradi. 5. Har bir havola uchun (relative_url, title) juftligini qaytaradi.

Trafilatura

Avtomatik ravishda asosiy kontentni aniqlaydi va boilerplate (navigatsiya, footer, sidebar) ni olib tashlaydi. Bizning extractor.py da fallback sifatida ishlatiladi:

python7 qator
# extractor.py dan olingan haqiqiy kod
def _extract_trafilatura(html, url, soup):
    text = trafilatura.extract(html, url=url, include_comments=False) or ""
    metadata = trafilatura.extract(html, url=url, output_format="xmltei")
    title_tag = soup.find("title")
    title = title_tag.get_text(strip=True) if title_tag else ""
    return {"title": title, "content_text": text, "content_html": metadata or ""}

Trafilatura qachon ishlatiladi? Agar sahifada css_selector_ctn (content selector) belgilanmagan bo'lsa. Ya'ni bizning tizimda ikki xil extraction usuli bor:

  1. CSS selector -- agar ma'lum bo'lsa, faqat kerakli elementni oladi. Aniqroq.
  2. Trafilatura -- agar selector yo'q bo'lsa, avtomatik aniqlaydi. Qulayroq, lekin kamroq aniq.
python13 qator
# extractor.py dan olingan haqiqiy kod
def extract_page(session, page):
    # ...
    html = resp.text
    soup = BeautifulSoup(html, "lxml")

    if page.css_selector_ctn:
        result = _extract_bs4(soup, page.css_selector_ctn)
    else:
        result = _extract_trafilatura(html, page_url, soup)

    result["content_text"] = normalize_text(result.get("content_text", ""))
    # ...

Scrapy

To'liq web crawling framework. Katta hajmli loyihalar uchun mo'ljallangan:

python17 qator
# Scrapy spider misoli (biz ishlatmaymiz, lekin katta loyihalarda kerak)
import scrapy

class NiholSpider(scrapy.Spider):
    name = "nihol"
    start_urls = ["https://nihol.uz/"]

    def parse(self, response):
        for link in response.css("a.project-link::attr(href)").getall():
            yield response.follow(link, callback=self.parse_project)

    def parse_project(self, response):
        yield {
            "title": response.css("h1::text").get(),
            "content": response.css(".content").get(),
            "url": response.url,
        }

Scrapy bizning loyihada ishlatilmaydi -- 200 dan kam sahifa uchun httpx + BeautifulSoup yetarli. Scrapy ming va millionlab sahifalar uchun kerak.

httpx

Asinxron HTTP client. Biz barcha HTTP so'rovlar uchun httpx dan foydalanamiz:

python5 qator
import httpx

resp = httpx.get(page_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
html = resp.text

httpx ning requests dan afzalligi -- async rejimni qo'llab-quvvatlaydi va timeout, redirect larni yaxshi boshqaradi.

PDF extraction vositalari

Nihol loyihasida PDF ishlatilmaydi (faqat veb-sahifalar), lekin ko'p RAG tizimlarda bu muhim:

PyMuPDF (fitz)

Eng tez PDF parser. Layout ni saqlaydi:

python6 qator
import fitz  # PyMuPDF

doc = fitz.open("document.pdf")
for page in doc:
    text = page.get_text()
    print(text)

pdfplumber

Jadvallarni ajratib olish uchun eng yaxshi:

python8 qator
import pdfplumber

with pdfplumber.open("report.pdf") as pdf:
    for page in pdf.pages:
        tables = page.extract_tables()
        for table in tables:
            for row in table:
                print(row)

Unstructured

Universal document parser -- PDF, Word, HTML, PowerPoint, rasmlar:

python5 qator
from unstructured.partition.auto import partition

elements = partition(filename="document.pdf")
for el in elements:
    print(el.category, el.text[:100])

Hujjatlarni o'qish

Vosita Format Afzalligi
python-docx Word (.docx) Paragraflar, jadvallar, stillar
textract Turli formatlar Bir interfeysda ko'p format
openpyxl Excel (.xlsx) Yacheykalar, formulalar
python-pptx PowerPoint Slaydlar, matn

OCR

Skanerdan o'tkazilgan hujjatlar uchun -- rasm ichidagi matnni o'qish:

python6 qator
# Tesseract OCR
import pytesseract
from PIL import Image

image = Image.open("scanned_page.png")
text = pytesseract.image_to_string(image, lang="rus+uzb")

Tesseract rus va o'zbek tillarini qo'llab-quvvatlaydi. Sifat rasm sifatiga bog'liq.

Bizning scraping pipeline batafsil

Nihol loyihasining data pipeline uch asosiy fazadan iborat.

Faza 1: Navigator -- havolalarni kashf qilish

navigator.py master sahifadan bola sahifalarni topadi. Bizning tizimda sahifalar ierarxiyasi mavjud:

python5 qator
master (generates_children_yn = "Y")
├── child_master (ham master, ham bola)
│   ├── detail (has_content_yn = "Y")
│   └── detail
└── detail

Navigator CSS selectors orqali havolalarni topadi. Maxsus >> operator orqali ikki darajali navigatsiya:

python6 qator
# navigator.py dan olingan haqiqiy kod
def _parse_master_child(css_selector):
    if css_selector and ">>" in css_selector:
        parts = [p.strip() for p in css_selector.split(">>", 1)]
        return parts[0], parts[1]  # master_selector, detail_selector
    return None, None

Masalan, selector "a.category >> a.project" degani: avval kategoriya havolalarini top, keyin har bir kategoriya ichidan loyiha havolalarini top.

CSS selector da | operator nom uchun alohida selector beradi:

python6 qator
# navigator.py dan olingan haqiqiy kod
def _parse_selector(css_selector):
    if css_selector and "|" in css_selector:
        parts = [p.strip() for p in css_selector.split("|", 1)]
        return parts[0], parts[1]  # url_selector, name_selector
    return css_selector, None

Pagination aniqlash

pagination.py sahifalash (1, 2, 3... sahifalar) ni avtomatik aniqlaydi. Uch usulni ketma-ket sinab ko'radi:

  1. rel="next" havolasi -- HTML standart usuli. <a rel="next" href="?page=2"> ni qidiradi.
  2. Raqamli ketma-ketlik -- sahifadagi "1 2 3 4 5" ko'rinishidagi raqamli havolalarni topadi, ularning parent elementiga qaraydi (bitta pagination blokida bo'lishi kerak), va URL parametrini aniqlaydi.
  3. URL pattern tahlili -- bir xil path, lekin turli query parametrli havolalarni topadi.
python18 qator
# pagination.py dan olingan haqiqiy kod
def detect_pagination(html, current_url):
    soup = BeautifulSoup(html, "lxml")
    parsed_current = urlparse(current_url)

    urls = _try_rel_next_chain(soup, current_url)
    if urls:
        return urls

    urls = _try_numbered_sequences(soup, current_url, parsed_current)
    if urls:
        return urls

    urls = _try_url_pattern_analysis(soup, current_url, parsed_current)
    if urls:
        return urls

    return []

Faza 2: Extractor -- kontentni olish

extractor.py har bir detail sahifani yuklab, matnini ajratib oladi. Avval dublikatni tekshiradi (bir xil sahifa ikki marta extract qilinmasin):

python17 qator
# extractor.py dan olingan haqiqiy kod
def extract_page(session, page):
    existing = crud.get_extracted_content(session, page.id)
    if existing:
        return {"id": existing[0].id, "url": existing[0].url, "skipped": True}

    base_url = page.site.base_url.rstrip("/")
    page_url = base_url + (page.relative_url or "")

    url_exists = crud.get_extracted_content_by_url(session, page_url)
    if url_exists:
        return {"id": url_exists.id, "url": page_url, "skipped": True}

    resp = httpx.get(page_url, timeout=30, follow_redirects=True)
    resp.raise_for_status()
    html = resp.text
    # ... extraction logic

Ikki xil dublikat tekshiruvi: 1. page_id bo'yicha -- bu sahifa allaqachon extract qilinganmi? 2. url bo'yicha -- boshqa sahifa ham shu URL ga ishora qilmaganmi?

HTML dan matnni olishda keraksiz elementlar tozalanadi:

python15 qator
# extractor.py dan olingan haqiqiy kod
def _clean_soup(element):
    for tag in element.find_all("img"):
        tag.decompose()                          # rasmlarni olib tashla
    for tag in element.find_all(["p", "h6", "span", "div"]):
        text = tag.get_text(strip=True).lower()
        if text in ("made with ai", "teglar:", "теги:", "tags:"):
            tag.decompose()                      # teglarni olib tashla
    for a in element.find_all("a"):
        text = a.get_text(strip=True).lower()
        if "ознакомиться здесь" in text:
            parent = a.find_parent("p")
            if parent:
                parent.decompose()               # "batafsil bu erda" havolalarini olib tashla
    _remove_contact_spans(element)               # kontakt ma'lumotlarini olib tashla

Faza 3: Normalizatsiya

normalizer.py matnni tozalaydi. Bu bosqich nima uchun kerak? Chunki veb-sahifalardan olingan xom matn shovqinli:

Belgilarni tozalash:

python12 qator
# normalizer.py dan olingan haqiqiy kod
def normalize_text(text):
    if not text:
        return ""

    text = text.replace("\r", "")           # Windows satr uzilishlari
    text = text.replace("\xad", "")          # soft hyphen (ko'rinmas tire)
    text = text.replace("\xa0", " ")         # non-breaking space -> oddiy bo'sh joy
    text = text.replace("\u2011", "-")       # maxsus tire -> oddiy tire
    text = text.replace("\u00b7", " ")       # middle dot -> bo'sh joy
    text = text.replace("\u2022", "- ")      # bullet -> tire
    text = re.sub(r"[ \t]+", " ", text)      # ko'p bo'sh joylarni birlashtir

Nima uchun bu muhim? \xad (soft hyphen) ko'rinmas belgi -- matnda bor, lekin ko'rinmaydi. Embedding model uni "ko'radi" va vektor sifatiga salbiy ta'sir qiladi. \xa0 (non-breaking space) oddiy bo'sh joydan farq qiladi va so'zlarni noto'g'ri bo'lishiga olib keladi.

Kontakt ma'lumotlarini olib tashlash:

python9 qator
# normalizer.py dan olingan haqiqiy kod
def _remove_contact_blocks(text):
    text = re.sub(r"Тел:?\s*[\d\s\-\(\)\+]{5,}", "", text)
    text = re.sub(r"^\+?\d[\d\s\-\(\)]{6,}$", "", text, flags=re.MULTILINE)
    text = re.sub(
        r"^(Начальник|Ведущий|Глава|ИТ.?Менеджер|Руководитель)\b.*$",
        "", text, flags=re.MULTILINE
    )
    # ... kontakt shaxslarini va mustaqil ismlarni olib tashlash

Nima uchun kontaktlarni olib tashlaymiz? Sabab oddiy: kontakt ma'lumotlari tez eskiradi. Telefon raqami o'zgaradi, xodim boshqa kompaniyaga ketadi. Agar chunk da "Bosh mutaxassis: Eshmatov Toshmat, tel: +998-71-123-4567" bo'lsa, LLM buni fakt sifatida qaytaradi. Lekin bu ma'lumot allaqachon eskirgan bo'lishi mumkin. Shuning uchun kontaktlarni olib tashlaymiz -- foydalanuvchi rasmiy veb-saytdan olishni tavsiya qiladi.

Keraksiz satrlarni olib tashlash:

python14 qator
# normalizer.py dan olingan haqiqiy kod
def _remove_junk_lines(text):
    junk = [
        r"^Все проекты по годам\s*$",
        r"^Все проекты\s*$",
        r"^.*(?:смотрите|можно)\s+здесь\.?\s*$",
        r"^made with ai\s*$",
        r"^qo'shimcha ravishda bu erda\s*$",
        r"^Рисунок\s*\d+\.?\s*$",
        r"^Дата:\s*$",
    ]
    for pat in junk:
        text = re.sub(pat, "", text, flags=re.MULTILINE | re.IGNORECASE)
    return text

Bu navigatsiya elementlari, rasm taglavhalari va boshqa keraksiz matnlar. Ular chunk ga tushsa, embedding sifatini pasaytiradi va LLM ni chalg'itadi.

Pipeline boshqaruvi

pipeline.py barcha bosqichlarni birlashtirib boshqaradi:

python37 qator
# pipeline.py dan olingan haqiqiy kod
def run_pipeline_for_site(session, site_id):
    total_discovered = 0
    total_extracted = 0
    errors = []

    # 1-bosqich: barcha master sahifalardan havolalarni kashf qilish (iterativ)
    processed_ids = set()
    while True:
        pages = crud.get_target_pages(session, site_id)
        masters = [
            p for p in pages
            if p.page_type and p.page_type.generates_children_yn == "Y"
            and p.active_yn == "Y" and p.id not in processed_ids
        ]
        if not masters:
            break
        for master in masters:
            processed_ids.add(master.id)
            try:
                result = discover_links(session, master)
                total_discovered += result["links"]
            except Exception as e:
                errors.append({"page_id": master.id, "phase": "navigate", "error": str(e)})

    # 2-bosqich: barcha detail sahifalardan kontentni olish
    details = crud.get_target_pages(session, site_id)
    details = [p for p in details if p.page_type and p.page_type.has_content_yn == "Y" and p.active_yn == "Y"]

    for detail in details:
        try:
            extract_page(session, detail)
            total_extracted += 1
        except Exception as e:
            errors.append({"page_id": detail.id, "phase": "extract", "error": str(e)})

    return {"pages_discovered": total_discovered, "pages_extracted": total_extracted, "errors": errors}

Pipeline iterativ ishlaydi: yangi child_master lar topilsa, ular ham navbatga qo'shiladi. Bu while True loop tufayli. Masalan:

  1. nihol.uz/projects -- 5 ta kategoriya topildi (child_master).
  2. nihol.uz/projects/banking -- 12 ta loyiha topildi (detail).
  3. nihol.uz/projects/telecom -- 8 ta loyiha topildi (detail).
  4. Boshqa master yo'q -- loop tugaydi.
  5. Barcha detail sahifalar extract qilinadi.

To'liq pipeline diagrammasi

python41 qator
[nihol.uz]
    │
    ▼
┌──────────────┐
│  Navigator   │  CSS selectors orqali havolalarni topadi
│  (httpx +    │  Pagination ni aniqlaydi
│  BeautifulSoup)│  Master → Child Master → Detail ierarxiyasi
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  Extractor   │  Har bir detail sahifani yuklaydi
│  (BS4 yoki   │  CSS selector yoki Trafilatura orqali matnni oladi
│  Trafilatura)│  Dublikatlarni tekshiradi
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  Normalizer  │  Maxsus belgilarni tozalaydi
│  (regex)     │  Kontaktlarni, junk satrlarni olib tashlaydi
│              │  Bo'sh satrlarni tartibga soladi
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  Chunker     │  Paragraph-based bo'lish (\n\n)
│              │  Prefix qo'shish (rubrika + nom)
│              │  MAX_TOKENS chegarasi
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  Embedder    │  qwen3-embedding (local, 4096 o'lcham)
│  (Ollama)    │  yoki OpenAI (cloud, 1536 o'lcham)
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  PostgreSQL  │  pgvector orqali vektor saqlash
│  + pgvector  │  embedding_local + embedding_cloud ustunlari
└──────────────┘

Amaliy maslahatlar

  1. Har doim dublikat tekshiruvi qo'ying. Bizning tizimda sahifa ham page_id va ham url bo'yicha tekshiriladi. Scraping jarayoni to'xtab qayta ishga tushishi mumkin -- dublikat tekshiruvi holda bir xil ma'lumot qayta-qayta yoziladi.

  2. Xatolarni swallow qilmang. Pipeline da har bir bosqich try/except ichida, lekin xato yutilmaydi -- errors listiga yoziladi va natijada qaytariladi.

  3. Normalizatsiyani embedding dan oldin qiling. Aks holda \xa0 va \xad kabi ko'rinmas belgilar vektor sifatiga ta'sir qiladi.

  4. Kontaktlarni, navigatsiya elementlarini olib tashlang. Ular qidiruv natijasini buzadi -- "Bosh sahifa > Loyihalar > Bank" kabi breadcrumb qidiruv natijasida keraksiz.

  5. Incremental pipeline quling. Bizning pipeline faqat yangi sahifalarni qayta ishlaydi -- allaqachon extract qilinganlar skipped qaytariladi. Bu real production muhitda muhim -- har safar noldan boshlamaslik uchun.


</> Loyihaning haqiqiy kodi — bu bob amalda qanday ishlaydi

extractor/extractor.py — sahifadan matn ajratish

python130 qator
import re
import httpx
from bs4 import BeautifulSoup
import trafilatura
from sqlalchemy.orm import Session
from app.models import LvTargetPages
from app.services import crud
from extractor.normalizer import normalize_text


def extract_page(session: Session, page: LvTargetPages) -> dict:
    existing = crud.get_extracted_content(session, page.id)
    if existing:
        return {"id": existing[0].id, "url": existing[0].url, "skipped": True}

    base_url = page.site.base_url.rstrip("/")
    page_url = base_url + (page.relative_url or "")

    url_exists = crud.get_extracted_content_by_url(session, page_url)
    if url_exists:
        return {"id": url_exists.id, "url": page_url, "skipped": True}

    resp = httpx.get(page_url, timeout=30, follow_redirects=True)
    resp.raise_for_status()

    html = resp.text
    soup = BeautifulSoup(html, "lxml")

    if page.css_selector_ctn:
        result = _extract_bs4(soup, page.css_selector_ctn)
    else:
        result = _extract_trafilatura(html, page_url, soup)

    result["content_text"] = normalize_text(result.get("content_text", ""))
    result["content_raw"] = html

    rubric = page.rubric_name or ""
    page_name = page.name or result.get("title", "")
    parts = [p for p in [rubric, page_name] if p]
    result["annotation"] = "\n".join(parts)

    content = crud.create_extracted_content(session, {
        "page_id": page.id,
        "url": page_url,
        "title": result.get("title", ""),
        "content_text": result["content_text"],
        "content_html": result.get("content_html", ""),
        "content_raw": result["content_raw"],
        "annotation": result["annotation"],
    })
    return {"id": content.id, "url": page_url, "title": result.get("title", "")}


def _extract_bs4(soup: BeautifulSoup, selector: str) -> dict:
    title_tag = soup.find("title")
    title = title_tag.get_text(strip=True) if title_tag else ""

    elements = soup.select(selector)
    if not elements:
        return {"title": title, "content_text": "", "content_html": ""}

    content_html = "\n".join(str(el) for el in elements)
    content_text = "\n".join(_element_to_text(el) for el in elements)
    return {"title": title, "content_text": content_text, "content_html": content_html}


def _clean_soup(element):
    for tag in element.find_all("img"):
        tag.decompose()
    for div in element.find_all("div", class_="row"):
        if div.find(class_="blog-list-left-heading"):
            div.decompose()
    for p in element.find_all("p", align="center"):
        text = p.get_text(strip=True)
        if re.search(r"\d+[-\s]*(rasm|расм|рис)", text, re.IGNORECASE):
            p.decompose()
    for tag in element.find_all(["p", "h6", "span", "div"]):
        text = tag.get_text(strip=True).lower()
        if text in ("made with ai", "teglar:", "теги:", "tags:",
                     "qo'shimcha ravishda bu erda"):
            tag.decompose()
        elif re.match(r"рисунок\s*\d+\.?$", text):
            tag.decompose()
    for a in element.find_all("a"):
        text = a.get_text(strip=True).lower()
        if "ознакомиться здесь" in text or "можно ознакомиться" in text:
            parent = a.find_parent("p")
            if parent:
                parent.decompose()
            else:
                a.decompose()
    for p in element.find_all("p"):
        text = p.get_text(strip=True).lower()
        if "с презентацией" in text and ("ознакомиться" in text or "здесь" in text):
            p.decompose()
    _remove_contact_spans(element)


def _remove_contact_spans(element):
    to_remove = []
    for span in element.find_all("span"):
        text = span.get_text(strip=True)
        if re.match(r"Контактное лицо", text, re.IGNORECASE):
            to_remove.append(span)
        elif re.match(r"Тел:?\s*[\d\s\-\(\)\+]+$", text):
            to_remove.append(span)
        elif re.match(r"(Начальник|Ведущий|Глава|ИТ.?Менеджер|Руководитель)\s", text, re.IGNORECASE):
            to_remove.append(span)
        elif re.match(r"^\d{2,3}[-\s]?\d{2,3}[-\s]?\d{2,4}$", text.strip()):
            to_remove.append(span)
    for span in to_remove:
        span.decompose()


def _element_to_text(element) -> str:
    _clean_soup(element)
    for br in element.find_all("br"):
        br.replace_with("\n")
    for tag in element.find_all(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "div", "tr"]):
        tag.insert_before("\n")
        tag.insert_after("\n")
    return element.get_text()


def _extract_trafilatura(html: str, url: str, soup: BeautifulSoup) -> dict:
    text = trafilatura.extract(html, url=url, include_comments=False) or ""
    metadata = trafilatura.extract(html, url=url, output_format="xmltei")
    title_tag = soup.find("title")
    title = title_tag.get_text(strip=True) if title_tag else ""
    return {"title": title, "content_text": text, "content_html": metadata or ""}

extractor/pipeline.py — sayt bo'yicha pipeline

python34 qator
def run_pipeline_for_site(session: Session, site_id: int) -> dict:
    total_discovered = 0
    total_extracted = 0
    errors = []

    processed_ids = set()
    while True:
        pages = crud.get_target_pages(session, site_id)
        masters = [
            p for p in pages
            if p.page_type and p.page_type.generates_children_yn == "Y"
            and p.active_yn == "Y" and p.id not in processed_ids
        ]
        if not masters:
            break
        for master in masters:
            processed_ids.add(master.id)
            try:
                result = discover_links(session, master)
                total_discovered += result["links"]
            except Exception as e:
                errors.append({"page_id": master.id, "phase": "navigate", "error": str(e)})

    details = crud.get_target_pages(session, site_id)
    details = [p for p in details if p.page_type and p.page_type.has_content_yn == "Y" and p.active_yn == "Y"]

    for detail in details:
        try:
            extract_page(session, detail)
            total_extracted += 1
        except Exception as e:
            errors.append({"page_id": detail.id, "phase": "extract", "error": str(e)})

    return {"pages_discovered": total_discovered, "pages_extracted": total_extracted, "errors": errors}

Strukturalangan ma'lumot: CSV va JSON

Kodlar: CSV va JSON RAG — jadvalni qidiriladigan qilish 5-bob · qator → jumla → vektor · ~90 qator

1. CSV qatorini jumlaga aylantirish

Asosiy g'oya: ustun nomini qiymat bilan birga o'qiladigan gapga qo'shish. Shablon oldindan yoziladi — LLM kerak emas.

python16 qator
import pandas as pd

ROW_TEMPLATE = (
    "{loyiha_nomi} loyihasi {mijoz} kompaniyasi uchun {yil}-yilda "
    "{soha} sohasida bajarilgan. Loyiha byudjeti {byudjet} AQSH dollari. "
    "Loyiha holati: {holat}."
)

def row_to_sentence(row: pd.Series) -> str:
    values = {k: ("noma'lum" if pd.isna(v) else v) for k, v in row.items()}
    return ROW_TEMPLATE.format(**values)


df = pd.read_csv("loyihalar.csv")
print(df.shape)
print(row_to_sentence(df.iloc[0]))
Natija
(147, 6)
Billing modernizatsiya loyihasi Ucell kompaniyasi uchun 2023-yilda telekom
sohasida bajarilgan. Loyiha byudjeti 450000 AQSH dollari. Loyiha holati: tugagan.

2. Matn va ustunlarni birga saqlash

Jumla embedding uchun, ustunlar filtr uchun. Ikkalasi ham kerak — bu bo'limning eng muhim qoidasi.

python29 qator
CSV_SQL = (
    "INSERT INTO chunks "
    "(title, content, source_type, mijoz, yil, soha, byudjet, embedding) "
    "VALUES (:t, :c, 'csv', :m, :y, :s, :b, :e)"
)

def index_csv(session, path: str) -> int:
    df = pd.read_csv(path)
    saved = 0
    for _, row in df.iterrows():
        sentence = row_to_sentence(row)
        emb, err = generate_query_embedding(sentence)
        if err or emb is None:
            continue
        session.execute(text(CSV_SQL), {
            "t": row["loyiha_nomi"],
            "c": sentence,
            "m": row["mijoz"],
            "y": int(row["yil"]) if not pd.isna(row["yil"]) else None,
            "s": row["soha"],
            "b": float(row["byudjet"]) if not pd.isna(row["byudjet"]) else None,
            "e": str(emb),
        })
        saved += 1
    session.commit()
    return saved


print(f"Indekslandi: {index_csv(session, 'loyihalar.csv')} qator")
Natija
Indekslandi: 147 qator

3. JSON — avval tekislash

Ichma-ich obyektlarni yassi kalitlarga aylantiramiz. Massivlar uchun qoida: element mustaqil ma'noga ega bo'lsa — alohida chunk, aks holda vergul bilan birlashtiramiz.

python26 qator
def flatten_json(obj, prefix: str = "", sep: str = "_") -> dict:
    flat = {}
    if isinstance(obj, dict):
        for key, value in obj.items():
            new_key = f"{prefix}{sep}{key}" if prefix else key
            flat.update(flatten_json(value, new_key, sep))
    elif isinstance(obj, list):
        simple = [x for x in obj if not isinstance(x, (dict, list))]
        if simple and len(simple) == len(obj):
            flat[prefix] = ", ".join(str(x) for x in simple)
        else:
            for i, item in enumerate(obj):
                flat.update(flatten_json(item, f"{prefix}{sep}{i}", sep))
    else:
        flat[prefix] = obj
    return flat


record = {
    "loyiha": "Billing modernizatsiya",
    "mijoz": {"nomi": "Ucell", "soha": "telekom"},
    "texnologiyalar": ["FastAPI", "PostgreSQL", "Redis"],
    "bosqichlar": [{"nom": "tahlil", "oy": 2}, {"nom": "ishlab chiqish", "oy": 5}],
}
for k, v in flatten_json(record).items():
    print(f"{k:<24} {v}")
Natija
loyiha                   Billing modernizatsiya
mijoz_nomi               Ucell
mijoz_soha               telekom
texnologiyalar           FastAPI, PostgreSQL, Redis
bosqichlar_0_nom         tahlil
bosqichlar_0_oy          2
bosqichlar_1_nom         ishlab chiqish
bosqichlar_1_oy          5

4. Yassi kalitlardan jumla

Shablon yo'q bo'lsa (JSON strukturasi har xil bo'lsa), kalit nomini o'qiladigan matnga aylantiramiz.

python25 qator
def flat_to_sentence(flat: dict) -> str:
    parts = []
    for key, value in flat.items():
        if value in (None, "", "noma'lum"):
            continue
        label = key.replace("_", " ")
        parts.append(f"{label}: {value}")
    return ". ".join(parts) + "."


def index_json(session, records: list[dict], title_key: str) -> int:
    saved = 0
    for record in records:
        flat = flatten_json(record)
        sentence = flat_to_sentence(flat)
        emb, err = generate_query_embedding(sentence)
        if err or emb is None:
            continue
        session.execute(text(
            "INSERT INTO chunks (title, content, source_type, embedding) "
            "VALUES (:t, :c, 'json', :e)"
        ), {"t": str(record.get(title_key, "?")), "c": sentence, "e": str(emb)})
        saved += 1
    session.commit()
    return saved

5. Gibrid savol — semantik + raqamli

Mana shu yerda ustunlarni saqlaganimiz o'zini oqlaydi. "Byudjeti 400000 dan katta telekom loyihalari" — embedding raqamni taqqoslay olmaydi, SQL qiladi.

python26 qator
def structured_search(session, question: str, min_budget: float = None,
                      sector: str = None, top_k: int = 5) -> list[dict]:
    emb, _ = generate_query_embedding(question)

    where, params = ["TRUE"], {"e": str(emb), "k": top_k}
    if min_budget is not None:
        where.append("byudjet > :b")
        params["b"] = min_budget
    if sector:
        where.append("soha = :s")
        params["s"] = sector

    sql = (
        "SELECT title, mijoz, yil, byudjet, "
        "       1 - (embedding <=> CAST(:e AS vector)) AS score "
        "FROM chunks "
        f"WHERE {' AND '.join(where)} "
        "ORDER BY embedding <=> CAST(:e AS vector) LIMIT :k"
    )
    rows = session.execute(text(sql), params).mappings().all()
    return [dict(r) for r in rows]


for r in structured_search(session, "yirik telekom loyihalari",
                           min_budget=400000, sector="telekom"):
    print(f"{r['yil']}  {r['byudjet']:>9,.0f}  {r['score']:.3f}  {r['title'][:38]}")
Natija
2023    450,000  0.823  Billing modernizatsiya
2024    780,000  0.791  Tarmoq kengaytirish
2022    520,000  0.744  SMS gateway integratsiyasi
Shablon LLM dan yaxshiroq. Qatorni jumlaga aylantirish uchun LLM ishlatmang — 147 qator uchun 147 chaqiruv, natija esa shablondan yaxshiroq emas. Shablon bepul, tez va bashorat qilinadigan.

Katta jadvallar uchun: 100 000 qatorli CSV da har qatorni embedding qilish qimmat. Bunday holatda RAG emas, text-to-SQL yondashuvi to'g'riroq — LLM savoldan SQL yozadi, baza javob beradi.

Shu paytgacha biz erkin matn bilan ishladik — veb-sahifa, PDF, hujjat. Lekin ko'p tashkilotlarda eng qimmatli ma'lumot jadvalda yotadi: mijozlar ro'yxati, narxlar, loyihalar registri, API javoblari.

Jadvalni RAG ga qo'shishning noto'g'ri yo'li — butun CSV ni matn sifatida chunk qilish. Natijada chunk chegarasi qator o'rtasidan o'tadi va ma'lumot buziladi.

To'g'ri yondashuv: har qator — bitta jumla

Har bir qatorni o'qiladigan gapga aylantiring. Ustun nomlari gapning bir qismi bo'lsin.

python10 qator
CSV qatori:
  loyiha_nomi,mijoz,yil,soha,byudjet
  Billing modernizatsiya,Ucell,2023,telekom,450000

Noto'g'ri (xom matn):
  "Billing modernizatsiya,Ucell,2023,telekom,450000"

To'g'ri (tabiiy jumla):
  "Billing modernizatsiya loyihasi Ucell kompaniyasi uchun 2023-yilda
   telekom sohasida bajarilgan. Loyiha byudjeti 450000 AQSH dollari."

Ikkinchi variant embedding uchun ancha yaxshi: unda so'zlar bor, kontekst bor, va u foydalanuvchining savoliga o'xshash tilda yozilgan.

Metadata ni alohida saqlang

Muhim qoida: jumlaga aylantirilgan matn embedding uchun, lekin asl qiymatlar ustun sifatida ham saqlanishi kerak. Chunki "byudjeti 400000 dan katta loyihalar" degan savolga embedding javob bera olmaydi — bu SQL WHERE ishi.

Bu kitobning Self-Query bo'limida ko'rgan yondashuvimiz aynan shu yerda ishlaydi: semantik qism embedding ga, raqamli filtr SQL ga.

JSON va ichma-ich struktura

JSON da qo'shimcha muammo bor — ichma-ich joylashgan obyektlar. Ularni tekislash (flatten) kerak, aks holda ichki maydonlar yo'qoladi. {"mijoz": {"nomi": "Ucell", "soha": "telekom"}} → mijoz_nomi, mijoz_soha.

Massivlar uchun qaror qabul qilish kerak: har bir element alohida chunk bo'ladimi, yoki hammasi bitta jumlaga qo'shiladimi. Qoida oddiy — element mustaqil ma'noga ega bo'lsa, alohida chunk.

Bu bobni bepul o'qidingiz.

Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.

Telegramda yozish Narxlar