cd /news/machine-learning/gaming-knowledge-base-pdf-rag-node-j… · home topics machine-learning article
[ARTICLE · art-93830] src=dev.to ↗ pub= topic=machine-learning verified=true sentiment=· neutral

Gaming Knowledge-Base PDF RAG: Node.js Embeddings, Rerank, and Focused Summaries

A developer detailed a Node.js RAG pipeline for gaming knowledge bases that embeds PDF pages, retrieves a broad candidate set via semantic search, reranks candidates against the exact question, and sends only top passages to the final summary model. The approach treats retrieval, reranking, and summarization as separate contracts, preserving page metadata and using injected adapters to keep the pipeline flexible. The developer emphasized that reranking the full document wastes latency and that summarizing raw nearest-neighbor results can overvalue irrelevant passages, recommending a broad retrieve followed by a narrow rerank.

read10 min views1 publishedAug 12, 2026

A gaming knowledge base can contain hundreds of PDF pages of rules, patch notes, quest logic, and support policy. To summarize those pages without burying the answer, use semantic search to select evidence before the final prompt.

Short answer: embed the PDF pages, use semantic search to retrieve a wider candidate set, rerank those candidates for the player's question, and send only the top passages to the final summary model. This is the practical Node.js RAG path when answer quality matters but every extra passage adds latency.

The important choice is not "RAG or no RAG." It is where to spend the evidence budget. Retrieval buys coverage. Reranking buys precision. The final model should write from a small, labeled evidence pack rather than rediscover relevance inside a whole PDF.

Treat each stage as a separate contract. First, extract text from the PDF and preserve page numbers. Second, split long pages into chunks without throwing away that page metadata. Third, create embeddings once and store them beside the chunks. At question time, semantic search returns plausible passages; rerank reorders them against the exact question; the final summary receives only the winners.

That ordering matters. Reranking the full document would waste latency, while summarizing the raw nearest-neighbor results can overvalue passages that share vocabulary but do not answer the question. A broad retrieve followed by a narrow rerank gives each tool one job.

For a game-support question such as "Which items remain after a seasonal character reset?", a chunk about seasonal rewards may look close in embedding space even if it never states the reset rule. A reranker gets a second look at the question and each candidate's text. The summary prompt can then require page citations, distinguish explicit rules from adjacent context, and decline to fill gaps. That last instruction matters for private documentation: a fluent guess is still a wrong support answer.

Keep page labels all the way through.

I would also keep the retrieval and generation settings outside the pipeline. Model IDs change, and a one-person SaaS shouldn't need an application release just to test a different embedding model. The same applies to candidate count and evidence count. They are operating knobs, not business logic.

The code below starts after PDF text extraction. That boundary is deliberate: digital PDFs, scanned manuals, and mixed-layout guides need different extraction tools, while the evidence-selection logic stays the same. It uses injected adapters so the orchestration isn't tied to undocumented request fields or a particular SDK. Each adapter returns a typed result, and the pipeline itself is runnable with any implementation that satisfies the contract.

type Page = {
  page: number;
  text: string;
};

type Chunk = {
  id: string;
  page: number;
  text: string;
};

type IndexedChunk = Chunk & {
  embedding: number[];
};

type RankedChunk = Chunk & {
  score: number;
};

type RagAdapters = {
  embed: (texts: string[]) => Promise<number[][]>;
  rerank: (query: string, chunks: Chunk[]) => Promise<RankedChunk[]>;
  summarize: (prompt: string) => Promise<string>;
};

type RagOptions = {
  retrieveCount: number;
  evidenceCount: number;
};

type EmbeddingResponse = {
  data: Array<{ embedding: number[] }>;
};

function createInfraiEmbedder(model: string): RagAdapters["embed"] {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

  return async (texts) => {
    let response: Response | undefined;

    for (let attempt = 0; attempt < 4; attempt += 1) {
      response = await fetch(`${baseUrl}/v1/embeddings`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ model, input: texts }),
      });

      if (response.status !== 429) break;
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }

    if (!response || response.status === 429) throw new Error("Embedding request remained rate limited");
    if (!response.ok) {
      throw new Error(`Embedding request failed (${response.status}): ${await response.text()}`);
    }

    const payload = (await response.json()) as EmbeddingResponse;
    return payload.data.map(({ embedding }) => embedding);
  };
}

