ai-agents
artykuł··10 min czytania

Twój RAG bot myli się z pełnym przekonaniem i nie zauważysz tego, dopóki nie zadzwoni klient

#n8n #rag #vector-db #chatbot #data-quality

Chatbot działał na produkcji przez sześć tygodni, zanim ktokolwiek zauważył, że cytuje cennik sprzed dziewięciu miesięcy. Nie od czasu do czasu. Za każdym razem, gdy ktoś pytał o plan premium, bot podawał liczbę o czterdzieści procent za niską i wskazywał źródłowy dokument z pełnym spokojem. Dokument był prawdziwy. Tylko że pochodził sprzed listopadowej podwyżki cen. Gdzieś w vector store siedział obok aktualnego arkusza i retriever konsekwentnie go wybierał, bo zapytanie "ile kosztuje plan premium" lepiej pasowało leksykalnie do starego pliku. Cosine similarity nie zna pojęcia "aktualność". Model nie wiedział, że jest okłamywany. My też nie wiedzieliśmy przez sześć tygodni.

To jest specyficzny tryb awarii RAG z brudnymi danymi: nie cisza, nie błędy, nie halucinacje w klasycznym sensie. Pewne, sourced, wiarygodne złe odpowiedzi. LLM robi dokładnie to, co powinien. Problem leży dwa kroki wcześniej, w pipeline ingestion, i nie znajdziesz go analizując outputy modelu.

Założenie, które niszczy wszystko

Większość zespołów traktuje ingestion do RAG jak jednokierunkową bramę. Dokumenty wchodzą, chunki trafiają do vector store, bot odpowiada na pytania. Mentalna mapa jest gdzieś pomiędzy wyszukiwarką a szafą na dokumenty: dodajesz pliki, retriever znajduje odpowiednie, koniec.

Problem w tym, że ten model nie ma pojęcia o czasie, konflikcie ani autorytecie. Vector store wypełniony chunkami z różnych wersji tego samego dokumentu to nie jest baza wiedzy. To stanowisko archeologiczne. Każda warstwa jest obecna jednocześnie i retriever wykopuje tę, która ma najwyższe podobieństwo do zapytania, niezależnie od tego, czy ta warstwa opisuje świat, który jeszcze istnieje.

W starannie utrzymanym korpusie jest to do zarządzenia. W korpusie budowanym przez nocny workflow n8n, który czyta folder Google Drive i dopisuje wszystko co znajdzie, staje się miną. Foldery na Dysku akumulują dokumenty tak samo jak skrzynki mailowe akumulują newslettery. Nikt nie usuwa cennika z Q3 2022, bo "może się przyda". Nikt nie oznacza go jako zdeprecjonowanego. Po prostu siedzi, czeka na embedowanie.

Jak wyglądał workflow n8n przed poprawkami

Oryginalny flow ingestion miał trzy węzły: trigger Google Drive uruchamiający się przy każdej zmianie pliku, document loader dzielący treść na chunki i Pinecone upsert zapisujący wszystko do vector store z kluczem opartym na hashu treści chunka.

// n8n Function node — naiwne przygotowanie chunków
// Uruchamia się po document loaderze dzielącym tekst
const chunks = $input.all();

return chunks.map(chunk => ({
  json: {
    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),
    content: chunk.json.pageContent,
    metadata: {
      source: chunk.json.metadata.source,
    }
  }
}));

Trzy rzeczy są tutaj złe. Chunk ID pochodzi z numeru strony i prefixu treści, co oznacza, że dwie wersje tej samej strony w tym samym pliku generują identyczne ID i Pinecone poprawnie nadpisze starą. Ale dwie wersje tego samego dokumentu pod różnymi nazwami pliku generują różne ID i obie przeżywają. Metadata zawiera tylko nazwę pliku, co retrieverowi nic nie mówi o tym, kiedy dokument był aktualny. I nie ma żadnego kroku deduplikacji: każde uruchomienie ingestuje wszystko co trigger obserwuje, włącznie z plikami zmodyfikowanymi dwa lata temu.

Działaliśmy tak trzy tygodnie, zanim zorientowaliśmy się, że odpowiedzi bota o limitach integracji cytują dokument sprzed podniesienia limitów. Retriever nie był zepsuty. Pobierał dokładnie to, co znalazł.

Właściwe podejście: ingestion to pipeline z bramkami walidacji

