Najlepszy fragment bywa odrzucony, zanim zacznie konkurować. Wyszukiwanie semantyczne jest świetne, dopóki użytkownik nie powie czegoś bardzo konkretnego. „Pokaż tylko PDF-y z Warszawą w nazwie, dotyczące faktur z 2024”. Jeśli potraktujemy to jak zwykłe pytanie do embeddingów, indeks może zwrócić fragmenty o fakturach, o Warszawie, o roku 2024, ale niekoniecznie spełniające wszystkie te ograniczenia jednocześnie. Model odpowiedzi dostaje wtedy mieszankę prawdy i przypadkowego podobieństwa. Zaczyna brzmieć pewnie. A ja zaczynam się pocić.
Miałem taki przypadek w projekcie dla firmy, która trzymała dokumenty w SharePoint: około 220 tysięcy plików, sporo duplikatów, nazwy po polsku i angielsku, do tego skany od księgowości. Użytkownik pytał: „czy w plikach z nazwą Warszawa są faktury korygujące za 2024?”. System bez filtra znalazł piękne fragmenty o fakturach korygujących, tylko z katalogu Gdańsk. Odpowiedź była merytorycznie sensowna, ale biznesowo błędna. To gorsze niż brak odpowiedzi, bo wygląda wiarygodnie.
Rozwiązaniem jest dynamiczny filtr metadanych, czasem nazywany self-query retrieval. Przed właściwym wyszukiwaniem lekki model czyta pytanie i wyciąga twarde ograniczenia: typ dokumentu, daty, wzorce nazw plików, konkretne identyfikatory, zakres stron, sekcje. Dopiero fragmenty przechodzące przez filtr są punktowane przez wyszukiwarkę. Semantyka nadal robi swoje, ale pracuje na sensownie zawężonym zbiorze.
Filtr to nie sugestia dla modelu, tylko warunek dla indeksu
To rozróżnienie jest ważne. Jeśli w promptcie do modelu napiszemy „odpowiadaj tylko na podstawie PDF-ów”, ale retrieval poda fragmenty z plików DOCX, model może je zignorować albo nie. Zależy od dnia, temperatury i konstrukcji odpowiedzi. Jeśli filtr działa na poziomie indeksu, fragmenty spoza PDF-ów w ogóle nie trafiają do punktacji. Mniej szumu. Mniej pokusy. Mniej ręcznego tłumaczenia, dlaczego system zacytował zły plik.
W praktyce filtr jest zwykłym JSON-em albo obiektem domenowym. Nie musi być piękny. Musi być stabilny i łatwy do przetestowania. Najczęściej używam pól typu fileNamePatterns, exactFiles, documentTypes, sectionPatterns, pages i documentIdentifiers. Jeśli indeks ma metadane o dacie dokumentu, właścicielu, języku albo folderze, dokładam je dopiero wtedy, gdy użytkownicy realnie o to pytają. Nadmiar pól kusi model do zgadywania.
{
"fileNamePatterns": ["*Warszawa*"],
"exactFiles": [],
"documentTypes": ["pdf", "invoice_correction"],
"sectionPatterns": [],
"pages": null,
"documentIdentifiers": [],
"dateRange": {
"field": "documentDate",
"from": "2024-01-01",
"to": "2024-12-31"
}
}
Mały model albo tańsza konfiguracja dużego modelu dostaje krótkie zadanie: z pytania wyciągnąć tylko ograniczenia, których użytkownik naprawdę zażądał. Nie interpretować intencji szerzej. Nie dopowiadać brakujących dat. Nie zamieniać „Warszawa” na „mazowieckie”, chyba że mamy taką logikę w metadanych i została uzgodniona. To jedno z miejsc, w których prompt powinien być bardziej księgowy niż kreatywny.
Przepływ, który lubię wdrażać
Najpierw normalizuję pytanie i wysyłam je do ekstraktora filtrów. Odpowiedź waliduję schematem. Jeśli model zwróci pole spoza kontraktu, wyrzucam je. Jeśli data jest niepoprawna, filtr daty nie przechodzi. Potem wykonuję szybkie zapytanie kontrolne: ile fragmentów albo dokumentów pasuje do filtra. Dopiero jeśli liczba jest większa od zera, używam filtra we właściwym wyszukiwaniu. Ten dodatkowy krok wydaje się zbędny, dopóki pierwszy raz nie zobaczy się pustych odpowiedzi w produkcji.
Fallback jest obowiązkowy. Jeśli filtr nie pasuje do niczego, system powinien automatycznie wrócić do całej bazy albo do słabszej wersji filtra. Inaczej użytkownik dostaje „nie znaleziono informacji”, mimo że informacja istnieje, tylko nazwa pliku była trochę inna. W jednym projekcie model wyciągał wzorzec „Warsaw”, bo pytanie było po angielsku, a pliki miały „Warszawa”. Bez fallbacku system milczał. Z fallbackiem znajdował dokument, a w odpowiedzi dodawaliśmy ostrożne zdanie, że pierwotny filtr nazw plików nie zwrócił trafień.
filter = extract_filter(user_question)
validated = validate_filter(filter)
candidate_count = index.count(where=validated)
if candidate_count == 0:
retrieval_filter = None
note = "Filtr z pytania nie zwrócił dokumentów, użyto szerszego wyszukiwania."
else:
retrieval_filter = validated
note = None
results = index.search(
text=user_question,
vector=embed(user_question),
where=retrieval_filter,
top_k=12
)
Przykładowe zapytanie do indeksu zależy od technologii. W Azure AI Search będzie to filtr OData plus search query, w Elasticsearch bool query z filter i should, w pgvector warunki SQL przed sortowaniem po dystansie, w Qdrant payload filter. Mechanizm jest ten sam: ograniczenia metadanych wykonują się przed rankingiem albo razem z nim, ale nie po odpowiedzi modelu.
{
"search": "faktury korygujące za 2024",
"vectorQueries": [
{
"kind": "vector",
"fields": "contentVector",
"vector": [0.012, -0.044, 0.091],
"k": 50
}
],
"filter": "documentType eq 'pdf' and search.ismatch('Warszawa', 'fileName') and documentDate ge 2024-01-01 and documentDate le 2024-12-31",
"top": 10
}
Metadane muszą być warte zaufania
Dynamiczny filtr obnaża jakość indeksacji. Jeśli documentType jest raz „PDF”, raz „pdf”, a raz pusty, filtr będzie zachowywał się losowo. Jeśli data dokumentu pochodzi z daty uploadu, a użytkownik pyta o datę faktury, mamy problem semantyczny przebrany za techniczny. Widziałem bazę, w której 38% dokumentów miało datę 1970-01-01, bo parser metadanych tak oznaczał brak wartości. Filtr „z 2024” działał poprawnie, ale biznes pytał, czemu system ignoruje stare skany. Odpowiedź była niewygodna: bo nie wiedzieliśmy, jakie naprawdę mają daty.
Dlatego przed wdrożeniem self-query robię prosty audyt metadanych. Ile dokumentów ma typ? Ile ma datę? Ile ma pustą nazwę oryginalną? Czy nazwy plików przetrwały migrację z SharePoint albo dysku sieciowego? Czy identyfikatory umów są wyciągnięte do osobnego pola, czy tylko siedzą w tekście OCR? To nie jest efektowna praca. To sortowanie CSV-ów, rozmowy z administratorem i ręczne sprawdzanie dwudziestu losowych plików. Ale bez tego filtr jest dekoracją.
W branży prawniczej szczególnie często trafiam na pytania o identyfikatory dokumentów: „umowa 12/2021”, „aneks nr 3”, „sprawa KOW/44/24”. Embeddingi nie są stworzone do precyzyjnego łapania takich wartości. Full-text pomaga, ale tylko jeśli OCR nie zgubił ukośnika albo spacji. Najlepiej mieć documentIdentifiers jako osobne pole, z normalizacją wariantów. Wtedy filtr może działać twardo, a wyszukiwanie semantyczne odpowiada za treść paragrafów.
Ekstraktor filtrów powinien być ostrożny
Najczęstszy błąd to zbyt gorliwy filtr. Użytkownik pisze „interesują mnie umowy najmu, zwłaszcza Warszawa”, a model wyciąga fileNamePatterns równe „*Warszawa*”. Potem odpadają umowy, które dotyczą Warszawy, ale nie mają miasta w nazwie pliku. To nie jest drobnostka. System zaczyna odpowiadać na węższe pytanie niż zadano. Brzmi niewinnie, dopóki dział prawny nie oprze na tym decyzji.
Pomaga rozdzielenie ograniczeń twardych od miękkich. „Tylko PDF-y” jest twarde. „Pliki z Warszawa w nazwie” też. „Dotyczące Warszawy” już niekoniecznie, bo może być treścią dokumentu, metadanym folderu albo nazwą kontrahenta. Wtedy wolę potraktować Warszawę jako termin wyszukiwania, nie filtr po nazwie pliku. Prompt ekstraktora powinien mówić wprost: filtruj po nazwie pliku tylko wtedy, gdy użytkownik odnosi się do nazwy pliku, ścieżki lub katalogu.
Dobrym testem są pytania nieprecyzyjne. „Pokaż dokumenty o urlopach z ostatniego okresu”. Jaki to okres? Ostatni miesiąc, kwartał, rok? Jeśli ekstraktor wpisze datę na podstawie domysłu, mamy błąd. Ja wolę zwrócić pusty filtr daty i zostawić „ostatni okres” w treści zapytania. Ewentualnie system może dopytać, ale w wielu interfejsach nie chcemy przerywać rozmowy. Wtedy odpowiedź powinna być ostrożna.
Koszt jednego małego wywołania
Dynamiczny filtr kosztuje zwykle jedno dodatkowe wywołanie LLM na pytanie. Da się je zrobić małym modelem, krótkim promptem i niską temperaturą. W jednym wdrożeniu koszt ekstrakcji filtrów wynosił około 3–5% całego kosztu rozmowy, bo odpowiedzi generował większy model na długim kontekście. W innym, gdzie odpowiedzi były bardzo krótkie, ekstraktor stał się zauważalną częścią rachunku. Nie ma jednej liczby. Trzeba mierzyć.
Latencja też jest realna. Jeśli ekstraktor działa 400 ms, nikt nie narzeka. Jeśli czasem czeka 2 sekundy, użytkownik czuje opóźnienie, zanim jeszcze zacznie się właściwy retrieval. Można ekstrakcję wykonywać równolegle z embeddingiem pytania, bo te operacje są niezależne. Można też cache’ować filtr dla identycznego pytania, choć w praktyce identyczne pytania zdarzają się rzadziej niż podobne.
Nie stosowałbym tego w małych bazach. Jeśli mamy 600 dokumentów i sensowną hybrydę, filtr może więcej zepsuć niż poprawić. Szczególnie gdy metadane są ubogie. Zamiast dynamicznego filtra lepiej poprawić chunking, OCR, ranking i cytowanie źródeł. Self-query zaczyna błyszczeć dopiero tam, gdzie baza jest duża, użytkownicy naturalnie zawężają pytania, a metadane są w miarę wiarygodne.
Jak pokazuję to użytkownikowi
Przez długi czas ukrywałem filtr. System po prostu działał. Zmieniłem zdanie po serii zgłoszeń, w których użytkownicy nie rozumieli, czemu odpowiedź pomija część dokumentów. Teraz lubię pokazywać krótką linijkę: „Zastosowano filtr: PDF, nazwa zawiera Warszawa, data 2024”. Jeśli filtr został odrzucony przez fallback, pokazuję to również. Nie w formie technicznego JSON-a, raczej ludzkim zdaniem. To buduje zaufanie i ułatwia debugowanie.
Dla administratorów zapisuję pełny JSON filtra w logach razem z liczbą kandydatów przed i po filtrze. To kopalnia wiedzy. Po miesiącu widać, że użytkownicy najczęściej pytają o lata, typy dokumentów i nazwy kontrahentów, a prawie nigdy o strony. Można wtedy uprościć schemat. Albo odwrotnie: dodać pole, którego wszyscy próbują używać językiem naturalnym, ale indeks go nie ma.
Jest jeszcze jeden szczegół, który doceniłem dopiero po awarii w piątek po południu: wersjonowanie schematu filtra. Zespół dodał pole folderPath, ale część instancji aplikacji nadal wysyłała stary format. W logach wyglądało to jak losowe pomijanie dokumentów. Od tamtej pory każdy filtr ma version, a parser potrafi obsłużyć przynajmniej jedną wersję wstecz. To mało romantyczne, za to ratuje weekend.
Warto też rozdzielić filtr jawny od filtra wywnioskowanego. Jeżeli użytkownik pisze „tylko PDF-y”, confidence jest wysokie. Jeżeli pisze „umowy z Warszawy”, confidence dla fileNamePatterns powinno być niskie, chyba że zdanie mówi wprost o nazwie pliku. W niektórych systemach zapisuję ten poziom pewności i przy niskiej wartości używam ograniczenia jako boost, nie jako twardy warunek. Elasticsearch, Azure AI Search czy własne zapytanie SQL można do tego dopasować różnymi sposobami. Sama idea jest ważniejsza niż składnia.
Takie niuanse wydają się przesadą przy demo. Demo zwykle ma dziesięć ładnych dokumentów i pytania przygotowane przez zespół. Produkcja ma pliki „scan_final_v3_poprawiony.pdf”, daty z migracji, kontrahentów z literówkami i użytkowników, którzy piszą skrótami. Dynamiczny filtr ma sens dopiero wtedy, gdy zakładamy ten bałagan od początku.
Dynamiczny filtr metadanych nie naprawi słabego RAG-a. Nie zastąpi dobrych embeddingów, sensownego podziału dokumentów ani testów. Jest za to jednym z tych mechanizmów, które robią różnicę w brudnych, firmowych bazach, gdzie pytanie użytkownika zawiera więcej ograniczeń niż samej treści. Jeśli twoi użytkownicy mówią „tylko”, „z plików”, „z 2024”, „w nazwie”, „na stronach”, to indeks powinien ich słuchać przed rankingiem, nie dopiero po wygenerowaniu odpowiedzi.

