Hands-On Project — RAG Chatbot from Scratch
Nima quramiz?
Bu bobda noldan RAG chatbot yaratamiz. Chatbot kompaniya veb-saytidan ma'lumot yig'ib, foydalanuvchi savollariga bazadagi haqiqiy ma'lumotlar asosida javob beradi.
Texnologiyalar: FastAPI + PostgreSQL + pgvector + BeautifulSoup + embedding API + LLM API.
Arxitektura:
Veb-sayt --> Scraper --> Chunker --> Embedder --> PostgreSQL + pgvector
|
Foydalanuvchi savoli --> Embed --> Vector Search --> Rerank --> LLM --> Javob
Prerequisites
- Python 3.11+
- PostgreSQL 15+ (pgvector extension bilan)
- pip
- Embedding va LLM uchun API kalit (OLLAMA local uchun yoki OpenRouter cloud uchun)
Step 1: Setup
Virtual environment yaratish
mkdir rag-chatbot && cd rag-chatbot python -m venv venv # Linux/Mac: source venv/bin/activate # Windows: venv\Scripts\activate
Kutubxonalarni o'rnatish
pip install fastapi uvicorn sqlalchemy psycopg2-binary httpx \ beautifulsoup4 lxml trafilatura python-dotenv jinja2 \ starlette itsdangerous
Har bir kutubxona:
- fastapi — web framework
- uvicorn — ASGI server
- sqlalchemy — ORM (database bilan ishlash)
- psycopg2-binary — PostgreSQL driver
- httpx — HTTP client (API chaqiruvlar uchun)
- beautifulsoup4 + lxml — HTML parsing
- trafilatura — veb-sahifadan toza matn ajratish
- python-dotenv — .env fayldan sozlamalar o'qish
- jinja2 — HTML template engine
- starlette + itsdangerous — session middleware
Database yaratish
-- PostgreSQL ga kiring psql -U postgres -- Bazani yarating CREATE DATABASE rag_chatbot; -- Bazaga ulaning \c rag_chatbot -- pgvector extensionni o'rnating CREATE EXTENSION IF NOT EXISTS vector; -- Schema yarating CREATE SCHEMA IF NOT EXISTS wde;
.env fayl
# .env DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=your_password DB_NAME=rag_chatbot # Local (OLLAMA): OLLAMA_URL=http://127.0.0.1:11434 EMBED_MODEL=qwen3-embedding:8b EMBED_DIMENSIONS=4096 CHAT_MODEL=qwen3:8b # Yoki Cloud (OpenRouter): # OPENROUTER_API_KEY=sk-or-v1-... # OPENROUTER_MODEL=google/gemini-2.0-flash-001 # EMBED_MODEL=openai/text-embedding-3-small # EMBED_DIMENSIONS=1536
Loyiha strukturasi
rag-chatbot/
app/
__init__.py
config.py
database.py
models/
__init__.py
tables.py
services/
__init__.py
chat.py
routes/
__init__.py
chat.py
templates/
chat.html
main.py
extractor/
__init__.py
extractor.py
chunker.py
normalizer.py
.env
Step 2: Database Models
config.py — Sozlamalar
# app/config.py from functools import lru_cache from os import getenv from dotenv import load_dotenv load_dotenv() class Settings: DB_HOST: str = getenv("DB_HOST", "localhost") DB_PORT: str = getenv("DB_PORT", "5432") DB_USER: str = getenv("DB_USER", "postgres") DB_PASSWORD: str = getenv("DB_PASSWORD", "") DB_NAME: str = getenv("DB_NAME", "rag_chatbot") OLLAMA_URL: str = getenv("OLLAMA_URL", "http://127.0.0.1:11434") EMBED_MODEL: str = getenv("EMBED_MODEL", "qwen3-embedding:8b") EMBED_DIMENSIONS: int = int(getenv("EMBED_DIMENSIONS", "4096")) CHAT_MODEL: str = getenv("CHAT_MODEL", "qwen3:8b") RAG_TOP_K: int = int(getenv("RAG_TOP_K", "10")) RAG_RERANK_TOP: int = int(getenv("RAG_RERANK_TOP", "5")) RAG_TEMPERATURE: float = float(getenv("RAG_TEMPERATURE", "0.1")) RAG_SIMILARITY_THRESHOLD: float = float(getenv("RAG_SIMILARITY_THRESHOLD", "0.3")) @property def DATABASE_URL(self) -> str: return ( f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}" f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" ) @lru_cache def get_settings() -> Settings: return Settings()
database.py — Database connection
# app/database.py from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base, Session from typing import Generator from app.config import get_settings engine = create_engine( get_settings().DATABASE_URL, pool_size=5, max_overflow=10, pool_pre_ping=True, ) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) Base = declarative_base() def get_session() -> Generator[Session, None, None]: session = SessionLocal() try: yield session finally: session.close()
tables.py — Database modellari
# app/models/tables.py from datetime import datetime, timezone from sqlalchemy import ( Column, Integer, String, Text, ForeignKey, DateTime, Sequence, ) from sqlalchemy.orm import relationship from app.database import Base SCHEMA = "wde" class LvSites(Base): __tablename__ = "lv_sites" __table_args__ = {"schema": SCHEMA} id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(200), nullable=False) base_url = Column(String(4000), nullable=False) pages = relationship("LvTargetPages", back_populates="site") class LvTargetPages(Base): __tablename__ = "lv_target_pages" __table_args__ = {"schema": SCHEMA} id = Column(Integer, primary_key=True, autoincrement=True) site_id = Column(Integer, ForeignKey("wde.lv_sites.id"), nullable=False) name = Column(String(200), nullable=False) rubric_name = Column(String(200)) relative_url = Column(String(4000)) created_on = Column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), ) site = relationship("LvSites", back_populates="pages") extracted_contents = relationship( "LvExtractedContent", back_populates="page" ) class LvExtractedContent(Base): __tablename__ = "lv_extracted_content" __table_args__ = {"schema": SCHEMA} id = Column(Integer, primary_key=True, autoincrement=True) page_id = Column( Integer, ForeignKey("wde.lv_target_pages.id"), nullable=False ) url = Column(String(4000), nullable=False) title = Column(String(1000)) content_text = Column(Text) extracted_on = Column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), ) page = relationship("LvTargetPages", back_populates="extracted_contents") chunks = relationship( "LvContentChunks", back_populates="content", cascade="all, delete-orphan", ) class LvContentChunks(Base): __tablename__ = "lv_content_chunks" __table_args__ = {"schema": SCHEMA} id = Column(Integer, primary_key=True, autoincrement=True) content_id = Column( Integer, ForeignKey("wde.lv_extracted_content.id", ondelete="CASCADE"), nullable=False, ) chunk_text = Column(Text, nullable=False) content_size_bytes = Column(Integer) embedding = Column(Text) # JSON string: [0.123, -0.456, ...] created_on = Column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), ) content = relationship("LvExtractedContent", back_populates="chunks")
Jadvallarni yaratish:
# create_tables.py from app.database import engine, Base from app.models.tables import * Base.metadata.create_all(bind=engine) print("Tables created successfully.")
python create_tables.py
Step 3: Scraper
Veb-sahifadan matn ajratib olish.
# extractor/extractor.py import httpx from bs4 import BeautifulSoup import trafilatura from extractor.normalizer import normalize_text def scrape_page(url: str, css_selector: str | None = None) -> dict: resp = httpx.get(url, timeout=30, follow_redirects=True) resp.raise_for_status() html = resp.text soup = BeautifulSoup(html, "lxml") # Sahifa sarlavhasini olamiz title_tag = soup.find("title") title = title_tag.get_text(strip=True) if title_tag else "" # Matnni ajratamiz if css_selector: # CSS selector berilgan bo'lsa — aniq elementlardan olamiz elements = soup.select(css_selector) content_text = "\n".join(el.get_text() for el in elements) else: # Trafilatura — avtomatik matn ajratish content_text = trafilatura.extract( html, url=url, include_comments=False ) or "" # Matnni tozalaymiz content_text = normalize_text(content_text) return {"url": url, "title": title, "content_text": content_text}
normalizer.py — Matn tozalash
# extractor/normalizer.py import re def normalize_text(text: str) -> str: if not text: return "" # Maxsus belgilarni tozalash text = text.replace("\r", "") text = text.replace("\xad", "") # soft hyphen text = text.replace("\xa0", " ") # non-breaking space text = text.replace("\u200b", "") # zero-width space # Ortiqcha bo'shliqlarni tozalash text = re.sub(r"[ \t]+", " ", text) # Har bir qatorni strip qilish lines = [line.strip() for line in text.splitlines()] text = "\n".join(lines) # Uchdan ortiq bo'sh qatorni ikkitaga kamaytirish text = re.sub(r"\n{3,}", "\n\n", text) return text.strip()
Step 4: Chunker
Matnni chunk larga bo'lish. Katta matn to'g'ridan-to'g'ri LLM ga sig'maydi, shuning uchun uni kichik qismlarga bo'lamiz.
# extractor/chunker.py MAX_TOKENS = 38000 CHARS_PER_TOKEN = 3 # Taxminiy: 1 token ~ 3 belgi def chunk_text( content_text: str, rubric: str = "", page_name: str = "", ) -> list[str]: # Prefix: rubric + sahifa nomi prefix_parts = [p for p in [rubric, page_name] if p] prefix = "\n\n".join(prefix_parts) # To'liq matn parts = prefix_parts + [content_text] full_text = "\n\n".join(parts) # Agar matn kichik bo'lsa — bitta chunk qaytaramiz if _estimate_tokens(full_text) <= MAX_TOKENS: return [full_text] # Katta matnni paragraflar bo'yicha bo'lamiz paragraphs = full_text.split("\n\n") prefix_str = f"{prefix}\n\n" if prefix else "" chunks = [] current = prefix_str for paragraph in paragraphs: if paragraph.strip() == prefix.strip(): continue candidate = current + paragraph + "\n\n" if _estimate_tokens(candidate) > MAX_TOKENS and current != prefix_str: # Joriy chunk tayyor — saqlaymiz chunks.append(current.rstrip()) # Yangi chunk boshlaymiz (prefix bilan) current = prefix_str + paragraph + "\n\n" else: current = candidate # Oxirgi chunk ni qo'shamiz if current.rstrip() and current.rstrip() != prefix_str.rstrip(): chunks.append(current.rstrip()) return chunks if chunks else [full_text] def _estimate_tokens(text: str) -> int: return len(text) // CHARS_PER_TOKEN
Muhim tushuncha: har bir chunk ga prefix (rubric + sahifa nomi) qo'shiladi. Bu kontekst beradi — chunk ni o'qigan LLM biladi ki bu qaysi sahifadan olingan ma'lumot.
Step 5: Embedding
Chunk matnini vector ga aylantirish. Bu vector orqali keyin semantic search qilamiz.
OLLAMA (local) bilan
# app/services/embedding.py import json import httpx from app.config import get_settings def generate_embedding(text: str) -> list[float] | None: settings = get_settings() try: resp = httpx.post( f"{settings.OLLAMA_URL}/api/embed", json={"model": settings.EMBED_MODEL, "input": text}, timeout=60, ) resp.raise_for_status() return resp.json()["embeddings"][0] except Exception: return None
OLLAMA API javob formati:
{ "embeddings": [ [0.0231, -0.0456, 0.0789, ...] ] }
Qaytgan vector — 4096 ta float son (qwen3-embedding:8b uchun). Bu sonlar matnning semantik ma'nosini ifodalaydi.
Barcha chunk larni embedding qilish
def embed_all_chunks(session) -> dict: from app.models.tables import LvContentChunks pending = session.query(LvContentChunks).filter( LvContentChunks.embedding.is_(None), ).all() embedded = 0 errors = [] for chunk in pending: vector = generate_embedding(chunk.chunk_text) if vector is None: errors.append({"chunk_id": chunk.id, "error": "empty response"}) continue # Vector ni JSON string sifatida saqlaymiz chunk.embedding = json.dumps(vector) session.commit() embedded += 1 return {"embedded": embedded, "errors": errors}
Step 6: Search
Foydalanuvchi savoli kelganda: savolni embed qilamiz, pgvector da cosine similarity bo'yicha eng yaqin chunk larni topamiz.
# app/services/chat.py (qidiruv qismi) from sqlalchemy import text from sqlalchemy.orm import Session from app.config import get_settings def retrieve_chunks( session: Session, query_embedding: list[float], top_k: int | None = None, ) -> list[dict]: settings = get_settings() top_k = top_k or settings.RAG_TOP_K threshold = settings.RAG_SIMILARITY_THRESHOLD sql = text(""" SELECT ch.id, ch.content_id, ch.chunk_text, c.title, c.url, 1 - (ch.embedding <=> :vec) AS similarity FROM wde.lv_content_chunks ch JOIN wde.lv_extracted_content c ON c.id = ch.content_id WHERE ch.embedding IS NOT NULL ORDER BY ch.embedding <=> :vec LIMIT :lim """) rows = session.execute( sql, {"vec": str(query_embedding), "lim": top_k} ).fetchall() return [ { "chunk_id": r[0], "content_id": r[1], "chunk_text": r[2], "title": r[3], "url": r[4], "similarity": float(r[5]), } for r in rows if float(r[5]) >= threshold ]
SQL tushuntirish:
- ch.embedding <=> :vec — cosine distance (pgvector operatori)
- 1 - (distance) = cosine similarity (0 dan 1 gacha, 1 = eng o'xshash)
- ORDER BY ... LIMIT — eng o'xshash top K ta chunk ni qaytaradi
- WHERE embedding IS NOT NULL — faqat embedding qilingan chunk lar
Step 7: Chunk Tanlash (Post-Retrieval)
Top 15 nomzoddan kontekstga eng tegishli 8 tasini tanlash. Nihol production kodida bu LLM-siz, tez heuristika bilan bajariladi:
def select_context_chunks(chunks: list[dict], question: str) -> list[dict]: top_chunks = chunks[:3] # eng yaxshi 3 har doim olinadi q_lower = question.lower() key_terms = [w[:6] if len(w) > 6 else w for w in q_lower.split() if len(w) > 3] extra = [] seen_ids = {c["chunk_id"] for c in top_chunks} for chunk in chunks[3:]: # qolganlari kalit so'z bo'yicha text_lower = chunk["chunk_text"].lower() title_lower = (chunk.get("title") or "").lower() if any(term in text_lower or term in title_lower for term in key_terms): if chunk["chunk_id"] not in seen_ids: extra.append(chunk) seen_ids.add(chunk["chunk_id"]) return (top_chunks + extra)[:8] # jami maksimum 8
Mantiq: 1. Vector search allaqachon similarity bo'yicha tartiblagan -- top-3 ga ishonamiz. 2. Savoldagi 3 belgidan uzun so'zlar kalit termin sifatida olinadi, 6 belgigacha qisqartiriladi (o'zbek/rus qo'shimchalarini qamrab olish uchun: "serverlar", "serverni" → "server"). 3. 4-15 o'rindagi chunk lar faqat kalit termin bo'lsa qo'shiladi. 4. Natija: 3-8 ta chunk, mikrosekundlarda, qo'shimcha LLM chaqiruvisiz.
Agar sifat yetarli bo'lmasa, keyingi bosqich sifatida cross-encoder reranker qo'shish mumkin -- masalan Jina Reranker API (8-bobda batafsil):
def _jina_rerank(chunks, question, top_n): resp = httpx.post( "https://api.jina.ai/v1/rerank", headers={"Authorization": f"Bearer {JINA_API_KEY}"}, json={ "model": "jina-reranker-v2-base-multilingual", "query": question, "documents": [c["chunk_text"][:500] for c in chunks], "top_n": top_n, }, timeout=30, ) resp.raise_for_status() results = resp.json().get("results", []) return [chunks[item["index"]] for item in results]
Nihol loyihasi buni sinab ko'rib, lokal use case uchun heuristika yetarli degan xulosaga keldi -- reranker qo'shimcha 1-3 soniya kechikish evaziga sezilarli sifat farqi bermadi.
Step 8: Generation
Topilgan chunk lar va savoldan prompt tuzib, LLM dan javob olamiz. SSE (Server-Sent Events) orqali stream qilamiz.
from typing import Generator SYSTEM_PROMPT = ( "You are a strict factual assistant for a company knowledge base. " "RULES:\n" "1. Answer ONLY using the provided CONTEXT. Never use outside knowledge.\n" "2. If the context lacks information, say: " "'Информация не найдена в базе знаний.'\n" "3. Do NOT invent or assume information not present in the context.\n" "4. Respond in the language the user wrote in.\n" "5. Use plain text only. No markdown formatting.\n" "6. Keep your answer concise and factual." ) def build_prompt( chunks: list[dict], question: str, history: list[dict] | None = None, ) -> list[dict]: messages = [{"role": "system", "content": SYSTEM_PROMPT}] if chunks: context_parts = [] for i, chunk in enumerate(chunks, 1): context_parts.append(f"[{i}] {chunk['chunk_text']}") context = "\n\n".join(context_parts) messages.append({ "role": "system", "content": f"CONTEXT:\n\n{context}", }) else: messages.append({ "role": "system", "content": "No relevant information found in the knowledge base.", }) # Suhbat tarixini qo'shamiz (oxirgi 6 ta tur) if history: messages.extend(history[-12:]) messages.append({"role": "user", "content": question}) return messages def stream_response( messages: list[dict], temperature: float = 0.1, ) -> Generator[str, None, None]: settings = get_settings() try: resp = httpx.post( f"{settings.OLLAMA_URL}/api/chat", json={ "model": settings.CHAT_MODEL, "messages": messages, "stream": False, "options": { "temperature": temperature, "num_predict": 1536, "repeat_penalty": 1.5, }, }, timeout=300, ) resp.raise_for_status() content = resp.json()["message"]["content"].strip() # Javobni kichik qismlarga bo'lib yuboramiz (SSE uchun) for chunk in [content[i:i+4] for i in range(0, len(content), 4)]: yield chunk except httpx.ConnectError: yield "[Error: LLM service unavailable]" except httpx.ReadTimeout: yield "[Error: Response timed out]"
Step 9: FastAPI Routes
main.py — Application
# app/main.py from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.routes.chat import router as chat_router app = FastAPI(title="RAG Chatbot") app.add_middleware(SessionMiddleware, secret_key="your-secret-key-here") app.include_router(chat_router)
routes/chat.py — Chat endpoint lari
# app/routes/chat.py import json import uuid from pathlib import Path from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import StreamingResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.database import get_session from app.services.chat import ( generate_embedding, retrieve_chunks, select_context_chunks, build_prompt, stream_response, ) BASE_DIR = Path(__file__).resolve().parent.parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) router = APIRouter() # In-memory session storage _sessions: dict[str, list[dict]] = {} class ChatMessage(BaseModel): message: str = Field(min_length=1, max_length=2000) @router.get("/chat") def chat_page(request: Request): return templates.TemplateResponse("chat.html", {"request": request}) @router.post("/api/chat") def chat_send( body: ChatMessage, request: Request, session: Session = Depends(get_session), ): question = body.message.strip() # 1. Savolni embedding ga aylantirish embedding = generate_embedding(question) if embedding is None: raise HTTPException( status_code=503, detail="Embedding service unavailable", ) # 2. pgvector da qidiruv chunks = retrieve_chunks(session, embedding) # 3. Chunk tanlash (heuristik) chunks = select_context_chunks(chunks, question) # 4. Session tarixini olish sid = request.session.get("chat_id", str(uuid.uuid4())) request.session["chat_id"] = sid history = _sessions.get(sid, []) # 5. Prompt tuzish messages = build_prompt(chunks, question, history) # 6. SSE stream def event_stream(): full_response = [] for token in stream_response(messages): full_response.append(token) yield f"data: {json.dumps({'token': token})}\n\n" answer = "".join(full_response) history.append({"role": "user", "content": question}) history.append({"role": "assistant", "content": answer}) _sessions[sid] = history[-20:] sources = [ { "title": c["title"], "similarity": round(c["similarity"], 4), } for c in chunks ] yield f"data: {json.dumps({'done': True, 'sources': sources})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") @router.post("/api/chat/clear") def chat_clear(request: Request): sid = request.session.get("chat_id") if sid: _sessions.pop(sid, None) return {"status": "ok"}
SSE (Server-Sent Events) tushuntirish: Oddiy HTTP javob bir marta keladi. SSE da esa server ulanishni ochiq qoldirib, ma'lumotlarni qismlab yuboradi. Har bir qism data: {...}\n\n formatida. Frontend bu qismlarni olgan zahoti ekranga chiqaradi — foydalanuvchi javobni "jonli yozilayotgandek" ko'radi. Halollik uchun: bizning versiyada bu pseudo-streaming — javob avval to'liq generatsiya qilinadi, keyin 4 belgidan parchalab yuboriladi. Haqiqiy token-streaming uchun Ollama ga stream: true berish va tokenlarni kelgani zahoti uzatish kerak.
Step 10: Test va Evaluate
Test savollar yarating
TEST_QUESTIONS = [ { "id": 1, "question": "Kompaniya qanday loyihalar qilgan?", "expected_keywords": ["loyiha", "проект"], "topic": "general", }, { "id": 2, "question": "Bank sohasidagi ishlar haqida", "expected_keywords": ["банк", "bank"], "topic": "banking", }, { "id": 3, "question": "Bugungi ob-havo qanday?", "expected_keywords": [], "topic": "irrelevant", }, ]
Evaluation funksiyasi
def evaluate(session, test_cases: list[dict]) -> dict: results = [] for tc in test_cases: embedding = generate_embedding(tc["question"]) chunks = retrieve_chunks(session, embedding) if embedding else [] chunks = select_context_chunks(chunks, tc["question"]) messages = build_prompt(chunks, tc["question"]) answer = "".join(stream_response(messages)) # Retrieval score if tc["topic"] == "irrelevant": retrieval = 1.0 elif tc["expected_keywords"]: combined = " ".join(c["chunk_text"] for c in chunks).lower() found = sum( 1 for kw in tc["expected_keywords"] if kw.lower() in combined ) retrieval = found / len(tc["expected_keywords"]) else: retrieval = 1.0 results.append({ "question": tc["question"], "retrieval": round(retrieval * 100), "answer": answer[:200], }) avg = sum(r["retrieval"] for r in results) / len(results) return {"results": results, "avg_retrieval": round(avg)}
Evaluation ni ishga tushirish
python -c " from app.database import SessionLocal from evaluate import evaluate, TEST_QUESTIONS session = SessionLocal() result = evaluate(session, TEST_QUESTIONS) print(f'Average retrieval: {result[\"avg_retrieval\"]}%') for r in result['results']: print(f' {r[\"retrieval\"]}% | {r[\"question\"][:50]}') session.close() "
Step 11: Deploy
Local network da ishga tushirish
# Bitta worker bilan (development) uvicorn app.main:app --host 0.0.0.0 --port 8005 --reload # Ko'p worker bilan (production) uvicorn app.main:app --host 0.0.0.0 --port 8005 --workers 4
--host 0.0.0.0 — barcha tarmoq interfeyslarida tinglaydi. Bu local network dagi boshqa kompyuterlar ham kirishini ta'minlaydi.
Nihol production loyihasida ikkita alohida ilova ishlaydi: admin + chat (port 8005) va ommaviy /ask sahifasi (port 8006, IP bo'yicha daqiqasiga 10 so'rov rate limit bilan). Hands-on loyihamiz soddalik uchun bitta ilova.
Firewall sozlamalari (Linux)
# 8005 portni ochish sudo ufw allow 8005/tcp # Yoki iptables bilan sudo iptables -A INPUT -p tcp --dport 8005 -j ACCEPT
Windows Firewall
# PowerShell dan (Administrator sifatida) New-NetFirewallRule -DisplayName "RAG Chatbot" ` -Direction Inbound -Protocol TCP -LocalPort 8005 -Action Allow
URL ni hamkasblar bilan ulashing
http://192.168.1.100:8005/chat
IP adresingizni bilish uchun:
# Linux ip addr show | grep "inet " # Windows ipconfig | findstr "IPv4"
Doimiy ishga tushirish (systemd)
# /etc/systemd/system/rag-chatbot.service [Unit] Description=RAG Chatbot After=network.target postgresql.service [Service] Type=simple User=your_user WorkingDirectory=/path/to/rag-chatbot Environment="PATH=/path/to/rag-chatbot/venv/bin" ExecStart=/path/to/rag-chatbot/venv/bin/uvicorn app.main:app \ --host 0.0.0.0 --port 8005 --workers 2 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable rag-chatbot sudo systemctl start rag-chatbot sudo systemctl status rag-chatbot
Endi server qayta ishga tushsa ham, chatbot avtomatik boshlanadi.
Xulosa
Siz hozir noldan RAG chatbot qurishni o'rgandingiz. Butun pipeline:
- Scrape — veb-saytdan matnni olish
- Chunk — matnni kichik qismlarga bo'lish
- Embed — har bir chunk ni vector ga aylantirish
- Store — vector larni PostgreSQL + pgvector da saqlash
- Search — foydalanuvchi savoliga eng o'xshash chunk larni topish
- Rerank — topilgan chunk lardan eng tegishlilarini tanlash
- Generate — LLM ga chunk lar va savolni berib javob olish
- Stream — javobni SSE orqali qismlab ko'rsatish (pseudo-streaming: to'liq javob 4 belgidan parchalanadi)
- Evaluate — tizim sifatini raqamlar bilan o'lchash
Keyingi qadamlar: - 10-bobdagi evaluation tizimini to'liq qo'shing - 11-bobdagi production patternlarni (caching, rate limiting, monitoring) joriy qiling - Yangi ma'lumot manbalari qo'shing (PDF, API, database) - Boshqa embedding va LLM modellarni sinab ko'ring va natijalarni taqqoslang
</> Loyihaning haqiqiy kodi — bu bob amalda qanday ishlaydi
app/routes/chat.py — to'liq chat endpoint
import json
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_session, SessionLocal
from app.models import LgQuestions, LgRequestChunks, LgAnalyticsChat, LvContentChunks, LvExtractedContent
from app.services.chat import (
detect_lang,
expand_query,
generate_query_embedding,
retrieve_chunks,
select_context_chunks,
build_prompt,
stream_response,
)
from app.services.sys_params import get_sys_param
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
router = APIRouter()
def _write_analytics_chat(**kwargs):
s = SessionLocal()
try:
s.add(LgAnalyticsChat(**kwargs))
s.commit()
except Exception:
s.rollback()
finally:
s.close()
class ChatMessage(BaseModel):
message: str = Field(min_length=1, max_length=2000)
search_mode: str = Field(default="semantic")
rubrics: list[str] | None = Field(default=None)
@router.get("/chat")
def chat_page(request: Request, session: Session = Depends(get_session)):
is_guest = not request.session.get("authenticated")
guest_limit = get_sys_param("GUEST_LIMIT", int, 5)
# Remember the conversation: reload this session's answered Q&A from the DB.
history = []
history_sources = {}
session_id = request.session.get("session_id")
cleared_at = request.session.get("chat_cleared_at")
if session_id and not is_guest:
query = (
session.query(LgQuestions)
.filter(LgQuestions.session_id == session_id)
.filter(LgQuestions.answer_text.isnot(None))
)
if cleared_at:
try:
query = query.filter(LgQuestions.created_at > datetime.fromisoformat(cleared_at))
except ValueError:
pass
rows = query.order_by(LgQuestions.created_at.asc()).all()
sources_by_q = {}
q_ids = [r.id for r in rows]
if q_ids:
src_rows = (
session.query(
LgRequestChunks.question_id,
LvExtractedContent.title,
LvExtractedContent.url,
LvContentChunks.chunk_text,
)
.join(LvContentChunks, LvContentChunks.id == LgRequestChunks.chunk_id)
.join(LvExtractedContent, LvExtractedContent.id == LvContentChunks.content_id)
.filter(LgRequestChunks.question_id.in_(q_ids))
.filter(LgRequestChunks.was_in_context == "Y")
.order_by(LgRequestChunks.rank.asc())
.all()
)
for qid, title, url, chunk_text in src_rows:
sources_by_q.setdefault(qid, []).append({"title": title, "url": url, "text": chunk_text})
for r in rows:
history.append({"role": "user", "content": r.question_text})
history.append({
"role": "assistant",
"content": r.answer_text,
"question_id": str(r.id),
"rated": r.mark_id is not None,
})
history_sources = {str(qid): srcs for qid, srcs in sources_by_q.items()}
return templates.TemplateResponse("chat.html", {
"request": request,
"history": history,
"history_sources": history_sources,
"is_guest": is_guest,
"guest_count": request.session.get("guest_count", 0) if is_guest else 0,
"guest_limit": guest_limit,
})
@router.post("/api/chat")
def chat_send(
body: ChatMessage,
request: Request,
session: Session = Depends(get_session),
):
is_guest = not request.session.get("authenticated")
user_id = request.session.get("user_id")
guest_count = request.session.get("guest_count", 0)
guest_limit = get_sys_param("GUEST_LIMIT", int, 5)
if is_guest and guest_count >= guest_limit:
raise HTTPException(status_code=429, detail={
"code": "guest_limit",
"used": guest_count,
"limit": guest_limit,
})
if "session_id" not in request.session:
request.session["session_id"] = uuid.uuid4().hex
session_id = request.session["session_id"]
if request.session.get("role") in ("user", "analytics"):
pending_q = (
session.query(LgQuestions)
.filter(LgQuestions.session_id == session_id)
.filter(LgQuestions.answer_text.isnot(None))
.filter(LgQuestions.mark_id.is_(None))
)
cleared_at = request.session.get("chat_cleared_at")
if cleared_at:
try:
pending_q = pending_q.filter(LgQuestions.created_at > datetime.fromisoformat(cleared_at))
except ValueError:
pass
pending = pending_q.order_by(LgQuestions.created_at.desc()).first()
if pending is not None:
raise HTTPException(
status_code=403,
detail={
"code": "feedback_required",
"pending_question_id": str(pending.id),
},
)
question = body.message.strip()
lang_code = detect_lang(question)
effective_rubrics = body.rubrics or None
if not effective_rubrics:
_default = get_sys_param("DEFAULT_RUBRIC", str, None)
if _default:
effective_rubrics = [_default]
expanded_query = None
search_text = question
if get_sys_param("QUERY_EXPANSION", str, "N") == "Y":
expanded_query = expand_query(question)
if expanded_query:
search_text = expanded_query
question_id = None
if not is_guest:
q_row = LgQuestions(
session_id=session_id,
user_id=user_id,
question_text=question,
expanded_query=expanded_query,
detected_lang=lang_code,
rubrics_used=effective_rubrics,
)
session.add(q_row)
session.commit()
session.refresh(q_row)
question_id = q_row.id
settings = get_settings()
embed_model = settings.EMBED_MODEL
chat_model = settings.CHAT_MODEL
start_time = time.perf_counter()
retrieval_start = start_time
db_search_ms = 0
from app.routes.admin import _load_rag_from_db
rag = _load_rag_from_db(session)
embed_start = time.perf_counter()
embedding, embed_error = generate_query_embedding(search_text)
embedding_ms = int((time.perf_counter() - embed_start) * 1000)
if embedding is None:
retrieval_ms = int((time.perf_counter() - retrieval_start) * 1000)
_write_analytics_chat(
question_id=question_id, user_id=user_id,
retrieval_ms=retrieval_ms, embedding_ms=embedding_ms, db_search_ms=db_search_ms,
generation_ms=0, total_ms=retrieval_ms,
top_similarity=None, num_chunks_retrieved=0,
embed_model=embed_model, chat_model=chat_model,
status="embed_error", error_message=embed_error,
)
raise HTTPException(status_code=503, detail="Embedding service unavailable")
db_start = time.perf_counter()
chunks = retrieve_chunks(
session,
embedding,
top_k=rag["top_k"],
threshold=rag["similarity_threshold"],
lang_code=lang_code,
rubrics=effective_rubrics,
)
db_search_ms = int((time.perf_counter() - db_start) * 1000)
retrieval_ms = int((time.perf_counter() - retrieval_start) * 1000)
if not chunks:
_write_analytics_chat(
question_id=question_id, user_id=user_id,
retrieval_ms=retrieval_ms, embedding_ms=embedding_ms, db_search_ms=db_search_ms,
generation_ms=0, total_ms=retrieval_ms,
top_similarity=None, num_chunks_retrieved=0,
embed_model=embed_model, chat_model=chat_model,
status="no_chunks", error_message=None,
)
raise HTTPException(status_code=503, detail="No relevant data found")
used = select_context_chunks(chunks, question)
used_ids = {c["chunk_id"] for c in used}
if question_id is not None:
threshold_used = rag["similarity_threshold"]
for rank_idx, c in enumerate(chunks, start=1):
session.add(LgRequestChunks(
question_id=question_id,
chunk_id=c["chunk_id"],
similarity_score=c["similarity"],
rank=rank_idx,
threshold_used=threshold_used,
role="semantic",
was_in_context="Y" if c["chunk_id"] in used_ids else "N",
))
try:
session.commit()
except Exception:
session.rollback()
messages = build_prompt(chunks, question, None)
sources = [
{"title": c["title"], "url": c["url"], "text": c["chunk_text"]}
for c in used
]
if is_guest:
request.session["guest_count"] = guest_count + 1
new_guest_count = guest_count + 1
else:
new_guest_count = 0
def event_stream():
generation_start = time.perf_counter()
full_response = []
gen_status = "ok"
gen_error = None
try:
for token in stream_response(messages, chunks=chunks, question=question):
full_response.append(token)
yield f"data: {json.dumps({'token': token})}\n\n"
except Exception as e:
gen_status = "gen_error"
gen_error = str(e)
generation_ms = int((time.perf_counter() - generation_start) * 1000)
total_ms = int((time.perf_counter() - start_time) * 1000)
if question_id is not None:
s2 = SessionLocal()
try:
s2.query(LgQuestions).filter(LgQuestions.id == question_id).update({
"answer_text": "".join(full_response),
"answered_at": datetime.now(timezone.utc),
})
s2.commit()
except Exception:
s2.rollback()
finally:
s2.close()
_write_analytics_chat(
question_id=question_id, user_id=user_id,
retrieval_ms=retrieval_ms, embedding_ms=embedding_ms, db_search_ms=db_search_ms,
generation_ms=generation_ms, total_ms=total_ms,
top_similarity=chunks[0]["similarity"] if chunks else None,
num_chunks_retrieved=len(chunks),
embed_model=embed_model, chat_model=chat_model,
status=gen_status, error_message=gen_error,
)
done_payload = {"done": True, "sources": sources}
if question_id is not None:
done_payload["question_id"] = str(question_id)
if is_guest:
done_payload["guest_count"] = new_guest_count
done_payload["guest_limit"] = guest_limit
yield f"data: {json.dumps(done_payload)}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/api/chat/clear")
def chat_clear(request: Request):
request.session["chat_cleared_at"] = datetime.now(timezone.utc).isoformat()
return {"status": "ok"}public/main.py — ommaviy /ask ilovasi
import json
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, Request, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from app.config import get_settings
from app.database import get_session, SessionLocal
from app.models import (
LgQuestions,
LgRequestChunks,
LgAnalyticsChat,
LvFeedbackMarks,
)
from app.services.chat import (
detect_lang,
expand_query,
generate_query_embedding,
retrieve_chunks,
build_prompt,
stream_response,
)
from app.services.sys_params import get_sys_param
from public.rate_limit import check as rate_check
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class AskBody(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
message: str = Field(min_length=1, max_length=2000)
class FeedbackBody(BaseModel):
question_id: uuid.UUID
mark_id: int
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _write_analytics(**kwargs):
s = SessionLocal()
try:
s.add(LgAnalyticsChat(**kwargs))
s.commit()
except Exception:
s.rollback()
finally:
s.close()
def create_app() -> FastAPI:
settings = get_settings()
if not settings.SESSION_SECRET:
raise RuntimeError("SESSION_SECRET is not set")
application = FastAPI(title="Nihol — Помощник", docs_url=None, redoc_url=None)
application.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET)
@application.get("/")
def ask_page(request: Request):
return templates.TemplateResponse("ask.html", {"request": request})
@application.get("/api/feedback-marks")
def get_marks(session: Session = Depends(get_session)):
rows = (
session.query(LvFeedbackMarks)
.order_by(LvFeedbackMarks.display_order)
.all()
)
return {"data": [
{"id": r.id, "code": r.code, "name": r.name, "color_hex": r.color_hex}
for r in rows
]}
@application.post("/api/feedback")
def post_feedback(
body: FeedbackBody,
session: Session = Depends(get_session),
):
q = session.query(LgQuestions).filter(LgQuestions.id == body.question_id).first()
if q is None:
raise HTTPException(status_code=404, detail="Question not found")
if q.mark_id is not None:
raise HTTPException(status_code=409, detail="Already rated")
mark = session.query(LvFeedbackMarks.id).filter(LvFeedbackMarks.id == body.mark_id).first()
if mark is None:
raise HTTPException(status_code=400, detail="Invalid mark_id")
q.mark_id = body.mark_id
try:
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=409, detail="Already rated")
return {"status": "ok"}
@application.get("/api/feedback/pending")
def get_pending(request: Request, session: Session = Depends(get_session)):
session_id = request.session.get("session_id")
if not session_id:
return Response(status_code=204)
pending = (
session.query(LgQuestions)
.filter(LgQuestions.session_id == session_id)
.filter(LgQuestions.answer_text.isnot(None))
.filter(LgQuestions.mark_id.is_(None))
.order_by(LgQuestions.created_at.desc())
.first()
)
if pending is None:
return Response(status_code=204)
return {
"question_id": str(pending.id),
"question_text": pending.question_text,
"answer_text": pending.answer_text,
}
@application.post("/api/ask")
def ask_send(
body: AskBody,
request: Request,
session: Session = Depends(get_session),
):
ip = _client_ip(request)
rate_limit = get_sys_param("PUBLIC_RATE_LIMIT_PER_MIN", int, settings.PUBLIC_RATE_LIMIT_PER_MIN or 10)
if not rate_check(ip, rate_limit):
raise HTTPException(status_code=429, detail="Too many requests")
if "session_id" not in request.session:
request.session["session_id"] = uuid.uuid4().hex
session_id = request.session["session_id"]
# Grading is optional — no gate. Users may rate the previous answer or just continue.
question = body.message.strip()
lang_code = detect_lang(question)
public_rubrics = get_sys_param("PUBLIC_RUBRICS", str, "")
rubrics = [r.strip() for r in public_rubrics.split(",") if r.strip()]
if not rubrics:
fallback = get_sys_param("DEFAULT_RUBRIC", str, "project")
rubrics = [fallback] if fallback else ["project"]
expanded_query = None
search_text = question
if get_sys_param("QUERY_EXPANSION", str, "N") == "Y":
expanded_query = expand_query(question)
if expanded_query:
search_text = expanded_query
settings_local = get_settings()
embed_model = settings_local.EMBED_MODEL
chat_model = settings_local.CHAT_MODEL
q_row = LgQuestions(
session_id=session_id,
question_text=question,
expanded_query=expanded_query,
detected_lang=lang_code,
rubrics_used=rubrics,
)
session.add(q_row)
session.commit()
session.refresh(q_row)
question_id = q_row.id
start_time = time.perf_counter()
retrieval_start = start_time
db_search_ms = 0
embed_start = time.perf_counter()
embedding, embed_error = generate_query_embedding(search_text)
embedding_ms = int((time.perf_counter() - embed_start) * 1000)
if embedding is None:
retrieval_ms = int((time.perf_counter() - retrieval_start) * 1000)
_write_analytics(
question_id=question_id, user_id=None,
retrieval_ms=retrieval_ms, embedding_ms=embedding_ms, db_search_ms=db_search_ms,
generation_ms=0, total_ms=retrieval_ms,
top_similarity=None, num_chunks_retrieved=0,
embed_model=embed_model, chat_model=chat_model,
status="embed_error", error_message=embed_error,
)
raise HTTPException(status_code=503, detail="Сервис недоступен")
from app.routes.admin import _load_rag_from_db
rag = _load_rag_from_db(session)
db_start = time.perf_counter()
chunks = retrieve_chunks(
session,
embedding,
top_k=rag["top_k"],
threshold=rag["similarity_threshold"],
lang_code=lang_code,
rubrics=rubrics,
)
db_search_ms = int((time.perf_counter() - db_start) * 1000)
retrieval_ms = int((time.perf_counter() - retrieval_start) * 1000)
if not chunks:
_write_analytics(
question_id=question_id, user_id=None,
retrieval_ms=retrieval_ms, embedding_ms=embedding_ms, db_search_ms=db_search_ms,
generation_ms=0, total_ms=retrieval_ms,
top_similarity=None, num_chunks_retrieved=0,
embed_model=embed_model, chat_model=chat_model,
status="no_chunks", error_message=None,
)
raise HTTPException(status_code=404, detail="По этому вопросу информация не найдена")
threshold_used = rag["similarity_threshold"]
for rank_idx, c in enumerate(chunks, start=1):
session.add(LgRequestChunks(
question_id=question_id,
chunk_id=c["chunk_id"],
similarity_score=c["similarity"],
rank=rank_idx,
threshold_used=threshold_used,
role="semantic",
was_in_context="Y",
))
try:
session.commit()
except Exception:
session.rollback()
messages = build_prompt(chunks, question, None)
def event_stream():
generation_start = time.perf_counter()
full_response = []
gen_status = "ok"
gen_error = None
try:
for token in stream_response(messages, chunks=chunks, question=question):
full_response.append(token)
yield f"data: {json.dumps({'token': token})}\n\n"
except Exception as e:
gen_status = "gen_error"
gen_error = str(e)
generation_ms = int((time.perf_counter() - generation_start) * 1000)
total_ms = int((time.perf_counter() - start_time) * 1000)
s2 = SessionLocal()
try:
s2.query(LgQuestions).filter(LgQuestions.id == question_id).update({
"answer_text": "".join(full_response),
"answered_at": datetime.now(timezone.utc),
})
s2.commit()
except Exception:
s2.rollback()
finally:
s2.close()
_write_analytics(
question_id=question_id, user_id=None,
retrieval_ms=retrieval_ms, embedding_ms=embedding_ms, db_search_ms=db_search_ms,
generation_ms=generation_ms, total_ms=total_ms,
top_similarity=chunks[0]["similarity"] if chunks else None,
num_chunks_retrieved=len(chunks),
embed_model=embed_model, chat_model=chat_model,
status=gen_status, error_message=gen_error,
)
yield f"data: {json.dumps({'done': True, 'question_id': str(question_id)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
return application
app = create_app()Bu bobni bepul o'qidingiz.
Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.