Poprawka nie polega na tym, żeby dokładniej dobierać dokumenty do dodania. To jest problem polityki i polityki zawodzą. Poprawka polega na tym, żeby pipeline ingestion był strukturalnie niezdolny do tworzenia niejednoznacznego stanu w vector store.

Oznacza to trzy rzeczy: kanoniczny identyfikator dokumentu stabilny przez aktualizacje, metadata ze świeżością dołączona do każdego chunka w chwili zapisu, i krok deduplikacji usuwający poprzednie wersje dokumentu przed wstawieniem nowej.

// n8n Function node — walidowane przygotowanie chunków
// Wymaga: item.json.fileId (Drive ID), item.json.modifiedTime, item.json.content
const crypto = require('crypto');

const items = $input.all();
const results = [];

for (const item of items) {
  const { fileId, modifiedTime, content, mimeType } = item.json;

  // Odrzuć pliki bez treści albo za krótkie, żeby miały sens jako wiedza
  if (!content || typeof content !== 'string' || content.trim().length < 50) {
    continue;
  }

  // Kanoniczny docId to Drive fileId, nie nazwa pliku.
  // Nazwy plików się zmieniają. Drive ID nie.
  const docId = fileId;

  // Hash treści łapie zduplikowane pliki wgrane pod różnymi nazwami
  const contentHash = crypto
    .createHash('sha256')
    .update(content.trim())
    .digest('hex')
    .slice(0, 16);

  // Chunk ID = docId + indeks chunka, więc nowa wersja tego samego dokumentu
  // generuje te same chunk ID i nadpisuje stare w Pinecone.
  // Wymaga upsert mode w Pinecone, co powinieneś mieć.
  const chunkIndex = results.length;
  const chunkId = `${docId}_${chunkIndex}`;

  results.push({
    json: {
      id: chunkId,
      content: content,
      metadata: {
        docId,
        contentHash,
        source: item.json.name,
        // ISO timestamp pozwala filtrować po świeżości przy retrieval
        ingestedAt: new Date().toISOString(),
        // Czas modyfikacji z Drive jest wiarygodniejszy niż czas ingestion
        documentModifiedAt: modifiedTime,
        mimeType,
      }
    }
  });
}

return results;

Kluczowy element to docId oparty na Drive file ID, nie na nazwie pliku. Nazwa pliku to to, co ludzie piszą na plikach. File ID to to, co system nadaje przy tworzeniu i co nigdy się nie zmienia. Gdy kluczujesz chunki po file ID, nowa wersja dokumentu wgrana jako "cennik-2026-v2.pdf" na to samo miejsce w Drive generuje te same chunk ID co "cennik-2025.pdf", a upsert semantics Pinecone zastępuje stare chunki zamiast je akumulować.

Potrzebujesz też kroku czyszczenia dla dokumentów usuniętych lub zarchiwizowanych w źródle. Upsert obsługuje aktualizacje; usunięcie wymaga explicit delete. Dodaj węzeł reconciliation uruchamiający się co tydzień, który listuje wszystkie doc ID w vector store, porównuje z aktywnym folderem Drive i usuwa sieroty.

// n8n Function node — wykrywanie sierot
// Uruchamia się po pobraniu currentDriveIds i storedDocIds

const driveIds = new Set($('Pobierz pliki Drive').all().map(i => i.json.id));
const storedIds = $('Pobierz zapisane Doc ID').all().map(i => i.json.docId);

const orphans = storedIds.filter(id => !driveIds.has(id));

// Przekaż ID sierot do węzła Pinecone delete
return orphans.map(docId => ({ json: { docId } }));

Filtrowanie po świeżości przy retrieval

Nawet z czystym ingestion, chcesz żeby krok retrieval potrafił wnioskować o wieku dokumentu. Niektóre pytania są zależne od czasu. "Jakie są obecne limity API" powinno preferować dokumenty zmodyfikowane w ciągu ostatnich 90 dni nad tymi sprzed dwóch lat, nawet jeśli starszy dokument ma nieznacznie wyższy similarity score.

Pinecone i większość innych vector databases obsługuje filtrowanie po metadanych. Używaj tego.

