01 Context
The problem
People often know exactly how they feel but not which words to search for. Keyword search fails them, because “I moved for work and I feel alone” shares almost no words with the passages that would actually help. A general-purpose LLM can meet them in natural language, but on its own it can also produce text that sounds like scripture and isn’t. In a spiritual product, an invented verse isn’t a minor bug. It breaks trust completely.
- Every passage shown must be exact, attributable and checkable against its source.
- People describe feelings, not keywords, so search has to work on meaning.
- People choose the Bible, the Quran or both, and that choice has to be respected.
- A weak match must never be presented as a confident one.
02 Design
The architecture and decisions
I split the problem in two. Interpretation belongs to the model: Gemini reads the reflection and returns structured emotional themes. The text belongs to retrieval: semantic search with pgvector over a curated corpus of 37,338 passages, 31,102 from the Bible and 6,236 from the Quran. Anything shown as scripture comes from a stored record, never from generated output.
- FastAPI with Pydantic v2 for typed request and response contracts
- Supabase PostgreSQL with SQLAlchemy 2.x and Alembic migrations
- pgvector keeps embeddings next to the source passages, with no separate vector store to sync
- React Native client for a mobile-first reflection flow
A generated quote can’t be verified at the moment it’s shown. A stored passage can, every time.
Passages, metadata and embeddings live together, so there is nothing to sync and metadata filters run in the same query as the similarity search.
The backend can validate the shape before using it and fail clearly when it’s wrong, instead of passing guesses downstream.
class ReflectionAnalysis(BaseModel):
emotions: list[str] = Field(min_length=1, max_length=5)
themes: list[str] = Field(max_length=5)
need: str # e.g. "comfort", "guidance", "hope"
# There is deliberately no field for passage text.
# Whatever the model writes, it can only describe the feeling.03 Implementation
The difficult parts
Curating and validating the corpus
Each passage is stored with its source, translation, book or surah, chapter and verse, so every result can be cited exactly. The import checks structure before anything is embedded: references must be unique, chapter and verse numbers must be in range, and the totals must match the 31,102 and 6,236 expected. Text is normalised for whitespace and quotation marks only. The wording itself is never changed.
Retrieval with metadata filters
The reflection and the themes from the analysis form the search query. Similarity search and the person’s choice of scripture run in one SQL query, so filtering never happens after the fact on a shortened list. The top candidates are then ranked before one passage is chosen.
stmt = (
select(Passage)
.where(Passage.source.in_(sources)) # Bible, Quran or both
.order_by(Passage.embedding.cosine_distance(query_vec))
.limit(20)
)Handling weak matches
Similarity scores only mean something relative to each other, so the best result can still be a poor one. A passage is only shown when its score clears a threshold. Below it, the app says it couldn’t find a close match and invites the person to say a little more, instead of forcing a verse onto them.
Structured outputs and failures
Gemini’s response is parsed and validated against the schema before it’s used. If it doesn’t match, the request fails clearly and is logged with the validation error, rather than continuing with a half-understood reflection. The passage text never depends on this step, so a model failure can’t produce a wrong verse.
04 Validation
How I check it
Retrieval quality is hard to judge from a demo, so I check the system in layers: the guarantees that must always hold, and the quality that should keep improving.
- Every passage shown is looked up by its ID, and a test fails if the displayed text differs from the stored record.
- Malformed, empty and off-schema model responses are fed to the parser to make sure they are rejected, not shown.
- A fixed set of sample reflections is run after any prompt or model change, and the results are compared by hand.
- Next step: a labelled evaluation set, so I can publish retrieval relevance and latency numbers instead of describing them.
05 Reflection
What I learned and what comes next
Decide what the model is not allowed to do first
The most important line in MoodVerse is a constraint, not a prompt. Once the model had no way to supply scripture, every later decision got simpler.
Keep vectors next to the data they describe
Putting embeddings in the same database as the passages removed a whole class of sync bugs and made filtering trivial.
What comes next
Finishing the mobile app, adding a full web version, and publishing measured retrieval quality from the evaluation set.
