typescript and javascript logo
author avatar

Grzegorz Dubiel

27-08-2025

Przekształcanie całych blogów w krótkie podsumowania: Map-Reduce dla LLM-ów

Nasza potrzeba skalowania stale rośnie. Ludzkość przetwarza ogromne ilości danych. Każdego dnia inżynierowie muszą zmagać się z różnymi ograniczeniami. To środowisko często zmusza nas do pójścia na skróty i tworzenia obejść, aby osiągnąć wielkie rzeczy. Jednym z najlepszych przykładów jest wykorzystanie LLM-ów do przetwarzania dużych dokumentów. Z jednej strony mamy wciąż ograniczoną technologię, taką jak LLM-y z zawężonym oknem kontekstowym, z drugiej, ogromne bazy wiedzy reprezentowane przez wykresy, dokumenty, nagrania audio, filmy i zbiory kodu. W krótkim czasie powstało wiele przydatnych aplikacji, takich jak NotebookLM do pracy reaserchu, Claude Code czy Cursor do programowania. Każde z tych narzędzi musi przetwarzać informacje przekraczające okno kontekstowe LLM-a. Aby było to możliwe, musimy wykorzysywać pewne wzorce. Przyjrzyjmy się jednemu z tych wzorców, zwanemu Map-Reduce. Aby łatwiej zrozumieć jego działanie, zbudujemy małą aplikację do podsumowywania blogów.

Map-Reduce Pattern

Pattern ten został zaproponowany w 2004 roku przez dwójkę ludzi z Google’a:Jeffrey Dean'a i Sanjay Ghemawat'a, aby przetwarzać duże ilości rozproszonych danych. Mówiąc prościej, pozwala programowi efektywnie przetwarzać ogromne ilości danych we fragmentach, a następnie redukować je do ostatecznego wyniku, zapewniając szybkość i efektywność całego procesu.

Jak sama nazwa wskazuje, wzorzec jest połączeniem dwóch funkcji:

  • Map, która jednocześnie wywołuje funkcję zwracającą wartość dla każdego fragmentu danych (na przykład licząc oczekiwane słowa kluczowe we fragmencie, zwracając pośrednią parę klucz-wartość reprezentującą każde słowo i liczbę jego wystąpień).

  • Reduce, która przyjmuje wynik funkcjii map i agreguje go do ostatecznego wyniku (na przykład zwracając tabelę z całkowitą liczbą wystąpień każdego słowa w zbiorze danych).

Każde zadanie map może działać niezależnie na różnych węzłach, dlatego proces ten łatwo się skaluje, a stopień równoległości można kontrolować, aby zrównoważyć szybkość i wykorzystanie zasobów.

W kontekście używania LLM-ów ten wzorzec jest szczególnie przydatny, ponieważ zawsze operujemy na ograniczonym oknie kontekstowym. W prawdziwych scenariuszach zawartość lub dane, które chcemy przetworzyć, często przekraczają rozmiar okna kontekstowego. Nie możemy przetworzyć całego bloga za pomocą jednego lub dwóch zapytań do LLM'a. Nawet jeśli byłoby to możliwe, proces ten był by bardzo wolny i potencjalnie niedokładny, ponieważ LLM-y zazwyczaj lepiej radzą sobie z mniejszymi fragmentami danych.

Założenia projektu

Pierwszą rzeczą, którą chciałbym zaznaczyć, jest to, że w tym artykule spróbujemy zaimplementować ten wzorzec przy minimalnym wsparciu frameworków do budowania rozwiązań AI, takich jak langgraph. Główną logikę zaimplementujemy samodzielnie; wykorzystamy jedynie funkcje pomocnicze z langchain do dzielenia tekstu i formatowania danych.

Nasza logika zostanie podzielona na następujące kroki:

  1. Faza wstępna (Pre-processing phase) – Zbieranie wybranych artykułów bloga przy użyciu vibe-coded web scrapera.

  2. Faza mapowania (Mapping phase) – Przechodzenie przez każdy artykuł, dzielenie go na części i podsumowywanie każdej z nich za pomocą LLM równolegle, wykorzystując map prompt.

  3. Faza redukcji (Reducing phase) – Jeśli podsumowania wygenerowane w fazie mapowania przekraczają limit, zostają skondensowane poprzez podział na podlisty, następnie każda podlista zostaje streszczona ponownie w jedno podsumowanie zbiorcze przy użyciu promptu reduce. Faza redukcji działa rekurencyjnie, dopóki zmapowane lub skondensowane podsumowania nie zmieszczą się w limicie i nie zostaną sprowadzone do ostatecznego podsumowania, albo dopóki nie zostanie osiągnięty limit rekurencji. W przeciwnym razie, jeśli podsumowania mieszczą się w limicie od razu, są bezpośrednio redukowane do finalnego podsumowania przy użyciu wspomnianego promptu reduce.

