Riri is the AI companion we shipped at Relaxy, inside a mental-health platform serving 120,000+ people across Bangladesh. She talks with users about hard days, remembers previous conversations, and suggests coping techniques drawn from content our clinical team wrote. The architecture is what you'd expect — FastAPI, LangChain, an embedding store, a frontier model behind an API — and the demo took about two weeks.
The next eight months were spent learning the distance between a retrieval demo and a retrieval product. This is the honest version of that story: the places RAG genuinely carried the product, and the specific, repeatable ways it lied to us.
Why a companion needs retrieval at all
Riri retrieves from two very different corpora, and keeping them separate turned out to be one of the most important early decisions:
- Episodic memory — summaries of the user's own past sessions, so a Tuesday conversation can remember what Sunday was about.
- Technique content — grounding exercises, breathing routines, journaling prompts, written and reviewed by humans. When Riri suggests a technique, it must be one of these, not something the model improvised.
The second corpus is really a safety mechanism wearing a retrieval costume. In a wellness product you do not want the model freestyling advice; you want it paraphrasing content a human signed off on. Retrieval is how you keep the model on that leash.
The pipeline
The ordering is the design. Crisis detection — self-harm signals, in both Bangla and English, including romanised Bangla — runs before retrieval and the model, on deliberately boring technology: keyword lists, patterns, a small classifier. If it fires, the user gets a scripted response with real helpline numbers and a path to a human. We never let a generative system improvise in that moment, and we never let a retrieval miss make the decision.
Step 1 — write memory on purpose, not by accident
Our first memory implementation embedded raw conversation transcripts. Retrieval over raw dialogue is terrible: turns are fragmentary ("yeah exactly", "I don't know"), pronouns point at nothing, and the emotionally important sentence is buried under forty lines of filler.
What worked was writing memory deliberately when a session closes — a short, first-person factual summary, generated with a tightly scoped prompt and stored with an expiry:
class SessionMemory:
async def close(self, session: ChatSession) -> None:
summary = await summarise(session.turns)
# e.g. "Exam stress about final year results. Sleeping badly
# this week. The 4-7-8 breathing exercise helped."
await self.store.upsert(
user_id=session.user_id,
kind="episode",
text=summary,
expires_in=timedelta(days=90),
)
Three details that earned their keep: summaries are facts rather than diagnoses, everything is scoped hard to user_id at the query layer (not the prompt layer — the database enforces it), and memories expire. The expiry sounds like a technical footnote. It isn't; it's the next section.
What RAG got right
Two things, and they were worth the entire effort.
Continuity. The single most-loved behaviour in user feedback was Riri opening with something like "last time you mentioned the exam — how did it go?" That is a retrieval feature. It is also the difference between a chat toy and a companion, and no amount of model quality substitutes for it.
Grounded techniques. When suggestions were constrained to retrieved, human-written content, the failure mode changed shape. The worst case stopped being "the model invented plausible-sounding advice" and became "the model picked a less-fitting technique from the approved set" — a much smaller blast radius, and one our content team could fix by writing better content rather than by begging a model to behave.
Where it lied
Every failure below happened in testing or production. None of them are exotic; that's the point.
Lie 1 — similarity is not relevance. A user wrote about not sleeping after a family argument. Cosine similarity cheerfully surfaced a memory about sleep trouble from months earlier — during exam season. Riri asked about exams. The user, reasonably, felt unheard. Nothing malfunctioned: embeddings matched "sleep" to "sleep". The system retrieved something similar when what mattered was something current. The fix was boring and effective — recency-weighted scoring, and the 90-day expiry above.
Lie 2 — top-k always returns something. similarity_search(query, k=4) never comes back empty. Ask it for four chunks about a topic the corpus knows nothing about, and it hands you the four least-irrelevant chunks with a straight face — and the model, given confident-looking context, uses it. Our fix was a floor, and a rule about what to do beneath it:
hits = store.similarity_search_with_relevance_scores(query, k=12)
kept = [doc for doc, score in hits if score >= 0.62]
if not kept:
# No real context. Say less, not something wrong:
# the model gets NO documents and is instructed to just listen.
return []
The threshold number came from evals, not intuition. But the product decision mattered more than the number: below the floor, Riri retrieves nothing and simply listens — which, in this product, is often the right response anyway.
Lie 3 — retrieved truth, expired truth. A user told Riri weeks later that the situation with their friend was resolved. Old memory still outranked new on a related query, and Riri consoled them about a conflict that no longer existed. Fabricated? No — every retrieved word was true once. This is the failure people miss when they claim RAG "fixes" hallucination: retrieval can serve you stale truth with perfect confidence. We added contradiction handling at summary-write time — a new episode that supersedes an old one marks it superseded.
RAG doesn't stop a system from lying. It changes where the lies come from — and stale truth delivered warmly is still a lie in this product.
Step 2 — the eval harness that kept us honest
For months our retrieval "testing" was someone typing messages and nodding. The turning point was a golden set: a few hundred anonymised, consented message patterns from real usage, each annotated with what a good retrieval must include and — just as important — must not:
@dataclass
class GoldCase:
message: str
must_retrieve: set[str] # chunk ids the reply depends on
must_not: set[str] # chunks that would derail the reply
def hit_rate(cases: list[GoldCase], k: int = 4) -> float:
hits = 0
for case in cases:
got = {d.id for d in retrieve(case.message, k=k)}
if case.must_retrieve <= got and not (case.must_not & got):
hits += 1
return hits / len(cases)
This ran in CI on every change to chunking, embeddings, thresholds or prompts. The week we introduced it, it caught a "harmless" chunk-size change that dropped technique hit-rate from 81% to 64% — a regression that manual nodding had already approved. From then on, no retrieval change shipped without the number moving in the right direction, and the must_not sets caught more real bugs than the must_retrieve sets did.
What I'd tell anyone building one
The vector database was never the hard part; we could have swapped it any week without users noticing. The hard parts were all product decisions wearing engineering costumes: what deserves to be remembered, when memory should expire, what similarity score is low enough to mean "say nothing", and which failure modes are cheap embarrassments versus genuinely unacceptable in front of a struggling person at 2 a.m.
Decide those in a document before you decide anything in a config file. And build the golden set the same month you build the demo — every week without one, your pipeline is quietly deciding these things for you.