feat(V2-5차): pgvector 시맨틱 검색 + chat 스트리밍 + doc 편집 + VoiceAction + Realtime + storage + 메모
묶음 Q — pgvector + 시맨틱 검색:
- migrations/20260410000004_pgvector_knowledge.sql
- vector extension, embedding vector(1536) 컬럼
- ivfflat cosine 인덱스
- match_knowledge_chunks(query_embedding, match_count, similarity_threshold) RPC
- RLS: user_id 또는 소속 팀 기준
- functions/embed-chunks: 문서 소유권 확인 후 OpenAI text-embedding-3-small 배치 호출 → knowledge_chunks.embedding 업데이트
- functions/search-knowledge: 쿼리 텍스트 → OpenAI 임베딩 → user 권한 RPC 호출 → 상위 청크 반환
- config.toml에 embed-chunks/search-knowledge 등록
- components/knowledge/knowledge-search.tsx: 검색창 + 결과 카드(유사도 %)
- /knowledge 페이지에 검색 UI 추가
묶음 R — /chat SSE 스트리밍:
- functions/llm-proxy: Anthropic Messages API stream 지원
- ANTHROPIC_API_KEY 없으면 SSE placeholder 스트림
- stream=true일 때 response.body 그대로 프록시 (text/event-stream)
- stream=false는 JSON 응답
- components/chat/chat-panel.tsx:
- stream=true로 요청
- ReadableStream 파싱 (SSE: data: {type:content_block_delta, delta:{text_delta}})
- assistantId 메시지를 progressive 업데이트, scrollToBottom
- 불필요한 LlmResponse 인터페이스 제거
묶음 S — DocumentEditor:
- components/meetings/document-editor.tsx
- 문서 박스 클릭 → MUI Dialog (fullWidth, maxWidth md)
- TextField multiline 20~40 rows, monospace
- 제목 편집 + 저장/삭제 버튼
- meetings/[id] 페이지 Documents 섹션을 DocumentEditor로 교체 (+ typoSx 미사용 import 제거)
묶음 T — /actions (VoiceAction 이식):
- components/actions/action-runner.tsx
- SYSTEM_PROMPT로 JSON 스키마 강제 (create_meeting/search_knowledge/create_memo/send_team_invite/unknown)
- LLM 응답에서 JSON 추출 → 파싱 → 확인 후 실행
- 각 type별 실행 로직 (meetings/memos INSERT, 검색은 안내만)
- app/(app)/actions/page.tsx + Sidebar Actions 메뉴 + AutoAwesomeIcon
묶음 U — Realtime + Storage + 메모 UI:
- CloudSyncService:
- RealtimeChannel import 추가
- startRealtime(): meetings/history/dictionary 변경 구독, debounce 후 pullAll 자동 트리거
- stopRealtime(), signIn 직후/세션 복원 시 자동 시작, signOut 시 종료
- apps/web/components/meetings/memo-form.tsx
- TextField + 저장 버튼, 회의 시작 기준 경과 ms 자동 계산
- Realtime 구독자에게 자동 전파
- meetings/[id] MEMOS 섹션에 MemoForm 렌더
- apps/web/components/record/mic-recorder.tsx
- STT 성공 후 Supabase Storage 'audio' 버킷에 {user_id}/{ts}.webm 업로드
- meetings 테이블에 INSERT (raw_transcript, audio_storage_key, duration_ms, ended_at)
- Storage/meetings 실패는 전사 결과는 유지하며 경고
묶음 V — 11개 locale nav 키:
- en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th 에 nav.chat/nav.knowledge/nav.actions 추가
- ko.json에 nav.actions 추가
- Sidebar에 Actions 메뉴(AutoAwesomeIcon) 등록
검증:
- desktop typecheck + build OK
- web typecheck + build OK (15 라우트: 기존 14 + /actions)
- api-client test 19 passed
- 회귀 없음
통계:
- 총 Edge Functions 12개 (embed-chunks/search-knowledge 추가)
- 총 SQL 마이그레이션 8개
- 웹 라우트 15개 (accept-invite/actions/billing/chat/dashboard/knowledge/login/meetings/meetings[id]/record/teams/teams[id]/auth-callback/root/_not-found)
This commit is contained in:
parent
1fa24ce3c9
commit
b386733d1e
29 changed files with 1363 additions and 81 deletions
|
|
@ -102,5 +102,11 @@ verify_jwt = true
|
|||
[functions.send-push]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.embed-chunks]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.search-knowledge]
|
||||
verify_jwt = true
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
|
|
|
|||
159
server/supabase/functions/embed-chunks/index.ts
Normal file
159
server/supabase/functions/embed-chunks/index.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// server/supabase/functions/embed-chunks/index.ts
|
||||
// 특정 document의 청크들을 OpenAI 임베딩으로 변환하여 knowledge_chunks.embedding 컬럼 업데이트.
|
||||
//
|
||||
// 요청:
|
||||
// POST { document_id: "uuid" }
|
||||
// 응답:
|
||||
// { embedded: number, errors: string[] }
|
||||
//
|
||||
// 환경변수:
|
||||
// OPENAI_API_KEY (text-embedding-3-small 사용)
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
interface EmbedRequest {
|
||||
document_id: string
|
||||
}
|
||||
|
||||
interface OpenAIEmbeddingResponse {
|
||||
data: Array<{ embedding: number[]; index: number }>
|
||||
model: string
|
||||
usage: { prompt_tokens: number; total_tokens: number }
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const body = (await req.json()) as EmbedRequest
|
||||
|
||||
if (!body.document_id) {
|
||||
return new Response(JSON.stringify({ error: 'document_id 필요' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno.env
|
||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
||||
if (!openaiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'openai_not_configured', message: 'OPENAI_API_KEY 미설정' }),
|
||||
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
// 문서 소유권 확인
|
||||
const { data: doc } = await serviceClient
|
||||
.from('knowledge_documents')
|
||||
.select('id, user_id')
|
||||
.eq('id', body.document_id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!doc || (doc.user_id as string) !== user.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'forbidden' }),
|
||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 임베딩 대상 청크 조회 (embedding IS NULL인 것만)
|
||||
const { data: chunks, error: chunksErr } = await serviceClient
|
||||
.from('knowledge_chunks')
|
||||
.select('id, content')
|
||||
.eq('document_id', body.document_id)
|
||||
.is('embedding', null)
|
||||
|
||||
if (chunksErr) {
|
||||
throw new Error(`청크 조회 실패: ${chunksErr.message}`)
|
||||
}
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return new Response(JSON.stringify({ embedded: 0, errors: [] }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI 임베딩 API 배치 호출 (한 번에 최대 100개)
|
||||
const errors: string[] = []
|
||||
let embedded = 0
|
||||
const batchSize = 100
|
||||
|
||||
for (let i = 0; i < chunks.length; i += batchSize) {
|
||||
const batch = chunks.slice(i, i + batchSize) as Array<{ id: string; content: string }>
|
||||
try {
|
||||
const resp = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'text-embedding-3-small',
|
||||
input: batch.map((c) => c.content),
|
||||
dimensions: 1536
|
||||
})
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text()
|
||||
errors.push(`OpenAI ${resp.status}: ${errText.slice(0, 200)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as OpenAIEmbeddingResponse
|
||||
|
||||
// 각 청크에 embedding 업데이트
|
||||
for (const item of data.data) {
|
||||
const chunk = batch[item.index]
|
||||
const { error: updateErr } = await serviceClient
|
||||
.from('knowledge_chunks')
|
||||
.update({ embedding: item.embedding })
|
||||
.eq('id', chunk.id)
|
||||
|
||||
if (updateErr) {
|
||||
errors.push(`chunk ${chunk.id}: ${updateErr.message}`)
|
||||
} else {
|
||||
embedded++
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// 문서 indexed 플래그 업데이트
|
||||
await serviceClient
|
||||
.from('knowledge_documents')
|
||||
.update({ indexed: true, indexed_at: new Date().toISOString() })
|
||||
.eq('id', body.document_id)
|
||||
|
||||
return new Response(JSON.stringify({ embedded, errors }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -78,50 +78,100 @@ Deno.serve(async (req: Request) => {
|
|||
)
|
||||
}
|
||||
|
||||
// Anthropic API 호출 — placeholder
|
||||
//
|
||||
// 실제 구현 시:
|
||||
// const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')!
|
||||
// const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'x-api-key': anthropicKey,
|
||||
// 'anthropic-version': '2023-06-01'
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// model: requestedModel,
|
||||
// max_tokens: body.max_tokens ?? 2048,
|
||||
// system: body.system,
|
||||
// messages: body.messages,
|
||||
// stream: body.stream ?? false
|
||||
// })
|
||||
// })
|
||||
// if (body.stream) {
|
||||
// return new Response(resp.body, {
|
||||
// headers: { ...corsHeaders, 'Content-Type': 'text/event-stream' }
|
||||
// })
|
||||
// }
|
||||
// const data = await resp.json()
|
||||
// ...
|
||||
// Anthropic API 호출
|
||||
// @ts-expect-error — Deno.env
|
||||
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
|
||||
|
||||
await consumeQuota(user.id, 'llm_process', serviceClient, 1)
|
||||
|
||||
const placeholder = {
|
||||
id: `msg_placeholder_${Date.now()}`,
|
||||
model: requestedModel,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
if (!anthropicKey) {
|
||||
// Placeholder 응답 (키 미설정 시)
|
||||
if (body.stream) {
|
||||
// 스트리밍 placeholder — SSE로 "설정되지 않음" 메시지 전송
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const msg = '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]'
|
||||
for (const ch of msg) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n`
|
||||
)
|
||||
)
|
||||
}
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: `msg_placeholder_${Date.now()}`,
|
||||
model: requestedModel,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]'
|
||||
}
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
}),
|
||||
{
|
||||
type: 'text',
|
||||
text: '[llm-proxy placeholder — Anthropic API not yet wired]'
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(placeholder), {
|
||||
// 실제 Anthropic API 호출
|
||||
const anthropicResp = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': anthropicKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: requestedModel,
|
||||
max_tokens: body.max_tokens ?? 2048,
|
||||
system: body.system,
|
||||
messages: body.messages,
|
||||
stream: body.stream ?? false
|
||||
})
|
||||
})
|
||||
|
||||
if (!anthropicResp.ok) {
|
||||
const errText = await anthropicResp.text()
|
||||
throw new Error(`Anthropic ${anthropicResp.status}: ${errText.slice(0, 500)}`)
|
||||
}
|
||||
|
||||
if (body.stream && anthropicResp.body) {
|
||||
// SSE 스트림을 그대로 전달
|
||||
return new Response(anthropicResp.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const data = await anthropicResp.json()
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
|
|
|||
114
server/supabase/functions/search-knowledge/index.ts
Normal file
114
server/supabase/functions/search-knowledge/index.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// server/supabase/functions/search-knowledge/index.ts
|
||||
// 쿼리 텍스트 → OpenAI 임베딩 → match_knowledge_chunks RPC → 상위 청크 반환.
|
||||
//
|
||||
// 요청:
|
||||
// POST { query: "...", count?: number }
|
||||
// 응답:
|
||||
// { results: Array<{ id, document_id, chunk_index, content, similarity }> }
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
|
||||
interface SearchRequest {
|
||||
query: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.103.0'
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await requireUser(req) // 인증만 확인. RPC는 auth.uid() 기반 RLS
|
||||
|
||||
const body = (await req.json()) as SearchRequest
|
||||
if (!body.query || body.query.trim().length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'query 필요' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno.env
|
||||
const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? ''
|
||||
if (!openaiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'openai_not_configured' }),
|
||||
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 1) 쿼리 임베딩
|
||||
const embedResp = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'text-embedding-3-small',
|
||||
input: body.query,
|
||||
dimensions: 1536
|
||||
})
|
||||
})
|
||||
|
||||
if (!embedResp.ok) {
|
||||
const errText = await embedResp.text()
|
||||
throw new Error(`임베딩 실패: ${errText.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const embedData = (await embedResp.json()) as {
|
||||
data: Array<{ embedding: number[] }>
|
||||
}
|
||||
const queryEmbedding = embedData.data[0]?.embedding
|
||||
if (!queryEmbedding) {
|
||||
throw new Error('임베딩 응답이 비어있음')
|
||||
}
|
||||
|
||||
// 2) RLS 컨텍스트에서 RPC 호출 (authenticated 유저 토큰 사용)
|
||||
const authHeader = req.headers.get('Authorization') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
|
||||
// @ts-expect-error — Deno.env
|
||||
const anonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
|
||||
|
||||
const userClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } }
|
||||
})
|
||||
|
||||
const { data: matches, error: rpcErr } = await userClient.rpc('match_knowledge_chunks', {
|
||||
query_embedding: queryEmbedding,
|
||||
match_count: body.count ?? 5,
|
||||
similarity_threshold: 0.5
|
||||
})
|
||||
|
||||
if (rpcErr) {
|
||||
throw new Error(`RPC 실패: ${rpcErr.message}`)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ results: matches ?? [] }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
-- ============================================================================
|
||||
-- Phase V2-Q: pgvector 활성화 + knowledge 시맨틱 검색
|
||||
-- embedding 컬럼 + cosine 유사도 검색 RPC
|
||||
-- ============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- 기존 knowledge_chunks에 embedding 컬럼 추가 (1536 차원 — OpenAI text-embedding-3-small)
|
||||
ALTER TABLE public.knowledge_chunks
|
||||
ADD COLUMN embedding vector(1536);
|
||||
|
||||
-- IVFFlat 인덱스 (빠른 근사 최근접)
|
||||
-- 데이터 삽입 후 `REINDEX TABLE knowledge_chunks;` 권장
|
||||
CREATE INDEX idx_knowledge_chunks_embedding
|
||||
ON public.knowledge_chunks
|
||||
USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
|
||||
-- ============================================================================
|
||||
-- match_knowledge_chunks: 쿼리 임베딩과 가장 유사한 청크 반환
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.match_knowledge_chunks(
|
||||
query_embedding vector(1536),
|
||||
match_count integer DEFAULT 5,
|
||||
similarity_threshold double precision DEFAULT 0.5
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
document_id uuid,
|
||||
chunk_index integer,
|
||||
content text,
|
||||
similarity double precision
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
kc.id,
|
||||
kc.document_id,
|
||||
kc.chunk_index,
|
||||
kc.content,
|
||||
(1 - (kc.embedding <=> query_embedding))::double precision AS similarity
|
||||
FROM public.knowledge_chunks kc
|
||||
INNER JOIN public.knowledge_documents kd ON kd.id = kc.document_id
|
||||
WHERE
|
||||
kc.embedding IS NOT NULL
|
||||
AND (
|
||||
kd.user_id = auth.uid()
|
||||
OR (
|
||||
kd.team_id IS NOT NULL
|
||||
AND kd.team_id IN (
|
||||
SELECT team_id FROM public.team_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (1 - (kc.embedding <=> query_embedding)) > similarity_threshold
|
||||
ORDER BY kc.embedding <=> query_embedding
|
||||
LIMIT match_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.match_knowledge_chunks TO authenticated;
|
||||
Loading…
Add table
Add a link
Reference in a new issue