← Wszystkie wpisy

Budowa hybrydowego retrievera dla 16 894 plików Obsidian

From the guide: Claude Code Comprehensive Guide

Grep przez 16 894 plików markdown zajmuje od 11 do 66 sekund w zależności od szukanego terminu i zwraca setki mało trafnych wyników. Wyszukiwanie wektorowe zwraca semantycznie powiązaną treść, ale pomija dokładną nazwę funkcji, którą wpisaliśmy. Hybrydowy retriever łączący obie metody zwraca właściwą odpowiedź w 23 milisekundy (end-to-end, łącznie z embeddingiem zapytania) z pojedynczego pliku SQLite o rozmiarze 83 MB, bez żadnych wywołań API.1

Problem obsesyjnego notatkowicza nie polega na gromadzeniu. Problem polega na wyszukiwaniu. Obsidian sprawia, że zapisywanie jest bezwysiłkowe. Po zgromadzeniu wystarczającej liczby plików skarbiec staje się bazą danych tylko do zapisu: łatwo do niej dodawać, niemożliwe jest w niej wyszukiwać. Wyszukiwanie po nazwie pliku działa, dopóki nazwy nie tracą znaczenia. Wyszukiwanie pełnotekstowe działa, dopóki to samo słowo kluczowe nie pojawi się w 400 dokumentach. Tagi działają, dopóki nie zapomnimy coś otagować.

Jeden z komentujących na HN poprosił o pełną architekturę systemu wyszukiwania, który zbudowałem dla mojego skarbca Obsidian.2 Oto ona: strategia chunkowania, model embeddingowy, schemat SQLite z podwójnym indeksem, matematyka fuzji z rzeczywistymi liczbami oraz tryby awarii, które odkryłem po kilkuset zapytaniach do systemu.

TL;DR

Retriever łączy wyszukiwanie słów kluczowych FTS5 BM25 z wyszukiwaniem podobieństwa wektorowego Model2Vec, scalane za pomocą Reciprocal Rank Fusion (RRF) w jedną posortowaną listę. Wszystko działa lokalnie w jednej bazie SQLite: 49 746 chunków z 16 894 plików w 83 MB. Pełna reindeksacja zajmuje cztery minuty. Aktualizacje przyrostowe kończą się w mniej niż dziesięć sekund. System integruje się z Claude Code poprzez hooki, dając agentowi dostęp do wiedzy ze skarbca bez ładowania plików do kontekstu. BM25 wychwytuje dokładne identyfikatory i nazwy funkcji. Wyszukiwanie wektorowe wychwytuje dopasowania semantyczne pomiędzy różną terminologią. RRF scala oba wyniki bez konieczności kalibracji wyników. Uczciwy kompromis: dobrze otagowana płytka treść może wyprzedzać w rankingu słabo ustrukturyzowaną głęboką treść, ponieważ BM25 nagradza gęstość słów kluczowych, a nie głębię.


Kluczowe wnioski

Dla notatkowiczów z dużymi skarbcami. Z mojego doświadczenia wynika, że samo wyszukiwanie pełnotekstowe stało się nieużywalne powyżej kilku tysięcy plików — a istniejące wtyczki wyszukiwania Obsidian (Smart Connections, Omnisearch) indeksują wewnątrz aplikacji, nie jako zewnętrzna biblioteka, z której mogą korzystać inne narzędzia.1 Dodanie wyszukiwania wektorowego na wierzch BM25 wychwytuje zapytania, w których pamiętamy koncepcję, ale nie słowo kluczowe. Retriever działa w całości na SQLite bez zewnętrznych serwisów, bez GPU i bez kosztów API. Model2Vec tworzy embeddingi z prędkością CPU, ponieważ model to 30 MB statycznych wektorów słów, a nie transformer.3

Dla programistów budujących systemy wyszukiwania. RRF to metoda fuzji wymagająca najmniej strojenia. Formuła wykorzystuje wyłącznie pozycje w rankingu, nie surowe wyniki, więc nigdy nie trzeba kalibrować wyników BM25 względem odległości kosinusowych. Należy zacząć od k=60 i równych wag. Strojenie dopiero po zmierzeniu przypadków awarii na własnych danych. Rozszerzenie sqlite-vec wprowadza wyszukiwanie wektorowe KNN do SQLite bez osobnej bazy wektorowej.4

Dla użytkowników Claude Code. Retriever działa jako biblioteka, którą mogą wywoływać hooki. Hook PreToolUse odpytuje skarbiec, zanim agent zacznie pracę. Agent widzi 2-3 KB skupionych wyników z atrybucją ścieżki pliku zamiast ładowania całych plików. Integracja utrzymuje małe okna kontekstowe, jednocześnie dając agentowi dostęp do wiedzy z 16 894 plików.

Minimalna wersja. Najprostszy punkt startowy: utworzenie wirtualnej tabeli FTS5 nad plikami markdown (samo BM25, bez embeddingów). sqlite-vec i Model2Vec dodaje się, gdy wyszukiwanie słów kluczowych zaczyna pomijać dopasowania semantyczne. Fuzję RRF dodaje się na końcu. Każda warstwa działa niezależnie. Pełen stos wymaga Python 3, jednorazowego pobrania modelu 30 MB i pip install model2vec sqlite-vec. Bez GPU, bez Docker, bez zewnętrznych serwisów. Całkowity rozmiar na dysku dla 16 894 plików: 83 MB.

Potrzebny pełny przewodnik operacyjny? Przewodnik po infrastrukturze AI dla Obsidian obejmuje architekturę skarbca, konfigurację wtyczek, konfigurację serwera MCP, przepisy na indeksację przyrostową i rozwiązywanie problemów — krok po kroku uzupełnienie do głębokiego omówienia architektury w tym artykule.


Dlaczego samo wyszukiwanie słów kluczowych zawodzi na dużą skalę

Wyszukiwanie pełnotekstowe załamuje się w skali skarbca w przewidywalny sposób. FTS5 z rankingiem BM25 doskonale radzi sobie z dokładnymi dopasowaniami: wyszukanie requestAnimationFrame zwraca każdy plik zawierający dokładnie ten token, posortowany według częstości terminu i długości dokumentu.5 Przegląd probabilistycznych modeli trafności autorstwa Robertsona i Zaragozy potwierdza siłę BM25: algorytm dobrze radzi sobie z zapytaniami opartymi na słowach kluczowych przy minimalnym strojeniu parametrów.14 Tryb awarii to synonimy i dopasowywanie koncepcji. Wyszukanie „how to handle authentication failures” powoduje, że BM25 zwraca każdy plik wspominający „authentication” lub „failures” oddzielnie, rozcieńczając wyniki tangencjalnie powiązaną treścią.

Wyszukiwanie wektorowe rozwiązuje problem synonimów. Embedding zapytania pozwala znaleźć chunki, których embeddingi leżą blisko w przestrzeni wektorowej. „How to handle authentication failures” dopasowuje się do treści o „login error recovery” i „session expiration handling”, ponieważ embedding wychwytuje semantyczne podobieństwo pomiędzy różną terminologią.6 Karpukhin et al. wykazali w Dense Passage Retrieval (DPR), że gęste embeddingi przewyższają BM25 w otwartym odpowiadaniu na pytania o 9-19% w dokładności top-20, właśnie dlatego, że gęste reprezentacje wychwytują znaczenie wykraczające poza leksykalne nakładanie się.15 Tryb awarii jest odwrotny: wyszukiwanie wektorowe pomija dokładne identyfikatory. Wyszukanie nazwy funkcji _rrf_fuse powoduje, że wyszukiwanie wektorowe zwraca treści o fuzji i algorytmach rankingowych, ale może uszeregować rzeczywistą definicję funkcji poniżej koncepcyjnego wyjaśnienia.

Żadna z metod sama w sobie nie pokrywa obu trybów awarii. Pojedyncze zapytanie ilustruje różnicę (nie jest dowodem wyższości — agregatowa ocena wymaga zestawu referencyjnego, którego system jeszcze nie posiada). Zapytanie „PostToolUse hook for context compression” zwraca różne top-3 wyniki z każdej metody:

Pozycja Tylko BM25 Tylko wektorowe Hybrydowe (RRF)
1 hook-stdlib.sh „PostToolUse Handler” context-is-the-new-memory.md „Compression Layers” context-is-the-new-memory.md „Compression Layers”
2 settings.json „PostToolUse Events” token-budget-analysis.md „Context Engineering” hook-stdlib.sh „PostToolUse Handler”
3 compress-output.sh „Tool Output Filter” agent-memory-patterns.md „Retrieval Integration” compress-output.sh „Tool Output Filter”

BM25 znalazł dokładny plik hooka i referencję ustawień (dopasowanie słów kluczowych na „PostToolUse”), ale pominął koncepcyjną notatkę o inżynierii kontekstu. Wyszukiwanie wektorowe znalazło notatki o strategii kompresji (dopasowanie semantyczne na „context compression”), ale pominęło konkretną implementację hooka. RRF promowało notatki istotne zarówno dla koncepcji, jak i implementacji, umieszczając notatkę o strategii i plik hooka na pozycjach pierwszej i drugiej.13

Badania nad rankingiem fragmentów MS MARCO potwierdzają ten wzorzec w benchmarkach wyszukiwania internetowego: wyszukiwanie hybrydowe konsekwentnie przewyższa samo BM25 lub samo wyszukiwanie gęste, z największymi zyskami przy zapytaniach zawierających zarówno konkretne terminy, jak i abstrakcyjne koncepcje.716


Architektura: trzy warstwy, które się wzmacniają

System składa się z trzech niezależnych warstw. Każda działa bez pozostałych, ale razem wzmacniają swój efekt.

Warstwa 1: Przyjmowanie. 733-liniowy pipeline scoringu Python ocenia każdy przychodzący sygnał w czterech wymiarach: trafność, wykonalność, głębokość i autorytatywność. Sygnały z oceną 0,55 lub wyższą trafiają automatycznie do jednego z 12 folderów domenowych. Sygnały między 0,40 a 0,55 trafiają do kolejki na ręczny przegląd. Poniżej 0,40 pipeline odrzuca sygnał. Pipeline przetworzył 7 771 sygnałów w ciągu 14 miesięcy bez ręcznego tagowania.1 Warstwa przyjmowania decyduje, co trafia do skarbca. Warstwa wyszukiwania sprawia, że jest to znajdowalne.

Warstwa 2: Wyszukiwanie. Silnik wyszukiwania hybrydowego opisany szczegółowo poniżej. Silnik dzieli każdy plik na granicach nagłówków, tworzy embeddingi chunków za pomocą Model2Vec i indeksuje je w SQLite zarówno w tabeli vec0 do wyszukiwania wektorowego KNN, jak i w wirtualnej tabeli FTS5 do BM25. Zapytanie uruchamia się jednocześnie na obu indeksach, a RRF scala wyniki w jedną posortowaną listę.