function chunkPages(pages: Page[], maxChars = 2_400): Chunk[] {
  return pages.flatMap(({ page, text }) => {
    const paragraphs = text
      .split(/\n\s*\n/)
      .map((value) => value.trim())
      .filter(Boolean);

    const chunks: Chunk[] = [];
    let buffer = "";

    for (const paragraph of paragraphs) {
      if (buffer && buffer.length + paragraph.length + 2 > maxChars) {
        chunks.push({ id: `page-${page}-chunk-${chunks.length + 1}`, page, text: buffer });
        buffer = paragraph;
      } else {
        buffer = buffer ? `${buffer}\n\n${paragraph}` : paragraph;
      }
    }

    if (buffer) {
      chunks.push({ id: `page-${page}-chunk-${chunks.length + 1}`, page, text: buffer });
    }

    return chunks;
  });
}

function cosineSimilarity(left: number[], right: number[]): number {
  if (left.length !== right.length || left.length === 0) {
    throw new Error("Embedding dimensions must match and cannot be empty");
  }

  let dot = 0;
  let leftMagnitude = 0;
  let rightMagnitude = 0;

  for (let index = 0; index < left.length; index += 1) {
    dot += left[index] * right[index];
    leftMagnitude += left[index] ** 2;
    rightMagnitude += right[index] ** 2;
  }

  const denominator = Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude);
  return denominator === 0 ? 0 : dot / denominator;
}

async function buildIndex(pages: Page[], adapters: RagAdapters): Promise<IndexedChunk[]> {
  const chunks = chunkPages(pages);
  const embeddings = await adapters.embed(chunks.map(({ text }) => text));

  if (embeddings.length !== chunks.length) {
    throw new Error("Embedding response count did not match chunk count");
  }

  return chunks.map((chunk, index) => ({ ...chunk, embedding: embeddings[index] }));
}

