RAG ponad podstawami: reranking, hybrid search i pgvector w produkcji
Article
RAG ponad podstawami: reranking, hybrid search i pgvector w produkcji
Każdy tutorial RAG online wygląda tak samo: embeduj dokumenty, zapisz wektory, pobierz przez podobieństwo cosinusowe, przekaż do LLM, gotowe. Działa wystarczająco dobrze w demo. W produkcji z prawdziwymi użytkownikami i prawdziwymi danymi pada w sposób frustrujący do debugowania i kłopotliwy do wyjaśnienia klientom.
To jest to, czego nauczyliśmy się budując systemy RAG, które naprawdę wytrzymują obciążenie produkcyjne. Bez zabawkowych przykładów.
Dlaczego naiwny RAG zawodzi
Podstawowe założenie naiwnego RAG jest takie, że podobieństwo cosinusowe między embeddingami zapytania i dokumentu jest wiarygodnym wskaźnikiem trafności. Nie jest. Nie niezawodnie.
Trzy typowe tryby awarii:
Dryfowanie semantyczne pod zmianą domeny. Ogólne embeddingi (np. OpenAI text-embedding-3-small) kodują relacje semantyczne na podstawie ogólnego tekstu webowego. Jeśli Twoje dokumenty są wysoce specyficzne dla domeny — umowy prawne, dokumentacja medyczna, raporty finansowe — reprezentacja terminów domenowych przez model embeddingowy może nie odzwierciedlać dokładnie ich relacji trafności w Twoim kontekście.
Biaspopularności w przestrzeni embeddingów. Powszechne terminy grupują się w przestrzeni embeddingowej. Zapytania używające powszechnego słownictwa pobiorą dokumenty, które przypadkowo zawierają te powszechne terminy, niekoniecznie najbardziej trafne dokumenty.
Obcinanie pobierania top-K. Pobierasz top 5 lub 10 dokumentów. Najbardziej trafny dokument może być na pozycji 8 w wyszukiwaniu semantycznym, ale na pozycji 1 gdybyś uwzględnił też dopasowanie słów kluczowych. Przy naiwnym RAG nigdy go nie znajdziesz.
Reranking: brakująca warstwa
Reranking to pojedyncze ulepszenie o największym wpływie, jakie możesz zrobić w pipeline RAG. Idea: pobierz większy zestaw kandydatów (top 20-50) używając szybkiego przybliżonego wyszukiwania wektorowego, a następnie zastosuj dokładniejszy, ale wolniejszy model cross-encoder do ich przeordering i weź rzeczywiste top K.
Dlaczego to działa? Modele bi-encoder (używane do początkowego pobierania) kodują zapytanie i dokument niezależnie i porównują ich reprezentacje. Modele cross-encoder (używane do rerankingu) widzą zapytanie i dokument razem, co daje im znacznie więcej kontekstu dla oceny trafności.
API rerankingu Cohere czyni to prostym:
import cohere
co = cohere.Client(api_key=COHERE_API_KEY)
def rerank_results(query: str, documents: list[str], top_n: int = 5) -> list[dict]:
response = co.rerank(
model="rerank-multilingual-v3.0",
query=query,
documents=documents,
top_n=top_n,
return_documents=True,
)
return [
{
"document": result.document.text,
"relevance_score": result.relevance_score,
"index": result.index,
}
for result in response.results
]
# Użycie w pipeline
initial_results = vector_store.search(query, limit=30) # Pobierz 30
docs = [r.content for r in initial_results]
reranked = rerank_results(query, docs, top_n=5) # Zachowaj top 5
W naszych systemach produkcyjnych konsekwentnie obserwujemy 15-30% poprawy jakości odpowiedzi po dodaniu rerankingu, mierzonej zadowoleniem użytkowników i dokładnością merytoryczną. Koszt latencji jest realny (zazwyczaj dodatkowe 200-500ms), ale akceptowalny dla większości aplikacji biznesowych.
Używaj rerank-multilingual-v3.0 dla wszystkiego dotykającego języków europejskich. Obsługuje polski, niemiecki, ukraiński, francuski i inne bez degradacji jakości, jaką widzisz w modelach tylko dla angielskiego.
Hybrid search: najlepsze z obu światów
Hybrid search łączy semantyczne wyszukiwanie wektorowe z tradycyjnym wyszukiwaniem słów kluczowych (BM25/full-text). Intuicja: czasami ważne jest nie podobieństwo semantyczne, ale dokładne dopasowanie terminów. Nazwy modeli, kody produktów, odniesienia regulacyjne, nazwy własne — te wymagają dopasowania słów kluczowych, aby być niezawodnie znalezione.
Podstawowe podejście: uruchom oba wyszukiwania, scal i usuń duplikaty wyników, zastosuj funkcję fuzji.
def hybrid_search(
query: str,
query_embedding: list[float],
table: str,
semantic_weight: float = 0.7,
keyword_weight: float = 0.3,
limit: int = 20,
) -> list[dict]:
conn = get_connection()
# Reciprocal Rank Fusion
sql = """
WITH semantic AS (
SELECT
id,
content,
1 - (embedding <=> %s::vector) AS semantic_score,
ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS sem_rank
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
),
keyword AS (
SELECT
id,
content,
ts_rank_cd(to_tsvector('polish', content), query) AS keyword_score,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(to_tsvector('polish', content), query) DESC
) AS kw_rank
FROM documents,
plainto_tsquery('polish', %s) query
WHERE to_tsvector('polish', content) @@ query
LIMIT %s
),
fused AS (
SELECT
COALESCE(s.id, k.id) AS id,
COALESCE(s.content, k.content) AS content,
COALESCE(1.0 / (60 + s.sem_rank), 0) * %s +
COALESCE(1.0 / (60 + k.kw_rank), 0) * %s AS score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id
)
SELECT id, content, score
FROM fused
ORDER BY score DESC
LIMIT %s;
"""
cursor = conn.cursor()
cursor.execute(sql, (...))
rows = cursor.fetchall()
return [{"id": r[0], "content": r[1], "score": r[2]} for r in rows]
60 w formule RRF to stała kontrolująca wrażliwość na ranki. Wyższe wartości sprawiają, że fuzja jest mniej wrażliwa na różnice rankowe — użyj 60 jako punktu startowego i dostrajaj od tego.
pgvector vs Qdrant: uczciwy kompromis
Oba są uzasadnionymi wyborami. Decyzja nie jest technicznym dogmatyzmem — chodzi o istniejącą infrastrukturę i tolerancję złożoności operacyjnej.
pgvector ma sens gdy: - Jesteś już na PostgreSQL i chcesz zminimalizować złożoność operacyjną - Twój zestaw danych mieści się wygodnie w jednej instancji bazy danych (setki milionów wektorów lub mniej) - Potrzebujesz transakcji ACID łączących wyszukiwanie wektorowe z danymi relacyjnymi - Twój zespół zna SQL i nie chce uczyć się kolejnego języka zapytań
Qdrant ma sens gdy: - Działasz na bardzo dużą skalę (miliardy wektorów) - Potrzebujesz named vectors i multi-vector search od razu - Chcesz dedykowanej wydajności wektorowej bazy danych bez strojenia PostgreSQL - Potrzebujesz wbudowanego filtrowania payload bardziej ekspresywnego niż klauzule WHERE w SQL
Dla projektów B2B, na których zazwyczaj pracujemy — CRM-y, bazy wiedzy, Q&A dokumentów — pgvector obsługuje skalę komfortowo i drastycznie redukuje złożoność infrastrukturalną.
Wskazówki optymalizacji pgvector, które mają znaczenie:
-- Użyj indeksu HNSW dla produkcji (lepsza wydajność zapytań niż IVFFlat)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Ustaw ef_search w czasie zapytania na podstawie kompromisu dokładność/szybkość
SET hnsw.ef_search = 100; -- Wyższe = dokładniejsze, wolniejsze
-- Indeksy częściowe dla wyszukiwania z filtrowaniem
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WHERE active = true AND language = 'pl';
Chunking: semantyczny zamiast stałego rozmiaru
Chunking stałego rozmiaru (podziel co N tokenów) jest prosty i konsekwentnie przeciętny. Problem: dzieli na arbitralnych granicach, które często niszczą spójność semantyczną.
Semantyczny chunking grupuje tekst według granic konceptualnych: podziały akapitów, nagłówki sekcji, zmiany tematyczne. Droższy w obliczaniu, znacznie lepsze wyniki pobierania.
def semantic_chunk(text: str, max_chunk_size: int = 512) -> list[str]:
"""
Podziel tekst według granic akapitów, łącząc krótkie akapity
i dzieląc długie na granicach zdań.
"""
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
chunks = []
current_chunk = []
current_size = 0
for para in paragraphs:
para_size = len(para.split())
if current_size + para_size > max_chunk_size and current_chunk:
chunks.append(' '.join(current_chunk))
current_chunk = [para]
current_size = para_size
else:
current_chunk.append(para)
current_size += para_size
if current_chunk:
chunks.append(' '.join(current_chunk))
return chunks
Zawsze przechowuj metadane chunka: ID dokumentu źródłowego, pozycję chunka, nagłówek sekcji jeśli dostępny, numer strony dla PDF-ów.
Wybór modelu embeddingowego dla wielojęzyczności
Jeśli budujesz dla rynków europejskich, modele embeddingowe tylko dla angielskiego to realny problem jakościowy. text-embedding-3-small jest trenowany głównie na angielskim tekście.
Cohere embed-multilingual-v3.0 to co używamy jako domyślne dla każdego systemu obsługującego wiele języków europejskich. Wyraźnie wspiera 100+ języków z sensowną jakością w polskim, niemieckim, ukraińskim, francuskim i innych. Różnica jakości dla zapytań w językach innych niż angielski jest wystarczająco znacząca, żeby mieć znaczenie w produkcji.
Loguj swoje wyniki pobierania. Poważnie. Jeśli nie przechowujesz odległości embeddingowych i wyników rerankera dla każdego zapytania, nie masz sygnału do debugowania jakości pobierania. Logujemy wszystko to do PostgreSQL i przeprowadzamy cotygodniową analizę zapytań z niskimi wynikami ufności.
Składanie w całość
Produkcyjny pipeline RAG wygląda tak:
- Ingestia: semantyczny chunking → embed z Cohere multilingual → zapisz w pgvector z metadanymi
- Pobieranie: hybrid search (semantyczny + słów kluczowych) → zestaw kandydatów 20-30
- Reranking: Cohere rerank → top 5 wyników
- Generacja: LLM z pobranym kontekstem → odpowiedź
Każdy krok ma obserwowalność (latencja, wyniki, ID chunków). Każdy krok jest dostrojalny bez dotykania innych.
Przepaść między naiwnym RAG a tym pipeline'em nie jest marginalna. Dla rzeczywistego Q&A dokumentów z treścią specyficzną dla domeny różnica w dokładności zgłaszanej przez użytkowników często jest różnicą między systemem, któremu użytkownicy ufają, a tym, który ignorują po tygodniu.
Budujemy produkcyjne systemy RAG dla klientów B2B. Jeśli oceniasz architekturę RAG dla swojego przypadku użycia, porozmawiaj z nami.
No comments yet. Be the first to comment.