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))
|
if (!profile || !['hse', 'admin'].includes(profile.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
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
|
const { data: incident } = await supabase
|
||||||
.from('incidents')
|
.from('incidents')
|
||||||
@@ -34,7 +34,7 @@ export async function GET(
|
|||||||
if (inc.embedding) {
|
if (inc.embedding) {
|
||||||
embeddingVec = JSON.parse(inc.embedding) as number[]
|
embeddingVec = JSON.parse(inc.embedding) as number[]
|
||||||
} else {
|
} 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
|
// Closed incidents are locked at the DB level — the trigger would reject
|
||||||
// this backfill. The vector still serves the similarity query below.
|
// this backfill. The vector still serves the similarity query below.
|
||||||
if (inc.status !== 'closed') {
|
if (inc.status !== 'closed') {
|
||||||
|
|||||||
@@ -182,9 +182,9 @@ export async function POST(request: Request) {
|
|||||||
// Embed description asynchronously for future similarity search
|
// Embed description asynchronously for future similarity search
|
||||||
const supabaseForEmbed = supabase
|
const supabaseForEmbed = supabase
|
||||||
import('@/lib/settings').then(({ getApiKey }) =>
|
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 }) =>
|
import('@/lib/claude/embed').then(({ embedText }) =>
|
||||||
embedText(input.description.trim(), voyageKey).then(embedding =>
|
embedText(input.description.trim(), googleAiKey).then(embedding =>
|
||||||
supabase.from('incidents').update({
|
supabase.from('incidents').update({
|
||||||
embedding: `[${embedding.join(',')}]` as unknown as string,
|
embedding: `[${embedding.join(',')}]` as unknown as string,
|
||||||
}).eq('id', incident.id)
|
}).eq('id', incident.id)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
const ALLOWED_KEYS = [
|
const ALLOWED_KEYS = [
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
'DEEPSEEK_API_KEY',
|
'DEEPSEEK_API_KEY',
|
||||||
'VOYAGE_API_KEY',
|
'GOOGLE_AI_API_KEY',
|
||||||
'META_WHATSAPP_PHONE_NUMBER_ID',
|
'META_WHATSAPP_PHONE_NUMBER_ID',
|
||||||
'META_WHATSAPP_ACCESS_TOKEN',
|
'META_WHATSAPP_ACCESS_TOKEN',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ interface Props {
|
|||||||
|
|
||||||
const KEY_LABELS: Record<string, string> = {
|
const KEY_LABELS: Record<string, string> = {
|
||||||
DEEPSEEK_API_KEY: 'DeepSeek API Key (AI)',
|
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) {
|
export function ApiKeyForm({ settings }: Props) {
|
||||||
const [values, setValues] = useState<Record<string, string>>({
|
const [values, setValues] = useState<Record<string, string>>({
|
||||||
DEEPSEEK_API_KEY: '',
|
DEEPSEEK_API_KEY: '',
|
||||||
VOYAGE_API_KEY: '',
|
GOOGLE_AI_API_KEY: '',
|
||||||
})
|
})
|
||||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||||
const [results, setResults] = useState<Record<string, 'ok' | 'error'>>({})
|
const [results, setResults] = useState<Record<string, 'ok' | 'error'>>({})
|
||||||
|
|||||||
+16
-13
@@ -1,15 +1,18 @@
|
|||||||
export async function embedText(text: string, apiKey?: string): Promise<number[]> {
|
export async function embedText(text: string, apiKey?: string): Promise<number[]> {
|
||||||
const key = apiKey ?? process.env.VOYAGE_API_KEY
|
const key = apiKey ?? process.env.GOOGLE_AI_API_KEY
|
||||||
if (!key) throw new Error('VOYAGE_API_KEY is not set')
|
if (!key) throw new Error('GOOGLE_AI_API_KEY is not set')
|
||||||
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
|
const res = await fetch(
|
||||||
method: 'POST',
|
`https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${key}`,
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
'Authorization': `Bearer ${key}`,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
},
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({ input: [text], model: 'voyage-3-lite' }),
|
model: 'models/text-embedding-004',
|
||||||
})
|
content: { parts: [{ text }] },
|
||||||
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
|
)
|
||||||
|
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', () => {
|
describe('embedText', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.VOYAGE_API_KEY = 'test-key'
|
process.env.GOOGLE_AI_API_KEY = 'test-key'
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 1024-element embedding array', async () => {
|
it('returns 768-element embedding array', async () => {
|
||||||
const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i / 1024)
|
const mockEmbedding = Array.from({ length: 768 }, (_, i) => i / 768)
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: [{ embedding: mockEmbedding }] }),
|
json: () => Promise.resolve({ embedding: { values: mockEmbedding } }),
|
||||||
}))
|
}))
|
||||||
const { embedText } = await import('@/lib/claude/embed')
|
const { embedText } = await import('@/lib/claude/embed')
|
||||||
const result = await embedText('forklift hit racking in zone B')
|
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[0]).toBeCloseTo(0)
|
||||||
expect(result[1023]).toBeCloseTo(1023 / 1024)
|
expect(result[767]).toBeCloseTo(767 / 768)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws on non-ok response', async () => {
|
it('throws on non-ok response', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 }))
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 }))
|
||||||
const { embedText } = await import('@/lib/claude/embed')
|
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 () => {
|
it('throws when GOOGLE_AI_API_KEY is missing', async () => {
|
||||||
delete process.env.VOYAGE_API_KEY
|
delete process.env.GOOGLE_AI_API_KEY
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
const { embedText } = await import('@/lib/claude/embed')
|
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