Warstwa 3: Integracja. Hooki Claude Code, które podłączają retriever do przepływu pracy agenta. Hook uruchamia się przy wysłaniu promptu, odpytuje skarbiec o odpowiedni kontekst i wstrzykuje najlepsze wyniki do rozmowy. Agent widzi skupione chunki z atrybucją źródła zamiast surowej zawartości plików:

# Illustrative output (format matches production, content simplified)
## Relevant Memory Context

### OAuth Token Rotation (security-patterns)
Rotate tokens on 401 response. Store refresh token in keychain,
not environment variable. Implement retry with backoff...

### Session Expiration Handling (auth-architecture)
Three expiration modes: absolute (24h), sliding (30min idle),
refresh (7d with rotation). Hook into 401 interceptor...

Każdy wynik zawiera nagłówek sekcji i projekt źródłowy, ograniczony do budżetu 500 tokenów, aby uniknąć rozdęcia kontekstu.

Retriever umożliwia również drugi punkt integracji: hook PostToolUse, który kompresuje wyjścia narzędzi przed ich wejściem do rozmowy. Surowe wyjście narzędzia zawiera znaczniki czasu, artefakty kolejności i rozwlekłe formatowanie, które różni się między uruchomieniami. Retriever zastępuje surowy zrzut stabilnym, skupionym podzbiorem. Agent nigdy nie widzi szumu, tylko istotny ekstrakt. Dodatkowa korzyść: ponieważ wyjście retrievera jest deterministyczne dla tego samego zapytania (ten sam stan indeksu daje te same posortowane wyniki), skompresowane wyjście wspomaga buforowanie promptów. Powtórzone zapytania do niezmienionych danych generują identyczne bloki kontekstu, a automatyczne buforowanie promptów CLI ponownie wykorzystuje zbuforowany prefiks.

Szersza historia infrastruktury wyjaśnia, jak hooki, skille i agenci komponują się w programowalną warstwę wokół modelu.

Warstwy są celowo rozdzielone. Scoring przyjmowania nie wie nic o embeddingach. Retriever nie wie nic o regułach routingu sygnałów. Ale przyjmowanie zapewnia, że skarbiec zawiera treść wysokiej jakości, wyszukiwanie wydobywa odpowiedni podzbiór dla każdego zapytania, a integracja dostarcza ten podzbiór agentowi bez rozdęcia kontekstu. Pisałem o teoretycznym ujęciu kontekstu jako zasobu krytycznego. Retriever jest praktyczną implementacją.


Chunkowanie: gdzie zaczyna się jakość wyszukiwania

Chunkowanie determinuje granularność wyników wyszukiwania. Zbyt duże chunki powodują, że wyszukiwanie wektorowe zwraca całe pliki, w których istotny jest tylko jeden akapit. Zbyt małe chunki powodują utratę kontekstu potrzebnego do dopasowania semantycznego. Badania nad pipeline’ami RAG potwierdzają, że rozmiar chunka ma większy wpływ na jakość wyszukiwania niż wybór modelu w większości przypadków, przy czym chunki o rozmiarze 200-500 tokenów najlepiej sprawdzają się w zadaniach wyszukiwania na poziomie akapitów.18

