GTM OS

Tech

Retrieval in the browser. The model is optional.

No key, no vector database, no indexing job. The documents you drop in are chunked and scored in the tab you are sitting in, and the answer is built from what came back. A hosted model improves the write-up when a key is set, and the tool works without one.

01 · Pipeline

Five stages, one file doing most of the work.

  1. Stage 01

    Split at the headings

    Documents are cut at every heading below the title. The document control block is skipped so metadata does not pollute the text. A passage flushes at roughly 780 characters so nothing gets long enough to dilute its own score.

    chunkDocument()lib/rag.ts
  2. Stage 02

    Keep the line numbers

    Every passage records the first and last line it came from. This is the smallest decision on the page and the one everything else depends on: without it, a citation can only quote a snippet back at you instead of opening the file at the right place.

    lineStart, lineEndlib/rag.ts
  3. Stage 03

    Score with BM25

    Term frequency weighted by how rare each word is across the set, divided by how long the passage is. Words that land in a heading get a bonus, and adjacent query words appearing together get another.

    retrieve()lib/rag.ts
  4. Stage 04

    Read what came back

    The question picks a lens. Money questions pull every figure out of the retrieved text and key them on the amount alone, so $999 and $999/month are not counted as two prices. Risk questions scan for absolute wording. Both are computed from the loaded documents, so uploads behave the same way the demo does.

    moneyFigures()riskyPhrases()
  5. Stage 05

    Write the answer

    With no key, the summary is assembled from what stage four found and the passages it came from. With a key, those same passages go to the model with instructions to cite only the numbers it was given. The header says which one you are looking at.

    buildLocalAnswer()app/api/ask/route.ts
02 · Scoring

Why the long passage stopped winning.

The first version counted how often a query word appeared and weighted it by how rare the word was. Ask “Is our pricing consistent?” and it returned a product-marketing passage, because that passage was long and said “pricing” several times. Length normalisation was the fix.

score(passage, query) = Σ idf(term) × (f × (k₁ + 1)) ÷ (f + k₁ × (1 − b + b × len ÷ avglen))
idf(term) = log(1 + (N − n + 0.5) ÷ (n + 0.5))
k₁ = 1.4 b = 0.72
fhow often the term appears in this passage
nhow many passages contain the term at all
Ntotal passages in the loaded set, 95 for the demo
bhow hard length is punished, 0 for none and 1 for full

Same question after the change: the two conflicting figures come back first, from the pricing notes and the sales call notes.

03 · Two paths

The same passages either way.

Local · no key needed
  1. 01 Chunk and score in the browser
  2. 02 Pull figures and risky wording from the top passages
  3. 03 Assemble the summary from what was found
  4. 04 Label the answer “Local”
Model · OPENAI_API_KEY set
  1. 01 Chunk and score in the browser, identically
  2. 02 Post those passages to /api/ask
  3. 03 Model writes it up, citing only the given numbers
  4. 04 Label the answer “Model”

Retrieval is the same in both paths, so the citations point at the same lines either way. Your files never leave the browser. Only the matched passages are sent, and only when a key is configured.

04 · Calls made

Decisions worth defending, and what each one costs.

BM25 rather than embeddings

Embeddings need an API key, a vector store, and an indexing step before the tool does anything at all. BM25 runs on the documents you just dropped in, with no key and no wait, which is what makes the demo openable by someone who has 30 seconds.

Cost
Loses on synonyms. Ask about "cost" when the docs say "price" and it will do worse than a vector search would.

Line ranges rather than quoted snippets

Most RAG demos print the retrieved chunk under the answer. That still asks you to trust that the chunk is what the file says. Carrying line numbers means a citation opens the real document, scrolled and marked.

Cost
Chunking has to survive contact with the raw file, so the reader renders from source lines instead of cleaned text.

IntersectionObserver rather than ScrollTrigger

Anchor jumps, restored scroll positions, and find-in-page all move the page without a normal scroll sequence. A scroll-driven trigger can miss those and leave a section stuck at opacity zero. The observer fires on any of them.

Cost
No scroll-linked scrubbing. Fine here, since nothing on the page is tied to scroll progress.

The motion script sets the hidden state

If CSS hides content and JavaScript reveals it, a script that fails to run leaves a blank page. Here the start state is set on mount, so the worst case is a page that appears without animating.

Cost
Elements already in view when the script mounts animate immediately rather than on entry.

05 · Stack and numbers

What it is made of.

Next.js App RouterReact 19TypeScriptPlain CSS, no frameworkOKLCH colourGSAPLenisOpenAI (optional)Vercel
  • Landing page: 139 kB first load, including GSAP and Lenis. The tool: 113 kB. Both prerender as static.
  • Demo set: 7 documents, 95 passages, indexed on click with no network request.
  • Type and colour are one system. Every neutral is OKLCH tinted toward the cobalt brand hue, so surfaces and accent agree instead of fighting.
  • Still missing for production: accounts, a real vector store, permissions, connectors to Drive and Notion, background re-indexing, and scoring against a fixed question set.
Open the toolSee how it works