Improve suggested notes: limit 8, threshold 0.45, show relevance scores

- Raise similarity threshold 0.30 → 0.45: only genuinely relevant notes
  shown; loosely-related notes no longer pad the sidebar
- Increase max suggested notes 3 → 8 (zero added compute — threshold is
  the real gate; the embedding call is fixed regardless of limit)
- semantic_search_notes now returns list[tuple[float, Note]] instead of
  list[Note] so scores propagate through context_meta to the frontend
- Keyword fallback notes carry score=null (no cosine similarity available)
- ChatView sidebar shows % badge on each suggested note:
  green ≥75%, amber 60–74%, muted <60%
  Hovering reveals the raw score in a tooltip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:10:39 -05:00
parent 7728e38318
commit 90afd3f131
4 changed files with 53 additions and 19 deletions
+9 -5
View File
@@ -21,7 +21,9 @@ logger = logging.getLogger(__name__)
# Minimum cosine similarity to include a note in context results.
# nomic-embed-text produces unit-normalized vectors, so range is [-1, 1].
_SIMILARITY_THRESHOLD = 0.30
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
# loosely-related results that pad the sidebar without adding real value.
_SIMILARITY_THRESHOLD = 0.45
async def get_embedding(text: str, model: str | None = None) -> list[float]:
@@ -75,10 +77,12 @@ async def semantic_search_notes(
user_id: int,
query: str,
exclude_ids: set[int] | None = None,
limit: int = 3,
) -> list[Note]:
"""Return up to *limit* notes most relevant to *query* using cosine similarity.
limit: int = 8,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
Scores are cosine similarities in [-1, 1]; only notes at or above
_SIMILARITY_THRESHOLD are returned, sorted highest-first.
Returns an empty list if the embedding model is unavailable or on any error.
"""
try:
@@ -114,7 +118,7 @@ async def semantic_search_notes(
scored.append((sim, note))
scored.sort(key=lambda x: x[0], reverse=True)
return [note for _, note in scored[:limit]]
return scored[:limit]
async def backfill_note_embeddings() -> None:
+20 -12
View File
@@ -444,30 +444,38 @@ async def build_context(
if current_note_id:
search_exclude.add(current_note_id)
found_notes = []
# (score, note) pairs — score is float for semantic results, None for keyword fallback.
found_scored: list[tuple[float | None, object]] = []
# Try semantic search first; fall back to keyword search on failure / no results.
try:
from fabledassistant.services.embeddings import semantic_search_notes
found_notes = await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=3
)
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_notes:
if not found_scored:
keywords = _extract_keywords(user_message)
if keywords:
try:
found_notes = await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=3
)
for note in await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=8
):
found_scored.append((None, note))
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
# Populate sidebar candidates (never auto-injected).
for n in found_notes:
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
context_meta["auto_note_ids"] = [n.id for n in found_notes]
# Populate sidebar candidates (never auto-injected into system prompt).
for score, n in found_scored:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
})
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
# Inject explicitly included notes (user opted in via sidebar click).
if include_note_ids: