janol.pl_

AI self-hosted

RAG na Ollama, vLLM i OpenWebUI z bazy wiedzy Confluence

Po migracji Confluence do Cloud mieliśmy w końcu jedną, uporządkowaną bazę wiedzy — setki stron dokumentacji technicznej, procedur, runbooków. Problem był prozaiczny: nikt tego nie czytał, bo wyszukiwarka Confluence radzi sobie dobrze z dokładnymi frazami, a fatalnie z pytaniami w stylu „jak skonfigurować VPN dla nowego oddziału”. Odpowiedzią był wewnętrzny system RAG (retrieval-augmented generation) podpięty pod czat w OpenWebUI — coś na kształt ChatGPT, ale odpowiadający na podstawie naszej dokumentacji, a nie ogólnej wiedzy z internetu.

Dlaczego self-hosted, a nie API OpenAI czy Anthropic

To było pierwsze pytanie, jakie padło, i słusznie. Odpowiedź jest prosta: w Confluence leżą dane, których nie możemy wysyłać do zewnętrznego API — topologia sieci, adresacja wewnętrzna, procedury bezpieczeństwa, czasem dane, które podlegają wewnętrznym politykom klasyfikacji informacji. Nie chodzi o nieufność wobec dostawców API — chodzi o to, że część dokumentacji z definicji nie powinna opuszczać naszej infrastruktury, punkt. Self-hosted LLM na własnym sprzęcie (serwer z GPU) rozwiązuje to jednoznacznie: żadne zapytanie, żaden fragment kontekstu nie wychodzi poza sieć firmową.

Kompromis, który trzeba zaakceptować Modele open-weight (Llama, Mistral, Qwen w rozsądnych rozmiarach) w 2026 są dobre, ale nie dorównują największym modelom komercyjnym w rozumowaniu ogólnym. Dla zamkniętego zadania — odpowiadanie na podstawie dostarczonego kontekstu z Confluence — różnica jest dużo mniejsza niż w otwartych zadaniach kreatywnych czy programistycznych. RAG dobrze ukrywa słabości mniejszego modelu, bo model nie musi „wiedzieć”, tylko sklejać odpowiedź z podanych fragmentów.

Architektura

Cały pipeline dzieli się na dwie osobne ścieżki, które łatwo pomylić na starcie: ingest (budowanie i aktualizacja indeksu) oraz serwowanie zapytań (retrieval + generacja odpowiedzi). Trzymanie ich jako niezależnych procesów było jedną z lepszych decyzji na starcie — ingest może się wywalić albo działać wolno, a czat dla użytkowników ma działać cały czas.

Confluence Cloud (REST API)
        │
        ▼
   ingest-worker (cron, co 30 min)
   - pobiera zmienione strony (CQL: lastmodified > X)
   - czyści HTML/makra Confluence → czysty tekst
   - dzieli na chunki
        │
        ▼
   embedding model (bge-m3, przez Ollama)
        │
        ▼
   Qdrant (wektorowa baza danych)
        │
        ▼
   OpenWebUI  ──retrieval──►  Qdrant
        │
        ▼
   vLLM (serwowanie modelu głównego, np. Qwen2.5-32B-Instruct)
        │
        ▼
   odpowiedź + cytowane źródła (linki do stron Confluence)

Dlaczego Ollama i vLLM naraz

To pytanie, które zadaje sobie prawie każdy, kto zaczyna — po co dwa silniki inferencji. Odpowiedź: robią inne rzeczy dobrze. Ollama jest wygodna do modelu embeddingowego i do szybkich eksperymentów — jedna komenda, model ściągnięty i gotowy, minimalna konfiguracja. Ale jej throughput przy wielu równoległych zapytaniach (kilku użytkowników pytających czat jednocześnie) jest gorszy niż dedykowanego serwera inferencji. vLLM z kolei jest zbudowany właśnie pod ciągły, wielo-zapytaniowy ruch — continuous batching, PagedAttention, sensownie wykorzystuje pamięć GPU przy wielu jednoczesnych sesjach. Model generujący odpowiedzi w czacie stoi na vLLM, model embeddingowy (mniejszy, rzadziej odpytywany poza godzinami ingestu) zostaje na Ollama.

