feat: migrate semantic search to pgvector + full-text search
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
Replace JSON-string embeddings with native pgvector(1536) storage and add PostgreSQL full-text search (tsvector/GIN) with Reciprocal Rank Fusion for hybrid keyword + semantic ranking. Changes: - NoteEmbedding.embedding: String → vector(1536) via pgvector - NoteEmbedding: added updatedAt for reindex tracking - Note: added tsv (tsvector) with auto-update trigger for FTS - semantic-search.service: hybrid FTS + vector search with RRF fusion - embedding.service: toVectorString() for pgvector SQL literals - Removed JS-side cosine similarity loops (now DB-side via <=>) - Added HNSW index on NoteEmbedding.embedding (cosine distance) - Added GIN index on Note.tsv for FTS queries Schema migration in: prisma/migrations/20260512120000_pgvector_and_fts_search/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Check,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { Carnet } from '../types';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface HierarchicalCarnetSelectorProps {
|
||||
carnets: Carnet[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const HierarchicalCarnetSelector: React.FC<HierarchicalCarnetSelectorProps> = ({
|
||||
carnets,
|
||||
selectedId,
|
||||
onSelect,
|
||||
className = "",
|
||||
placeholder = "Sélectionner un carnet..."
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(['1', '4'])); // Default expand some
|
||||
|
||||
const selectedCarnet = carnets.find(c => c.id === selectedId);
|
||||
|
||||
// Derive the path for display
|
||||
const path = useMemo(() => {
|
||||
if (!selectedCarnet) return [];
|
||||
const trail: Carnet[] = [];
|
||||
let current = selectedCarnet;
|
||||
while (current) {
|
||||
trail.unshift(current);
|
||||
if (!current.parentId) break;
|
||||
const parent = carnets.find(c => c.id === current.parentId);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
}
|
||||
return trail;
|
||||
}, [selectedCarnet, carnets]);
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const newExpanded = new Set(expandedIds);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpandedIds(newExpanded);
|
||||
};
|
||||
|
||||
const filteredCarnets = useMemo(() => {
|
||||
if (!searchQuery) return carnets;
|
||||
return carnets.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [carnets, searchQuery]);
|
||||
|
||||
const renderTree = (parentId?: string, level = 0) => {
|
||||
const children = carnets.filter(c => c.parentId === parentId);
|
||||
if (children.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={level > 0 ? "ml-4 border-l border-border/40 pl-2" : ""}>
|
||||
{children.map(carnet => {
|
||||
const isExpanded = expandedIds.has(carnet.id) || searchQuery.length > 0;
|
||||
const hasChildren = carnets.some(c => c.parentId === carnet.id);
|
||||
const isSelected = selectedId === carnet.id;
|
||||
|
||||
// If searching and this carnet doesn't match AND none of its children match, skip it
|
||||
if (searchQuery && !carnet.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
const hasMatchingChild = (id: string): boolean => {
|
||||
const childrenOfId = carnets.filter(c => c.parentId === id);
|
||||
return childrenOfId.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(c.id));
|
||||
};
|
||||
if (!hasMatchingChild(carnet.id)) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={carnet.id} className="select-none">
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelect(carnet.id);
|
||||
if (!searchQuery) setIsOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => toggleExpand(e, carnet.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] truncate flex-1">{carnet.name}</span>
|
||||
|
||||
{isSelected && <Check size={14} className="opacity-60" />}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{renderTree(carnet.id, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/80 rounded-xl px-4 py-4 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3"
|
||||
>
|
||||
<Folder size={16} className="text-blueprint/60 shrink-0" />
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{path.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
{path.map((item, i) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
|
||||
<span className={`truncate ${i === path.length - 1 ? 'font-bold' : 'text-concrete'}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-concrete italic">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
className="absolute z-[70] mt-2 w-full bg-white dark:bg-dark-paper border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-[280px]"
|
||||
>
|
||||
<div className="p-3 border-b border-border/40 bg-slate-50/50">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filtrer les carnets..."
|
||||
className="w-full bg-white border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
|
||||
{renderTree(undefined)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/40 bg-slate-50/30 flex justify-between items-center px-4">
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">
|
||||
Structure des carnets
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-bold text-blueprint hover:underline"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user