wacrm/supabase/migrations/030_ai_knowledge.sql
Arnas Donauskas ce1fa4705d feat: AI knowledge base (RAG grounding)
Grounds the AI assistant (0.5.0) in the account's own content so it can
answer instead of handing off.

- Hybrid retrieval: Postgres full-text search for every account; optional
  semantic search (pgvector + OpenAI text-embedding-3-small) when an
  embeddings key is set. Semantic-primary, topped up with FTS.
- src/lib/ai: chunk, embeddings, knowledge (ingest + retrieve), query;
  buildSystemPrompt gains a knowledge block; config carries an
  (optional) embeddings key.
- Wired into /api/ai/draft and the auto-reply bot (best-effort — never
  blocks a reply).
- KB API (/api/ai/knowledge CRUD + /reindex) and a Settings → AI
  Assistant knowledge-base manager + embeddings-key field.
- Migration 030: pgvector, ai_knowledge_documents, ai_knowledge_chunks
  (fts + embedding), match RPCs, ai_configs.embeddings_api_key.

Migration required: supabase/migrations/030_ai_knowledge.sql.
Stacked on feat/ai-reply-assistant (#329).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:06:09 +03:00

175 lines
7.5 KiB
PL/PgSQL

-- ============================================================
-- 030_ai_knowledge.sql — AI knowledge base (RAG grounding)
--
-- Gives the AI assistant (migration 029) an account-owned knowledge
-- base — FAQ / policy / product text — that it retrieves into every
-- draft and auto-reply, so it can answer business-specific questions
-- instead of handing off.
--
-- Hybrid retrieval:
-- - Lexical: a generated `fts` tsvector on each chunk, ranked with
-- ts_rank. Works for every account with no extra credentials.
-- - Semantic: an optional pgvector `embedding` per chunk (OpenAI
-- text-embedding-3-small, 1536 dims), populated only when the
-- account configures an embeddings key. Anthropic-only accounts
-- (Anthropic has no embeddings API) keep the lexical path with
-- zero extra setup.
--
-- pgvector: `CREATE EXTENSION IF NOT EXISTS vector` works on a stock
-- Postgres. On hosted Supabase the extension usually lives in the
-- `extensions` schema — if your project pins that, run
-- `create extension if not exists vector with schema extensions;`
-- once, then this file is a no-op for the extension.
--
-- RLS: settings-class, mirroring `ai_configs` / `whatsapp_config` —
-- any member may read the knowledge base; only admin+ may change it.
-- The retrieval RPCs and the ingest path run under the service-role
-- client (the auto-reply bot has no auth.uid()), so RLS guards
-- dashboard reads.
--
-- Idempotent — safe to run multiple times.
-- ============================================================
CREATE EXTENSION IF NOT EXISTS vector;
-- Optional embeddings key (OpenAI-compatible). When set, the KB is
-- embedded and semantic search turns on. Stored AES-256-GCM-encrypted,
-- same as ai_configs.api_key.
ALTER TABLE ai_configs
ADD COLUMN IF NOT EXISTS embeddings_api_key text;
-- ============================================================
-- Documents — one row per KB entry the user pastes (title + body).
-- ============================================================
CREATE TABLE IF NOT EXISTS ai_knowledge_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
title text NOT NULL,
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ai_knowledge_documents_account_id_idx
ON ai_knowledge_documents (account_id);
ALTER TABLE ai_knowledge_documents ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ai_knowledge_documents_select ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_select ON ai_knowledge_documents FOR SELECT
USING (is_account_member(account_id));
DROP POLICY IF EXISTS ai_knowledge_documents_insert ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_insert ON ai_knowledge_documents FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_documents_update ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_update ON ai_knowledge_documents FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_documents_delete ON ai_knowledge_documents;
CREATE POLICY ai_knowledge_documents_delete ON ai_knowledge_documents FOR DELETE
USING (is_account_member(account_id, 'admin'));
CREATE OR REPLACE FUNCTION public.update_ai_knowledge_documents_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ai_knowledge_documents_updated_at ON ai_knowledge_documents;
CREATE TRIGGER ai_knowledge_documents_updated_at
BEFORE UPDATE ON ai_knowledge_documents
FOR EACH ROW
EXECUTE FUNCTION public.update_ai_knowledge_documents_updated_at();
-- ============================================================
-- Chunks — retrieval units. `account_id` is denormalized off the
-- document so the match RPCs and RLS filter without a join.
-- ============================================================
CREATE TABLE IF NOT EXISTS ai_knowledge_chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid NOT NULL REFERENCES ai_knowledge_documents(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
chunk_index integer NOT NULL DEFAULT 0,
content text NOT NULL,
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
embedding vector(1536),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_account_id_idx
ON ai_knowledge_chunks (account_id);
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_document_id_idx
ON ai_knowledge_chunks (document_id);
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_fts_idx
ON ai_knowledge_chunks USING gin (fts);
-- Cosine-distance ANN index for the semantic path. Rows with a NULL
-- embedding (lexical-only accounts) are simply absent from it.
CREATE INDEX IF NOT EXISTS ai_knowledge_chunks_embedding_idx
ON ai_knowledge_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
ALTER TABLE ai_knowledge_chunks ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ai_knowledge_chunks_select ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_select ON ai_knowledge_chunks FOR SELECT
USING (is_account_member(account_id));
DROP POLICY IF EXISTS ai_knowledge_chunks_insert ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_insert ON ai_knowledge_chunks FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_chunks_update ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_update ON ai_knowledge_chunks FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS ai_knowledge_chunks_delete ON ai_knowledge_chunks;
CREATE POLICY ai_knowledge_chunks_delete ON ai_knowledge_chunks FOR DELETE
USING (is_account_member(account_id, 'admin'));
-- ============================================================
-- Retrieval RPCs. Both SECURITY DEFINER and hard-scoped to the passed
-- account_id so the service-role caller can only ever read one
-- account's chunks.
-- ============================================================
-- Lexical: full-text rank. `plainto_tsquery` turns a raw customer
-- message into a query safely (no operator injection).
CREATE OR REPLACE FUNCTION public.match_ai_knowledge_fts(
p_account_id uuid,
p_query text,
p_match_count integer
)
RETURNS TABLE (id uuid, content text, rank real) AS $$
SELECT c.id,
c.content,
ts_rank(c.fts, plainto_tsquery('english', p_query)) AS rank
FROM ai_knowledge_chunks c
WHERE c.account_id = p_account_id
AND c.fts @@ plainto_tsquery('english', p_query)
ORDER BY rank DESC
LIMIT GREATEST(p_match_count, 0);
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public;
-- Semantic: cosine distance against the query embedding. Only rows
-- that actually have an embedding participate.
CREATE OR REPLACE FUNCTION public.match_ai_knowledge_semantic(
p_account_id uuid,
p_query_embedding vector(1536),
p_match_count integer
)
RETURNS TABLE (id uuid, content text, distance real) AS $$
SELECT c.id,
c.content,
(c.embedding <=> p_query_embedding) AS distance
FROM ai_knowledge_chunks c
WHERE c.account_id = p_account_id
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> p_query_embedding
LIMIT GREATEST(p_match_count, 0);
$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public;