# uruchomienie vLLM z modelem głównym, ograniczonym kontekstem i quantyzacją AWQ
docker run --gpus all -p 8000:8000 \
  -v /data/models:/models \
  vllm/vllm-openai:latest \
  --model /models/Qwen2.5-32B-Instruct-AWQ \
  --quantization awq \
  --max-model-len 16384 \
  --gpu-memory-utilization 0.90

Ingest i chunking — tu jest najwięcej realnej pracy

Największym błędem na starcie było dzielenie dokumentów na chunki „na sztywno”, po stałej liczbie znaków. Strony Confluence z tabelami konfiguracyjnymi (np. lista portów i usług, macierz uprawnień) rozjeżdżały się w połowie tabeli — model dostawał połówkę tabeli jako kontekst i generował odpowiedzi, które brzmiały pewnie, a były po prostu niepełne.

Rozwiązaniem było chunkowanie strukturalne — dzielenie po nagłówkach Confluence (h2/h3) zamiast po liczbie znaków, i traktowanie tabel jako niepodzielnych bloków, nawet jeśli robi to chunk większym niż zakładany limit. Overlap między sąsiednimi chunkami (ok. 15% pokrycia) dodatkowo łagodzi przypadki, gdy istotna informacja i tak leży na granicy dwóch sekcji:

def chunk_confluence_page(html: str) -> list[str]:
    sections = split_by_headings(html, levels=("h2", "h3"))
    chunks = []
    for section in sections:
        if contains_table(section) and len(section) > MAX_CHUNK:
            chunks.append(section)  # tabela zawsze cała, bez cięcia
        else:
            chunks.extend(split_with_overlap(section, size=MAX_CHUNK, overlap=0.15))
    return chunks

Aktualizacja indeksu — problem, który nie kończy się po wdrożeniu

RAG na statycznym zrzucie dokumentacji jest łatwy. Trudny jest RAG na dokumentacji, która zmienia się codziennie, bo ludzie normalnie edytują Confluence. Pierwsza wersja ingestu robiła pełny reindex co noc — działało, dopóki baza wiedzy była mała. Przy kilkuset stronach pełny reindex zaczął trwać zbyt długo i, co gorsza, marnował większość pracy na strony, które się nie zmieniły.

Zmiana na ingest przyrostowy oparty o CQL (lastmodified > "2026-09-20") i webhooki Confluence (event page_updated wywołujący od razu reindeks konkretnej strony) rozwiązała problem świeżości — teraz zmiana w Confluence trafia do indeksu w ciągu kilku minut, nie następnego dnia. Osobny problem to usuwanie: strona skasowana w Confluence musi też zniknąć z Qdrant, inaczej model odpowiada na podstawie danych, które już nie istnieją, i cytuje martwy link.

Reguła, którą wprowadziłem po wpadce Każda odpowiedź czatu musi zawierać link do źródłowej strony Confluence, z której pochodzi kontekst. Nie jest to tylko wygoda — to mechanizm kontrolny. Jeśli model zacznie „zmyślać” (halucynować) fakt bez pokrycia w źródle, użytkownik od razu widzi, że cytowany link nie potwierdza odpowiedzi, i traktuje ją z rezerwą zamiast bezkrytycznie.

OpenWebUI jako warstwa czatu

OpenWebUI dało nam gotowy interfejs czatu, zarządzanie użytkownikami przez SSO, i — co ważne — wbudowaną obsługę RAG (podłączenie kolekcji dokumentów albo, jak w naszym przypadku, zewnętrznego API retrievalu z Qdrant przez pipeline). Nie musieliśmy pisać własnego frontendu czatu od zera, tylko skonfigurować connector do modelu w vLLM i pipeline retrievalu. To był świadomy wybór „zbuduj to, co odróżnia, kup/użyj gotowego to, co nie odróżnia” — interfejs czatu nie jest naszą przewagą, jakość dokumentacji i chunking są.

RAG nie jest magicznym rozwiązaniem na „AI, które wie wszystko o firmie”. To tyle warte, ile warta jest twoja dokumentacja i sposób, w jaki ją potniesz na kawałki.

Największa korzyść okazała się nie tam, gdzie się jej spodziewaliśmy — nie w oszczędności czasu doświadczonych administratorów, tylko w onboardingu nowych osób w zespole, które zamiast przeszukiwać dziesiątki stron Confluence, zadają pytanie w czacie i dostają odpowiedź z linkiem do dokładnego miejsca, gdzie mogą sprawdzić szczegóły.

← wszystkie wpisy