Production Patterns
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.
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.
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
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.
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:
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:
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).
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:
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:
# 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.
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:
- Parol hech qachon ochiq saqlanmaydi -- faqat bcrypt hash (
lv_users.password_hashyokilv_sys_paramsdagi admin hash). - Timing attack himoyasi: login topilmasa ham dummy hash bilan
bcrypt.checkpwchaqiriladi -- javob vaqti bir xil bo'ladi, username mavjudligini vaqtdan bilib bo'lmaydi. - To'rt daraja:
admin(hammasi),user(chat + feedback),analytics(chat + statistika), mehmon (chat, 5 ta savol limiti, feedback yozolmaydi). - Session cookie
SameSite=LaxvaSESSION_SECRETbilan imzolanadi. CSRF token hali qo'shilmagan -- ommaviy deploy dan oldin rejada. - 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.
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:
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.
-- 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.
-- 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
# 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
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)
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
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 Trueapp/routes/chat.py — analytics yozish
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.