fix(search): hybrid keyword + semantic search — exact title/body matches rank first
This commit is contained in:
@@ -122,34 +122,74 @@ async def _semantic_knowledge_search(
|
|||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
) -> tuple[list[dict], 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:
|
try:
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
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)
|
is_task_filter = True if note_type == "task" else (False if note_type else None)
|
||||||
candidates = await semantic_search_notes(
|
candidates = await semantic_search_notes(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
query=q,
|
query=q,
|
||||||
limit=min(200, limit * 8),
|
limit=min(200, limit * 4),
|
||||||
threshold=0.3,
|
threshold=0.3,
|
||||||
is_task=is_task_filter,
|
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:
|
except Exception:
|
||||||
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True)
|
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
|
||||||
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
|
|
||||||
|
|
||||||
results = []
|
# 3. Merge — keyword matches first, then semantic (deduplicated)
|
||||||
for _score, note in candidates:
|
seen_ids: set[int] = set()
|
||||||
if note_type == "task" and not note.is_task:
|
merged: list[Note] = []
|
||||||
continue
|
for note in keyword_notes:
|
||||||
elif note_type and note_type != "task" and note.entity_type != note_type:
|
if note.id not in seen_ids:
|
||||||
continue
|
seen_ids.add(note.id)
|
||||||
if tags and not all(t in (note.tags or []) for t in tags):
|
merged.append(note)
|
||||||
continue
|
for note in semantic_notes:
|
||||||
results.append(note)
|
if note.id not in seen_ids:
|
||||||
|
seen_ids.add(note.id)
|
||||||
|
merged.append(note)
|
||||||
|
|
||||||
total = len(results)
|
total = len(merged)
|
||||||
page_items = results[offset: offset + limit]
|
page_items = merged[offset: offset + limit]
|
||||||
return [_note_to_item(n) for n in page_items], total
|
return [_note_to_item(n) for n in page_items], total
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user