← HomeRead as a page
01sankettambare.in · v1 → v17.1

Ask the Archive

How years of books, races, forts, essays and short posts were gathered, moved into one database, and made answerable in a sentence — with readers grading the answers and one admin dashboard controlling all of it.

2,702
indexed chunks
1,661
micro posts
100,199
words of essays
$0
per month
Ask the Archive
02The problem

Every page could search itself. None could search another.

Six filter boxes — books, projects, micro posts, races, treks, tags — each scanning its own array in the browser. They all worked; none talked to each other.

booksforts?
“Which forts did I climb the year I read the most Marathi non-fiction?”
Needs

Two tables

Treks store dates as DD-MM-YYYY text; books store the year read as an integer.

Needs

A definition

“Most” is an aggregate. No filter box counts.

So

A conversation

That is not a filter. It is a question — and it needed a place to go.

03Part I · The records

The data came first — years before the feature

Nothing here was collected for a chatbot. It accumulated: a Tumblr habit, a reading log, race certificates, fort treks, a writing challenge.

20182026
Micro posts1,661 total23223053528011256665892Essays published104 total795641261Books read51 total2145371910Races run25 total1879Forts trekked20 total36335201820192020202120222023202420252026

Micro posts peaked at 535 in 2020; essays exploded in 2026 with 61 of the 104. Every bar is a row a question can now reach.

04Part I · What each record is

Structured, but never in the same shape twice

Each type arrived on its own terms. The quirks are the reason one generic search could never work — and the reason the chatbot reads a hand-written brief on what fields mean.

Micro posts · 1,661

photo953
text471
quote237

97% imported from Tumblr; many are reblogs — passing thoughts, not positions.

Books · 51

English30
Marathi21

year is the year read, not published — half the dates are reconstructed, so date_precision exists.

Races · 25

10 km3
21 km18
35 km1
42 km2
50 km1

Distance is a text label (“21 Kms”, “21K”), dates read “February 22, 2026”.

Treks · 20

Easy6
Medium11
Hard3

Dates are DD-MM-YYYY — a different format from races, for historical reasons.

05Part I · The Writing Ledger

100,199 words, and most of them recent

104 essays on Substack and WordPress since 2020. Only 60 have a row in the blogs table — the rest lived only on the platforms, until v17.1 pulled in their full text.

20202026
6.3k2020 · 71.9k2021 · 92.3k2022 · 53.9k2023 · 63.1k2024 · 415.5k2025 · 1267.3k2026 · 61

By platform

Substack75,514
WordPress24,685

Substack 76 posts · WordPress 28 posts. Busiest month: June 2026, 12 posts. Longest: The Un-Orthodox Ways of Seeing!, 3,529 words.

06Part II · One place

From files in a repo to one Postgres

For two years the content was JavaScript data files, then markdown behind a CMS. On 13 June 2026 it all moved into Supabase — and every later feature, including the chatbot, stands on that move.

one database

