feat: switch embeddings from Voyage AI to Google Gemini text-embedding-004
- embedText: call Gemini REST API (768-dim) instead of Voyage (1024-dim) - Migration: drop+recreate incidents.embedding as vector(768), update match_incidents function, swap VOYAGE_API_KEY → GOOGLE_AI_API_KEY in app_settings - Settings UI: relabel to "Google AI API Key (Embeddings)" - All call sites updated (incidents POST, similar GET) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
@@ -18,7 +18,7 @@ export async function GET(
|
||||
if (!profile || !['hse', 'admin'].includes(profile.role))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const voyageKey = await getApiKey(supabase, 'VOYAGE_API_KEY')
|
||||
const googleAiKey = await getApiKey(supabase, 'GOOGLE_AI_API_KEY')
|
||||
|
||||
const { data: incident } = await supabase
|
||||
.from('incidents')
|
||||
@@ -34,7 +34,7 @@ export async function GET(
|
||||
if (inc.embedding) {
|
||||
embeddingVec = JSON.parse(inc.embedding) as number[]
|
||||
} else {
|
||||
embeddingVec = await embedText(inc.description, voyageKey)
|
||||
embeddingVec = await embedText(inc.description, googleAiKey)
|
||||
// Closed incidents are locked at the DB level — the trigger would reject
|
||||
// this backfill. The vector still serves the similarity query below.
|
||||
if (inc.status !== 'closed') {
|
||||
|
||||
@@ -182,9 +182,9 @@ export async function POST(request: Request) {
|
||||
// Embed description asynchronously for future similarity search
|
||||
const supabaseForEmbed = supabase
|
||||
import('@/lib/settings').then(({ getApiKey }) =>
|
||||
getApiKey(supabaseForEmbed, 'VOYAGE_API_KEY').then(voyageKey =>
|
||||
getApiKey(supabaseForEmbed, 'GOOGLE_AI_API_KEY').then(googleAiKey =>
|
||||
import('@/lib/claude/embed').then(({ embedText }) =>
|
||||
embedText(input.description.trim(), voyageKey).then(embedding =>
|
||||
embedText(input.description.trim(), googleAiKey).then(embedding =>
|
||||
supabase.from('incidents').update({
|
||||
embedding: `[${embedding.join(',')}]` as unknown as string,
|
||||
}).eq('id', incident.id)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
const ALLOWED_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'DEEPSEEK_API_KEY',
|
||||
'VOYAGE_API_KEY',
|
||||
'GOOGLE_AI_API_KEY',
|
||||
'META_WHATSAPP_PHONE_NUMBER_ID',
|
||||
'META_WHATSAPP_ACCESS_TOKEN',
|
||||
] as const
|
||||
|
||||
@@ -10,13 +10,13 @@ interface Props {
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
DEEPSEEK_API_KEY: 'DeepSeek API Key (AI)',
|
||||
VOYAGE_API_KEY: 'Voyage AI API Key (Embeddings)',
|
||||
GOOGLE_AI_API_KEY: 'Google AI API Key (Embeddings)',
|
||||
}
|
||||
|
||||
export function ApiKeyForm({ settings }: Props) {
|
||||
const [values, setValues] = useState<Record<string, string>>({
|
||||
DEEPSEEK_API_KEY: '',
|
||||
VOYAGE_API_KEY: '',
|
||||
GOOGLE_AI_API_KEY: '',
|
||||
})
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [results, setResults] = useState<Record<string, 'ok' | 'error'>>({})
|
||||
|
||||
+15
-12
@@ -1,15 +1,18 @@
|
||||
export async function embedText(text: string, apiKey?: string): Promise<number[]> {
|
||||
const key = apiKey ?? process.env.VOYAGE_API_KEY
|
||||
if (!key) throw new Error('VOYAGE_API_KEY is not set')
|
||||
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
|
||||
const key = apiKey ?? process.env.GOOGLE_AI_API_KEY
|
||||
if (!key) throw new Error('GOOGLE_AI_API_KEY is not set')
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${key}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({ input: [text], model: 'voyage-3-lite' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Voyage embed failed: ${res.status}`)
|
||||
const json = await res.json() as { data: Array<{ embedding: number[] }> }
|
||||
return json.data[0].embedding
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'models/text-embedding-004',
|
||||
content: { parts: [{ text }] },
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!res.ok) throw new Error(`Gemini embed failed: ${res.status}`)
|
||||
const json = await res.json() as { embedding: { values: number[] } }
|
||||
return json.embedding.values
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Migrate embeddings from Voyage AI (1024-dim) to Gemini text-embedding-004 (768-dim)
|
||||
|
||||
-- Drop dependent objects first
|
||||
drop index if exists incidents_embedding_idx;
|
||||
drop function if exists match_incidents;
|
||||
|
||||
-- Replace column (dimension change requires drop+add)
|
||||
alter table incidents drop column if exists embedding;
|
||||
alter table incidents add column embedding vector(768);
|
||||
|
||||
-- Recreate index
|
||||
create index incidents_embedding_idx
|
||||
on incidents using ivfflat (embedding vector_cosine_ops)
|
||||
with (lists = 10);
|
||||
|
||||
-- Recreate similarity function at 768-dim
|
||||
create or replace function match_incidents(
|
||||
query_embedding vector(768),
|
||||
exclude_id uuid,
|
||||
match_count int default 5
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
reference_no text,
|
||||
incident_type text,
|
||||
description text,
|
||||
severity int,
|
||||
similarity float
|
||||
)
|
||||
language sql
|
||||
security definer
|
||||
as $$
|
||||
select
|
||||
i.id,
|
||||
i.reference_no,
|
||||
i.incident_type,
|
||||
i.description,
|
||||
i.severity,
|
||||
1 - (i.embedding <=> query_embedding) as similarity
|
||||
from incidents i
|
||||
where i.id != exclude_id
|
||||
and i.embedding is not null
|
||||
order by i.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
$$;
|
||||
|
||||
-- Register Gemini key slot, remove Voyage slot
|
||||
insert into app_settings (key, value) values ('GOOGLE_AI_API_KEY', '') on conflict (key) do nothing;
|
||||
delete from app_settings where key = 'VOYAGE_API_KEY';
|
||||
@@ -2,36 +2,36 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
describe('embedText', () => {
|
||||
beforeEach(() => {
|
||||
process.env.VOYAGE_API_KEY = 'test-key'
|
||||
process.env.GOOGLE_AI_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns 1024-element embedding array', async () => {
|
||||
const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i / 1024)
|
||||
it('returns 768-element embedding array', async () => {
|
||||
const mockEmbedding = Array.from({ length: 768 }, (_, i) => i / 768)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ embedding: mockEmbedding }] }),
|
||||
json: () => Promise.resolve({ embedding: { values: mockEmbedding } }),
|
||||
}))
|
||||
const { embedText } = await import('@/lib/claude/embed')
|
||||
const result = await embedText('forklift hit racking in zone B')
|
||||
expect(result).toHaveLength(1024)
|
||||
expect(result).toHaveLength(768)
|
||||
expect(result[0]).toBeCloseTo(0)
|
||||
expect(result[1023]).toBeCloseTo(1023 / 1024)
|
||||
expect(result[767]).toBeCloseTo(767 / 768)
|
||||
})
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 }))
|
||||
const { embedText } = await import('@/lib/claude/embed')
|
||||
await expect(embedText('test')).rejects.toThrow('Voyage embed failed: 401')
|
||||
await expect(embedText('test')).rejects.toThrow('Gemini embed failed: 401')
|
||||
})
|
||||
|
||||
it('throws when VOYAGE_API_KEY is missing', async () => {
|
||||
delete process.env.VOYAGE_API_KEY
|
||||
it('throws when GOOGLE_AI_API_KEY is missing', async () => {
|
||||
delete process.env.GOOGLE_AI_API_KEY
|
||||
vi.resetModules()
|
||||
const { embedText } = await import('@/lib/claude/embed')
|
||||
await expect(embedText('test')).rejects.toThrow('VOYAGE_API_KEY')
|
||||
await expect(embedText('test')).rejects.toThrow('GOOGLE_AI_API_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user