Faza wstępna (Pre-processing phase)

Jak już wspomniałem, do pozyskania artykułów do podsumowania użyjemy scrapera. Nie będziemy zagłębiać się w jego kod. Wywołamy scraper w funkcji main, aby pobrać wszystkie posty z mojego bloga:

typescript

import { summarizeDocuments } from "./summarizer/main";
import { runDirectScraper, runSitemapBasedScraper } from "./scraper/main";

async function main() {
  const scrappingResults = await runSitemapBasedScraper([
    "https://www.aboutjs.dev",
  ]);

  const filteredScrappedResults = scappingResults.filter((result) => {
    if (result.error) {
      console.error(`${result.url}: ${result.error}`);
    }
    return result.success;
  });
}

void main();

Następnie musimy zadeklarować główną funkcję dla summarizer'a — miejsce, w którym będzie działać główna logika:

typescript

// summarizer/types.ts

export type Document = {
  title: string;
  content: string;
  link: string;
  date: string;
  source: string;
  selector: string;
  index: number;
};

// summarizer/main.ts
import type { Document as LocalDocument } from "./type";

export async function summarizeDocuments(
  documents: LocalDocument[],
  maxIterations = 5,
) {}

Funkcja ma dwa parametry: documents, które stanowią dane wejściowe (w naszym przypadku artykuły), oraz opcjonalny parametr maxIterations, określający, ile operacji rekurencyjnych można wykonać w sytuacji, gdy treść nie mieści się w limicie.

Faza mapowania (Mapping phase)

Pierwszą rzeczą, którą robimy, jest przejście po wszystkich postach na blogu i przygotowanie ich dla text splittera, formatując je do typu Document:

typescript

import type { Document as LocalDocument } from "./type";
import { Document } from "@langchain/core/documents";

export async function summarizeDocuments(
  documents: LocalDocument[],
  maxIterations = 5,
) {
  const formattedDocs = documents.map(
    (doc) =>
      new Document({
        pageContent: doc.content,
        metadata: {
          title: doc.title,
          link: doc.link,
          date: doc.date,
          source: doc.source,
          selector: doc.selector,
          index: doc.index,
        },
      }),
  );
}

Teraz przygotowujemy funkcję, która podzieli nasze dokumenty na fragmenty i podsumuje każdy z fragmentów za pomocą LLM-a.

Aby móc ją stworzyć, musimy najpierw przygotować kilka rzeczy:

  1. LLM model runnable

    Użyjemy gpt-5-mini. Dla naszego summarizer'a ten model jest lekki i szybki — w pełni wystarczający do tego zadania. Do obsługi instancji modelu wykorzystamy integrację openai z langchain.

    typescript

    /* REST OF THE CODE */
    
    import { ChatOpenAI } from "@langchain/openai";
    
    const model = new ChatOpenAI({
      model: "gpt-5-mini",
      apiKey: process.env.OPENAI_API_KEY,
    });
    
    /* REST OF THE CODE */
  2. prompt

    W naszym wypadku kluczowym zadaniem fazy mapowania jest podsumowanie każdego posta na blogu. Potrzebujemy promptu, który zostanie wysłany razem z tekstem posta lub jego fragmentem do LLM-a w celu podsumowania:

    typescript

    export const mapTemplate = (content: string) => `
    You are an expert content analyzer. Your task is to extract and summarize the key information from the following document.
    
    Please analyze the content and provide:
    1. Main topics and themes
    2. Key insights and takeaways
    3. Important facts, statistics, or examples
    4. Core concepts or ideas presented
    
    Format your summary in the bullet points format.
    
    Summary should be brief and to the point.
    
    Document Content: ${content}
    
    Provide a concise but comprehensive summary that captures the essential information from this document. Focus on the most valuable and actionable content.
    `;

    Prompt jest zwracany przez funkcję template, która przyjmuje treść posta na blogu lub jego fragment jako argument. Dzięki temu możemy dynamicznie tworzyć prompt z osadzoną treścią do podsumowania.

  3. text splitter

    Musimy przygotować tokenTextSplitter, który potrafi podzielić dokument na mniejsze poddokumenty:

    typescript

    // summarizer/const.ts
    export const CHUNK_SIZE = 1000;
    
    // summarizer/main.ts
    import {
      TokenTextSplitter,
      RecursiveCharacterTextSplitter,
    } from "@langchain/textsplitters";
    import { CHUNK_SIZE } from "./const";
    
    const textSplitter = new TokenTextSplitter({
      chunkSize: CHUNK_SIZE,
      chunkOverlap: 0,
    });
    
    /* REST OF THE CODE */

    Używamy bardzo małego limitu dla fragmentu, aby zilustrować rekurencyjny proces analizowania tekstów.

