Asosiy qismga o'tish

Production Patterns

11-bob 19 kod bloki 3126 so'z

Prototipdan Productionga

Prototip — bu "ishlaydi" degani. Production — bu "ishonchli ishlaydi, 24/7, yuzlab foydalanuvchilar bilan, xatoliklarni o'zi hal qiladi" degani. Bu ikki narsa orasidagi farq juda katta.

Nihol loyihasining joriy versiyasida bitta repo ichida ikkita FastAPI ilova ishlaydi: admin + chat (port 8005) va ommaviy /ask sahifasi (port 8006, rate limit bilan). Bundan oldin ikkita parallel versiya ham bo'lgan edi -- WDE (local) va Chef (cloud) -- ular orqali biz production patternlarni real sharoitda sinab ko'rdik. Bu bobda shu tajribalarni baham ko'ramiz.


Caching

Embedding Cache

Har bir savol uchun embedding yaratish — tarmoq chaqiruvi (network call). Cloud API larda bu 100-300ms vaqt oladi. Agar bir xil savol qayta-qayta kelsa, har safar embedding yaratish — behuda xarajat.

Yechim: embedding cache.

python27 qator
from functools import lru_cache
import hashlib

# In-memory cache: savolni hash qilib, embedding ni saqlaymiz
_embedding_cache: dict[str, list[float]] = {}

def get_cached_embedding(text: str) -> list[float] | None:
    cache_key = hashlib.sha256(text.encode()).hexdigest()
    if cache_key in _embedding_cache:
        return _embedding_cache[cache_key]
    return None

def set_cached_embedding(text: str, embedding: list[float]) -> None:
    cache_key = hashlib.sha256(text.encode()).hexdigest()
    _embedding_cache[cache_key] = embedding

def generate_query_embedding(text_input: str) -> list[float] | None:
    # Avval cache da bor-yo'qligini tekshiramiz
    cached = get_cached_embedding(text_input)
    if cached is not None:
        return cached

    # Cache da yo'q — API ga so'rov yuboramiz
    embedding = _call_embedding_api(text_input)
    if embedding is not None:
        set_cached_embedding(text_input, embedding)
    return embedding

Samaradorlik: agar 10 ta foydalanuvchi bir xil "HUMO haqida gapirib bering" savolini bersa, faqat birinchisi uchun API chaqiriladi. Qolgan 9 tasi cache dan olinadi — 0ms.

Response Cache

Xuddi shu mantiq javob lar uchun ham ishlaydi. Agar savol va topilgan chunk lar bir xil bo'lsa, javob ham bir xil bo'ladi.

python21 qator
import time

_response_cache: dict[str, dict] = {}
CACHE_TTL = 3600  # 1 soat

def get_cached_response(question: str) -> str | None:
    cache_key = hashlib.sha256(question.encode()).hexdigest()
    entry = _response_cache.get(cache_key)
    if entry is None:
        return None
    if time.time() - entry["timestamp"] > CACHE_TTL:
        del _response_cache[cache_key]
        return None
    return entry["response"]

def set_cached_response(question: str, response: str) -> None:
    cache_key = hashlib.sha256(question.encode()).hexdigest()
    _response_cache[cache_key] = {
        "response": response,
        "timestamp": time.time(),
    }

TTL (Time-To-Live) — cache ning amal qilish muddati. Agar ma'lumotlar tez-tez o'zgarsa, TTL qisqa bo'lishi kerak (15 daqiqa). Agar ma'lumotlar barqaror bo'lsa (kompaniya loyihalari), 1-24 soat yetarli.

Katta hajmdagi production uchun in-memory dict o'rniga Redis ishlatiladi — u tez, mustaqil server, va bir nechta worker lar o'rtasida umumiy cache bo'la oladi.


Rate Limiting

Nega kerak?

Bitta foydalanuvchi sekund iga 100 ta so'rov yuborsa — API budjetingiz tez tugaydi. Yoki atayin DDoS hujumi bo'lishi mumkin.

FastAPI Middleware bilan

python37 qator
import time
from collections import defaultdict
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware

class RateLimitMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, max_requests: int = 10, window_seconds: int = 60):
        super().__init__(app)
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self.requests: dict[str, list[float]] = defaultdict(list)

    async def dispatch(self, request: Request, call_next):
        # Faqat API endpoint lariga rate limit qo'yamiz
        if not request.url.path.startswith("/api/"):
            return await call_next(request)

        client_ip = request.client.host
        now = time.time()
        window_start = now - self.window_seconds

        # Eskirgan so'rovlarni tozalaymiz
        self.requests[client_ip] = [
            t for t in self.requests[client_ip] if t > window_start
        ]

        if len(self.requests[client_ip]) >= self.max_requests:
            raise HTTPException(
                status_code=429,
                detail="Too many requests. Please wait before trying again.",
            )

        self.requests[client_ip].append(now)
        return await call_next(request)

# main.py da:
app.add_middleware(RateLimitMiddleware, max_requests=10, window_seconds=60)

Bu konfiguratsiya har bir IP adresga daqiqasiga 10 ta so'rovga ruxsat beradi. Cloud API larning o'z limitlari ham bor — OpenRouter minutiga 200 so'rov, Jina kundiga 1 million token.


Monitoring

Har bir so'rovni loglash

Production da har bir so'rov haqida bilishingiz kerak: nima so'raldi, qanday chunk lar topildi, qancha vaqt ketdi, javob qanday bo'ldi.

python45 qator
import logging
import time

logger = logging.getLogger("rag")
logger.setLevel(logging.INFO)

handler = logging.FileHandler("rag_queries.log")
handler.setFormatter(logging.Formatter(
    "%(asctime)s | %(levelname)s | %(message)s"
))
logger.addHandler(handler)

def process_chat(session, question: str) -> str:
    start = time.time()

    # Embedding
    embedding = generate_query_embedding(question)
    embed_time = time.time() - start

    # Retrieval
    chunks = retrieve_chunks(session, embedding)
    retrieval_time = time.time() - start - embed_time
    top_sim = chunks[0]["similarity"] if chunks else 0

    # Chunk tanlash + Generation
    chunks = select_context_chunks(chunks, question)
    messages = build_prompt(chunks, question)
    answer = "".join(stream_response(messages))
    total_time = time.time() - start

    # Log
    logger.info(
        f"Q: {question[:80]} | "
        f"Chunks: {len(chunks)} | "
        f"TopSim: {top_sim:.3f} | "
        f"EmbedTime: {embed_time:.2f}s | "
        f"RetrTime: {retrieval_time:.2f}s | "
        f"TotalTime: {total_time:.2f}s"
    )

    # Alert: similarity juda past bo'lsa
    if top_sim < 0.3:
        logger.warning(f"LOW SIMILARITY: {question[:80]} -> {top_sim:.3f}")

    return answer

Log natijasi quyidagicha ko'rinadi:

matn2 qator
2025-07-15 14:32:01 | INFO | Q: Расскажите про проект HUMO | Chunks: 5 | TopSim: 0.847 | EmbedTime: 0.23s | RetrTime: 0.05s | TotalTime: 2.41s
2025-07-15 14:32:15 | WARNING | LOW SIMILARITY: Какая погода в Ташкенте? -> 0.187

Production tizimlarda Prometheus + Grafana ishlatiladi — metrikalar real-time dashboard da ko'rinadi, alert lar Telegram yoki Slack ga yuboriladi.

Nihol loyihasidagi haqiqiy monitoring: lg_analytics_chat

Biz log fayl o'rniga har bir so'rovni bazadagi lg_analytics_chat jadvaliga yozamiz:

Ustun Ma'nosi
embedding_ms Savolni vektorga aylantirish vaqti
db_search_ms pgvector qidiruv vaqti
retrieval_ms Umumiy retrieval (embedding + qidiruv)
generation_ms LLM javob yaratish vaqti
total_ms Boshidan oxirigacha
top_similarity Eng yaxshi chunk similarity
num_chunks_retrieved Nechta chunk topildi
status ok / no_chunks / embed_error / gen_error

