Add semantic note search (nomic-embed-text) and per-conversation note cache

- New NoteEmbedding model + migration 0014 stores float embeddings (JSONB)
- services/embeddings.py: get_embedding, upsert_note_embedding,
  semantic_search_notes (cosine similarity), backfill_note_embeddings
- build_context() now tries semantic search first, falls back to keyword search;
  accepts cached_note_ids to reuse last-turn notes and stabilise the system
  prompt prefix for Ollama's KV cache
- generation_buffer.py: per-conversation note ID cache (get/set/clear)
- generation_task.py: passes cached IDs into build_context, updates cache
  after each turn, and invalidates it after create_note/update_note/create_task
- app.py: pulls nomic-embed-text at startup and launches a background backfill
  to embed all existing notes (30 s delay so Ollama has time to load the model)
- routes/notes.py + services/tools.py: fire-and-forget embedding update on
  every note create or update via the API or LLM tool calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 21:44:58 -05:00
parent de5921904d
commit d6f4a6dbb6
11 changed files with 349 additions and 22 deletions
+56 -21
View File
@@ -307,6 +307,7 @@ async def build_context(
user_message: str,
exclude_note_ids: list[int] | None = None,
history_summary: str | None = None,
cached_note_ids: list[int] | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -370,29 +371,63 @@ async def build_context(
f"--- End Note ---"
)
# Search notes by keywords from user message — single OR query
keywords = _extract_keywords(user_message)
if keywords:
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
# Find related notes to inject into context.
# Priority: (1) use cached note IDs from a previous turn in this conversation
# (2) try semantic search via nomic-embed-text
# (3) fall back to keyword search
# The cache stabilises the system prompt prefix so Ollama's KV cache stays warm.
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
found_notes = []
if cached_note_ids:
# Load the same notes as last turn — keeps system prompt prefix identical.
try:
notes = await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=3
)
snippets: list[str] = []
for n in notes:
body_preview = n.body[:2000] if n.body else ""
snippets.append(f"- {n.title}: {body_preview}")
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
if snippets:
system_parts.append(
"\n\n--- Related Notes ---\n"
+ "\n".join(snippets)
+ "\n--- End Related Notes ---"
)
from fabledassistant.services.notes import get_note as _get_note
for nid in cached_note_ids:
if nid not in search_exclude:
n = await _get_note(user_id, nid)
if n:
found_notes.append(n)
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
logger.warning("Failed to load cached notes for context", exc_info=True)
found_notes = []
if not found_notes:
# 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
)
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_notes:
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
)
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
if found_notes:
snippets: list[str] = []
for n in found_notes:
body_preview = n.body[:2000] if n.body else ""
snippets.append(f"- {n.title}: {body_preview}")
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
system_parts.append(
"\n\n--- Related Notes ---\n"
+ "\n".join(snippets)
+ "\n--- End Related Notes ---"
)
# Expose note IDs so the caller can update the per-conversation cache.
context_meta["auto_note_ids"] = [n.id for n in found_notes]
# Fetch URL content from user message
urls = _find_urls(user_message)