Before · v1 → v7

  • src/data/*.js hand-edited, then generated from markdown
  • Decap CMS writing commits; a CI gate failing on drift
  • Images in the repo — 114 MB before compression
  • 12 posts, 4 books and 1 trek existed only in generated files

After · v9 → today

  • Supabase Postgres is the single source of truth
  • Row-Level Security: public read, owner-only write
  • Images in a public storage bucket, compressed on upload
  • A schema-driven admin at /admin writes every table

v8.0 (Vite), v9.0 (Supabase), v9.1 (CMS pipeline removed) and v10.0 (1,600-post Tumblr import) all shipped the same day: 13 June 2026.

07Part II · The database

Fifteen content tables, one tag axis, one search index

Tags and search are both polymorphic — an entity_type plus an entity_id — so a book, a race and a micro post share one tag list and one index.

tags
CONTENT · 15 TABLESmicroblog1,661blogs60books51sports25treks20projects13instagram11now_months · now_meta12 · 1resume · degrees6 · 2certs · skills12 · 30OUTSIDE POSTGRESabout.md · changelog.mdfilesSubstack · WordPress104 postsSHARED AXIStags213tag_associationsany typemedia bucketphotosSEARCH LAYERcontent_chunks2,702 · 13 typeshybrid_search()RRFsite_facts()countsrelated_content_ranked()similarindexer · nightly · hash-diffedASK OPERATIONSask_settings1 row · the controlsask_usagequota, fails closedask_conversationsper browserask_messages60 rows · feedbackask_log() · ask_feedback()RPCs
08Part II · Rules held by Postgres

The database, not React, keeps the promises

Every guarantee the chatbot relies on is enforced below the application, so no code path can forget it.

owner only
v15 · 0005

Drafts stay drafts

The projects read policy is visible or is_owner(): a hidden project never reaches a page, a tag page, a share card — or the index.

v14 · 0003

Tags are central

213 tags in one table, linked through tag_associations; set_entity_tags() replaces a row's set atomically.

v15–v16

Dates are real

Project dates became a Postgres date; book finish dates carry a precision. Sorting stopped lying.

v17 · 0012

The log is owner-only

Visitors write the conversation log through a definer function and can never read it back.

09Part III · Ask the Archive

A question in, grounded answers out

At /ask and in a launcher on every page. English or Marathi. Sources arrive first, prose streams after, and every name links back to its row.

askanswer
What are his running PBs?

Across 25 races and 577 km, his half-marathon best is 1:59:57 at NMDC Hyderabad Marathon 2025 Race1, and his marathon best is 05:05:53 at Tata Mumbai Marathon 2026 Race2. He wrote about the training on sankettambare.substack.com ↗3.

1 · Race2 · Race3 · Essay
CopyPermalinkImage▲ Good▼ PoorSuggest

Rendered the way v17.1 renders an answer — site links as chips labelled with their type, outside links with their domain.

10Part III · How a question travels

Six stations, about four seconds

Measured on a logged exchange: retrieval 1.81 s, generation 2.39 s, 4.20 s end to end. Logging happens after the answer is on its way, so it can never slow one down.

about 4 seconds
01QuestionTurnstile tokenask_quota() cap02Embedbge-m3 · 1024 dimsskipped if no budget03Retrievehybrid_search()keyword + meaning04Groundfacts card/api/stats snapshot05Generate4-rung ladderhistory ≤ 8 turns06Clean + streamallow-listed linksSSE, sources firstafter the answer is sent · waitUntil() → ask_log() writes the exchange with tier, model, timings and a message idfails before the first token → next rung · fails after → keep what was written
11Part III · Retrieval

Two searches, one ranking

Keyword search knows “Ghangad” is a word. Vector search knows “forts near Pune” means Ghangad. Both run, and Reciprocal Rank Fusion adds up 1 / (k + rank) from each list.

wordsmeaningone ranking
half 1

Keyword

tsvector with the simple config — stemming Devanagari is worse than not stemming. Terms ORed, prefix-matched (trek:*).

half 2

Meaning

pgvector with HNSW over @cf/baai/bge-m3, multilingual, 1024 dimensions.

fallback

Degrades in place

No embedding budget? Pass a null vector: the same function becomes keyword-only. No second code path to rot.

12Part III · The facts card

Vectors cannot count

“How many books?” returns eight books that feel like the question. So every prompt carries a card of aggregates — the same numbers the /stats page shows, from one hourly snapshot.

51counts
{ counts: { books: 51, microblog: 1661,
          blogs: 60, projects: 13,
          sports: 25, treks: 20, tags: 213 },
  books: { by_language: { English: 30, Marathi: 21 } },
  writing: { words: 100199, posts: 104,
             current_period: …recomputed today },
  site_stats: …every figure on /stats }

One module

siteStats.js computes every /stats figure; the page, the facts card and the index all import it.

Once an hour

GET /api/stats is edge-cached for 3,600 s. The chatbot and the page cannot disagree.

13Part III · Generation

Four rungs, and the last one always holds

Tried top to bottom. Order, model ids, timeouts and switches are rows in ask_settings — when a model was retired mid-build, the fix was editing a field.

search-only
  1. Gemini Flash-Litebest quality · falls over on 429, 5xx or a 6 s timeout
  2. Workers AI · gpt-oss-20bno key, runs beside the worker
  3. Workers AI · llama-3.2-3bsame neuron pool, about five times cheaper
  4. Search-onlythe matching pages and one honest line — never an error page

Every one of the 30 answers logged so far came from rung one.

14Part III · Guardrails

It cannot link to anything it did not retrieve

The micro-post archive is full of reblogged third-party text pasted into every prompt. So retrieved rows are fenced as data, and any URL or image not in a retrieved row is stripped — in the worker and again in the page.

retrievedstripped

What the model wrote

PB **1:59:57** at [NMDC Hyderabad
Marathon 2025](/sports/15) [1].
Read more at [this link](https://evil.example/x).
![Finish](https://evil.example/pixel.png)

What the reader sees

PB 1:59:57 at NMDC Hyderabad Marathon 2025 Race1. Read more at this link.

The words stay; the invented URL and the tracking pixel are gone.

Caps fail closed

Global and per-visitor daily caps counted in Postgres over a salted IP hash. If the quota check is down, the request is refused.

Turnstile recovers

Every path waits for a fresh token; a rejected one offers Verify and retry.

Data, never instructions

Retrieved items sit inside delimiters the system prompt declares inert.

15Part IV · What it knows

2,702 chunks across thirteen kinds of content

v17.0 indexed nine types and saw an essay only as its title. The audit found five sources the chatbot could not see — the résumé, full essay text, every stats figure, tag notes, the site map. v17.1 indexes all of them.

tablesessaysrésuméstatssite pagesindex
micro posts1,670
100 Days posts + text455
other essays, full text · new221
about + changelog161
books51
races25
projects24
résumé · new21
treks20
site pages + contact · new20
now months13
instagram11
stats chapters · new10

Orange rows arrived in v17.1. The blog row grew from 60 metadata chunks to 455 when every tracked post's text was added. A tag source is built too, waiting on tag descriptions.

16Part IV · Plug-in sources

A new source is one file

Every module in scripts/ask-sources/ is a source. Since migration 0016 the index accepts any well-formed type name, so there is no migration per source and no registry to edit.

one file
// scripts/ask-sources/reading_list.mjs
export default {
  type: "reading_list",
  load: (ctx) => ctx.fetchAll("reading_list"),
  toChunks: (rows, { compose }) => rows.map((r) => ({
    entity_type: "reading_list",
    entity_id: r.id,
    chunk_index: 0,
    title: r.title,
    url: "/reading-list",
    body: compose([["Reading list", r.title], [null, r.note]]),
  })),
};

A failing source keeps its chunks

If load() throws, that type is skipped for deletion — a Substack outage cannot empty the index.

Unknown types still render

The chat labels a type it has never seen with a title-cased name.

15 sources today

book, blog, microblog, project, sport, trek, instagram, now, page, resume, writing, stats, tag, site.

17Part IV · Incremental indexing

Only genuinely new text is embedded

Timestamps lied twice: a CI checkout resets file times, and the changelog grows at the top, renumbering every chunk. Hashing the text fixed both.

unchangednew text
KeyHashOutcomeEmbeds?
samesameunchanged — skippedno
newseen elsewheremoved — copy the vectorno
newnewnew — embed, upsertyes
only in DB—gone — delete, unless its source failedno

The migration run

1,868 reused825 new

2,693 chunks planned; 825 embedded (~210K tokens, about $0.003 at list price). The next run: 0.

18Part IV · Keeping it current

Every night at 02:00, a quiet night costs nothing

A GitHub Action refreshes the Writing Ledger and the index. No new posts means no downloads, no embeddings, no commit and no deploy.

02:00
  1. Count every postblogs:wordcount lists Substack + WordPress and rebuilds the ledger JSON.
  2. Download only new bodiesText is cached by URL and version; the first run fetched 34 and reused 42.
  3. Hash every chunkEmbedded text and metadata get separate hashes.
  4. Embed only new textMoved text keeps its vector.
  5. Refresh the facts cardPushed into ask_settings.context_doc.
  6. Commit only real changesA new date alone never triggers a deploy.
19Part IV · Coverage check

“Indexed” was checked row by row

Production was compared against every source after the v17.1 run.

all in
1,661 / 1,661
micro posts indexed
104 / 104
essays with full text
100,369
words extracted vs 100,199 counted
0
chunks without a vector

The one real gap: two micro posts ran past the embedding model's 4,000-character window. Long posts are split on paragraphs now — the fix embedded 15 chunks and left 2,687 alone.

20Part V · Upvote, downvote, suggest

Readers grade every answer

Under each answer: thumbs up, thumbs down, and Suggest. A thumbs down or Suggest opens reason tags and a comment box. The verdict survives a reload.

likeddislikedsuggestionevals
▲ Good answer▼ Poor answerSuggest

What could be better?

Wrong factsMissing dataBad linksFormattingToo longOther
It missed the 50 km ultra.
Send feedback

Stored on the answer

Written onto that answer's ask_messages row, beside the tier, model, timings and sources — so a bad answer can be traced to its cause.

Only its reader can rate it

ask_feedback() updates a row only when the caller's session owns the conversation. Comments cap at 1,000 characters.

21Part V · Evals

Every verdict becomes a test case

The loop is live: 30 exchanges logged so far, 2 of them rated — one up, one down. Small numbers, honest signal.

goodpoor
AnswermessageId (UUID)returned with doneReader ratesthumbs up / downreason tags + commentask_feedback()session must own itretried once after 2 sask_messagesfeedback · tagscomment · feedback_atAdminfilter by ratingevals JSONL export"missing data" → ingest a new source · "wrong facts" → tighten the prompt
{"question":"When did he start running?","answer":"The micro posts show the build-up…",
 "tier":"gemini-flash-lite","keyword_only":false,"rating":-1,
 "tags":["missing data"],"comment":"Doesn't mention the first 10K race."}

An illustrative line in the export's shape — one JSON line per rated exchange.

22Part VI · The admin dashboard

Everything is controlled from /admin

One owner login. Content, tags, the Now page, the résumé and the whole chatbot are edited in one workspace — no redeploy for any of it.

capsmodelsweights

Schema-driven forms

Fields declared once in resources.js; date pickers, tag autocomplete, link lists, unsaved-changes guard.

Images compress themselves

Resized to ≤1200 px and ≤150 KB in the browser before upload.

Tags managed centrally

Rename, merge duplicates, recolour, export and import metadata.

23Part VI · /admin/ask/settings

Every dial of the chatbot is a form field

Read by the worker with a 60-second cache: a change is live within a minute.

on
Switches
Second brain enabled
Require Turnstile
Caps
Questions per day · everyonenumber
Questions per day · per visitornumber
Max question length500
Turns of history sent8
Retrieval
Items retrieved8
Keyword weightnumber
Meaning weightnumber
Model ladder
Label · provider · model · timeout · on/offreorderable
Usage panel · last 14 days by tierchart
Words
System prompt · when nothing matches · when the cap is hit · when switched off · suggested questionstext
24Part VI · /admin/ask/conversations

Every chat, every filter, one export

Each chat shown in full, newest first — a browser's questions stay together until it goes quiet for 30 minutes. The summary at the top follows whatever is filtered.

every chat
From · ToSearchFeedbackReasonTierModelProviderCited typeDegradedKeyword-onlyErrorsNo sourcesSession
14
filters
30 min
gap that splits chats
3
exports: CSV · JSON · evals JSONL
p50 · p95
latency by stage

Summary for the selection

Questions, chats, satisfaction, comments, latency by stage, tier and model mix, reasons, content cited, questions per day, and index coverage per type.

Pure functions underneath

Grouping, filtering and summarising run client-side over one date-ranged load, all unit-tested.

25Part VII · Release cadence

83 releases, and the curve bends in 2026

Every bar is a changelog version. Three years of occasional updates, then a summer where the site was rebuilt around its data.

2023–20252026
1202320241120251111202622232211511

June 2026 alone shipped 22 versions; September 2026, 11 — the data migrations and the chatbot.

26Part VII · How the main features arrived

2023 → 2026: a template became an archive

Major versions from the changelog, coloured by what they changed.

v1v17
site & pagesdata & databaseadminask the archive
  1. v1.0Forked a template; resume, stats, routing
  2. v1.2Instagram and videos pages
  3. v2.0Sports page — first race data
  4. v3.5Books page, personal stats data, dark mode
  5. v4.0Redesign; 100 Days To Offload tracker
  6. v5.0Changelog page; sports statistics
  7. v5.1Treks page; Cloudflare middleware
  8. v6.0First admin panel and Decap CMS
  9. v7.0Markdown as source of truth; CI drift gate
  10. v9.0Supabase: Postgres, RLS, storage, live data
  11. v10.0Micro Blog: 1,600-post Tumblr import, full-text search
  12. v11.0The Wanderer's Atlas — gamified homepage
  13. v12.2Blog word counts and the Writing Ledger
  14. v13.0Admin redesigned as a workspace
  15. v14.0Centralised tags across every table
  16. v15.0Projects reborn: real dates, visibility in RLS
  17. v16.0Books reborn: metadata, statuses, precision
  18. v17.0Ask the Archive: hybrid search, ladder, log
  19. v17.1Every source, incremental index, feedback, evals
27Part VII · The last ninety days

Data first, then the brain

Read the order: the chatbot is the last layer, not the first. Each step made the next one possible.

dataask
Jun 2026 · Move

v8 Vite · v9 Supabase · v9.1 storage, CMS removed · v10 Micro Blog import · v10.1 detail pages · v10.2 share as image

Jul 2026 · Show

v10.3 Substack RSS, monthly digest · v11 Atlas · v12 Stats Almanac · v12.1 Now auto-fill · v12.2 Writing Ledger · v13 admin workspace

Aug 2026 · Tidy

v13.1 images compress on upload · v13.2 canonical domain, classic default

Sep 2026 · Structure, then ask

v14 central tags · v15 projects · v16 books · v17.0 Ask the Archive · v17.0.1–2 streaming + Turnstile fixes · v17.1 sources, feedback, evals

28Closing · Cost

Still $0 a month

Free tiers are the design constraint. At list price one question would cost about $0.00037 — and the real ceiling is requests per day, which is why the caps sit below the free allowance.

$0
$0.005
embed the whole corpus once
11 MB
vectors, of 500 MB free
$0.00037
per question at list price
≈2,700
questions per dollar
29Closing · Next

What the evals will decide

The foundation is data, not the model. The next moves are chosen by what readers mark as wrong.

nextlater
cheapest

Search with context

Prepend the previous question when a follow-up is vague — retrieval currently reads the new message alone.

content

Describe the tags

The tag source is built and indexes nothing until descriptions are written.

bigger

A compiled digest layer

Maintained notes that answer what do I think, not only what did I say.

reuse

One search everywhere

Point the six page filters at the same hybrid search.

Try it at sankettambare.in/ask · the build log: Ask the Archive dossier · the audit