Feedback Loop¶
A cache hit is not automatically a correct answer. Semantic similarity can match a question whose stored query is subtly wrong for the new phrasing — and, without a correction signal, that entry keeps being served forever.
The feedback loop lets your application report whether a served query was right. Medha accumulates these signals per entry, can retire entries that keep getting it wrong, and (since 0.5.0) can promote the ones that keep being confirmed.
Recording Feedback¶
from medha.types import SearchStrategy
question = "How many orders were placed last month?"
hit = await cache.search(question)
if hit.strategy is not SearchStrategy.NO_MATCH:
rows = run_query(hit.generated_query)
# ... the user (or a validator) judges the result ...
await cache.feedback(question, correct=True)
feedback() resolves the entry by exact normalised-question lookup — the same mechanism as invalidate(), not a vector search — increments the matching counter, and returns a bool:
| Return | Meaning |
|---|---|
True |
Entry found and counter updated |
False |
No entry for that question (expired, invalidated, or never stored) |
A sync wrapper is available as feedback_sync().
Pass the question, not the query
The lookup key is the question text, so keep the original string around — CacheHit does not carry it back. Pass the same value you passed to search(), never hit.generated_query.
Because the lookup is a plain text match, feedback() needs no working embedder — which is why the CLI can record feedback without loading a model.
Counters¶
Every cache entry carries two counters, both defaulting to 0:
| Field | Description |
|---|---|
feedback_correct |
Times the stored query was reported correct |
feedback_incorrect |
Times it was reported wrong |
They live on CacheEntry (the stored form) and on CacheResult (backend search results), and persist in the backend, so they survive restarts on every durable backend.
They are not on CacheHit
The object returned by search() carries the query, confidence, strategy and expiry — not the feedback counters. To inspect them, export the collection:
Entries written by Medha 0.3.x and earlier are read back with both counters at 0 — the schema change is backward compatible.
Auto-Invalidation¶
Set feedback_incorrect_threshold to have Medha retire an entry once it accumulates enough negative reports:
settings = Settings(feedback_incorrect_threshold=3)
async with Medha("demo", embedder=embedder, settings=settings) as cache:
await cache.store("How many orders exist?", "SELECT COUNT(*) FROM orders")
await cache.feedback("How many orders exist?", correct=False)
await cache.feedback("How many orders exist?", correct=False)
await cache.feedback("How many orders exist?", correct=False) # triggers invalidation
hit = await cache.search("How many orders exist?")
print(hit.strategy) # SearchStrategy.NO_MATCH
Or via environment:
When auto-invalidation fires, the vector backend entry and the L1 cache entry are both removed, the removal is logged at INFO, and the next search() misses so your application regenerates the query with the LLM. Calling feedback() again afterwards returns False without raising.
The default is None — counters accumulate but nothing is ever auto-removed. Start there, watch the counters for a while, and enable the threshold once you know what a realistic error rate looks like for your workload.
Only negative feedback triggers invalidation
The threshold is checked exclusively on correct=False calls. Any number of correct feedbacks leaves the entry untouched, and a high feedback_correct count never protects an entry from being invalidated.
Behaviour reference¶
| Scenario | Return value | Side effect |
|---|---|---|
Entry found, correct=True |
True |
feedback_correct incremented by 1 |
Entry found, correct=False, below threshold |
True |
feedback_incorrect incremented by 1 |
Entry found, correct=False, threshold reached |
True |
Entry invalidated from backend and L1 |
| Entry not found | False |
No change |
| Entry already invalidated, called again | False |
No change |
Score Boosting¶
New in 0.5.0
Auto-invalidation acts on negative feedback. Score boosting is its counterpart: it makes entries that keep being confirmed easier to retrieve.
When the factor is above zero, a candidate's similarity score is adjusted before it is compared against the threshold:
trust = feedback_correct / (feedback_correct + feedback_incorrect)
adjusted = min(1.0, score × (1 + feedback_boost_factor × trust))
An entry with only positive feedback (trust = 1.0) gets the full boost; one with a mixed record gets a proportional share. Entries with no feedback, or negative feedback only, are left untouched — boosting never lowers a score. Use feedback_incorrect_threshold to act on negative signal.
Where it applies¶
| Tier | Boosted? | Why |
|---|---|---|
| L1 cache | No | Exact question hash — already a certainty |
| Template | No | Pattern match, not a similarity score |
| Exact | No | Hash-equivalent match |
| Semantic | Yes | Cosine similarity ranking |
| Fuzzy | Yes | Levenshtein ratio ranking |
Two things change in the semantic tier when boosting is on. Candidates are retrieved down to score_threshold_semantic / (1 + factor) — the lowest score a boost could possibly lift over the line, so recall widens by exactly that much and no more. And candidates are re-ranked by adjusted score, so a marginally less similar entry with a strong track record can outrank a closer one nobody has confirmed.
Choosing a factor¶
| Factor | Effect |
|---|---|
0.0 (default) |
Disabled — scoring is identical to 0.4.x |
0.1 |
Subtle; breaks ties between near-equal candidates |
0.2 – 0.3 |
Recommended starting range |
0.5+ |
Aggressive; a trusted entry can absorb a large similarity gap |
Start at 0.2 and compare the hit rate before and after — persistent stats make that comparison survive restarts. Raise it only if the extra hits are genuinely correct: the failure mode of a high factor is a well-rated entry answering questions it should not.
The two feedback features compose. feedback_boost_factor promotes what works, feedback_incorrect_threshold retires what does not:
Set via environment as MEDHA_FEEDBACK_BOOST_FACTOR (a float in [0.0, 1.0]).
Integration Pattern¶
async def handle_user_correction(question: str, was_correct: bool, cache: Medha) -> None:
updated = await cache.feedback(question, correct=was_correct)
if not updated:
# Entry expired or was never cached — nothing to update
return
if not was_correct:
logger.warning("Incorrect cache hit reported for: %s", question[:80])
From the CLI¶
medha feedback "How many orders were placed last month?" --correct
medha feedback "How many orders were placed last month?" --incorrect
See the CLI page for setup and the full command list.
Choosing a Signal¶
The hard part is deciding what counts as "correct". Some options, roughly in order of cost:
- Execution success — the query ran without an error. Cheap, catches schema drift and broken SQL, but a query can run fine and still answer the wrong question.
- User action — the user accepted the result, kept the dashboard, or did not immediately rephrase. A rephrasing within a few seconds is a strong negative signal.
- Explicit rating — a thumbs up/down in the UI. Accurate but sparse; most users never click.
- LLM-as-judge — a second model compares the question to the generated query. Most expensive, and it reintroduces the LLM call you were caching to avoid — so reserve it for sampling, not every request.
Mixing a cheap always-on signal (execution success) with a sparse high-quality one (explicit rating) tends to work better than either alone.
See Also¶
- Invalidation — manual and bulk removal
- Observability — hit rate and per-strategy metrics
- TTL & Lifecycle — time-based expiry
- Configuration —
feedback_incorrect_thresholdandfeedback_boost_factor - Demo 25 — Feedback Loop