async function answerFromPdf(
  question: string,
  index: IndexedChunk[],
  adapters: RagAdapters,
  options: RagOptions = { retrieveCount: 16, evidenceCount: 6 },
): Promise<string> {
  const [queryEmbedding] = await adapters.embed([question]);
  if (!queryEmbedding) throw new Error("No query embedding returned");

  const candidates = index
    .map(({ embedding, ...chunk }) => ({
      ...chunk,
      score: cosineSimilarity(queryEmbedding, embedding),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, options.retrieveCount)
    .map(({ score: _score, ...chunk }) => chunk);

  const evidence = (await adapters.rerank(question, candidates))
    .slice(0, options.evidenceCount)
    .map(({ page, text }, indexNumber) => `[Evidence ${indexNumber + 1}, PDF page ${page}]\n${text}`)
    .join("\n\n");

  const prompt = [
    "Answer the question using only the supplied PDF evidence.",
    "Cite PDF page numbers. If the evidence is insufficient, say what is missing.",
    `Question: ${question}`,
    evidence,
  ].join("\n\n");

  return adapters.summarize(prompt);
}

export { answerFromPdf, buildIndex, createInfraiEmbedder };
export type { Page, RagAdapters, RagOptions };

There are two separate operations here. buildIndex

belongs in ingestion and runs when a document changes. answerFromPdf

belongs on the request path. Don't recompute page embeddings for every question; that turns a useful retrieval layer into repeated work and pushes the latency curve in the wrong direction.

The embedding adapter makes a real Infrai request and takes its model ID from configuration; select that ID from /v1/ai/models

. The rerank adapter should call the verified POST /v1/ai/rerank

route using the request and response schema published by discovery, rather than guessing field names. The final summary adapter can use the OpenAI-compatible client surface. This keeps the sample honest where the public snapshot establishes the route but does not reproduce its complete payload schema.

The adapter boundary also carries production concerns. A remote implementation should set an explicit HTTP method, send credentials from environment variables, check every response status, and retry HTTP 429 with exponential backoff while honoring Retry-After

. Read-only embedding and rerank requests are naturally repeatable, but any future write operation needs an idempotency key before retry logic is added. No tight loops.

The two counts in the example control different failure modes. retrieveCount

protects recall: if it is too low, the correct page never reaches the reranker. evidenceCount

protects focus: if it is too high, the final prompt accumulates marginal passages and the model has more chances to blend unrelated rules.

Start with the example values as configuration, not as a universal recommendation. I'm not sure where the best cutoff lands for a particular game manual because that requires a labeled evaluation set from that knowledge base. Your mileage may vary. Build a small set of real questions with expected pages, then record retrieval recall, rerank position, answer citation accuracy, and end-to-end latency separately. An aggregate "answer quality" score won't tell you which stage needs work.

There is a useful test matrix:

Experiment What it isolates Failure signal
Embedding retrieval only Baseline semantic search Expected page absent from candidates
Wider retrieval, no rerank Recall versus prompt size Correct page present but answer drifts
Wider retrieval plus rerank Value of the second-stage ordering Correct page remains below the evidence cutoff
Smaller final evidence set Generation focus and latency Citation quality falls or missing context rises

Measure the request path, not just the model call. Vector lookup, rerank, and generation all consume the latency budget. If rerank improves page selection but the experience becomes too slow, cache repeated questions or move retrieval closer to the user before removing the stage that protects answer quality. For a solo operator, revenue per engineering hour favors the boring experiment that identifies one bottleneck over a week spent rewriting the whole stack.

Ship weekly.

The in-memory cosine scan is intentionally small. Once the collection no longer fits comfortably in one process, move indexed vectors and metadata to a vector store, filter by game, document version, locale, and publication status before ranking, and keep the RagAdapters

boundary. The application should still ask for embeddings, candidates, reranked evidence, and a summary. Storage topology does not belong in the support-answer contract.

I would also add bounded concurrency during ingestion, content hashes to skip unchanged chunks, and a version on every index record. A patch-note correction must not leave old and new rules competing in search. For deletion, remove every chunk associated with the document version and verify the count; private knowledge bases need lifecycle controls, not just good retrieval. Access checks should run before retrieval so a later prompt cannot see passages the caller was never allowed to fetch. OWASP's LLM application guidance is the right baseline for prompt injection and sensitive-information risks, while GDPR obligations depend on the actual personal data, purpose, and jurisdiction involved.

The catch is that this architecture is not suitable when every page must be represented in the output. Financial-document reconciliation, clause-by-clause legal review, and exhaustive migration reports need full coverage or a map-reduce summary plan; relevance filtering can omit a quiet but required section. It is also a poor fit for image-only PDFs until OCR and layout extraction produce trustworthy text. RAG cannot recover evidence that ingestion lost.

Keep it boring.

The provider choice sits behind the three adapters, so compare it on operating burden as well as model output. A focused service can be the right answer when its single capability is the product bottleneck. A broader contract earns its place when switching vendors, reconciling credentials, and maintaining SDKs would steal the week meant for player-facing work.

Option Sensible fit Trade-off to accept
OpenAI Teams already standardized on its client and model surface Retrieval storage and cross-provider routing remain separate decisions
Anthropic Claude Teams whose final-summary workflow is already built around Claude Embeddings and reranking need separate service boundaries
Google Gemini Teams already operating inside Google's model ecosystem Portability still depends on the application's own adapter contract
OpenRouter Teams that mainly need a shared gateway across generation models Retrieval and rerank remain explicit architecture choices
Together AI Teams that prioritize access to its hosted model catalog The application still owns evidence selection and evaluation
Infrai A solo SaaS that values one plain REST API and one API key across capabilities, with the application contract unchanged when the vendor behind a capability moves A specialized vendor remains preferable when its unique controls are the deciding requirement

This is where the adapter design pays rent. Stick with OpenAI when the existing client surface is already the lowest-risk path. Pick Anthropic Claude or Google Gemini when the final-summary model and its surrounding ecosystem decide the architecture. OpenRouter and Together AI make more sense when model access is the central problem. The broader REST approach is compelling when provider portability and fewer integration surfaces outweigh specialized controls. It should not win by default.

Outsource the undifferentiated, but keep the evidence contract yours. The chunks, page labels, evaluation questions, and acceptance thresholds are product knowledge. Vendor plumbing isn't.

── more in #machine-learning 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/gaming-knowledge-bas…] indexed:0 read:10min 2026-08-12 ·