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
+1 -1
View File
@@ -54,7 +54,7 @@ export interface ConversationDetail extends Conversation {
export interface ContextMeta { export interface ContextMeta {
context_note_id: number | null; context_note_id: number | null;
context_note_title: string | null; context_note_title: string | null;
auto_notes: { id: number; title: string }[]; auto_notes: { id: number; title: string; score?: number | null }[];
} }
export interface OllamaStatus { export interface OllamaStatus {
+23 -1
View File
@@ -38,7 +38,7 @@ const includedNoteIds = ref<Set<number>>(new Set());
const includedNotes = ref<{ id: number; title: string }[]>([]); const includedNotes = ref<{ id: number; title: string }[]>([]);
// Suggested notes — auto-found by search, not yet included // Suggested notes — auto-found by search, not yet included
const suggestedNotes = ref<{ id: number; title: string }[]>([]); const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
let prevConvId: number | null = null; let prevConvId: number | null = null;
@@ -482,6 +482,16 @@ onUnmounted(() => {
<router-link :to="`/notes/${note.id}`" class="context-note-name"> <router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }} {{ note.title }}
</router-link> </router-link>
<span
v-if="note.score != null"
class="context-note-score"
:class="{
'score-high': note.score >= 0.75,
'score-medium': note.score >= 0.60 && note.score < 0.75,
'score-low': note.score < 0.60,
}"
:title="`Relevance score: ${note.score}`"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button> <button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div> </div>
</template> </template>
@@ -807,6 +817,18 @@ onUnmounted(() => {
.context-note-suggested { .context-note-suggested {
opacity: 0.85; opacity: 0.85;
} }
.context-note-score {
font-size: 0.67rem;
font-weight: 600;
padding: 0.05rem 0.22rem;
border-radius: 3px;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.score-high { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); }
.score-medium { color: #c98a00; background: rgba(201, 138, 0, 0.12); }
.score-low { color: var(--color-text-muted); background: var(--color-bg-secondary); }
.context-note-remove { .context-note-remove {
background: none; background: none;
border: none; border: none;
+9 -5
View File
@@ -21,7 +21,9 @@ logger = logging.getLogger(__name__)
# Minimum cosine similarity to include a note in context results. # Minimum cosine similarity to include a note in context results.
# nomic-embed-text produces unit-normalized vectors, so range is [-1, 1]. # 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]: async def get_embedding(text: str, model: str | None = None) -> list[float]:
@@ -75,10 +77,12 @@ async def semantic_search_notes(
user_id: int, user_id: int,
query: str, query: str,
exclude_ids: set[int] | None = None, exclude_ids: set[int] | None = None,
limit: int = 3, limit: int = 8,
) -> list[Note]: ) -> list[tuple[float, Note]]:
"""Return up to *limit* notes most relevant to *query* using cosine similarity. """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. Returns an empty list if the embedding model is unavailable or on any error.
""" """
try: try:
@@ -114,7 +118,7 @@ async def semantic_search_notes(
scored.append((sim, note)) scored.append((sim, note))
scored.sort(key=lambda x: x[0], reverse=True) 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: async def backfill_note_embeddings() -> None:
+20 -12
View File
@@ -444,30 +444,38 @@ async def build_context(
if current_note_id: if current_note_id:
search_exclude.add(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 semantic search first; fall back to keyword search on failure / no results.
try: try:
from fabledassistant.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
found_notes = await semantic_search_notes( for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=3 user_id, user_message, exclude_ids=search_exclude or None, limit=8
) ):
found_scored.append((score, note))
except Exception: except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True) 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) keywords = _extract_keywords(user_message)
if keywords: if keywords:
try: try:
found_notes = await search_notes_for_context( for note in await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=3 user_id, keywords, exclude_ids=search_exclude or None, limit=8
) ):
found_scored.append((None, note))
except Exception: except Exception:
logger.warning("Failed to search notes for context", exc_info=True) logger.warning("Failed to search notes for context", exc_info=True)
# Populate sidebar candidates (never auto-injected). # Populate sidebar candidates (never auto-injected into system prompt).
for n in found_notes: for score, n in found_scored:
context_meta["auto_notes"].append({"id": n.id, "title": n.title}) context_meta["auto_notes"].append({
context_meta["auto_note_ids"] = [n.id for n in found_notes] "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). # Inject explicitly included notes (user opted in via sidebar click).
if include_note_ids: if include_note_ids: