DEV Community

Cover image for Two databases, one question: how I built Eligible
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

Two databases, one question: how I built Eligible

A government benefits finder that answers one sentence, and shows its work.


Type this:

I am a 34 year old tailor in Hyderabad, I make about 2.4 lakh a year, two kids in school

You get back 488 Indian government schemes you actually qualify for. Each card names the rule that let it through. Underneath, 72 schemes that came close, each one naming the exact number that stopped it: "Your ₹2.4 lakh is ₹10,000 over the ₹2.4 lakh ceiling."

That is Eligible. It reads 2,066 real schemes from the MyScheme corpus: 527 central, 1,539 state, 80 issuing ministries and departments, 13.4 million characters of eligibility prose written by clerks, not copywriters.

This post is about the design decision that made it work, and the three bugs that nearly broke it.


The wrong first design

The obvious build is one table, one search, one list.

schemes: name, summary, state, income_ceiling, min_age, max_age
Enter fullscreen mode Exit fullscreen mode

Ask for everything, filter by income and age, done in an afternoon.

It fails immediately, and the failure is interesting. A tailor in Hyderabad is not searching for the word "tailor". He needs the scheme written for "artisans and weavers engaged in the handloom sector". There is no keyword overlap at all. Full text search returns nothing, and the person who most needed the scheme goes away thinking it does not exist.

So the first half of the problem is meaning, and meaning wants embeddings.

But the second half is arithmetic, and embeddings are terrible at arithmetic. A cosine similarity score of 0.83 says nothing about whether ₹2,40,000 sits under a ₹2,50,000 ceiling. Filtering 80 float scores in application memory means the database did nothing and the process did everything, which is exactly what the "how deliberately did you use the database" question is asking about.

Eligibility is two questions. So it gets two databases.

                        browser (React 19 + Vite)
                                     │  POST /api/search
                                     ▼
                        ┌────────────────────────────┐
                        │  server/app.ts             │
                        └──────────────┬─────────────┘
                       ┌───────────────┴────────────────┐
              parseProfile()                    evaluate(scheme, profile)
              optional LLM                      pure, no I/O
                       │                                │
        ┌──────────────▼─────────────┐     ┌────────────▼──────────────┐
        │  VectorsDB                 │     │  TablesDB                 │
        │  "does this mean me?"      │     │  "can they qualify?"      │
        │  slug + embedding          │     │  23 typed columns         │
        │  cosine, top 80            │     │  5 indexes                │
        └──────────────┬─────────────┘     └────────────┬──────────────┘
                       │  slug                        │  slug
                       └───────────► buildResult() ◄──┘
Enter fullscreen mode Exit fullscreen mode

VectorsDB holds one document per scheme: the slug and the embedded eligibility paragraph. Nothing else. TablesDB holds the arithmetic: jurisdiction, level, minimum and maximum age, income ceiling, benefit amount, document count, women-only and disability-only flags. A slug joins them.

The rule I keep coming back to: if both databases stored the income ceiling, there would be two sources of truth for the same fact, and the day they disagreed the app would start lying. The vector store knows meaning. The table knows facts.


Reading 2,066 schemes without inventing thresholds

server/extract.ts turns prose into typed facts. The hard part is not parsing. It is deciding what to do when the document says nothing.

A wrong threshold is worse than a missing one. If a scheme states no income ceiling and the parser writes 0, that scheme silently excludes every human on earth. If it writes Infinity, nobody notices a broken parse until a ministry changes the rules. So the parser returns whether a rule was stated, alongside the value:

export function parseIncomeCeiling(text: string): { ceiling: number; stated: boolean; clause: string } {
  const parts = sentences(text);
  let best: { ceiling: number; clause: string } | null = null;
  for (const sentence of parts) {
    const low = sentence.toLowerCase();
    if (!low.includes('income')) continue;
    const re = /(?:₹|rs\.?|inr)?\s*(\d[\d,]*(?:\.\d+)?)\s*(crore|cr\b|lakh|lac|lacs|lakhs|thousand|k\b)?/gi;
    let m: RegExpExecArray | null;
    while ((m = re.exec(sentence))) {
      const value = parseRupees(m[0]);
      if (!value) continue;
      if (value < 5000) continue;
      if (value > 50_000_000) continue;
      if (!best || value > best.ceiling) best = { ceiling: value, clause: sentence.trim() };
    }
  }
  if (!best) return { ceiling: NO_INCOME_CEILING, stated: false, clause: '' };
  return { ceiling: best.ceiling, stated: true, clause: best.clause.slice(0, 260) };
}
Enter fullscreen mode Exit fullscreen mode

Three details matter more than they look:

  • The sentence filter. Only lines containing "income" are considered. Spend ceilings, project costs and loan amounts all say "lakh" and are not income limits.
  • The best rule. When a paragraph states several amounts, the parser takes the highest under "income". A scheme that says "up to ₹2 lakh, ₹2.5 lakh for SC/ST" admits everyone at 2.4 lakh if you take the wrong one. Taking the maximum is the choice that broadens access, which is the safer error for a benefits tool.
  • The clause. Every ceiling keeps the sentence it came from, so the UI can show the government's own words instead of a number with no provenance.

The same shape applies to age bands and to the two flags that cannot be guessed: womenOnly and disabilityOnly. Those are computed with left anchored regexes, because the first version matched male inside female and turned a women's scheme into a men's scheme.


