Předchozí kapitoly popsaly chunkování, vektorové hledání, RAG i evaluaci. Tahle je postaví dohromady do něčeho, co běží. Vznikne RAG nad tvojí vlastní složkou s poznámkami, celý v NumPy, bez jediné externí služby, indexu nebo klíče k API. Hlavně ale na konci změříš, jestli funguje, a to je ta část, kterou skoro každý přeskočí.
Spoiler: až to změříš, vyjde ti něco jiného, než čekáš.
▶ Spustitelné. Ulož jako
rag.pya pusťpython3 rag.py cesta/ke/slozce. Potřebuješ NumPy a složku s.mdsoubory. Na tisícovce chunků to běží kolem minuty.
Celý retrieval
"""Zeptej se svých souborů: RAG nad složkou s markdownem. Jen NumPy, žádná služba.
Spusť: python3 rag.py slozka/"""
import sys, re, glob, os, math, collections
import numpy as np
# ---------- 1. načti a rozsekej na chunky podle nadpisů ----------
def chunky(slozka, max_slov=180, prekryv=30):
out = []
for cesta in sorted(glob.glob(os.path.join(slozka, '**', '*.md'), recursive=True)):
text = open(cesta, encoding='utf8').read()
text = re.sub(r'```.*?```', ' ', text, flags=re.S) # kód pryč
nadpis, buf = os.path.basename(os.path.dirname(cesta)), []
for radek in text.split('\n'):
if radek.startswith('## '): # nový nadpis = nový chunk
if buf: out += rozdel(cesta, nadpis, ' '.join(buf), max_slov, prekryv)
nadpis, buf = radek[3:].strip(), []
else:
buf.append(radek.strip())
if buf: out += rozdel(cesta, nadpis, ' '.join(buf), max_slov, prekryv)
return [c for c in out if len(c['text'].split()) > 20]
def rozdel(cesta, nadpis, telo, max_slov, prekryv):
slova = telo.split()
kusy, i = [], 0
while i < len(slova):
kusy.append({'zdroj': cesta, 'nadpis': nadpis,
'text': ' '.join(slova[i:i + max_slov])})
i += max_slov - prekryv
return kusy
# ---------- 2. lexikální hledání: BM25 od nuly ----------
def tokenizuj(s):
return re.findall(r'[a-záčďéěíňóřšťúůýž0-9]{2,}', s.lower())
class BM25:
def __init__(self, docs, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.docs = [tokenizuj(d) for d in docs]
self.dl = np.array([len(d) for d in self.docs], dtype=float)
self.avgdl = self.dl.mean()
self.tf = [collections.Counter(d) for d in self.docs]
df = collections.Counter(t for d in self.docs for t in set(d))
N = len(self.docs)
self.idf = {t: math.log(1 + (N - n + .5) / (n + .5)) for t, n in df.items()}
def skore(self, dotaz):
s = np.zeros(len(self.docs))
for t in tokenizuj(dotaz):
if t not in self.idf: continue
idf = self.idf[t]
for i, tf in enumerate(self.tf):
f = tf.get(t, 0)
if f:
s[i] += idf * f * (self.k1 + 1) / (f + self.k1 * (1 - self.b + self.b * self.dl[i] / self.avgdl))
return s
# ---------- 3. významové hledání: embeddingy z ko-výskytu ----------
class Embeddingy:
def __init__(self, docs, dim=128, okno=4, slovnik=4000):
slova = [w for d in docs for w in tokenizuj(d)]
self.top = [w for w, _ in collections.Counter(slova).most_common(slovnik)]
self.idx = {w: i for i, w in enumerate(self.top)}
N = len(self.top)
C = np.zeros((N, N))
ids = [self.idx.get(w, -1) for w in slova]
for i, a in enumerate(ids):
if a < 0: continue
for b in ids[i + 1:i + 1 + okno]:
if b >= 0: C[a, b] += 1; C[b, a] += 1
tot, row = C.sum(), C.sum(1, keepdims=True)
with np.errstate(divide='ignore', invalid='ignore'):
P = np.log((C * tot) / (row * row.T))
P[~np.isfinite(P)] = 0
U, S, _ = np.linalg.svd(np.maximum(P, 0), full_matrices=False)
self.E = U[:, :dim] * np.sqrt(S[:dim])
self.E /= np.linalg.norm(self.E, axis=1, keepdims=True) + 1e-9
self.D = np.stack([self.vektor(d) for d in docs])
def vektor(self, text):
v = [self.E[self.idx[w]] for w in tokenizuj(text) if w in self.idx]
if not v: return np.zeros(self.E.shape[1])
v = np.mean(v, axis=0)
return v / (np.linalg.norm(v) + 1e-9)
def skore(self, dotaz):
return self.D @ self.vektor(dotaz)
# ---------- 4. hybridní hledání ----------
def poradi(skore):
"""Z hodnot udělá pořadí (0 = nejlepší), aby šly různé metriky sečíst."""
r = np.empty(len(skore), dtype=int)
r[np.argsort(-skore)] = np.arange(len(skore))
return r
def hledej(dotaz, bm, emb, k=5, K=60):
# Reciprocal Rank Fusion: sečti převrácené pořadí z obou metod
rrf = 1 / (K + poradi(bm.skore(dotaz))) + 1 / (K + poradi(emb.skore(dotaz)))
return np.argsort(-rrf)[:k]
if __name__ == '__main__':
slozka = sys.argv[1] if len(sys.argv) > 1 else '.'
ch = chunky(slozka)
print(f'{len(ch)} chunků z {len({c["zdroj"] for c in ch})} souborů')
texty = [f"{c['nadpis']}. {c['text']}" for c in ch]
bm, emb = BM25(texty), Embeddingy(texty)
for dotaz in ['jak se počítá KV cache', 'proč model špatně počítá písmena',
'co dělat když loss vyskočí nahoru']:
print(f'\n> {dotaz}')
for i in hledej(dotaz, bm, emb):
print(f' {os.path.basename(os.path.dirname(ch[i]["zdroj"]))} :: {ch[i]["nadpis"][:55]}')
Na kapitolách téhle učebnice to vypadá takhle:
570 chunků z 62 souborů
> jak se počítá KV cache
attention-hloubeji :: Shrnutí
model-card :: 1. Vejde se mi to do karty?
attention-hloubeji :: Co si z toho odnést do praxe
> co dělat když loss vyskočí nahoru
co-se-pokazi :: Diagnostika podle příznaku
co-se-pokazi :: Shrnutí
optimizer :: Jak vypadá, když se to pokazí
Funguje to. A přesně tady většina návodů skončí, protože „vypadá to dobře“ je nejnebezpečnější věta v celém oboru.
Co v tom kódu stojí za všimnutí
Chunkuje se podle nadpisů, ne po pevném počtu znaků. Nadpis se navíc přilepí k textu chunku, takže nese kontext. Bez toho by chunk „Šetří se výpočet, ne paměť“ neměl jak dát vědět, že je o MoE. Viz dělení dokumentů.
BM25 je třicet řádků a žádná knihovna. Za tím vzorcem není nic magického: idf dá váhu
vzácným slovům, tf počítá výskyty se sytící se návratností a dl / avgdl trestá dlouhé
dokumenty, aby nevyhrávaly jen proto, že jsou dlouhé.
Embeddingy se počítají z ko-výskytu, stejně jako v kapitole embeddingy. Nejsou to embeddingy z pořádného modelu, ale běží u tebe a zdarma, a hlavně je na nich vidět, co embeddingy dělají.
Spojení přes pořadí, ne přes skóre. Tomu triku se říká Reciprocal Rank Fusion a je to
nejpraktičtější věc v celém souboru. BM25 vrací hodnoty typu 8,4 a kosinová podobnost hodnoty
mezi nulou a jedničkou. Sečíst je nejde, museli by se kalibrovat. RRF proto zahodí hodnoty
a použije jen pořadí: 1 / (60 + pozice). Žádná kalibrace, žádné ladění vah.
A teď to podstatné: změř to
Bez tohohle kroku nevíš nic. Potřebuješ sadu otázek, u kterých předem víš, kde je odpověď. Patnáct jich stačí na začátek, napsat je zabere dvacet minut.
"""Změř, jestli tvůj retrieval funguje. Recall@k na ručně označené sadě otázek."""
import sys, os, numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rag import chunky, BM25, Embeddingy, poradi
# otázka -> složka, ve které leží správná odpověď
SADA = [
('jak se počítá KV cache', 'attention-hloubeji'),
('proč model neumí spočítat písmena ve slově', 'tokeny'),
('co dělat, když loss během tréninku vyskočí', 'co-se-pokazi'),
('jak se z webu vyrobí trénovací data', 'trenovaci-data'),
('co je to teacher forcing', 'trenink-vs-generovani'),
('jak vynutit, aby model vrátil platný JSON', 'strukturovany-vystup'),
('kolik tokenů stojí obrázek', 'multimodalita'),
('jak zabránit tomu, aby agent smazal data', 'agenti'),
('co znamená 400 miliard z toho 17 aktivních', 'moe'),
('proč se skóre dělí odmocninou', 'attention-rucne'),
('jak funguje warmup a cosine decay', 'optimizer'),
('co je prompt injection a jak se bránit', 'prompt-injection'),
('proč se váhy kvantizují po skupinách', 'numerika'),
('jak dlouhý má být chunk', 'chunking'),
('co je perplexita', 'metriky'),
]
ch = chunky(sys.argv[1] if len(sys.argv) > 1 else '.')
texty = [f"{c['nadpis']}. {c['text']}" for c in ch]
kap = [os.path.basename(os.path.dirname(c['zdroj'])) for c in ch]
bm, emb = BM25(texty), Embeddingy(texty)
def recall(fn, k):
hit = 0
for q, spravne in SADA:
top = fn(q)[:k]
if any(kap[i] == spravne for i in top): hit += 1
return hit / len(SADA)
jen_bm = lambda q: np.argsort(-bm.skore(q))
jen_emb = lambda q: np.argsort(-emb.skore(q))
def hybrid(q, K=60):
return np.argsort(-(1/(K+poradi(bm.skore(q))) + 1/(K+poradi(emb.skore(q)))))
print(f'sada: {len(SADA)} otázek, {len(ch)} chunků\n')
print(f"{'metoda':<22}{'recall@1':>10}{'recall@3':>10}{'recall@5':>10}{'recall@10':>11}")
for jmeno, fn in [('jen BM25', jen_bm), ('jen embeddingy', jen_emb), ('hybrid (RRF)', hybrid)]:
print(f'{jmeno:<22}' + ''.join(f'{recall(fn,k):>10.0%}' for k in (1,3,5)) + f'{recall(fn,10):>11.0%}')
Výsledek na patnácti otázkách a 570 chuncích:
metoda recall@1 recall@3 recall@5 recall@10
jen BM25 80% 100% 100% 100%
jen embeddingy 73% 93% 93% 93%
hybrid (RRF) 87% 93% 93% 100%
Čekal jsi tohle?
Já ne. Samotné BM25, tedy obyčejné počítání slov, má recall@3 rovných 100 %. Hybridní hledání, o kterém se píše jako o dnešním standardu, je na tom hůř: 93 %.
Než z toho uděláš závěr „embeddingy jsou k ničemu“, rozeber si proč:
- Otázky používají stejná slova jako text. Ptám se „jak se počítá KV cache“ a v kapitole je napsáno „KV cache“. Za takové situace je fulltext nepřekonatelný, protože hledá přesně to, co tam je.
- Moje embeddingy jsou slabé. Vznikly z jednoho megabajtu ko-výskytů, ne z modelu trénovaného na miliardách vět. Pořádný embedding model by dopadl výrazně líp.
- RRF pustí nahoru i slabé kandidáty. Když je jedna z metod horší, hybrid ji přesto
pouští ke slovu, a tím vytlačí správný chunk z první pětky. Proto hybrid vyhrál na
recall@1(87 % proti 80 %), kde pomáhá shoda obou metod, a prohrál dál v pořadí.
Poučení není „nepoužívej embeddingy“. Poučení je: na tvých datech a tvých otázkách může vyhrát ta nudná metoda, a zjistíš to jedině měřením. Kdybych tuhle sadu nenapsal, nasadil bych hybrid, platil za embeddingy a měl horší výsledek než s třiceti řádky BM25.
Zkus si to na vlastních souborech. Pokud se tvoji uživatelé ptají jinými slovy, než jsou v dokumentech, čísla se otočí a embeddingy začnou vyhrávat. To je přesně ten okamžik, kdy se hybrid vyplatí.
Co tomu ještě chybí
Tohle je retrieval, tedy ta půlka RAGu, kterou jde měřit offline a zadarmo. Do plné aplikace zbývá:
- Generování odpovědi. Nalezené chunky vložíš do promptu se zadáním „odpovídej jen z těchto podkladů a cituj zdroj“. Viz RAG krok za krokem a volání API.
- Přeřazení (rerank). Model, který přečte dvojici dotaz a chunk a oboduje relevanci. Zlepší pořadí nejvíc ze všeho, ale potřebuje volání modelu.
- Ukotvení odpovědi. Kontrola, že tvrzení v odpovědi opravdu jsou v citovaných chuncích. Viz halucinace a evaluace.
- Cena a latence. Jakmile přibude volání modelu, začni počítat tokeny. Viz cena a latence.
Cvičení
- Rozšiř sadu na třicet otázek, ale tentokrát je formuluj jinými slovy, než jsou v textu (třeba „kolik paměti sežere dlouhá konverzace“ místo „jak se počítá KV cache“). Změř znovu. Co se stalo s poměrem BM25 a embeddingů?
- Zkus
max_slov80 a 400 místo 180 a změřrecall@5pro obě. Proč nevyhrává ani nejmenší, ani největší chunk? - V RRF je konstanta
K = 60. Zkus 5 a 500 a vysvětli, co ta konstanta vlastně řídí.
Náčrt řešení: rozbal, až si cvičení zkusíš sám
- Poměr se otočí, a to je celá pointa cvičení. Fulltext hledá shodu řetězců, takže jakmile se uživatel ptá synonymy, BM25 nemá co najít a jeho recall spadne. Embeddingy naopak měří význam, takže jim přeformulování vadí mnohem méně. Tohle je ten scénář, na který je hybridní hledání navržené: pokrývá obě situace, aniž bys dopředu věděl, jakými slovy se lidé ptají.
- Malé chunky nemají dost kontextu, velké ho mají moc. Při 80 slovech se souvislá myšlenka rozpadne na dvě půlky a ani jedna neobsahuje celou odpověď. Při 400 slovech chunk sice odpověď obsahuje, ale utopenou mezi dalšími tématy, takže má rozmělněné skóre a klesá v pořadí. Navíc pak zabírá víc místa v promptu. Optimum bývá kolem jednoho souvislého tématu, což u odborného textu odpovídá zhruba jednomu až dvěma odstavcům.
Křídí, jak moc se počítá jen s prvními místy. Ve vzorci1 / (K + pozice)je při malémKrozdíl mezi pozicí 1 a 2 obrovský, takže rozhoduje skoro výhradně vítěz každé metody. Při velkémKse rozdíly mezi pozicemi smazávají a fúze se blíží prostému průměru pořadí. Hodnota kolem 60 je osvědčený kompromis: první místa mají výraznou váhu, ale shoda obou metod na pozicích 3 až 10 pořád něco znamená.
Shrnutí
- Použitelný retrieval nad vlastními soubory se vejde do devadesáti řádků NumPy bez jediné služby.
- Chunkuj podle struktury a přilep k chunku nadpis, jinak přijdeš o kontext.
- Různé metriky se nesčítají; spoj je přes pořadí pomocí RRF, ušetříš si kalibraci.
- Bez měřicí sady stavíš naslepo. Patnáct otázek s předem známou odpovědí odhalí víc než týden ladění.
- Na tomhle korpusu porazilo samotné BM25 hybridní hledání. Na tvém to může být obráceně, a přesně proto se to měří.
Proč se u hybridního hledání spojují výsledky přes pořadí a ne přes skóre?
Protože skóre z různých metod nejsou ve stejné jednotce ani rozsahu: BM25 vrací neomezené kladné hodnoty, kosinová podobnost čísla mezi nulou a jedničkou. Sečíst je nejde bez kalibrace, která se navíc mění s daty. Reciprocal Rank Fusion proto hodnoty zahodí a použije jen pozici ve výsledku podle vzorce jedna lomeno konstanta plus pozice, což žádnou kalibraci nepotřebuje.
Na svých datech ti vyjde, že samotný fulltext má lepší recall než hybridní hledání. Co s tím?
Nasadit fulltext a ušetřit. Znamená to, že se uživatelé ptají stejnými slovy, jaká jsou v dokumentech, což je situace, kde je hledání podle shody slov nepřekonatelné. Zároveň si ale ověř, jestli tvoje testovací otázky nejsou psané podle textu: pokud je psal někdo, kdo dokumenty znal, budou mu slova podsouvat. Sada s otázkami formulovanými nezávisle často výsledek otočí.
Proč se k textu chunku připojuje nadpis sekce, ze které pochází?
Protože chunk vytržený z textu často ztratí téma. Věta „Šetří se výpočet, ne paměť“ neobsahuje slovo, podle kterého by se dala najít, ale s nadpisem „Mixture of Experts“ už ano. Nadpis je nejlevnější dostupný kontext a zlepšuje jak fulltext, tak embedding daného chunku.
