Merge pull request #51 from wahyd4/feat/vector-search-fast-track

feat: rapid vector search UI
This commit is contained in:
2026-02-13 20:19:03 +11:00
committed by GitHub
3 changed files with 77 additions and 149 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
from django.urls import path
from .search_views import SearchReactView, search, search_aliases, search_api_v2
from .search_views import SearchReactView, search, search_aliases, search_api_v2, search_vector_api
urlpatterns = [
path('', SearchReactView.as_view(), name='search_main'),
path('api/', search, name='search_api'),
path('api/v2/', search_api_v2, name='search_api_v2'),
path('api/vector/', search_vector_api, name='search_vector_api'),
path('aliases/', search_aliases, name='search_aliases'),
]
+70 -146
View File
@@ -11,9 +11,63 @@ from .search_backend import search_backend
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt
import json
import requests
import os
from qdrant_client import QdrantClient
logger = logging.getLogger(__name__)
def get_ollama_embedding(text):
ollama_url = os.getenv("OLLAMA_URL", "http://ollama-service.ollama.svc.cluster.local:11434")
model = os.getenv("OLLAMA_MODEL", "llama3")
try:
r = requests.post(f"{ollama_url}/api/embeddings", json={"model": model, "prompt": text}, timeout=5)
r.raise_for_status()
return r.json()["embedding"]
except Exception as e:
logger.error(f"Ollama embedding error: {e}")
return None
@require_http_methods(["GET"])
def search_vector_api(request):
query = request.GET.get('q', '').strip()
if not query:
return JsonResponse({'results': [], 'total': 0})
qdrant_host = os.getenv("QDRANT_HOST", "192.168.1.2")
qdrant_port = int(os.getenv("QDRANT_PORT", "6333"))
collection = os.getenv("COLLECTION_NAME", "links")
try:
vector = get_ollama_embedding(query)
if not vector:
return JsonResponse({'error': 'Failed to generate embedding'}, status=500)
client = QdrantClient(host=qdrant_host, port=qdrant_port)
search_result = client.search(
collection_name=collection,
query_vector=vector,
limit=20
)
results = []
for hit in search_result:
p = hit.payload
results.append({
'id': p.get('id'),
'type': 'post' if 'content' in p else 'link',
'title': p.get('title'),
'url': p.get('url'),
'summary': p.get('description') or p.get('summary', ''),
'score': hit.score,
'tags': p.get('tags', [])
})
return JsonResponse({'results': results, 'total': len(results)})
except Exception as e:
logger.error(f"Vector search error: {e}")
return JsonResponse({'error': str(e)}, status=500)
class SearchView(ListView):
template_name = 'links/search.html'
context_object_name = 'results'
@@ -32,7 +86,6 @@ class SearchView(ListView):
if query:
try:
# Use Whoosh search backend for full-text search
search_results = search_backend.search(
query_string=query,
model_type=type_filter if type_filter else None,
@@ -41,14 +94,12 @@ class SearchView(ListView):
sort_by=sort_by
)
# Enrich results with actual model data
enriched_results = []
for result in search_results['results']:
enriched_result = self._enrich_result(result)
if enriched_result:
enriched_results.append(enriched_result)
# Store search metadata for pagination
self.search_total = search_results['total']
self.search_has_next = search_results['has_next']
self.search_has_prev = search_results['has_prev']
@@ -56,205 +107,80 @@ class SearchView(ListView):
return enriched_results
except Exception as e:
logger.error(f"Search error: {e}", exc_info=True)
# Store error for display
self.search_error = str(e)
return []
return []
def _enrich_result(self, result):
"""Fetch full model data for search result"""
model_type = result['model_type']
model_id = result['model_id']
try:
if model_type == 'link':
link = Link.objects.get(id=model_id)
return {
'id': link.id,
'model_type': 'link',
'alias': link.alias,
'original_url': link.original_url,
'description': link.description,
'click_count': link.click_count,
'created_at': link.created_at,
'tags': list(link.tags.all())
}
return {'id': link.id, 'model_type': 'link', 'alias': link.alias, 'original_url': link.original_url, 'description': link.description, 'click_count': link.click_count, 'created_at': link.created_at, 'tags': list(link.tags.all())}
elif model_type == 'page':
page = Page.objects.get(id=model_id)
return {
'id': page.id,
'model_type': 'page',
'title': page.title,
'url': page.url,
'summary': page.summary,
'created_at': page.created_at,
'tags': list(page.tags.all())
}
return {'id': page.id, 'model_type': 'page', 'title': page.title, 'url': page.url, 'summary': page.summary, 'created_at': page.created_at, 'tags': list(page.tags.all())}
elif model_type == 'post':
post = Post.objects.get(id=model_id)
return {
'id': post.id,
'model_type': 'post',
'title': post.title,
'summary': post.summary,
'created_at': post.created_at,
'tags': list(post.tags.all())
}
return {'id': post.id, 'model_type': 'post', 'title': post.title, 'summary': post.summary, 'created_at': post.created_at, 'tags': list(post.tags.all())}
except Exception as e:
logger.error(f"Error enriching result {model_type} {model_id}: {e}")
return None
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'query': self.request.GET.get('q', ''),
'selected_type': self.request.GET.get('type', ''),
'sort_by': self.request.GET.get('sort', 'relevance'),
'total_results': getattr(self, 'search_total', 0),
'search_error': getattr(self, 'search_error', None),
})
context.update({'query': self.request.GET.get('q', ''), 'selected_type': self.request.GET.get('type', ''), 'sort_by': self.request.GET.get('sort', 'relevance'), 'total_results': getattr(self, 'search_total', 0), 'search_error': getattr(self, 'search_error', None)})
return context
def search(request):
query = request.GET.get('q', '').strip()
if query:
results = []
# Search links
links = Link.objects.filter(
Q(alias__icontains=query) |
Q(original_url__icontains=query)
)[:5]
results.extend([{
'type': 'link',
'alias': link.alias,
'url': link.original_url
} for link in links])
# Search posts
posts = Post.objects.filter(
Q(title__icontains=query) |
Q(summary__icontains=query) |
Q(content__icontains=query)
)[:5]
results.extend([{
'type': 'post',
'title': post.title,
'url': f'/ui/posts/{post.id}'
} for post in posts])
links = Link.objects.filter(Q(alias__icontains=query) | Q(original_url__icontains=query))[:5]
results.extend([{'type': 'link', 'alias': link.alias, 'url': link.original_url} for link in links])
posts = Post.objects.filter(Q(title__icontains=query) | Q(summary__icontains=query) | Q(content__icontains=query))[:5]
results.extend([{'type': 'post', 'title': post.title, 'url': f'/ui/posts/{post.id}'} for post in posts])
else:
results = []
return JsonResponse(results, safe=False)
@require_http_methods(["GET"])
def search_api_v2(request):
"""
Modern API endpoint for React search interface
"""
query = request.GET.get('q', '').strip()
type_filter = request.GET.get('type', '')
sort_by = request.GET.get('sort', 'relevance')
page = int(request.GET.get('page', 1))
per_page = int(request.GET.get('per_page', 20))
if not query:
return JsonResponse({
'results': [],
'total': 0,
'page': page,
'per_page': per_page,
'has_next': False,
'has_prev': False
})
return JsonResponse({'results': [], 'total': 0, 'page': page, 'per_page': per_page, 'has_next': False, 'has_prev': False})
try:
# Use Whoosh search backend
search_results = search_backend.search(
query_string=query,
model_type=type_filter if type_filter else None,
page=page,
per_page=per_page,
sort_by=sort_by
)
# Enrich results with actual model data
search_results = search_backend.search(query_string=query, model_type=type_filter if type_filter else None, page=page, per_page=per_page, sort_by=sort_by)
enriched_results = []
for result in search_results['results']:
model_type = result['model_type']
model_id = result['model_id']
try:
if model_type == 'link':
link = Link.objects.get(id=model_id)
enriched_results.append({
'id': link.id,
'type': 'link',
'title': link.alias,
'url': f'/{link.alias}',
'original_url': link.original_url,
'description': link.description or '',
'click_count': link.click_count,
'created_at': link.created_at.isoformat(),
'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in link.tags.all()]
})
enriched_results.append({'id': link.id, 'type': 'link', 'title': link.alias, 'url': f'/{link.alias}', 'original_url': link.original_url, 'description': link.description or '', 'click_count': link.click_count, 'created_at': link.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in link.tags.all()]})
elif model_type == 'page':
page_obj = Page.objects.get(id=model_id)
enriched_results.append({
'id': page_obj.id,
'type': 'page',
'title': page_obj.title or 'Untitled',
'url': page_obj.url,
'detail_url': f'/ui/pages/{page_obj.id}/',
'summary': page_obj.summary or '',
'created_at': page_obj.created_at.isoformat(),
'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in page_obj.tags.all()]
})
enriched_results.append({'id': page_obj.id, 'type': 'page', 'title': page_obj.title or 'Untitled', 'url': page_obj.url, 'detail_url': f'/ui/pages/{page_obj.id}/', 'summary': page_obj.summary or '', 'created_at': page_obj.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in page_obj.tags.all()]})
elif model_type == 'post':
post = Post.objects.get(id=model_id)
enriched_results.append({
'id': post.id,
'type': 'post',
'title': post.title,
'url': f'/ui/posts/{post.id}/',
'summary': post.summary or '',
'created_at': post.created_at.isoformat(),
'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in post.tags.all()]
})
enriched_results.append({'id': post.id, 'type': 'post', 'title': post.title, 'url': f'/ui/posts/{post.id}/', 'summary': post.summary or '', 'created_at': post.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in post.tags.all()]})
except Exception as e:
logger.error(f"Error enriching {model_type} {model_id}: {e}")
continue
return JsonResponse({
'results': enriched_results,
'total': search_results['total'],
'page': search_results['page'],
'per_page': search_results['per_page'],
'has_next': search_results['has_next'],
'has_prev': search_results['has_prev']
})
return JsonResponse({'results': enriched_results, 'total': search_results['total'], 'page': search_results['page'], 'per_page': search_results['per_page'], 'has_next': search_results['has_next'], 'has_prev': search_results['has_prev']})
except Exception as e:
logger.error(f"Search API error: {e}", exc_info=True)
return JsonResponse({
'error': str(e),
'results': [],
'total': 0
}, status=500)
return JsonResponse({'error': str(e), 'results': [], 'total': 0}, status=500)
def search_aliases(request):
query = request.GET.get('q', '').strip()
# Prioritize results: exact match first, then starts with, then contains
links = Link.objects.filter(alias__icontains=query).annotate(
priority=Case(
When(alias__iexact=query, then=Value(1)),
When(alias__istartswith=query, then=Value(2)),
default=Value(3),
output_field=IntegerField()
)
).order_by('priority', 'alias')[:10]
links = Link.objects.filter(alias__icontains=query).annotate(priority=Case(When(alias__iexact=query, then=Value(1)), When(alias__istartswith=query, then=Value(2)), default=Value(3), output_field=IntegerField())).order_by('priority', 'alias')[:10]
results = []
for link in links:
try:
@@ -263,9 +189,7 @@ def search_aliases(request):
except Exception as e:
logger.error(f"Error creating URL for alias {link.alias}: {str(e)}")
continue
return JsonResponse(results, safe=False)
class SearchReactView(TemplateView):
"""Modern React-based search interface"""
template_name = 'links/search_react.html'
+5 -2
View File
@@ -200,6 +200,7 @@
const [query, setQuery] = useState('');
const [type, setType] = useState('');
const [sort, setSort] = useState('relevance');
const [isVector, setIsVector] = useState(false);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
@@ -226,7 +227,8 @@
per_page: perPage
});
const response = await fetch(`/search/api/v2/?${params}`);
const endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/";
const response = await fetch(`${endpoint}?${params}`);
const data = await response.json();
if (response.ok) {
@@ -336,7 +338,8 @@
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<Select value={sort} onChange={(e) => setSort(e.target.value)}>
<div className="flex items-center gap-2 mb-2"><label className="text-sm font-medium text-gray-700">AI Vector Search</label><input type="checkbox" checked={isVector} onChange={(e) => setIsVector(e.target.checked)} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /></div>
<Select value={sort} onChange={(e) => setSort(e.target.value)} disabled={isVector}>
<option value="relevance">Most Relevant</option>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>