"I built Nyaya — an AI-powered legal platform for Indian law. The core problem I solved is that Indian legal information is complex and inaccessible. Most people don't know their rights, and lawyer consultations are expensive.
Nyaya has three main layers. First, a RAG-based AI chatbot — when a user asks a legal question, the system retrieves relevant sections from a database of 17 Indian Legal Acts (8,166 embedded chunks) using PostgreSQL full-text search and, when memory allows, pgvector semantic search fused with Reciprocal Rank Fusion. Those results are then reranked by Cohere's cross-encoder model, and the top chunks are fed as context into Groq's
groq/compoundreasoning model (withqwen/qwen3.6-27bfor fast classification/translation) to generate a grounded, cited answer.Second, a multi-role auth system — Citizens, Lawyers, Judges, and Admins each have different access levels. Lawyers must submit their Bar Council credentials, which admins manually verify. Third, a Lawyer Marketplace where verified lawyers can list their services.
The stack is Next.js 14 on the frontend, Node.js/Express with TypeScript on the backend, PostgreSQL with pgvector for the legal knowledge base, and Prisma as the ORM."
"I built this entire platform end-to-end as a solo developer. Key contributions:
- Designed and implemented the full database schema — 20+ Prisma models covering users, legal hierarchy (Acts→Sections→Clauses→Chunks), case management, payments, and subscriptions
- Built the entire auth system: JWT rotation, OTP via crypto module, Google OAuth redirect flow, bcrypt hashing, rate limiting
- Implemented the RAG pipeline from scratch: data ingestion scripts, embedding generation, hybrid search with RRF, Cohere reranking, and structured LLM prompting
- Built all 16 backend route modules and the complete frontend with 20+ pages
- Debugged a series of production-level issues including cross-origin cookie failures, Redis crash on startup, LangChain template parser conflicts with legal text"
"The most interesting challenge was making the AI answers reliable — preventing hallucination while maintaining response quality.
My first attempt used the
useChathook from@ai-sdk/reactfor streaming, but it had a state sync issue where the input would freeze after sending a message. I completely replaced it with a customfetch-based streaming implementation usingReadableStream.The second challenge was prompt design. I initially used LangChain's
ChatPromptTemplate, but legal text contains curly braces like{resolvedLabel}in section references, which LangChain's template parser mistook for unbound variables and threw errors. I switched to building a raw message array —[SystemMessage, ...history, HumanMessage]— and calling.invoke()directly.The third was production memory. The Python RAG service originally depended on a heavier embedding stack, and Render's 512MB instance ran out of memory. I switched to FastEmbed ONNX with lazy imports for full vector mode, and added a low-memory Render mode that disables local embeddings and uses Postgres full-text search plus Cohere reranking."
"Several improvements for production scale:
Database: Use native pgvector HNSW index for full semantic retrieval — O(log N) instead of O(N) — while keeping Postgres FTS as a reliable low-memory fallback. Also add Redis caching for frequent query embeddings.
AI Pipeline: Add response streaming via SSE or Server-Sent Events so the user sees tokens as they arrive instead of waiting for the full response. Add a query router (small fast model like llama-3.1-8b-instant) to classify query intent before hitting the expensive 70B model.
Infrastructure: The Express backend is fully stateless, so horizontal scaling behind a load balancer is trivial. Refresh tokens are in the database, not memory, so any instance can validate them. Move to Kubernetes with auto-scaling on CPU/memory.
Reliability: Add circuit breakers around Groq and Cohere APIs with graceful degradation — if Cohere reranking fails, fall back to pure hybrid scores. If Groq fails, return a cached similar response if available.
Observability: Add distributed tracing (OpenTelemetry) to track the full RAG pipeline latency per step: embedding, retrieval, reranking, generation."
"Each choice was deliberate:
- Next.js 14 App Router: SSR for SEO (legal information pages need to be indexed), file-based routing, and the ability to add API routes for BFF patterns later.
- PostgreSQL + pgvector: I needed a relational DB for the complex user/case schema AND vector storage for embeddings. pgvector lets me do both in one database without managing a separate vector store like Pinecone.
- Groq: Fastest LLM inference available — sub-100ms for LLaMA 70B. For a chat interface, latency matters more than raw accuracy.
- FastEmbed ONNX: It gives local 384-dim embeddings without the memory cost of torch/SentenceTransformers, and it can be lazy-loaded only when vector search is enabled.
- Cohere reranking: Cross-encoders are more accurate than bi-encoders for ranking but too slow for retrieval. Two-stage pipeline: fast retrieval with bi-encoders, accurate ranking with cross-encoder.
- Neon: Serverless Postgres scales to zero cost when not in use — perfect for a side project that may have variable traffic."
"A JWT has three parts: header, payload, signature. I sign access tokens with
JWT_ACCESS_SECRETand set 2-hour expiry. The payload carries{ userId, email, role, isPro, isEmailVerified }.The problem with JWTs is they can't be invalidated before expiry. So I use a refresh token with DB backing. When a user logs in, I create a
RefreshTokenrow in the database withtokenId, then sign a JWT with{ userId, tokenId }as the refresh token.On each
/auth/refreshcall, I verify the JWT signature, look up thetokenIdin the DB. IfrevokedAtis set, I know someone reused an old token — potential theft — so I immediately revoke ALL tokens for that user. Otherwise, I revoke the old token and issue a new pair. This is called rotation-based refresh."
"RRF is a way to merge two ranked lists into one without caring about the actual scores — only the ranks matter. The formula is:
score = 1/(60 + rank_vector) + 1/(60 + rank_keyword)The constant 60 prevents top ranks from being infinitely valuable.
Why not a weighted sum of scores? Because BM25 scores and cosine similarity scores are on completely different scales. A BM25 score of 0.8 doesn't mean the same thing as a cosine similarity of 0.8. If I multiplied them with weights, the higher-scale scores would dominate.
RRF is scale-agnostic — a document ranked #1 in both lists will score highest, regardless of what the raw scores were. It's also simple and empirically proven to outperform many complex fusion methods."
"In
otp.service.ts, I use Node'scrypto.randomBytes(4)which generates 4 cryptographically secure random bytes using the OS CSPRNG (on Linux:/dev/urandom). I then read these 4 bytes as a 32-bit unsigned integer and take modulo 1,000,000 to get a 6-digit number, left-padded to 6 digits.This is NOT
Math.random()— which is a pseudo-random number generator not suitable for security.crypto.randomBytesis suitable.Additional security: OTPs expire in 10 minutes, have a 5-attempt limit after which they're invalidated, and any existing OTP for the same target+type is invalidated before creating a new one, preventing overlap attacks."
"bcrypt is an adaptive password hashing function. The cost factor determines how many iterations the algorithm runs —
cost=12means 2^12 = 4,096 rounds. Higher cost = slower to compute = harder to brute-force.I use cost=12 for citizens and cost=14 for admins. Admin accounts are higher-value targets — compromising an admin allows verifying fraudulent lawyers or accessing all user data. The extra two rounds (2^14 = 16,384 iterations) make each admin password guess ~4x slower, significantly raising the cost of a brute-force attack on admin credentials.
The tradeoff is login time — cost=14 takes ~500ms vs ~125ms for cost=12. For an admin who logs in rarely, this is acceptable."
"planLimiter is a custom middleware in
src/middleware/planLimiter.tsthat enforces API quotas per subscription tier.When a request hits a protected route (like
/chat), planLimiter:
- Fetches the user's
Subscriptionrecord from the DB- If none exists, auto-provisions a FREE subscription (100 calls/30 days)
- Checks if the billing period has expired → resets
apiTokensUsedto 0- Compares
apiTokensUsed >= apiTokensLimit→ returns 429 if exceeded- Otherwise, increments
apiTokensUsedatomically via PrismaIt fails-open on errors — if the DB call fails, it calls
next()rather than blocking the user. This is a deliberate reliability tradeoff."
"Currently the chat endpoint does
await groq.invoke()and returns the full response. For streaming, I'd:
- Use
groq.stream()which returns an async iterator of token chunks- Set
Content-Type: text/event-streamon the response- For each chunk:
res.write('data: ' + JSON.stringify({token}) + '\n\n')- On the frontend, use
EventSourceorfetchwithbody.getReader()to consume the stream- The confidence sentinel
[[NYAYA_CONFIDENCE:N]]would need to be extracted during streaming — I'd buffer until I find the sentinel pattern, then strip it and start rendering the visible content.The challenge is persisting the full message after streaming completes — I'd need to buffer the entire streamed content server-side or client-side before the DB
prisma.message.create()call."
"The ingestion pipeline has four phases:
Phase 1 — PDF Ingestion (
rag/ingest_legal_pdfs.py): Parse raw PDF files for 17 major Indian Acts (BNS, BNSS, BSA, CPC, Constitution, DPDP, Hindu Marriage Act, Indian Contract Act, IBC, etc.) usingpypdf. Extract section numbers, titles, and body content, then insert or updateActandSectionrecords in PostgreSQL.Phase 2 — Metadata-Rich Chunking (
rag/generate_embeddings.py): Query all Sections and split their content usingRecursiveCharacterTextSplitterwithchunkSize=600, chunkOverlap=100. Each chunk is prefixed with a rich metadata header:[Act: <Title>] [Year: <Year>] [<Section/Article>: <Num>] [Title: <SecTitle>]\nContent:\n...to ensure context is never lost across chunk boundaries.Phase 3 — Batch Embedding & Database Insertion: Process chunk batches through
SentenceTransformers/ FastEmbed ONNXall-MiniLM-L6-v2→ 384-dimensional normalized vectors. Execute bulkINSERT INTO "LegalChunk"usingpsycopg2.extras.execute_valueswith raw SQL vector casting (%s::vector) and auto-reconnecting DB retry logic (8,166 total chunks populated across 17 Acts).Phase 4 — Hybrid Indexing:
CREATE INDEX USING hnsw (embedding vector_cosine_ops); CREATE INDEX USING GIN (fts); CREATE TRIGGER tsvectorupdate ... tsvector_update_trigger(fts, 'pg_catalog.english', content);The trigger auto-populates the GIN-indexed
fts(tsvector) column on insert/update."
"Key risks and mitigations:
1. JWT theft: Short-lived access tokens (2h). If stolen, expires quickly. 2. Refresh token theft/reuse: DB-backed rotation with reuse detection. If someone steals a refresh token, using it after the legitimate client rotated it triggers revocation of ALL sessions. 3. Brute force login:
loginLimiter— 10 attempts per 15 min per IP. 4. OTP bombing (flooding someone's inbox/SMS):otpSendLimiter— 5 requests per 10 min per IP. 5. SQL injection: Prisma parameterized queries by default. Raw SQL uses tagged template literals (Prisma auto-parameterizes). 6. XSS: Helmet CSP headers restrict script sources. HTTP-only refresh token cookie not accessible to JavaScript. 7. CSRF: Access token is a Bearer token (not cookie), immune to CSRF. Refresh token cookie uses SameSite=None but only sent to/auth/refreshendpoint. 8. Privilege escalation:requireRole()middleware checksreq.user.rolefrom verified JWT — not from request body. 9. Fake lawyer verification: Admins manually review Bar Certificate, Degree, and Government ID documents before settingverificationStatus = VERIFIED."
"I implemented the OAuth 2.0 Implicit Grant (redirect) flow:
- User clicks 'Sign in with Google' in
login/page.tsx- Frontend constructs Google OAuth URL with
response_type=token(implicit) and redirects the browser- User authenticates on Google, Google redirects back to
/login#access_token=...&expires_in=3600- A
useEffectinlogin/page.tsxparseswindow.location.hashto extractaccess_token- Frontend calls Google's
userinfoendpoint with the access token to get{ email, name, sub }- Frontend POSTs to
/api/auth/google/tokenwith{ access_token, email, name, googleId }- Backend upserts the user (create if new, find if existing), then calls
issueTokenPair()- Frontend calls
login(accessToken, user)from AuthContext → redirects by rolewindow.history.replaceState()cleans the URL (removes the hash fragment)Note: I switched from popup to redirect mode (
window.location.href = url) because popup-blockers were preventing the OAuth window in production on Vercel."
"You're right — the OAuth 2.0 implicit grant with
response_type=tokenis deprecated in OAuth 2.1 in favour of PKCE (Proof Key for Code Exchange). The concern is that access tokens in the URL hash can leak via browser history, Referrer headers, or server logs.For a production hardening, I'd migrate to
response_type=codewith PKCE: the frontend generates a randomcode_verifier, hashes it ascode_challenge, includes it in the auth request. Google returns an authorizationcode. The backend exchanges the code for tokens at Google's token endpoint, sending thecode_verifierfor verification. This keeps access tokens server-side."
"Intersection would only keep documents appearing in BOTH lists — potentially zero if the keyword and semantic matches have no overlap. That's why we use a FULL OUTER JOIN. The value of hybrid search is precisely that BM25 excels at exact keyword matching (e.g., 'BNS Section 103') while vector search excels at semantic intent ('what is the punishment for murder'). These often retrieve complementary documents, and RRF merges them fairly."
"Currently, the chat route returns a 500 error with the message. In production, I'd add:
- Retry with exponential backoff for transient failures
- Fallback model: Route to a different provider (e.g., Anthropic Claude or OpenAI) via LangChain's
ChatOpenAIswapped in- Cached responses: If the exact same query was answered before, return cached response
- Graceful degradation: Return the raw retrieved chunks with a message 'AI analysis temporarily unavailable, here are the relevant legal sections'
The
retrieval.tsservice already gracefully falls back from Cohere to slice-based ranking, so retrieval itself is resilient."
"For this project, Postgres + pgvector was the pragmatic choice:
- Fewer moving parts: One database for both relational data (users, cases) and vector data
- ACID transactions: A single transaction can insert a user AND their profile, no consistency issues across two databases
- Cost: Neon Postgres scales to zero; Pinecone has minimum costs
- Joins: I can do
LegalChunk JOIN Section JOIN Actin one query — impossible with a standalone vector DBAt true scale (50M+ chunks), I would separate them: use pgvector for chunks ≤10M, then evaluate Weaviate/Qdrant for larger corpora since HNSW performance degrades with very large datasets."
"10^6 = 1,000,000 possibilities. With the 5-attempt limit and 10-minute expiry, an attacker gets 5 guesses. Probability of guessing: 5/1,000,000 = 0.0005%. Rate limiting (5 OTP requests per 10 min per IP) also prevents generating fresh OTPs rapidly. For a legal platform, this is sufficient — financial platforms typically use 6 digits with the same limits."
"I'll explain the RAG chat pipeline in detail.
When a user sends a message in the chat interface (
POST /api/chat/conversations/:id/messages):1. Query Expansion: I take the last 2 user messages and join them with the current message. If someone asks 'What about minors?' after asking about IPC 302, the expanded query becomes 'IPC 302 murder | What about minors?' — this ensures the vector search retrieves contextually relevant chunks, not generic 'minor' chunks.
2. Optional Embedding: In full vector mode, the expanded query is passed to FastEmbed ONNX
all-MiniLM-L6-v2, generating a 384-dimensional vector. On 512MB Render instances,RAG_VECTOR_SEARCH=falseskips this step to avoid loading a local model.3. Retrieval (
rag/retrieval.py): In full vector mode, a PostgreSQL raw query runs two CTEs — one using pgvector's cosine distance operator<=>on the HNSW index, another usingts_rank_cdon the full-text search GIN index. These 30+30 results are merged via a FULL OUTER JOIN and ranked by RRF scores. In Render low-memory mode, the service uses Postgres full-text search plus anILIKEfallback instead.4. Reranking (
retrieval.ts): The 20 candidates plus the original query are sent to Cohere'srerank-english-v3.0. This cross-encoder model reads each (query, document) pair jointly — much more accurate than bi-encoder similarity. Top 8 returned.5. Prompt: I build the system prompt as a plain string (not LangChain template) interpolating the retrieved context directly. The prompt enforces structured output: Confidence score, Act name, Section number, Explanation, Punishment, Source citation.
6. Generation:
ChatGroq(groq/compound, temperature=0.1).pipe(StringOutputParser()).invoke(messages). The response is parsed with regex to extract confidence, then prepended as[[NYAYA_CONFIDENCE:85]]sentinel.7. Persistence: User message + assistant message saved to DB. User's
queriesCountincremented."
"Three things:
- Implement streaming from day one. Building streaming into the architecture from the start via SSE/ReadableStream is cleaner than retrofitting non-streaming endpoints.
- Use a proper query router. Before hitting the primary
groq/compoundreasoning model, a fast model likeqwen/qwen3.6-27bor small 8B model should classify: Is this query about Indian law? Does it need full RAG? Is it a simple factual query answerable from metadata? This optimizes latency and token usage.
- Automate incremental ingestion. Although PDF ingestion and embedding generation are now isolated in dedicated Python scripts (
rag/ingest_legal_pdfs.pyandrag/generate_embeddings.py), a production system should add event-driven triggers to automatically ingest new legal Gazette notifications or amendments as soon as published."