Inspiration
As we are international students in NTU, we see how room renting and swapping work and it's not good. Right now, everyone relies on massive Telegram group chats where messages flood in every second, buried under spam and random chatter. We wanted room seekers to just say what they want in plain English and get back ranked rooms with an actual reason, next to a map, making it easier for them to decide. That's where we came up with "NTU NestMatch".
What it does
"NTU NestMatch" works like a dating app: you describe your dream room in plain English, and an open-source LLM parses the query into structured intent while an embedding model turns the query into a vector. By cosine similarity, It then shows you the room of your dreams, along with its location, travel time to NTU, price, and other conditions. Seekers can also shortlist rooms with a heart, draw a freehand boundary on the map to restrict results to a neighborhood, and get a ranked list of rooms with a one-line reason per match.
Room data comes from providers. Providers post a room with fixed tags plus a free-text description and a pin dropped on a map. When a seeker messages them through the built-in chat, they get notified.
Under the hood, search runs a three-stage pipeline: an open-source LLM, a SQL filter with vector similarity search, and a reranking pass — so results render in ~3.3s and explanations fill in a second later.
How we built it
I built NTU Room Finder as a Next.js 16 app (App Router, React 19, TypeScript, Tailwind v4), with Postgres behind Prisma 7 and Supabase for the database and photo storage. Everything that changes data goes through server actions next to the page that uses them, so there is almost no client-side state to keep in sync. Sign-in is NextAuth v5 with Google as the only provider for now.
The matching pipeline is the heart of it, and it runs in three stages.
First, parse. The seeker's sentence goes to an LLM through OpenRouter and comes back as structured JSON: budget, must-have tags, nice-to-have tags, room type, travel mode, commute limit, plus the leftover nuance like "chill landlord". I validate it with zod, and any tag outside my fixed enum gets thrown away.
Second, filter and order. The intent becomes one raw SQL statement that filters and sorts in a single pass, ordering by cosine distance between the query vector and each listing's vector using pgvector with an HNSW index. Listings are embedded when they are posted or edited, never at search time. If the filter returns nothing, it relaxes step by step (drop the commute limit, demote must-haves to preferences, stretch the budget 20%) and tells the seeker exactly what it loosened, because an over-eager must-have tag is the most common reason for an empty page.
Third, rerank and explain. Cosine similarity cannot tell you whether $420 is better than $650, so the top 10 go back to the model in one streamed call that returns a new order and a one-line reason per room. I made the prompt emit "order" before "reasons", which turned out to be the single best perf trick in the app: the order is complete after about 120 tokens, so I await only that and paint the cards in final positions, then let the reasons stream into per-card Suspense boundaries. Rooms land at ~3.3s, explanations at ~4.6s, and nothing ever jumps after paint.
Maps. Providers pick an address with Google Places autocomplete, proxied server-side so the key never hits the browser, then drag a pin to the right block. I compute real walking, transit and driving times to campus with the Distance Matrix API once, on write, and cache them on the row. Search reads columns and never calls a Maps API.
Room photos get resized in the browser first (longest edge 1600px, re-encoded to WebP, quality stepped down until under ~700KB), written back into the file input with DataTransfer so the form still submits as plain multipart.
The whole thing came together over about a week and 87 commits and 30 seeded demo rooms with real Distance Matrix numbers and real photos so the demo is indistinguishable from live use.
Challenges we ran into
Getting the LLM's parsed intent to behave: an early version of the parser turned "quiet room near campus" into a hard 15-minute-walk, off-campus-only filter, silently cutting 20 rooms to 1. We had to add a grounding step that strips any hard constraint the model inferred but the seeker didn't actually say, while still letting those inferences softly influence ranking.
Vector similarity alone wasn't good enough to rank rooms. Embeddings capture semantic meaning well‚ they can tell a "quiet study-friendly single" is topically close to a query about wanting to focus‚ but semantic closeness isn't the same as being the better match. Cosine similarity has no concept of a numeric tradeoff, so it can't weigh "$420 vs. a $650 budget" or "a 12-minute commute vs. a 45-minute one." A room whose description read similarly in meaning routinely outranked a room that was actually the better fit on hard numbers. That's why we split ranking into two stages: the vector step uses semantic similarity only to pick the shortlist (the top candidates worth considering), and a second LLM pass actually reasons about price, commute, and stated preferences to decide the final order and explain each pick. This way, the system is also scalable as cosine similarity can be computed against huge number of room embeddings cheaply.
The search pipeline started out slow enough to feel broken‚ 3 sequential LLM calls (parse, embed, rerank) meant a seeker could be staring at a blank page for many seconds before anything appeared, which is a hard thing to ship as a "search" feature. We had to actually profile where the time went (turned out to be ~93% LLM, database round trips were negligible) and attack it from a few angles at once: running the parse and embedding calls in parallel since neither depends on the other's output, streaming the final reranking call so the new result order arrives in ~120 tokens while the ~700 tokens of match reasons are still being generated, and awaiting only the order so cards render in their final position immediately. That took perceived time-to-content from over 7 seconds to roughly 3-4 seconds, with reasons filling in a beat later per-card instead of blocking the page.
Accomplishments that we're proud of
- We ran an embedding model to turn the user's search query into a vector, so that search algorithm works much quicker. The order of rooms render first with ranking, then reasons arrive, nothing reshuffles after paint. The room ranking matches what user ask for.
- Similar rooms appear at the bottom of a room page, computed from similar embeddings, fast and little to no cost. I learned to implement this from observing other hotel/room renting production websites, which lead to more conversions.
What we learned
- Notice problem around us and find the way to tackle them within our ability. I have the domain knowledge, my friend has the technical know-how. So we combined what we each knew and built it.
- By routing LLM calls through OpenRouter (using lightweight, low-cost models like Gemini 2.5 Flash-Lite) and relying on Google Maps' free tier for map rendering, we can keep the cost of a single search to roughly $0.0076. And even at 100 users, total spend is under a dollar.
What's next for NTU NestMatch
We divide our next goals into two areas: technical build and business model. On the technical side, we want to support mobile for the freehand area-drawing tool, as well as work on verification, payments, and review/reporting flows. For the business model, we want to bring this to the real world. Right now, the app is made for students living on campus, but our long-term vision is to sell this platform directly to universities worldwide. Then, we can integrating with their housing regulations to make room renting and swapping official.
Built With
- embeddings
- google-maps
- llm
- next-auth
- next.js
- open-router
- postgresql
- prisma-orm
- supabase
- tailwind-css
- typescript
- vector-database
Log in or sign up for Devpost to join the conversation.