Chunker dzieli na granicach nagłówków H2 (##), zachowując strukturę markdown.8 Notatka o rotacji tokenów OAuth z trzema sekcjami H2 staje się trzema chunkami, z których każdy jest wystarczająco samodzielny, aby embedding uchwycił jego znaczenie. Indekser przechowuje tekst nagłówka i tytuł notatki nadrzędnej jako metadane obok każdego chunka, dostarczając kontekst do dopasowania BM25 nawet gdy sam tekst chunka jest skromny.

# chunker.py: H2 splitting with heading context
MIN_CHUNK_CHARS = 30
MAX_CHUNK_CHARS = 2000

def _split_at_headings(body):
    sections = []
    current_heading = ""
    current_lines = []
    for line in body.split("\n"):
        if line.startswith("## "):
            if current_lines:
                text = "\n".join(current_lines).strip()
                if text:
                    sections.append((current_heading, text))
            current_heading = line[3:].strip()
            current_lines = []
        else:
            current_lines.append(line)
    if current_lines:
        text = "\n".join(current_lines).strip()
        if text:
            sections.append((current_heading, text))
    return sections

Chunker dalej dzieli sekcje przekraczające 2 000 znaków: najpierw na granicach H3, potem na granicach akapitów. Odrzuca sekcje krótsze niż 30 znaków. Chunker pomija również sekcje Related, See Also, Links i References, które zazwyczaj są listami wiki-linków, a nie przeszukiwalną treścią.

Dwa wybory projektowe mają znaczenie dla jakości wyszukiwania. Po pierwsze, indekser przechowuje ciąg kontekstu nagłówka ("OAuth Token Rotation | note | security, authentication") w osobnej kolumnie i indeksuje go w FTS5 z niższą wagą (0,3) niż tekst chunka (1,0). BM25 nadal dopasowuje się do nagłówka, gdy treść chunka nie zawiera szukanego terminu, ale dopasowanie nagłówka uzyskuje niższy wynik niż dopasowanie treści. Po drugie, chunker wyodrębnia tagi frontmatter i typ notatki i włącza je do kontekstu nagłówka, więc wyszukanie „security” dopasowuje notatki otagowane security nawet gdy treść używa innej terminologii.


Embedding: model 30 MB, zero wywołań API

Modelem embeddingowym jest potion-base-8M od Model2Vec, statyczny model embeddingów słów z 7,6 miliona parametrów generujący 256-wymiarowe wektory.3 W zestawie benchmarków MTEB potion-base-8M osiąga 89% wydajności all-MiniLM-L6-v2 (50,03 vs 56,09 średnio) przy do 500-krotnie szybszym wnioskowaniu, co czyni go praktycznym do indeksowania dużych korpusów na sprzęcie konsumenckim.917 Jedno zastrzeżenie: podwynik MTEB Retrieval tego modelu jest wyraźnie niższy (31,71) niż wyniki Classification (64,44) czy STS (73,24). Benchmarki wyszukiwania MTEB testują ranking na poziomie dokumentów na korpusach internetowych, a nie dopasowywanie na poziomie akapitów na jednorodnych chunkach markdown. Różnica ma mniejsze znaczenie, gdy chunki są krótkie, tematycznie skupione i napisane spójnym słownictwem. W odróżnieniu od transformerowych modeli embeddingowych, Model2Vec nie uruchamia warstw attention na wejściu. Model destyluje wiedzę sentence transformera do statycznych embeddingów tokenów, generując wektory przez ważone uśrednianie zamiast sekwencyjnych obliczeń.9

Dlaczego statyczne embeddingi działają w tym przypadku? Krótkie chunki markdown (średnio 200-400 słów) zawierają skoncentrowane słownictwo dotyczące jednego tematu. Ważona średnia wektorów tych tokenów trafia w znaczący region przestrzeni embeddingowej, ponieważ rozcieńczenie tematami pobocznymi jest minimalne. W praktyce dokument o 2 000 słowach poruszający trzy różne tematy generuje rozmyty centroid, który leży między klastrami tematów, a nie wewnątrz jednego. Chunk o rotacji tokenów OAuth natomiast generuje wektor, który ściśle klastruje się z inną treścią o uwierzytelnianiu. Statyczne embeddingi rezygnują z kontekstowej dezambiguacji (słowo „bank” w „brzeg rzeki” vs „bank konto”) na rzecz surowej prędkości. W osobistej bazie wiedzy, gdzie każdy chunk dotyczy jednej koncepcji, kara za niejednoznaczność jest niewielka, a artykuł raportuje do 500-krotne przyspieszenie wnioskowania.9

# embedder.py: lazy-loading Model2Vec in a dedicated venv
DEFAULT_MODEL = "minishlab/potion-base-8M"
EMBEDDING_DIM = 256

class Model2VecEmbedder:
    def __init__(self, model_name=DEFAULT_MODEL):
        self._model_name = model_name
        self._model = None

    def _ensure_model(self):
        if self._model is not None:
            return
        _activate_venv()  # Add memory venv to sys.path
        from model2vec import StaticModel
        self._model = StaticModel.from_pretrained(self._model_name)

    def embed_batch(self, texts):
        self._ensure_model()
        vecs = self._model.encode(texts)
        return [v.tolist() for v in vecs]

Praktyczna konsekwencja: pełna reindeksacja 16 894 plików kończy się w cztery minuty na Apple M3 Pro. Indeksacja przyrostowa (tylko zmienione pliki, wykrywane przez porównanie mtime) trwa poniżej dziesięciu sekund przy typowych edycjach z jednego dnia.1

Model działa w izolowanym środowisku wirtualnym w ~/.claude/venvs/memory/, aby uniknąć konfliktów zależności z resztą narzędzi. Embedder ładuje model leniwie przy pierwszym użyciu, a nie przy imporcie, więc zaimportowanie modułu nic nie kosztuje, gdy retriever przełącza się w tryb tylko-BM25.

Dlaczego nie większy model? Dwa powody. Po pierwsze, 256-wymiarowe wektory utrzymują bazę SQLite na poziomie 83 MB dla 49 746 chunków. Wektory o wyższych wymiarach (768 lub 1 024) potroiłyby lub poczwóriłyby rozmiar bazy przy marginalnej poprawie jakości na krótkich chunkach markdown.10 Po drugie, embeddingi oparte na API (np. text-embedding-3-small od OpenAI za 0,02 USD za milion tokenów) wprowadzają opóźnienia, koszty i zależność od sieci w systemie, który powinien działać offline.11 Pełne ponowne embeddowanie całego skarbca kosztuje około 0,30 USD po cenach API, kwota trywialna w izolacji, ale prawdziwy koszt to opóźnienie round-trip pomnożone przez 49 746 chunków i implikacja prywatności wysyłania osobistych notatek do zewnętrznego API.

Mechanizm hashu modelu śledzi kompatybilność embeddingów. Indekser przechowuje hash wyprowadzony z nazwy modelu i rozmiaru słownika. Jeśli model się zmieni, indeksacja przyrostowa wykrywa niezgodność i automatycznie uruchamia pełną reindeksację.


Schemat SQLite: trzy tabele, jeden plik

Cały indeks mieści się w jednym pliku SQLite (vectors.db, 83 MB) w trybie WAL dla bezpieczeństwa współbieżnych odczytów.12 Trzy tabele służą różnym celom:

-- Chunk content and metadata
CREATE TABLE chunks (
    id INTEGER PRIMARY KEY,
    file_path TEXT NOT NULL,
    section TEXT NOT NULL,
    chunk_text TEXT NOT NULL,
    heading_context TEXT DEFAULT '',
    mtime_ns INTEGER NOT NULL,
    embedded_at REAL NOT NULL
);

-- FTS5 for BM25 search (content-synced to chunks)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
    chunk_text, section, heading_context,
    content=chunks, content_rowid=id
);

