package service import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "time" ) type SemanticSearchResult struct { ID int64 `json:"id"` Title string `json:"title"` MediaType string `json:"media_type"` Year *int `json:"year,omitempty"` Score float64 `json:"score"` Overview string `json:"overview,omitempty"` } type ollamaEmbedRequest struct { Model string `json:"model"` Prompt string `json:"prompt"` } type ollamaEmbedResponse struct { Embedding []float64 `json:"embedding"` } type qdrantSearchRequest struct { Vector []float64 `json:"vector"` Limit int `json:"limit"` WithPayload bool `json:"with_payload"` } type qdrantSearchResponse struct { Result []qdrantHit `json:"result"` } type qdrantHit struct { ID string `json:"id"` Score float64 `json:"score"` Payload json.RawMessage `json:"payload"` } type qdrantPayload struct { ID int64 `json:"id"` Title string `json:"title"` MediaType string `json:"media_type"` Year *int `json:"year,omitempty"` Overview string `json:"overview,omitempty"` } type SemanticSearchService struct { ollamaURL string qdrantURL string httpClient *http.Client } func NewSemanticSearchService(ollamaURL, qdrantURL string) *SemanticSearchService { return &SemanticSearchService{ ollamaURL: ollamaURL, qdrantURL: qdrantURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } func (s *SemanticSearchService) Search(ctx context.Context, query string, k int) ([]SemanticSearchResult, error) { embedding, err := s.embed(ctx, query) if err != nil { return nil, fmt.Errorf("generate embedding: %w", err) } results, err := s.searchQdrant(ctx, embedding, k) if err != nil { return nil, fmt.Errorf("search qdrant: %w", err) } return results, nil } func (s *SemanticSearchService) embed(ctx context.Context, text string) ([]float64, error) { reqBody := ollamaEmbedRequest{ Model: "nomic-embed-text", Prompt: text, } bodyBytes, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal embed request: %w", err) } reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, s.ollamaURL+"/api/embeddings", bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("create embed request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("ollama request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) slog.Error("ollama embed returned non-200", "status", resp.StatusCode, "body", string(respBody)) return nil, fmt.Errorf("ollama returned status %d", resp.StatusCode) } var embedResp ollamaEmbedResponse if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil { return nil, fmt.Errorf("decode ollama response: %w", err) } if len(embedResp.Embedding) == 0 { return nil, fmt.Errorf("ollama returned empty embedding") } return embedResp.Embedding, nil } func (s *SemanticSearchService) searchQdrant(ctx context.Context, embedding []float64, k int) ([]SemanticSearchResult, error) { reqBody := qdrantSearchRequest{ Vector: embedding, Limit: k, WithPayload: true, } bodyBytes, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal qdrant request: %w", err) } reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() url := fmt.Sprintf("%s/collections/media/points/search", s.qdrantURL) req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(bodyBytes)) if err != nil { return nil, fmt.Errorf("create qdrant request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("qdrant request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) slog.Error("qdrant search returned non-200", "status", resp.StatusCode, "body", string(respBody)) return nil, fmt.Errorf("qdrant returned status %d", resp.StatusCode) } var searchResp qdrantSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return nil, fmt.Errorf("decode qdrant response: %w", err) } results := make([]SemanticSearchResult, 0, len(searchResp.Result)) for _, hit := range searchResp.Result { var payload qdrantPayload if err := json.Unmarshal(hit.Payload, &payload); err != nil { slog.Error("failed to unmarshal qdrant payload", "error", err) continue } results = append(results, SemanticSearchResult{ ID: payload.ID, Title: payload.Title, MediaType: payload.MediaType, Year: payload.Year, Score: hit.Score, Overview: payload.Overview, }) } return results, nil }