import { BaseModel, api } from "sonamu";
import { Embedding } from "sonamu/vector";
class ProductModelClass extends BaseModel {
@api({ httpMethod: 'POST' })
async hybridSearch(
query: string,
vectorWeight: number = 0.7,
ftsWeight: number = 0.3,
limit: number = 10
) {
// 1. Query embedding
const embedding = await Embedding.embedOne(query, 'voyage', 'query');
// 2. Hybrid search SQL
const results = await this.getPuri().raw(`
WITH vector_results AS (
SELECT
id,
1 - (embedding <=> ?) AS vector_score
FROM products
WHERE embedding IS NOT NULL
),
fts_results AS (
SELECT
id,
ts_rank(search_vector, plainto_tsquery('simple', ?)) AS fts_score
FROM products
WHERE search_vector @@ plainto_tsquery('simple', ?)
)
SELECT
p.id,
p.name,
p.description,
p.price,
COALESCE(v.vector_score, 0) AS vector_score,
COALESCE(f.fts_score, 0) AS fts_score,
(COALESCE(v.vector_score, 0) * ? + COALESCE(f.fts_score, 0) * ?) AS hybrid_score
FROM products p
LEFT JOIN vector_results v ON p.id = v.id
LEFT JOIN fts_results f ON p.id = f.id
WHERE v.id IS NOT NULL OR f.id IS NOT NULL
ORDER BY hybrid_score DESC
LIMIT ?
`, [
JSON.stringify(embedding.embedding), // vector
query, // fts (1)
query, // fts (2)
vectorWeight, // 0.7
ftsWeight, // 0.3
limit,
]);
return results.rows.map(row => ({
id: row.id,
name: row.name,
price: row.price,
vectorScore: parseFloat(row.vector_score.toFixed(3)),
ftsScore: parseFloat(row.fts_score.toFixed(3)),
hybridScore: parseFloat(row.hybrid_score.toFixed(3)),
}));
}
}