-- sqlite-vec for vector KNN search
CREATE VIRTUAL TABLE chunk_vecs USING vec0(
    id INTEGER PRIMARY KEY,
    embedding float[256]
);

Tabela FTS5 korzysta ze wzorca content-sync: odwołuje się bezpośrednio do tabeli chunks, zamiast przechowywać duplikat tekstu.5 Jedna pułapka: tabele content-sync nie propagują usunięć automatycznie. Indekser musi wydawać jawne polecenia INSERT INTO chunks_fts(chunks_fts, rowid) VALUES('delete', ?) przed usunięciem wierszy z tabeli chunks, w przeciwnym razie indeks FTS5 staje się cicho niespójny. Wagi kolumn w zapytaniach BM25 przypisują 1,0 tekstowi chunka, 0,5 nagłówkom sekcji i 0,3 kontekstowi nagłówka:

# vector_index.py: BM25 search with column weights
bm25(chunks_fts, 1.0, 0.5, 0.3) as score

Rozszerzenie sqlite-vec przechowuje 256-wymiarowe wektory float jako spakowane dane binarne i obsługuje zapytania KNN z odległością kosinusową.4 struct.pack z Python serializuje wektory:

def _serialize_vector(vec):
    return struct.pack(f"{len(vec)}f", *vec)

Schemat jest zaprojektowany z myślą o graceful degradation. Jeśli sqlite-vec nie uda się załadować (brakujące rozszerzenie, niekompatybilna platforma), retriever przełącza się na wyszukiwanie wyłącznie BM25. Właściwość vec_available śledzi, czy wyszukiwanie wektorowe jest operacyjne.


Reciprocal Rank Fusion: matematyka, która to wszystko łączy

RRF scala dwie posortowane listy bez konieczności kalibracji wyników.7 Dlaczego nie łączyć surowych wyników bezpośrednio? BM25 zwraca ujemne wyniki trafności (bardziej ujemne = bardziej trafne w implementacji FTS5 SQLite), podczas gdy odległość kosinusowa zwraca wartości między 0 a 2. Porównywanie tych skal wymaga normalizacji wrażliwej na rozkład zapytań. RRF omija ten problem całkowicie, używając wyłącznie pozycji w rankingu, nie wyników. Formuła przypisuje każdemu dokumentowi wynik na podstawie tego, gdzie pojawił się w każdej liście:

score(d) = Σ (weight_i / (k + rank_i))

Gdzie k to stała (60 w implementacji, zgodnie z oryginalnym artykułem Cormacka et al.7), rank_i to pozycja dokumentu w liście wyników i, a weight_i to opcjonalny mnożnik per lista (domyślnie 1,0 dla obu).

Oto przykład z rzeczywistymi pozycjami. Rozważmy zapytanie: „how does the review aggregator handle disagreements”. Pięć chunków pojawia się w połączonych wynikach:

Chunk Pozycja BM25 Pozycja Vec BM25 RRF Vec RRF Wynik scalony
review-aggregator.py „Disagreement Resolution” 3 1 1/63 = 0,0159 1/61 = 0,0164 0,0323
deliberation-config.json „Review Weights” 1 8 1/61 = 0,0164 1/68 = 0,0147 0,0311
code-review MOC „Multi-Agent Review” 7 2 1/67 = 0,0149 1/62 = 0,0161 0,0310
jiro-artisan.sh „Review State Machine” 2 12 1/62 = 0,0161 1/72 = 0,0139 0,0300
quality-loop.md „Evidence Gate” - 3 0 1/63 = 0,0159 0,0159

Pierwszy chunk wygrywa, ponieważ plasuje się dobrze na obu listach. BM25 dopasował „review”, „aggregator” i „disagreements” w tekście. Wyszukiwanie wektorowe dopasowało semantyczną koncepcję rozwiązywania konfliktów w przeglądzie kodu. Drugi chunk zajął pierwszą pozycję w BM25 (dokładne dopasowanie słowa kluczowego „review” w pliku konfiguracyjnym), ale ósmą w wyszukiwaniu wektorowym (konfiguracja JSON jest semantycznie skromna). RRF odpowiednio obniżyło jego pozycję. Ostatni chunk pojawił się wyłącznie w wynikach wektorowych, więc otrzymał wynik RRF tylko z jednego źródła.

# retriever.py: RRF fusion core
RRF_K = 60

def _rrf_fuse(self, bm25_results, vec_results,
              bm25_weight=1.0, vec_weight=1.0):
    scores = {}
    for rank, r in enumerate(bm25_results, start=1):
        cid = r["id"]
        if cid not in scores:
            scores[cid] = {"rrf_score": 0.0, ...}
        scores[cid]["rrf_score"] += bm25_weight / (self._rrf_k + rank)
        scores[cid]["bm25_rank"] = rank

    for rank, r in enumerate(vec_results, start=1):
        cid = r["id"]
        if cid not in scores:
            scores[cid] = {"rrf_score": 0.0, ...}
        scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
        scores[cid]["vec_rank"] = rank

    return [SearchResult(chunk_id=cid, **data)
            for cid, data in scores.items()]

Domyślna pula kandydatów to 30 wyników z każdego źródła przed fuzją, co daje do 60 kandydatów. Retriever zwraca 10 najlepszych scalonych wyników. Opcjonalny parametr max_tokens obcina wyniki do budżetu tokenowego, szacując 4 znaki na token.


