Ma'lumot Yig'ish (Data Ingestion)
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:
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.
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:
# 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:
# 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:
- CSS selector -- agar ma'lum bo'lsa, faqat kerakli elementni oladi. Aniqroq.
- Trafilatura -- agar selector yo'q bo'lsa, avtomatik aniqlaydi. Qulayroq, lekin kamroq aniq.
# 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:
# 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:
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:
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:
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:
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:
# 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:
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:
# 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:
# 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:
rel="next"havolasi -- HTML standart usuli.<a rel="next" href="?page=2">ni qidiradi.- 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.
- URL pattern tahlili -- bir xil path, lekin turli query parametrli havolalarni topadi.
# 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):
# 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:
# 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:
# 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:
# 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:
# 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:
# 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:
- nihol.uz/projects -- 5 ta kategoriya topildi (child_master).
- nihol.uz/projects/banking -- 12 ta loyiha topildi (detail).
- nihol.uz/projects/telecom -- 8 ta loyiha topildi (detail).
- Boshqa master yo'q -- loop tugaydi.
- Barcha detail sahifalar extract qilinadi.
To'liq pipeline diagrammasi
[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
-
Har doim dublikat tekshiruvi qo'ying. Bizning tizimda sahifa ham
page_idva hamurlbo'yicha tekshiriladi. Scraping jarayoni to'xtab qayta ishga tushishi mumkin -- dublikat tekshiruvi holda bir xil ma'lumot qayta-qayta yoziladi. -
Xatolarni swallow qilmang. Pipeline da har bir bosqich
try/exceptichida, lekin xato yutilmaydi --errorslistiga yoziladi va natijada qaytariladi. -
Normalizatsiyani embedding dan oldin qiling. Aks holda
\xa0va\xadkabi ko'rinmas belgilar vektor sifatiga ta'sir qiladi. -
Kontaktlarni, navigatsiya elementlarini olib tashlang. Ular qidiruv natijasini buzadi -- "Bosh sahifa > Loyihalar > Bank" kabi breadcrumb qidiruv natijasida keraksiz.
-
Incremental pipeline quling. Bizning pipeline faqat yangi sahifalarni qayta ishlaydi -- allaqachon extract qilinganlar
skippedqaytariladi. Bu real production muhitda muhim -- har safar noldan boshlamaslik uchun.
</> Loyihaning haqiqiy kodi — bu bob amalda qanday ishlaydi
extractor/extractor.py — sahifadan matn ajratish
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
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
1. CSV qatorini jumlaga aylantirish
Asosiy g'oya: ustun nomini qiymat bilan birga o'qiladigan gapga qo'shish. Shablon oldindan yoziladi — LLM kerak emas.
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]))(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.
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")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.
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}")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.
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 saved5. Gibrid savol — semantik + raqamli
Mana shu yerda ustunlarni saqlaganimiz o'zini oqlaydi. "Byudjeti 400000 dan katta telekom loyihalari" — embedding raqamni taqqoslay olmaydi, SQL qiladi.
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]}")2023 450,000 0.823 Billing modernizatsiya 2024 780,000 0.791 Tarmoq kengaytirish 2022 520,000 0.744 SMS gateway integratsiyasi
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.
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.