Asosiy qismga o'tish

Hands-On Project — RAG Chatbot from Scratch

12-bob 35 kod bloki 5980 so'z

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:

python3 qator
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

bash8 qator
mkdir rag-chatbot && cd rag-chatbot
python -m venv venv

# Linux/Mac:
source venv/bin/activate

# Windows:
venv\Scripts\activate

Kutubxonalarni o'rnatish

bash3 qator
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

matn14 qator
-- 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

python18 qator
# .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

python23 qator
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

python34 qator
# 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

python22 qator
# 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

python88 qator
# 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:

python6 qator
# create_tables.py
from app.database import engine, Base
from app.models.tables import *

Base.metadata.create_all(bind=engine)
print("Tables created successfully.")
bash1 qator
python create_tables.py

Step 3: Scraper

Veb-sahifadan matn ajratib olish.

python32 qator
# 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

python24 qator
# 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.

python50 qator
# 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

python17 qator
# 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:

json5 qator
{
    "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

python21 qator
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}

Foydalanuvchi savoli kelganda: savolni embed qilamiz, pgvector da cosine similarity bo'yicha eng yaqin chunk larni topamiz.

python45 qator
# 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:

python18 qator
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):

python15 qator
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.

python74 qator
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

python9 qator
# 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

python94 qator
# 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

python20 qator
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

python31 qator
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

bash11 qator
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

bash5 qator
# 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)

matn5 qator
# 8005 portni ochish
sudo ufw allow 8005/tcp

# Yoki iptables bilan
sudo iptables -A INPUT -p tcp --dport 8005 -j ACCEPT

Windows Firewall

python3 qator
# PowerShell dan (Administrator sifatida)
New-NetFirewallRule -DisplayName "RAG Chatbot" `
    -Direction Inbound -Protocol TCP -LocalPort 8005 -Action Allow

URL ni hamkasblar bilan ulashing

matn1 qator
http://192.168.1.100:8005/chat

IP adresingizni bilish uchun:

matn5 qator
# Linux
ip addr show | grep "inet "

# Windows
ipconfig | findstr "IPv4"

Doimiy ishga tushirish (systemd)

python17 qator
# /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
matn4 qator
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:

  1. Scrape — veb-saytdan matnni olish
  2. Chunk — matnni kichik qismlarga bo'lish
  3. Embed — har bir chunk ni vector ga aylantirish
  4. Store — vector larni PostgreSQL + pgvector da saqlash
  5. Search — foydalanuvchi savoliga eng o'xshash chunk larni topish
  6. Rerank — topilgan chunk lardan eng tegishlilarini tanlash
  7. Generate — LLM ga chunk lar va savolni berib javob olish
  8. Stream — javobni SSE orqali qismlab ko'rsatish (pseudo-streaming: to'liq javob 4 belgidan parchalanadi)
  9. 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

python319 qator
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

python287 qator
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.

Telegramda yozish Narxlar