Indeksacja: pełna i przyrostowa

Indekser obsługuje dwa tryby. Pełna reindeksacja czyści bazę danych i odbudowuje od zera. Indeksacja przyrostowa porównuje czasy modyfikacji plików (mtime_ns) z przechowywanymi znacznikami czasu i przetwarza tylko zmienione pliki.1

# index_vault.py: incremental detection
stale = index.get_stale_files(vault_mtimes)  # mtime changed or new
deleted = index.get_deleted_files(vault_paths)  # no longer in vault

Embedding działa w partiach po 64 teksty, aby zamortyzować narzut Model2Vec.8 Licznik postępu wyświetla się co 500 plików podczas pełnej reindeksacji. Handler SIGINT umożliwia graceful shutdown, kończąc bieżący plik przed zatrzymaniem.

Plik konfiguracyjny używa modelu allowlist do kontroli indeksowania folderów. Skarbiec ma 22 dozwolone foldery i 5 trwale wykluczonych folderów (osobiste notatki zdrowotne, dokumenty kariery, wewnętrzne katalogi Obsidian).20 Indekser przetwarza tylko pliki w dozwolonych folderach i pomija wszystko inne.

Jeden krytyczny wybór projektowy: indekser uruchamia filtr poświadczeń na każdym chunku przed zapisem. Osobiste notatki zawierają klucze API, tokeny bearer, ciągi połączeń z bazami danych i klucze prywatne wklejone podczas sesji debugowania. Filtr poświadczeń dopasowuje 21 wzorców specyficznych dla dostawców (klucze OpenAI, GitHub PAT, klucze dostępu AWS, tokeny Stripe i 17 innych) plus 11 generycznych detektorów dla URL-i baz danych, JWT, tokenów bearer, przypisań haseł i ciągów base64 o wysokiej entropii.20 Filtr zastępuje dopasowaną treść tokenami [REDACTED:pattern-name] i loguje, które wzorce zadziałały, ale nigdy nie loguje samego sekretu.

# chunker.py: credential filtering before storage
cleaned_text, scan_result = clean_content(sub_text)
if not scan_result.is_clean:
    logger.info("Scrubbed %d credential(s) from %s [%s]",
                scan_result.match_count, file_path, sub_heading)

Indeksowanie osobistych notatek bez filtrowania poświadczeń stworzyłoby przeszukiwalną bazę danych sekretów. Filtr uruchamia się przed embeddingiem, więc reprezentacje wektorowe nigdy nie kodują wzorców poświadczeń. Zapytanie o „klucz API” zwraca notatki omawiające zarządzanie kluczami API, a nie notatki zawierające rzeczywiste klucze.


Co idzie nie tak: uczciwe tryby awarii

Po setkach zapytań do produkcyjnego indeksu wyłoniły się cztery wzorce awarii.

Płytka treść gęsta w słowa kluczowe wyprzedza głęboką treść. Krótka notatka otagowana security, authentication, oauth z trzema zdaniami streszczenia uzyskuje wyższy wynik w BM25 niż 2 000-słowowy głęboki artykuł o implementacji OAuth, który używa tej terminologii raz we wstępie, a potem przechodzi do szczegółów konkretnego protokołu. BM25 nagradza częstość terminu względem długości dokumentu — właściwość, którą Robertson i Zaragoza udokumentowali jako komponent „nasycenia częstości terminu” algorytmu.514 Płytka notatka ma wyższą gęstość słów kluczowych. RRF częściowo koryguje ten problem, ponieważ wyszukiwanie wektorowe klasyfikuje głęboką treść wyżej (embedding wychwytuje głębię semantyczną), ale płytka notatka wciąż pojawia się w scalonych wynikach, gdy prawdopodobnie nie powinna.

Dane strukturalne indeksują się słabo. Pliki konfiguracyjne JSON, bloki frontmatter YAML i fragmenty kodu z nazwami zmiennych generują niskiej jakości dopasowania BM25. Wyszukanie „review configuration” dopasowuje każdy plik JSON z kluczem review. Wyszukiwanie wektorowe radzi sobie z danymi strukturalnymi nieco lepiej, ponieważ embedding wychwytuje relacje klucz-wartość, ale treść strukturalna jest fundamentalnie trudniejsza do chunkowania niż proza. Spłaszczenie JSON do par ścieżka-klucza: wartość przed embeddingiem poprawiłoby jakość wyszukiwania dla notatek z dużą ilością konfiguracji.

Granice chunków rozdzielają kontekst. Chunker dzieli akapit rozciągający się na granicy między dwoma sekcjami H2 na dwa chunki. Każdy chunk zawiera połowę wyjaśnienia. Żaden chunk nie ma dobrego embeddingu, ponieważ brakuje mu pełnego kontekstu. Chunker łagodzi ten problem kontekstem nagłówka (przenoszenie nagłówka nadrzędnego do metadanych), ale tekst treści wciąż traci ciągłość na granicy. Nakładające się okna pomogłyby, ale zwiększyłyby liczbę chunków i rozmiar bazy danych.

Trafność czasowa jest niewidoczna. Retriever nie ma pojęcia o aktualności. Notatka sprzed 14 miesięcy o wczesnej decyzji architektonicznej plasuje się na równi z notatką z wczoraj o bieżącej implementacji. Dla bazy wiedzy, która ewoluuje, nowsze notatki często zastępują starsze. Retriever tego nie wie.