Teraz możemy stworzyć funkcję odpowiedzialną za uruchamianie mapperów:

typescript

import type { Document as LocalDocument } from "./type";
import { TokenTextSplitter } from "@langchain/textsplitters";
import { ChatOpenAI } from "@langchain/openai";
import { mapTemplate } from "./prompts";
import { CHUNK_SIZE } from "./const";
import { Document } from "@langchain/core/documents";

const model = new ChatOpenAI({
  model: "gpt-5-mini",
  apiKey: process.env.OPENAI_API_KEY,
});

const textSplitter = new TokenTextSplitter({
  chunkSize: CHUNK_SIZE,
  chunkOverlap: 0,
});

async function runMappers(formattedDocs: Document[]): Promise<string[]> {
  console.log("Summarization started...");
  const splitDocs = await textSplitter.splitDocuments(formattedDocs);

  const results = await model.batch(
    splitDocs.map((doc) => [
      {
        role: "user",
        content: mapTemplate(doc.pageContent),
      },
    ]),
  );

  return results.map((result) => result.content as string);
}

export async function summarizeDocuments(
  documents: LocalDocument[],
  maxIterations = 5,
) {
  const formattedDocs = documents.map(
    (doc) =>
      new Document({
        pageContent: doc.content,
        metadata: {
          title: doc.title,
          link: doc.link,
          date: doc.date,
          source: doc.source,
          selector: doc.selector,
          index: doc.index,
        },
      }),
  );

  let summaries = await runMappers(formattedDocs);
}

Artykuły zostaną podzielone na mniejsze fragmenty (poddokumenty), każdy fragment zostanie następnie wysłany wraz z map prompt do LLM-a w celu podsumowania. Metoda batch pozwala nam efektywnie wysyłać wiele zapytań do LLM równolegle. Możemy kontrolować maksymalną liczbę jednoczesnych zapytań wysyłanych w jednej partii w konfiguracji instancji modelu LLM. Wyniki z wszystkich mapperów zostaną zwrócone do przetworzenia w następnej fazie.

Faza redukcji (Reducing phase)

W następnym kroku weźmiemy wszystkie podsumowania zwrócone w fazie mapowania i scalimy je w jedno ostateczne podsumowanie. W tej fazie musimy również obsłużyć sytuację, w której podsumowania z mapperów przekraczają limit. Zkompresujemy listę podsumowań, dzieląc ją na podlisty, a następnie wykonamy podsumowanie każdej podlisty. Jako rezultat otrzymamy jedno podsumowanie na podlistę.

Pierwsza funkcja, którą tworzymy dla fazy redukcji, określi, czy w ogóle musimy wykonać łączenie podsumowań:

typescript

/* REST OF CODE */

async function lengthFunction(summaries: string[]) {
  const tokenCounts = await Promise.all(
    summaries.map(async (summary) => {
      return model.getNumTokens(summary);
    }),
  );
  return tokenCounts.reduce((sum, count) => sum + count, 0);
}

async function checkShouldCollapse(summaries: string[]) {
  const tokenCount = await lengthFunction(summaries);
  return tokenCount > 2000;
}

/* REST OF CODE */

Funkcja lengthFunction sumuje liczbę tokenów we wszystkich podsumowaniach. Funkcja sprawdzająca porównuje tę łączną liczbę tokenów z ustalonym limitem, aby upewnić się, że podsumowania go nie przekraczają.

Łączenie Podsumowań(Collapsing)

Następnie stworzymy funkcję do rekurencyjnego łączenia podsumowań. Łączenie będzie odbywać się poprzez wywołanie LLM i rządanie skonsolidowania listy podsumowań.

Stwórzmy reduce prompt dla tego zadania:

typescript

/* REST OF THE CODE */
export const reduceTemplate = (summaries: string) => `

The following is a set of summaries:
${summaries}
Take these and create one summary as a whole context gathered from the summaries.

Keep it concise and focused on the main points, avoiding unnecessary details. The goal is to distill the essence of the summaries into a single, coherent summary.
`;
// https://js.langchain.com/docs/tutorials/summarization/#map-reduce

Następnie musimy przygotować dwie dodatkowe funkcje do podziału listy podsumowań na podlisty.

Pierwszą będzie kolejna funkcja dzieląca tokeny, odpowiedzialna za obsługę edge case'a, w którym pojedyńcze podsumowanie z listy przekracza maksymalny rozmiar podlisty.

typescript

/* REST OF THE CODE */

import {
  TokenTextSplitter,
  RecursiveCharacterTextSplitter,
} from "@langchain/textsplitters";

/* REST OF THE CODE */
const recursiveTextSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE,
  lengthFunction: (text) => {
    return model.getNumTokens(text);
  },
  chunkOverlap: 0,
});

/* REST OF THE CODE */

RecursiveCharacterTextSplitter jest wystarczający, ponieważ w tym przypadku skupiamy się na pojedynczym, dużym podsumowaniu które wykracza poza limit. Splitter dzieli tekst rekurencyjnie — od zdań do słów — starając się zachować możliwie jak najdłuższe fragmenty.

Teraz możemy zdefiniować funkcję do dzielienia listy:

typescript

/* REST OF THE CODE */

export async function splitSummariesByTokenLimit(
  summaries: string[],
  tokenLimit: number,
): Promise<string[][]> {
  const listOfSummariesSublists: string[][] = [];
  let sublist: string[] = [];
  for (const summary of summaries) {
    const chunks = await recursiveTextSplitter.splitText(summary);

    for (const chunk of chunks) {
      const candidateList = [...sublist, chunk];
      const candidateTokens = await lengthFunction(candidateList);
      if (candidateTokens > tokenLimit) {
        if (sublist.length > 0) {
          listOfSummariesSublists.push(sublist);
          sublist = [];
        }
      }
      sublist.push(chunk);
    }
  }
  if (sublist.length > 0) {
    listOfSummariesSublists.push(sublist);
  }
  return listOfSummariesSublists;
}

/* REST OF THE CODE */

Funkcja wygląda dość skomplikowanie, ponieważ zadanie nie jest trywialne. Funkcja Iteruje po każdym streszczeniu, automatycznie dzieląc go na mniejsze fragmenty, jeśli jego długość przekracza limit podlisty. Każdy potencjalny fragment jest następnie dodawany do listy kadnydującej. Liczba tokenów tej listy kadnydującej jest obliczana za pomocą funkcji lengthFunction, zdefiniowanej wcześniej. Jeśli długość przekroczy limit, bieżąca podlista zostaje dodana do tablicy wynikowej i rozpoczynana jest nowa podlista. W przeciwnym razie fragment jest dodawany do bieżącej podlisty.

Potrzebujemy jeszcze jednej funkcji, aby skompresować podsumowania:

typescript

/* REST OF THE CODE */
import { mapTemplate, reduceTemplate } from "./prompts";

/* REST OF THE CODE */

