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:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user