Co dalej: mapa rozwoju

Pięć dodatków rozwiązałoby tryby awarii i rozszerzyło możliwości systemu.

Warstwa re-rankingu learning-to-rank. Po fuzji RRF lekki re-ranker mógłby dostosowywać wyniki na podstawie sygnałów metadanych: aktualność notatki, trafność tagów do domeny zapytania, gęstość linków (notatki z dużą liczbą linków są często bardziej autorytatywne). Re-ranker działałby na scalonym top-30 wyników, nie na pełnym korpusie, utrzymując opóźnienie poniżej bazowego 23 ms.

Klasyfikacja intencji zapytania. Różne zapytania potrzebują różnych strategii wyszukiwania. Dokładne wyszukiwanie identyfikatora (_rrf_fuse) powinno mocno ważyć BM25. Pytanie koncepcyjne („how does review handle disagreements”) powinno ważyć wyszukiwanie wektorowe. Lekki klasyfikator dostosowujący bm25_weight i vec_weight per zapytanie poprawiłby precyzję bez zmiany architektury fuzji.

Zanikanie czasowe. Lekkie faworyzowanie nowszych notatek w zapytaniach o bieżący stan. Funkcja zanikania stosowana po fuzji zmniejszałaby wynik chunków z plików zmodyfikowanych ostatnio ponad N miesięcy temu. Znacznik czasu mtime_ns już istnieje w schemacie; zanikanie potrzebuje tylko funkcji ważącej w retrieverze.

Wiązka ewaluacyjna z referencyjnymi zapytaniami. System obecnie nie ma automatycznego pomiaru jakości. Zestaw 50-100 kuratorowanych par zapytanie-odpowiedź umożliwiłby testowanie regresji jakości wyszukiwania: uruchomienie zestawu testów po każdej zmianie chunkowania, embeddingu lub parametrów fuzji i weryfikacja, że recall@10 nie spada. Benchmark BEIR wykazał, że systemy wyszukiwania mogą różnić się o 20+ punktów w nDCG@10 w zależności od rozkładu zapytań, co czyni ewaluację specyficzną dla domeny niezbędną.19 Bez zestawu referencyjnego ulepszenia są anegdotyczne.

Indeksacja relacji między notatkami. Wiki-linki Obsidian ([[note-name]]) kodują jawne relacje między notatkami. Obecny system całkowicie ignoruje strukturę linków. Indeksowanie celów linków jako metadanych pozwoliłoby retrieverowi promować chunki z notatek, do których wiele innych wysoko ocenionych notatek linkuje, podobnie do PageRank dla skarbca.

Analiza topologii przestrzeni embeddingowej, którą przeprowadziłem na pełnym skarbcu, ujawnia, gdzie te ulepszenia miałyby największy wpływ. Gęste klastry (narzędzia AI, bezpieczeństwo) już dobrze się wyszukują, ponieważ terminologia jest spójna. Rzadkie regiony pomostowe między klastrami to miejsca, w których retriever ma największe trudności i gdzie indeksacja relacji oraz klasyfikacja intencji przyniosłyby największe zyski.


FAQ

Dlaczego SQLite zamiast dedykowanej bazy wektorowej?

Cały stos wyszukiwania działa w jednym pliku bez żadnych zewnętrznych zależności. Tryb WAL SQLite obsługuje współbieżne odczyty z wielu sesji Claude Code. Rozszerzenie sqlite-vec dodaje wyszukiwanie wektorowe KNN bez konieczności posiadania osobnej instancji Pinecone, Weaviate czy Qdrant.4 Przy 49 746 chunkach opóźnienie zapytania wynosi 23 ms.1 Dedykowana baza wektorowa dodałaby złożoność operacyjną (hosting, kopie zapasowe, uwierzytelnianie) dla jednoużytkownikowej bazy wiedzy mieszczącej się w 83 MB.

Dlaczego Model2Vec zamiast embeddingów OpenAI lub większego modelu?

Trzy powody: opóźnienie, prywatność i koszt. Model2Vec działa lokalnie z prędkością CPU bez żadnych wywołań sieciowych.3 Osobiste notatki nigdy nie opuszczają maszyny. Embeddingi oparte na API kosztowałyby około 0,30 USD za pełną reindeksację przy obecnym rozmiarze skarbca,11 kwota pomijalna w izolacji, ale opóźnienie round-trip na przestrzeni 49 746 chunków i ekspozycja prywatności osobistych treści to rzeczywiste koszty.

Czym jest Reciprocal Rank Fusion i kiedy należy go stosować?

RRF nie wymaga danych treningowych, kalibracji wyników ani strojenia hiperparametrów poza stałą k.7 Wyuczony model fuzji wymagałby etykietowanych osądów trafności do treningu, które nie istnieją dla osobistej bazy wiedzy. RRF to metoda fuzji z najniższą barierą wejścia do uzyskania użytecznych wyników. Należy stosować RRF przy łączeniu posortowanych list z metod wyszukiwania generujących niekompatybilne typy wyników.

Jak lokalny retriever łączy się z Claude Code?

Hook PreToolUse wywołuje metodę search() retrievera z bieżącym promptem, formatuje najlepsze wyniki jako blok kontekstu ze ścieżkami plików i nagłówkami sekcji, i wstrzykuje ten blok do rozmowy. Agent widzi skupione chunki, nie surowe pliki. Parametr max_tokens zapewnia, że wstrzyknięty kontekst mieści się w budżecie.

Jak zapobiegać indeksowaniu sekretów w systemie wyszukiwania?

Należy uruchomić filtr poświadczeń na każdym chunku przed zapisem. Filtr w tym systemie dopasowuje 21 wzorców specyficznych dla dostawców i 11 generycznych detektorów dla JWT, tokenów bearer i kluczy prywatnych.20 Zastępuje dopasowaną treść tokenami [REDACTED:pattern-name] i uruchamia się przed embeddingiem, więc reprezentacje wektorowe nigdy nie kodują wzorców poświadczeń.


Źródła


  1. Author’s production data. 49,746 chunks, 16,894 files, 83.56 MB SQLite database, 7,771 signals processed across 14 months. Query latency (23ms) measured via time.perf_counter() in retriever.py, wrapping the full search path: BM25 lookup, query embedding via Model2Vec, vector KNN search, and RRF fusion. grep -rl measured at 11-66 seconds depending on term frequency (Apple M3 Pro, APFS). Full reindex measured at ~4 minutes on Apple M3 Pro. Incremental measured at <10 seconds for typical daily changes. FTS5-only search became unusable for the author above ~3,000 files due to keyword collision rates. 

  2. HN thread: “Stop Burning Your Context Window”. Comments from danw1979 and tclancy requesting a detailed write-up. 

  3. Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. The potion-base-8M model uses static word embeddings distilled from a sentence transformer, producing 256-dimensional vectors without running attention layers. 

  4. sqlite-vec: A vector search SQLite extension. Alex Garcia, 2024. Provides vec0 virtual tables for KNN vector search within SQLite, using the same query interface as standard tables. 

  5. SQLite FTS5 Extension. SQLite documentation. FTS5 provides full-text search with BM25 ranking, content-sync tables, and configurable column weights via the bm25() auxiliary function. 

  6. Reimers, N. and Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. Foundational work on dense semantic similarity for text retrieval, establishing the vector search approach used in hybrid retrieval systems. 

  7. Cormack, G.V., Clarke, C.L.A., and Buettcher, S. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 2009. Introduces RRF with k=60 as a parameter-free method for combining ranked lists that outperforms trained fusion models. 

  8. Author’s implementation. chunker.py splits at H2 boundaries in the _split_at_headings function, with fallback to H3 then paragraph splitting for sections exceeding 2,000 characters. MIN_CHUNK_CHARS=30, MAX_CHUNK_CHARS=2000. index_vault.py embeds in batches of 64 (BATCH_SIZE=64). 

  9. van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. Describes the distillation approach producing static embeddings from sentence transformers with 50-500x inference speedup. 

  10. Author’s measurement. 256-dim vectors at 49,746 chunks produce 83 MB SQLite. Extrapolating to 768-dim vectors: ~215 MB. To 1024-dim: ~280 MB. Marginal quality improvement on short markdown chunks (avg 200-400 words) does not justify the storage and latency increase. 

  11. OpenAI Embeddings Pricing. text-embedding-3-small: $0.02 per million tokens. Estimated vault cost per full reindex: ~$0.30 based on average chunk length of ~200 tokens. 

  12. SQLite Write-Ahead Logging. SQLite documentation. WAL mode allows concurrent readers with a single writer, suitable for the retriever’s read-heavy access pattern. 

  13. Author’s query trace. Ran “PostToolUse hook for context compression” against BM25-only, vector-only, and hybrid modes. Results captured from retriever.py with method field tracking which search path produced each result. 

  14. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. Survey of the BM25 family of ranking functions and their theoretical foundations. 

  15. Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. Demonstrated that learned dense representations outperform BM25 by 9-19% on open-domain QA benchmarks, establishing dense retrieval as a complement to lexical search. 

  16. Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. Analysis of hybrid sparse-dense retrieval on MS MARCO, showing consistent improvements over single-modality approaches. 

  17. MTEB: Massive Text Embedding Benchmark. Muennighoff, N. et al., 2023. potion-base-8M scores 50.03 average MTEB vs 56.09 for all-MiniLM-L6-v2 (89.2% retention). Per-task breakdown: Classification 64.44, Clustering 32.93, Retrieval 31.71, STS 73.24. Source: Model2Vec results

  18. Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. Survey of RAG architectures including analysis of chunking strategies and their impact on retrieval quality. 

  19. Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. Demonstrates high variance in retrieval performance across domains, underscoring the need for domain-specific evaluation. 

  20. Author’s configuration and credential filter implementation. memory-config.json defines 22 allowed_folders and 5 excluded_always entries. credential_filter.py defines 21 vendor-specific CREDENTIAL_PATTERNS (OpenAI through Turnstile) plus 9 generic single-line patterns (DB URLs, bearer tokens, JWTs, passwords, secrets, API keys, auth tokens, base64 secrets) and 2 multiline patterns (RSA/SSH private keys, PGP keys). Total: 32 patterns. 

Powiązane artykuły

The Blind Judge: Scoring Claude Code vs Codex in 36 Duels

Claude Code vs Codex CLI, scored blind on 5 dimensions across 36 duels. The winner matters less than the synthesis combi…

14 min czytania

Thinking With Ten Brains: How I Use Agent Deliberation as a Decision Tool

You cannot debias yourself by trying harder. 10 AI agents debating each other is a structural intervention for better de…

15 min czytania

Topology of a Second Brain: What 15,000 Signals Look Like in Embedding Space

15,800 notes in embedding space reveal three knowledge topologies. Each has different failure modes practitioners can di…

15 min czytania