Yozish "best-effort" tarzda alohida DB session bilan bajariladi -- analytics yozilmasa ham chat ishlashda davom etadi:

python9 qator
def _write_analytics_chat(**kwargs):
    s = SessionLocal()
    try:
        s.add(LgAnalyticsChat(**kwargs))
        s.commit()
    except Exception:
        s.rollback()   # analytics xatosi asosiy oqimni buzmaydi
    finally:
        s.close()

embedding_ms va db_search_ms ni alohida o'lchash muhim: "retrieval sekin" degan shikoyat kelganda, aybdor embedding servisimi yoki SQL qidiruvmi -- darhol ko'rinadi.


A/B Testing: Bizning Yondashuvimiz

Loyihaning oldingi bosqichida biz A/B testing ni eng oddiy usulda qildik: ikkita mustaqil server (hozir bitta local stack qoldi, lekin usulning o'zi ibratli).

python5 qator
WDE (Local)                    Chef (Cloud)
Port 8005                      Port 8006
OLLAMA (qwen3:8b)              OpenRouter (gemini-2.0-flash)
qwen3-embedding:8b             text-embedding-3-small
LLM-based reranker             Jina Reranker v2

Ikkisi ham bitta PostgreSQL bazasidan ishladi, lekin har biri o'z embedding ustunini (embedding_local va embedding_cloud) ishlatdi. Bitta savolni ikkisiga ham berib, javoblarni yonma-yon taqqosladik. Dual-column dizayn hozir ham saqlangan -- kelajakda boshqa embedding modelni yonma-yon sinash uchun tayyor infratuzilma.

O'lchov mezonlari: - Accuracy: Evaluation ballari (retrieval, relevance, faithfulness) - Speed: Javob vaqti (local: 3-8 soniya, cloud: 1-3 soniya) - Cost: Local = faqat elektr, Cloud = API xarajatlar ($5-20/oy)

Bu yondashuv oddiy, lekin samarali. Har qanday yangi model yoki strategiyani mavjud tizim bilan taqqoslash mumkin.


Scheduled Re-embedding

Ma'lumotlar o'zgarganda, chunk lar qayta embedding qilinishi kerak. Yangi sahifalar qo'shildi, eski sahifalar yangilandi — embedding ham yangilanishi shart.

Bizning embed_all() funksiyamiz faqat NULL embedding li chunk larni qayta ishlaydi:

python31 qator
def embed_all(session: Session) -> dict:
    # Faqat embedding si yo'q chunk larni olamiz
    pending = session.query(LvContentChunks).filter(
        LvContentChunks.embedding_local.is_(None),
    ).all()

    embedded = 0
    errors = []

    for chunk in pending:
        try:
            vector = _generate_embedding(chunk.chunk_text)
            if vector is None:
                errors.append({"chunk_id": chunk.id, "error": "empty response"})
                continue
            chunk.embedding_local = json.dumps(vector)
            session.commit()
            embedded += 1
        except Exception as e:
            errors.append({"chunk_id": chunk.id, "error": str(e)})
            session.rollback()

    already = session.query(LvContentChunks).filter(
        LvContentChunks.embedding_local.isnot(None),
    ).count()

    return {
        "embedded": embedded,
        "already_done": already - embedded,
        "errors": errors,
    }

Bu funksiyani har 1-2 kunda cron job sifatida ishga tushirish mumkin:

matn2 qator
# crontab -e
0 3 * * * curl -X POST http://localhost:8005/api/admin/embed-all

Har kuni soat 3:00 da yangi chunk lar embedding qilinadi. Mavjud chunk larga tegmaydi — faqat yangilarni qayta ishlaydi.


Authentication

Admin panel himoyasi

Admin panelda ma'lumotlarni boshqarish mumkin — saytlar qo'shish, chunk lar o'chirish, embedding ishga tushirish. Bu sahifalar himoyalangan bo'lishi shart.

Nihol loyihasidagi haqiqiy yechim: session cookie + bcrypt + rolga asoslangan middleware.

python22 qator
class AuthMiddleware(BaseHTTPMiddleware):
    ALLOWED_PATHS = {"/login", "/api/auth/login", "/api/auth/logout"}
    USER_PATHS = {"/chat", "/api/chat", "/api/rubrics", "/api/feedback", ...}
    GUEST_PATHS = {"/chat", "/api/chat", "/api/rubrics", ...}  # feedback POST yo'q
    ANALYTICS_PATHS = {"/analytics", "/api/questions", "/api/analytics/users"}

    async def dispatch(self, request, call_next):
        path = request.url.path
        if path in self.ALLOWED_PATHS or path.startswith("/static"):
            return await call_next(request)
        role = request.session.get("role")
        if role == "admin":
            return await call_next(request)                    # hamma narsa ochiq
        if role == "user" and path in self.USER_PATHS:
            return await call_next(request)                    # faqat chat + feedback
        if role == "analytics" and path in self.ANALYTICS_PATHS | self.USER_PATHS:
            return await call_next(request)                    # + analytics sahifalar
        if not request.session.get("authenticated") and path in self.GUEST_PATHS:
            return await call_next(request)                    # mehmon: cheklangan
        if request.method == "GET":
            return RedirectResponse("/login", status_code=303)
        return JSONResponse({"detail": "Not authenticated"}, status_code=401)

Muhim nuqtalar:

  1. Parol hech qachon ochiq saqlanmaydi -- faqat bcrypt hash (lv_users.password_hash yoki lv_sys_params dagi admin hash).
  2. Timing attack himoyasi: login topilmasa ham dummy hash bilan bcrypt.checkpw chaqiriladi -- javob vaqti bir xil bo'ladi, username mavjudligini vaqtdan bilib bo'lmaydi.
  3. To'rt daraja: admin (hammasi), user (chat + feedback), analytics (chat + statistika), mehmon (chat, 5 ta savol limiti, feedback yozolmaydi).
  4. Session cookie SameSite=Lax va SESSION_SECRET bilan imzolanadi. CSRF token hali qo'shilmagan -- ommaviy deploy dan oldin rejada.
  5. Xato xabari umumiy: "Invalid credentials" -- username to'g'ri yoki noto'g'riligini oshkor qilmaydi.

Error Handling

Production da har bir bosqich xato berishi mumkin. Har birida fallback bo'lishi kerak.

python27 qator
def safe_chat(session: Session, question: str) -> str:
    # 1. Embedding service down
    try:
        embedding = generate_query_embedding(question)
    except Exception:
        return "[Embedding xizmati vaqtincha ishlamayapti. Keyinroq urinib ko'ring.]"

    if embedding is None:
        return "[Embedding yaratib bo'lmadi. Xizmat yuklanmoqda.]"

    # 2. Database connection failed
    try:
        chunks = retrieve_chunks(session, embedding)
    except Exception:
        return "[Ma'lumotlar bazasiga ulanib bo'lmadi.]"

    # 3. Chunk tanlash — heuristika, tashqi servisga bog'liq emas
    chunks = select_context_chunks(chunks, question)

    # 4. LLM timeout
    try:
        messages = build_prompt(chunks, question)
        answer = "".join(stream_response(messages))
    except Exception:
        return "[Javob yaratishda xatolik. Iltimos, qayta urinib ko'ring.]"

    return answer

Har bir bosqichda: - Embedding service down: HTTP 503, foydalanuvchiga tushunarli xabar - LLM timeout: Streaming to'xtaydi, xato xabari yuboriladi - DB connection failed: Graceful error sahifasi - Chunk tanlash: sof Python heuristika — tashqi servis yo'q, ishdan chiqadigan joyi ham yo'q

Agar tizimingizda tashqi reranker bo'lsa, fallback zanjiri shunday quriladi: Jina reranker ishlamasa — LLM-based reranking, u ham ishlamasa — oddiy similarity tartibi:

python16 qator
def rerank_chunks_with_fallback(chunks, question, top_n=None):
    # 1-urinish: Jina Reranker (tashqi API)
    if settings.JINA_API_KEY:
        try:
            return _jina_rerank(chunks, question, top_n)
        except Exception:
            pass

    # 2-urinish: LLM-based reranking (lokal)
    try:
        return _llm_rerank(chunks, question, top_n)
    except Exception:
        pass

    # 3-fallback: oddiy similarity tartibi
    return chunks[:top_n]

Bizning joriy pipeline da bu zanjir kerak emas — heuristik tanlash tashqi servisga bog'liq emas va o'zi "fallback" darajasida ishonchli.


Scaling

~377 chunk: Hozirgi holat

Nihol loyihasida ~377 ta chunk bor. Bu hajm uchun pgvector ning brute-force qidiruvi yetarli. Har bir so'rov butun jadvalni skanerlaydi — bu ~377 ta chunk uchun 5-20ms vaqt oladi. Index kerak emas.

python6 qator
-- Brute-force: barcha chunk larni tekshiradi
SELECT id, chunk_text, 1 - (embedding_local <=> :vec) AS similarity
FROM wde.lv_content_chunks
WHERE embedding_local IS NOT NULL
ORDER BY embedding_local <=> :vec
LIMIT 15;

10,000+ chunk: Index qo'shish

Chunk lar soni 10,000 dan oshganda, brute-force sekinlashadi. IVFFlat yoki HNSW index qo'shish kerak.

python9 qator
-- IVFFlat index: tez, lekin approximate
CREATE INDEX idx_chunks_embedding ON wde.lv_content_chunks
USING ivfflat (embedding_cloud vector_cosine_ops)
WITH (lists = 100);

-- HNSW index: aniqroq, lekin ko'proq xotira
CREATE INDEX idx_chunks_embedding_hnsw ON wde.lv_content_chunks
USING hnsw (embedding_cloud vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

IVFFlat tezroq yaratiladi, HNSW aniqroq qidiradi. 10K-100K chunk oralig'ida ikkisi ham yaxshi ishlaydi.

100,000+ chunk: Maxsus Vector DB

100K dan ortiq chunk da pgvector yetarli bo'lmasligi mumkin. Bu holatda maxsus vector database larga o'tish kerak: - Qdrant: Rust da yozilgan, juda tez, filtering imkoniyatlari keng - Milvus: Katta hajmlar uchun optimallashtirilgan, distributed - Weaviate: GraphQL API, hybrid search

Ko'p foydalanuvchi: Connection Pooling

python10 qator
# Bir nechta uvicorn worker bilan ishga tushirish
# uvicorn app.main:app --workers 4 --host 0.0.0.0 --port 8005

# SQLAlchemy connection pool sozlamalari
engine = create_engine(
    settings.DATABASE_URL,
    pool_size=5,       # Har bir worker uchun 5 ta connection
    max_overflow=10,   # Yuklama oshganda qo'shimcha 10 ta
    pool_pre_ping=True, # Har bir so'rovdan oldin connection tirikligini tekshirish
)

4 worker x 5 connection = 20 ta parallel database connection. Bu 100+ concurrent foydalanuvchi uchun yetarli.


Xulosa

Production tayyor tizim — bu faqat ishlaydi gan tizim emas. Bu xatolarni o'zi hal qiladigan, yuklamani ko'taradigan, har bir so'rovni kuzatib turadigan tizim. Caching tezlik beradi, rate limiting himoya qiladi, monitoring tushuncha beradi, error handling ishonchlilik beradi.

Eng muhimi — bosqichma-bosqich yondashing. Avval oddiy logging qo'shing. Keyin cache. Keyin rate limiting. Keyin monitoring dashboard. Har bir qadamda tizim ishonchliroq bo'ladi.


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

app/main.py — rolga asoslangan middleware

python32 qator
class AuthMiddleware(BaseHTTPMiddleware):
    ALLOWED_PATHS = {"/login", "/api/auth/login", "/api/auth/logout"}
    USER_PATHS = {
        "/chat", "/api/chat", "/api/chat/clear", "/api/rubrics",
        "/api/feedback-marks", "/api/feedback", "/api/feedback/pending",
    }
    GUEST_PATHS = {
        "/chat", "/api/chat", "/api/chat/clear", "/api/rubrics",
        "/api/feedback-marks", "/api/feedback/pending",
    }
    ANALYTICS_PATHS = {"/analytics", "/api/questions", "/api/analytics/users"}

    async def dispatch(self, request: Request, call_next):
        path = request.url.path
        if path in self.ALLOWED_PATHS or path.startswith("/static"):
            return await call_next(request)
        role = request.session.get("role")
        if role == "admin":
            return await call_next(request)
        if role == "user" and path in self.USER_PATHS:
            return await call_next(request)
        if role == "analytics" and (
            path in self.USER_PATHS
            or path in self.ANALYTICS_PATHS
            or path.startswith("/api/questions/")
        ):
            return await call_next(request)
        if not request.session.get("authenticated") and path in self.GUEST_PATHS:
            return await call_next(request)
        if request.method == "GET":
            return RedirectResponse("/login", status_code=303)
        return JSONResponse({"detail": "Not authenticated"}, status_code=401)

app/routes/auth.py — login (bcrypt)

python72 qator
from datetime import datetime, timezone
from pathlib import Path
import bcrypt
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.database import get_session
from app.models import LvSysParams, LvUsers

BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))

router = APIRouter()

_DUMMY_HASH = bcrypt.hashpw(b"x", bcrypt.gensalt())


class LoginBody(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)
    username: str = Field(min_length=1, max_length=100)
    password: str = Field(min_length=1, max_length=200)


def _get_param(session: Session, code: str) -> str:
    row = session.query(LvSysParams).filter(LvSysParams.code == code).first()
    return row.name if row else ""


@router.get("/login")
def login_page(request: Request):
    if request.session.get("authenticated"):
        return RedirectResponse("/chat", status_code=303)
    return templates.TemplateResponse("login.html", {"request": request})


@router.post("/api/auth/login")
def login(body: LoginBody, request: Request, session: Session = Depends(get_session)):
    given_pw = body.password.encode("utf-8")

    user = (
        session.query(LvUsers)
        .filter(LvUsers.username == body.username, LvUsers.active_yn == "Y")
        .first()
    )
    if user and bcrypt.checkpw(given_pw, user.password_hash.encode("utf-8")):
        user.last_login_on = datetime.now(timezone.utc)
        session.commit()
        request.session["authenticated"] = True
        request.session["user_id"] = user.id
        request.session["username"] = user.username
        request.session["role"] = user.role
        return {"status": "ok"}

    stored_username = _get_param(session, "AUTH_ADMIN_USERNAME")
    stored_hash = _get_param(session, "AUTH_ADMIN_PASSWORD_HASH")
    expected_hash = stored_hash.encode("ascii") if (stored_hash and body.username == stored_username) else _DUMMY_HASH
    if stored_username and bcrypt.checkpw(given_pw, expected_hash) and body.username == stored_username:
        request.session["authenticated"] = True
        request.session["user_id"] = None
        request.session["username"] = stored_username
        request.session["role"] = "admin"
        return {"status": "ok"}

    raise HTTPException(status_code=401, detail="Invalid credentials")


@router.post("/api/auth/logout")
def logout(request: Request):
    request.session.clear()
    return {"status": "ok"}

public/rate_limit.py — rate limiting

python15 qator
from collections import defaultdict, deque
from time import time

_hits: dict[str, deque[float]] = defaultdict(deque)


def check(ip: str, limit: int) -> bool:
    now = time()
    window = _hits[ip]
    while window and now - window[0] > 60:
        window.popleft()
    if len(window) >= limit:
        return False
    window.append(now)
    return True

app/routes/chat.py — analytics yozish

python9 qator
def _write_analytics_chat(**kwargs):
    s = SessionLocal()
    try:
        s.add(LgAnalyticsChat(**kwargs))
        s.commit()
    except Exception:
        s.rollback()
    finally:
        s.close()

Bu bobni bepul o'qidingiz.

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

Telegramda yozish Narxlar