// Ciało n8n HTTP Request node — zapytanie Pinecone z filtrem świeżości
// Ustaw jako expression żeby data liczyła się w runtime
{
  "vector": "{{ $json.embedding }}",
  "topK": 6,
  "includeMetadata": true,
  "filter": {
    "documentModifiedAt": {
      "$gte": "{{ new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString() }}"
    }
  }
}

To jest tępe narzędzie i warto o tym wiedzieć. Filtr 90 dni wykluczy perfekcyjnie aktualny dokument architektury, którego nikt nie ruszał przez dwa lata, bo jest nadal poprawny. Trzeba zdecydować, które kategorie dokumentów są time-sensitive i stosować filtr selektywnie. Cenniki: filtruj agresywnie. Changelogii API: filtruj do ostatnich wersji. Architecture Decision Records: nie filtruj wcale. Ta decyzja powinna być zakodowana w metadanych dokumentu przy ingestion, nie hardkodowana w query.

Co potrzebujesz w obserwowalności produkcyjnej

Pierwsza rzecz, którą dodałem po naprawieniu pipeline ingestion, to logowanie chunków. Za każdym razem gdy retriever zwraca wyniki, pełna lista pobranych chunków, ich dokumenty źródłowe, similarity scores i znaczniki documentModifiedAt trafiają do structured loga. Nie sample. Każde zapytanie.

Tak łapiesz następną wersję tego problemu zanim dotrze do niej klient. Zapytanie, które konsekwentnie pobiera chunk z similarity score poniżej 0.75, to zapytanie, na które twoja baza wiedzy faktycznie nie odpowiada. Zapytanie pobierające chunki z trzech różnych wersji tego samego dokumentu to sygnał, że deduplikacja gdzieś zawiodła. Chunk z documentModifiedAt sprzed osiemnastu miesięcy pojawiający się w odpowiedziach o bieżących cenach to sygnał, który potrzebuje alertu, nie postmortem.

// n8n Function node — structured log retrieval
const query = $('Zapytanie użytkownika').first().json.text;
const retrieved = $('Pinecone Query').all();

const logEntry = {
  timestamp: new Date().toISOString(),
  query,
  retrievedChunks: retrieved.map(r => ({
    id: r.json.id,
    score: r.json.score,
    source: r.json.metadata.source,
    documentModifiedAt: r.json.metadata.documentModifiedAt,
    contentPreview: r.json.metadata.content?.slice(0, 120),
  })),
  minScore: Math.min(...retrieved.map(r => r.json.score)),
  maxAge: retrieved.reduce((oldest, r) => {
    const t = new Date(r.json.metadata.documentModifiedAt).getTime();
    return t < oldest ? t : oldest;
  }, Date.now()),
};

// Wyślij do swojego endpointu logowania
return [{ json: logEntry }];

Pole maxAge w tym logu powie ci, w ciągu jednego dnia produkcyjnego, czy deduplikacja działa. Jeśli widzisz chunki sprzed ostatniego uruchomienia ingestion dla dokumentów, które wiesz że zaktualizowałeś, twoje chunk ID są złe.

Na co zwracam uwagę w code review

Chunk ID oparty wyłącznie na hashu treści to żółta flaga. Deduplikacja po hashu treści w obrębie jednej wersji dokumentu jest w porządku. Hash treści jako klucz główny między wersjami po cichu odrzuci aktualizację, jeśli nowa wersja zaczyna się tym samym akapitem co stara.

Metadata zawierająca wyłącznie nazwę pliku to czerwona flaga. Nazwy plików są nadawane przez ludzi i ludzie są niespójni. Potrzebujesz systemowego, stabilnego identyfikatora plus znacznika modyfikacji jako osobnych pól, nie sklejonych w string.

Workflow ingestion bez kroku usunięcia ani reconciliation to incydent w zwolnionym tempie. Vector store będzie dryfował od źródła prawdy z prędkością proporcjonalną do częstotliwości archiwizowania i aktualizowania dokumentów przez twój zespół. Sześć tygodni driftu dało nam bota pewnie cytującego złe ceny. Rok driftu w kontekście regulacyjnym byłby inną historią.

Pytanie, które zadaję w każdym review: jeśli ktoś zaktualizuje dokument właśnie teraz, co stanie się ze wszystkimi chunkami ze starej wersji? Jeśli odpowiedź brzmi "to zależy od nazwy pliku" albo "dostają dodatkową kopię gdzieś tam", pipeline ingestion nie jest skończony.

koniec artykułu