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:
2026-07-13 06:50:35 +08:00
co-authored by Claude Sonnet 4.6
parent 614c792225
commit d10c690c12
7 changed files with 82 additions and 30 deletions
+2 -2
View File
@@ -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') {
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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';
+10 -10
View File
@@ -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')
}) })
}) })