async function reduceSummariesBatch(listOfSummaries: string[][]) {
  const result = await model.batch(
    listOfSummaries.map((summaries) => [
      {
        role: "user",
        content: reduceTemplate(summaries.join("

")),
      },
    ]),
  );
  return result.map((res) => res.content as string);
}

/* REST OF THE CODE */

Funkcja wykonuje równoległe wywołania do LLM w celu podsumowania każdej podlisty. W efekcie lista podsumowań staje się krótsza.

Teraz możemy zdefiniować główną funkcję do rekurencyjnego kompresowania podsumowań:

typescript

/* REST OF THE CODE */

async function collapseSummaries(
  summaries: string[],
  recursionLimit = 5,
  iteration = 0,
) {
  console.log("Collapsing summaries...");
  if (summaries.length === 0) {
    return [];
  }
  const splitDocLists = await splitSummariesByTokenLimit(summaries, CHUNK_SIZE);

  const results = await reduceSummariesBatch(splitDocLists);

  let shouldCollapse = await checkShouldCollapse(results);
  if (shouldCollapse && iteration < recursionLimit) {
    console.log("Token count exceeds limit, collapsing summaries further...");
    return collapseSummaries(results, recursionLimit, iteration + 1);
  }
  return results;
}
/* REST OF THE CODE */

W tej funkcji możemy łatwo i przejrzyście zorganizować każdy krok kompresowania listy, ponieważ nasza złożona logika jest schludnie zapakowana w opisowe funkcje. Najpierw dzielimy listę podsumowań na podlisty. Następnie redukujemy każdą podlistę w partii przy użyciu LLM. Kolejno sprawdzamy, czy zredukowana lista mieści się w oknie kontekstowym. Jeśli nie, wykonujemy kolejną kompresję; w przeciwnym razie, lub gdy osiągnięto limit rekurencji, po prostu zwracamy zredukowaną listę.

typescript

/* REST OF THE CODE */

export async function summarizeDocuments(
  documents: LocalDocument[],
  maxIterations = 5,
) {
  const formattedDocs = documents.map(
    (doc) =>
      new Document({
        pageContent: doc.content,
        metadata: {
          title: doc.title,
          link: doc.link,
          date: doc.date,
          source: doc.source,
          selector: doc.selector,
          index: doc.index,
        },
      }),
  );

  let summaries = await runMappers(formattedDocs);

  const shouldCollapse = await checkShouldCollapse(summaries);
  if (shouldCollapse) {
    summaries = await collapseSummaries(summaries, maxIterations);
  }
}
/* REST OF THE CODE */

Ostateczna Redukcja

Po wykonaniu łączenia i upewnieniu się, że lista zmapowanych podsumowań nie przekracza limitu okna kontekstowego, możemy zredukować podsumowania do jednej skonsolidowanej, ostatecznej wersji listy.

W tym celu używamy promptu reduceTemplate, którego używaliśmy wcześniej podczas łączenia podlist podsumowań.

Zdefiniujmy funkcję do redukcji pojedynczej listy podsumowań:

typescript

/* REST OF THE CODE */
async function reduceSummaries(summaries: string[]) {
  const result = await model.invoke([
    {
      role: "user",
      content: reduceTemplate(summaries.join("

")),
    },
  ]);
  return result.content as string;
}
/* REST OF THE CODE */

Wcześniej robiliśmy coś podobnego, kompresując każdą podlistę podsumowań w partiach. Teraz redukujemy tylko jedną listę naraz.

Ostatnią rzeczą, którą powinniśmy zrobić, jest wywołanie tej funkcji w głównej funkcji podsumowującej:

typescript

/* REST OF THE CODE */

export async function summarizeDocuments(
  documents: LocalDocument[],
  maxIterations = 5,
) {
  const formattedDocs = documents.map(
    (doc) =>
      new Document({
        pageContent: doc.content,
        metadata: {
          title: doc.title,
          link: doc.link,
          date: doc.date,
          source: doc.source,
          selector: doc.selector,
          index: doc.index,
        },
      }),
  );

  let summaries = await runMappers(formattedDocs);

  const shouldCollapse = await checkShouldCollapse(summaries);
  if (shouldCollapse) {
    summaries = await collapseSummaries(summaries, maxIterations);
  }
  const finalSummary = await reduceSummaries(summaries);
  console.log("finalSummary", finalSummary);
}

Podsumowanie

Po zaimplementowaniu naszej aplikacji możemy wyraźnie zobaczyć, jak wzorzec Map-Reduce sprawdza się w świecie generatywnej sztucznej inteligencji. Dzięki temu wzorcowi możemy pokonać ograniczenia okna kontekstowego LLM'a. W tworzeniu oprogramowania proste, sprawdzone w boju rozwiązania, wynalezione wiele lat temu, mogą działać doskonale w połączeniu z nowymi, przełomowymi technologiami, takimi jak AI. Pokazuje to, że zanim zdecydujemy się na nową technologię, powinniśmy zapytać, jakie wzorce możemy zastosować, aby przezwyciężyć jej ograniczenia. Nie bójmy się korzystać ze sprawdzonych wzorców i algorytmów i łączyć je z kodem wykorzystującym najnowocześniejsze technologie.

Jeśli chcesz dowiedzieć się więcej o oknie kontekstowym, sprawdź mój artykuł o zarządzaniu oknem kontekstu w GPT-4o-mini.

Również sprawdź repozytorium naszego blog summarizera na GitHubie.

typescript and javascript logogreg@aboutjs.dev

©Grzegorz Dubiel | 2025