The engine, and the comment that is a bug report

server/rules.ts is a pure function. No database, no network, no model. That is deliberate: the decision is the part that has to be auditable, so it is the part with no dependencies.

// Central schemes apply everywhere, however the row spells it: a table that
// stores `level = central` must not be judged by the string in jurisdiction.
const everywhere =
  scheme.level === 'central' || scheme.jurisdiction === 'All India' || scheme.jurisdiction === 'Central' || !scheme.jurisdiction;

if (!everywhere && profile.state) {
  if (scheme.jurisdiction === profile.state) {
    reasons.push(`${scheme.jurisdiction} scheme`);
    score += 0.2;
  } else {
    blockers.push({
      field: 'state',
      detail: `Only for people in ${scheme.jurisdiction}`,
      gap: `${profile.state} is not ${scheme.jurisdiction}`,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

That comment is me explaining a bug I shipped. The first version compared jurisdiction strings. It worked for the local fixture, because ingest.ts writes All India for central schemes. Then the rows landed in Appwrite and a few came back with jurisdiction: Central and level: central. Every one of those schemes was rejected for a Telangana user, correctly spelled and completely wrong. level decides now. The string is decoration.

The second rule is the one that keeps the tool honest:

A scheme is only rejected on a field it actually states.

Every blocker carries a gap string the card prints verbatim. That is why a near miss reads "Your income, ₹2,40,000, is above the ₹1,00,000 ceiling" instead of "not eligible". The number is the product. People do not want a verdict, they want to know how far away they are.


The join, and what the vector store is not allowed to hold

export async function semanticCandidates(text: string, limit = 80): Promise<Hit[]> {
  const { vectors } = services();
  const [vector] = await embed([text]);
  if (!vector) return [];
  const list = await vectors.listDocuments({
    databaseId: VDB,
    collectionId: V_COLLECTION,
    queries: [Query.vectorCosine('embedding', vector), Query.limit(limit)],
  });
  return (list.documents as unknown as Record<string, unknown>[]).map((doc) => ({
    slug: String(doc.slug ?? doc.$id),
    similarity: readSimilarity(doc),
  }));
}
Enter fullscreen mode Exit fullscreen mode

Eighty candidates by cosine similarity. Then the table layer runs its own predicate query, and the union of the two goes to the rules engine. Neither layer decides anything, which is the point: recall and truth are separate jobs, and the engine is the only thing allowed to say yes.

A request looks like this end to end:

semanticCandidates 80   tableCandidates 500   union 560
killedByRules 72        matched 488           total 95 ms
Enter fullscreen mode Exit fullscreen mode

Those are real numbers from the demo sentence on the real corpus, printed in the UI on every search. I would rather ship a receipt than a spinner.


Three bugs worth writing down

1. Invalid query: Syntax error. The setup script passed queries: ['limit(1)'] and this SDK version wants Query.limit(1). The app code had it right; only the scripts were wrong. Half an hour lost to a raw string.

2. Cannot read properties of undefined (reading 'length'). ingest.ts writes a plain array of 2,066 schemes. The setup script expected { rows, embedText, meta }. Every schema statement succeeded, then the first row call read .length off undefined. The fix reads either shape, because a fixture format that changes should not look like a database outage.

3. A blank screen with a valid key. A project can be configured and empty. No tables, no rows, no vectors. The honest version of that state is a fallback that says so on the receipt:

if (rows.length === 0 && hits.length === 0) {
  const result = searchLocal({ text, profile: merged, parsedBy });
  result.receipts.vectorLayer = 'local fallback: Appwrite returned no rows (run npm run verify:appwrite)';
  res.json(result);
  return;
}
Enter fullscreen mode Exit fullscreen mode

A wrong key should degrade with a label, not a white page.


The UI is the argument

Two screens. One question, one answer.

The answer opens with a funnel, not a sentence: 560 shortlisted → 80 by meaning → 488 you qualify for → 72 close calls. Four pills and a note explaining that 20 schemes came from meaning alone and 72 were killed by a rule. The count is a story about how the decision was made.

Type is 18px at the root, every interactive target is at least 44 pixels, contrast clears AA, and there is not a single gesture to discover. The person this tool is for may be 60, may be reading on a three year old Android phone, and should not have to learn an interface to find out whether a scheme can pay for a school year.

The long tail is collapsed into one panel: 464 matches behind a single click, counted in the Playwright run so it cannot silently regress.


What it costs, honestly

TablesDB on shared compute is free. VectorsDB needs dedicated compute, about $10/month, because an HNSW index keeps its graph in memory. The setup script creates the table layer first and the vector layer last, and if the project cannot host the second one it says so and leaves you a working app with lexical recall. The receipt then reads semantic: lexical. It never claims a vector search it did not run.

That is the whole rule of the project. If the tool cannot do the thing, it says what it did instead.


What I would build next

  • Deadlines. Most schemes open in windows. A closesOn column turns a search result into a calendar.
  • Follow up questions. "Two kids in school" should re-rank the list, not clear it.
  • Document checklists. The data knows how many documents a scheme needs. It does not yet know which ones.
  • Telugu and Hindi. The interface is the easy half; 2,066 cleaned summaries is the real work.

Two databases, one question, and a receipt on every answer. That is the whole project.

Code & more: https://www.dailybuild.xyz/project/298-eligible

Top comments (0)