diff --git a/src/fabledassistant/services/knowledge.py b/src/fabledassistant/services/knowledge.py index 741a919..83432cd 100644 --- a/src/fabledassistant/services/knowledge.py +++ b/src/fabledassistant/services/knowledge.py @@ -122,34 +122,74 @@ async def _semantic_knowledge_search( limit: int, offset: int, ) -> tuple[list[dict], int]: - """Semantic search over knowledge objects, with SQL filters applied post-rank.""" + """Hybrid search: keyword matches first (title/body ILIKE), then semantic results. + + Exact keyword matches always rank above semantic-only matches so that + searching for a name like "Weston" surfaces the note with that title + before conceptually related notes. + """ + # 1. Keyword search — title and body ILIKE + keyword_notes: list[Note] = [] + try: + async with async_session() as session: + pattern = f"%{q}%" + base = ( + select(Note) + .where(Note.user_id == user_id) + .where(Note.title.ilike(pattern) | Note.body.ilike(pattern)) + ) + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) + for tag in tags: + base = base.where(Note.tags.contains([tag])) + # Title matches first, then body-only matches, newest first within each + base = base.order_by( + Note.title.ilike(pattern).desc(), + Note.updated_at.desc(), + ).limit(limit * 2) + keyword_notes = list((await session.execute(base)).scalars().all()) + except Exception: + logger.warning("Keyword search failed", exc_info=True) + + # 2. Semantic search — conceptual similarity + semantic_notes: list[Note] = [] try: from fabledassistant.services.embeddings import semantic_search_notes - # Fetch a larger candidate set to allow for filtering is_task_filter = True if note_type == "task" else (False if note_type else None) candidates = await semantic_search_notes( user_id=user_id, query=q, - limit=min(200, limit * 8), + limit=min(200, limit * 4), threshold=0.3, is_task=is_task_filter, ) + for _score, note in candidates: + if note_type == "task" and not note.is_task: + continue + elif note_type and note_type != "task" and note.entity_type != note_type: + continue + if tags and not all(t in (note.tags or []) for t in tags): + continue + semantic_notes.append(note) except Exception: - logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) - return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset) + logger.warning("Semantic search unavailable, using keyword results only", exc_info=True) - results = [] - for _score, note in candidates: - if note_type == "task" and not note.is_task: - continue - elif note_type and note_type != "task" and note.entity_type != note_type: - continue - if tags and not all(t in (note.tags or []) for t in tags): - continue - results.append(note) + # 3. Merge — keyword matches first, then semantic (deduplicated) + seen_ids: set[int] = set() + merged: list[Note] = [] + for note in keyword_notes: + if note.id not in seen_ids: + seen_ids.add(note.id) + merged.append(note) + for note in semantic_notes: + if note.id not in seen_ids: + seen_ids.add(note.id) + merged.append(note) - total = len(results) - page_items = results[offset: offset + limit] + total = len(merged) + page_items = merged[offset: offset + limit] return [_note_to_item(n) for n in page_items], total