CREATE OR REPLACE FUNCTION hybrid_search_quizzes(
query_text TEXT,
query_embedding VECTOR(1536),
user_id UUID,
match_count INT DEFAULT 10,
full_text_weight FLOAT DEFAULT 1.0,
semantic_weight FLOAT DEFAULT 1.0,
rrf_k INT DEFAULT 60
)
RETURNS TABLE (
id UUID,
title TEXT,
description TEXT,
topic TEXT[],
tags TEXT[],
type TEXT,
language TEXT,
created_at TIMESTAMPTZ,
questions_count SMALLINT,
user_score SMALLINT,
correct_answers_count SMALLINT,
generated_tags TEXT[],
generated_categories TEXT[]
)
LANGUAGE SQL
AS $$
WITH full_text AS (
SELECT
id,
ROW_NUMBER() OVER(
ORDER BY
ts_rank_cd(
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'B') ||
setweight(to_tsvector('english', array_to_string(topic, ' ')), 'C'),
websearch_to_tsquery(query_text)
) DESC
) AS rank_ix
FROM
quizzes
WHERE
user_id = hybrid_search_quizzes.user_id
AND (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'B') ||
setweight(to_tsvector('english', array_to_string(topic, ' ')), 'C')
) @@ websearch_to_tsquery(query_text)
ORDER BY rank_ix
LIMIT LEAST(match_count * 2, 100)
),
semantic AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <#> query_embedding) AS rank_ix
FROM
quizzes
WHERE
user_id = hybrid_search_quizzes.user_id
AND embedding IS NOT NULL
ORDER BY rank_ix
LIMIT LEAST(match_count * 2, 100)
)
SELECT
q.id,
q.title,
q.description,
q.topic,
q.tags,
q.type::TEXT,
q.language,
q.created_at,
q.questions_count,
q.user_score,
q.correct_answers_count,
q.generated_tags,
q.generated_categories
FROM
full_text ft
FULL OUTER JOIN semantic sem ON ft.id = sem.id
JOIN quizzes q ON COALESCE(ft.id, sem.id) = q.id
WHERE
q.user_id = hybrid_search_quizzes.user_id
ORDER BY
(COALESCE(1.0 / (rrf_k + ft.rank_ix), 0.0) * full_text_weight +
COALESCE(1.0 / (rrf_k + sem.rank_ix), 0.0) * semantic_weight) DESC
LIMIT
match_count;
$$;