mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Merge pull request #51 from wahyd4/feat/vector-search-fast-track
feat: rapid vector search UI
This commit is contained in:
@@ -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
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user