frimeet-api-nlp / sql /aws_pgvector_contract.sql
AlleksDev's picture
Fix: Custom Feed
5c793cc unverified
Raw
History Blame Contribute Delete
25 kB
-- Reference contract for AWS RDS/Aurora PostgreSQL + pgvector.
-- Run this once with an admin/DBA role, not from Hugging Face.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS place_embeddings (
external_id TEXT PRIMARY KEY,
document TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
embedding VECTOR(300) NOT NULL,
content_hash TEXT NOT NULL,
embedding_model TEXT NOT NULL,
embedding_version TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS post_embeddings (
external_id TEXT PRIMARY KEY,
document TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
embedding VECTOR(300) NOT NULL,
content_hash TEXT NOT NULL,
embedding_model TEXT NOT NULL,
embedding_version TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Post-feed derived storage. The versioned companion migration defines the
-- controlled functions, revokes and grants used by the adapters.
CREATE TABLE IF NOT EXISTS post_cluster_runs (
id UUID PRIMARY KEY,
algorithm TEXT NOT NULL CHECK (algorithm = 'kmeans'),
algorithm_version TEXT NOT NULL,
embedding_model TEXT NOT NULL,
embedding_version TEXT NOT NULL,
embedding_dimension INTEGER NOT NULL CHECK (embedding_dimension = 300),
k INTEGER NOT NULL CHECK (k > 1),
sample_size INTEGER NOT NULL CHECK (sample_size >= k),
inertia DOUBLE PRECISION,
silhouette_score DOUBLE PRECISION,
status TEXT NOT NULL CHECK (status IN ('running', 'validated', 'active', 'failed', 'superseded')),
parameters JSONB NOT NULL DEFAULT '{}'::jsonb,
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
error_message TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS post_cluster_runs_one_active_idx
ON post_cluster_runs ((status)) WHERE status = 'active';
CREATE TABLE IF NOT EXISTS post_clusters (
run_id UUID NOT NULL REFERENCES post_cluster_runs(id) ON DELETE CASCADE,
cluster_id INTEGER NOT NULL,
centroid VECTOR(300) NOT NULL,
label TEXT,
size INTEGER NOT NULL CHECK (size >= 0),
representative_post_ids TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (run_id, cluster_id)
);
CREATE TABLE IF NOT EXISTS post_cluster_memberships (
run_id UUID NOT NULL,
post_id TEXT NOT NULL REFERENCES post_embeddings(external_id) ON DELETE CASCADE,
cluster_id INTEGER NOT NULL,
distance_to_centroid DOUBLE PRECISION NOT NULL CHECK (distance_to_centroid >= 0),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, post_id),
FOREIGN KEY (run_id, cluster_id) REFERENCES post_clusters(run_id, cluster_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_interest_embeddings (
user_id TEXT PRIMARY KEY,
embedding VECTOR(300) NOT NULL,
weighted_sum VECTOR(300) NOT NULL,
total_weight DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (total_weight >= 0),
source_hash TEXT NOT NULL,
profile_version TEXT NOT NULL,
interaction_count INTEGER NOT NULL DEFAULT 0 CHECK (interaction_count >= 0),
last_event_id BIGINT NOT NULL DEFAULT 0 CHECK (last_event_id >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS post_sync_checkpoints (
consumer TEXT PRIMARY KEY,
last_event_id BIGINT NOT NULL DEFAULT 0 CHECK (last_event_id >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS post_embedding_tombstones (
post_id TEXT PRIMARY KEY,
source_version BIGINT NOT NULL CHECK (source_version >= 0),
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_embeddings (
external_id TEXT PRIMARY KEY,
document TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
embedding VECTOR(300) NOT NULL,
content_hash TEXT NOT NULL,
embedding_model TEXT NOT NULL,
embedding_version TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
author_type TEXT,
author_id TEXT,
published_at TIMESTAMPTZ,
source_version BIGINT,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS club_embeddings (LIKE user_embeddings INCLUDING ALL);
CREATE TABLE IF NOT EXISTS group_embeddings (LIKE user_embeddings INCLUDING ALL);
CREATE TABLE IF NOT EXISTS event_embeddings (LIKE user_embeddings INCLUDING ALL);
ALTER TABLE place_embeddings ADD COLUMN IF NOT EXISTS textsearch TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', document)) STORED;
ALTER TABLE post_embeddings ADD COLUMN IF NOT EXISTS textsearch TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', document)) STORED;
ALTER TABLE user_embeddings ADD COLUMN IF NOT EXISTS textsearch TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', document)) STORED;
ALTER TABLE club_embeddings ADD COLUMN IF NOT EXISTS textsearch TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', document)) STORED;
ALTER TABLE group_embeddings ADD COLUMN IF NOT EXISTS textsearch TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', document)) STORED;
ALTER TABLE event_embeddings ADD COLUMN IF NOT EXISTS textsearch TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', document)) STORED;
CREATE INDEX IF NOT EXISTS place_embeddings_embedding_hnsw_idx
ON place_embeddings USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS post_embeddings_embedding_hnsw_idx
ON post_embeddings USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS place_embeddings_metadata_gin_idx
ON place_embeddings USING gin (metadata);
CREATE INDEX IF NOT EXISTS post_embeddings_metadata_gin_idx
ON post_embeddings USING gin (metadata);
CREATE INDEX IF NOT EXISTS user_embeddings_embedding_hnsw_idx
ON user_embeddings USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS club_embeddings_embedding_hnsw_idx
ON club_embeddings USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS group_embeddings_embedding_hnsw_idx
ON group_embeddings USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS event_embeddings_embedding_hnsw_idx
ON event_embeddings USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS user_embeddings_metadata_gin_idx ON user_embeddings USING gin (metadata);
CREATE INDEX IF NOT EXISTS club_embeddings_metadata_gin_idx ON club_embeddings USING gin (metadata);
CREATE INDEX IF NOT EXISTS group_embeddings_metadata_gin_idx ON group_embeddings USING gin (metadata);
CREATE INDEX IF NOT EXISTS event_embeddings_metadata_gin_idx ON event_embeddings USING gin (metadata);
CREATE INDEX IF NOT EXISTS place_embeddings_textsearch_gin_idx ON place_embeddings USING gin (textsearch);
CREATE INDEX IF NOT EXISTS post_embeddings_textsearch_gin_idx ON post_embeddings USING gin (textsearch);
CREATE INDEX IF NOT EXISTS user_embeddings_textsearch_gin_idx ON user_embeddings USING gin (textsearch);
CREATE INDEX IF NOT EXISTS club_embeddings_textsearch_gin_idx ON club_embeddings USING gin (textsearch);
CREATE INDEX IF NOT EXISTS group_embeddings_textsearch_gin_idx ON group_embeddings USING gin (textsearch);
CREATE INDEX IF NOT EXISTS event_embeddings_textsearch_gin_idx ON event_embeddings USING gin (textsearch);
CREATE OR REPLACE FUNCTION match_places(
query_embedding VECTOR(300),
match_count INTEGER,
filters JSONB DEFAULT '{}'::jsonb
)
RETURNS TABLE (
external_id TEXT,
document TEXT,
metadata JSONB,
score DOUBLE PRECISION
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
p.external_id,
p.document,
p.metadata,
1 - (p.embedding <=> query_embedding) AS score
FROM public.place_embeddings p
WHERE p.is_active = true
AND COALESCE((filters->>'is_active')::boolean, true) = true
AND ((filters ? 'city') IS FALSE OR lower(p.metadata->>'city') = lower(filters->>'city'))
AND ((filters ? 'state') IS FALSE OR lower(p.metadata->>'state') = lower(filters->>'state'))
AND ((filters ? 'category') IS FALSE OR lower(p.metadata->>'category') = lower(filters->>'category'))
AND (
(filters ? 'categories') IS FALSE
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text(filters->'categories') AS allowed(value)
WHERE lower(p.metadata->>'category') = lower(allowed.value)
)
)
AND ((filters ? 'price_range') IS FALSE OR p.metadata->>'price_range' = filters->>'price_range')
AND (
(filters ? 'price_ranges') IS FALSE
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text(filters->'price_ranges') AS allowed(value)
WHERE p.metadata->>'price_range' = allowed.value
)
)
AND (
(filters ? 'tags') IS FALSE
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text(filters->'tags') AS allowed(value)
WHERE lower(COALESCE(p.metadata->>'tags', ''))
LIKE ('%' || lower(allowed.value) || '%')
)
)
AND ((filters ? 'occasion') IS FALSE OR p.metadata->>'occasion' ILIKE ('%' || (filters->>'occasion') || '%'))
AND (
(filters ? 'place_ids') IS FALSE
OR p.external_id IN (SELECT jsonb_array_elements_text(filters->'place_ids'))
)
ORDER BY p.embedding <=> query_embedding, p.external_id ASC
LIMIT match_count;
$$;
CREATE OR REPLACE FUNCTION match_posts(
query_embedding VECTOR(300),
match_count INTEGER,
filters JSONB DEFAULT '{}'::jsonb
)
RETURNS TABLE (
external_id TEXT,
document TEXT,
metadata JSONB,
score DOUBLE PRECISION
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
p.external_id,
p.document,
p.metadata,
1 - (p.embedding <=> query_embedding) AS score
FROM public.post_embeddings p
WHERE p.is_active = true
AND COALESCE((filters->>'is_active')::boolean, true) = true
AND ((filters ? 'city') IS FALSE OR lower(p.metadata->>'city') = lower(filters->>'city'))
ORDER BY p.embedding <=> query_embedding
LIMIT match_count;
$$;
CREATE OR REPLACE FUNCTION upsert_place_embedding(
p_external_id TEXT,
p_document TEXT,
p_metadata JSONB,
p_embedding VECTOR(300),
p_content_hash TEXT,
p_embedding_model TEXT,
p_embedding_version TEXT,
p_is_active BOOLEAN
)
RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
INSERT INTO public.place_embeddings (
external_id,
document,
metadata,
embedding,
content_hash,
embedding_model,
embedding_version,
is_active,
updated_at
)
VALUES (
p_external_id,
p_document,
p_metadata,
p_embedding,
p_content_hash,
p_embedding_model,
p_embedding_version,
p_is_active,
now()
)
ON CONFLICT (external_id) DO UPDATE SET
document = EXCLUDED.document,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding,
content_hash = EXCLUDED.content_hash,
embedding_model = EXCLUDED.embedding_model,
embedding_version = EXCLUDED.embedding_version,
is_active = EXCLUDED.is_active,
updated_at = now();
$$;
CREATE OR REPLACE FUNCTION upsert_post_embedding(
p_external_id TEXT,
p_document TEXT,
p_metadata JSONB,
p_embedding VECTOR(300),
p_content_hash TEXT,
p_embedding_model TEXT,
p_embedding_version TEXT,
p_is_active BOOLEAN,
p_author_type TEXT,
p_author_id TEXT,
p_published_at TIMESTAMPTZ,
p_source_version BIGINT
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM public.post_embedding_tombstones tombstone
WHERE tombstone.post_id = p_external_id
AND (p_source_version IS NULL OR tombstone.source_version >= p_source_version)
) THEN
RETURN;
END IF;
IF p_source_version IS NOT NULL THEN
DELETE FROM public.post_embedding_tombstones
WHERE post_id = p_external_id AND source_version < p_source_version;
END IF;
INSERT INTO public.post_embeddings (
external_id, document, metadata, embedding, content_hash,
embedding_model, embedding_version, is_active, author_type,
author_id, published_at, source_version, indexed_at, updated_at
)
VALUES (
p_external_id, p_document, p_metadata, p_embedding, p_content_hash,
p_embedding_model, p_embedding_version, p_is_active, p_author_type,
p_author_id, p_published_at, p_source_version, now(), now()
)
ON CONFLICT (external_id) DO UPDATE SET
document = EXCLUDED.document,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding,
content_hash = EXCLUDED.content_hash,
embedding_model = EXCLUDED.embedding_model,
embedding_version = EXCLUDED.embedding_version,
is_active = EXCLUDED.is_active,
author_type = EXCLUDED.author_type,
author_id = EXCLUDED.author_id,
published_at = EXCLUDED.published_at,
source_version = EXCLUDED.source_version,
indexed_at = now(),
updated_at = now()
WHERE EXCLUDED.source_version IS NULL
OR post_embeddings.source_version IS NULL
OR EXCLUDED.source_version >= post_embeddings.source_version;
END;
$$;
CREATE OR REPLACE FUNCTION get_place_content_hashes(p_external_ids TEXT[])
RETURNS TABLE (
external_id TEXT,
content_hash TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p.external_id, p.content_hash
FROM public.place_embeddings p
WHERE p.external_id = ANY(p_external_ids);
$$;
CREATE OR REPLACE FUNCTION get_post_content_hashes(p_external_ids TEXT[])
RETURNS TABLE (
external_id TEXT,
content_hash TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p.external_id, p.content_hash
FROM public.post_embeddings p
WHERE p.external_id = ANY(p_external_ids);
$$;
CREATE OR REPLACE FUNCTION search_resource_embeddings(
p_resource_type TEXT,
p_query_text TEXT,
p_query_embedding VECTOR(300),
p_match_count INTEGER,
p_filters JSONB DEFAULT '{}'::jsonb
)
RETURNS TABLE (
external_id TEXT,
document TEXT,
metadata JSONB,
score DOUBLE PRECISION,
semantic_score DOUBLE PRECISION,
lexical_score DOUBLE PRECISION
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
target_table TEXT;
BEGIN
target_table := CASE p_resource_type
WHEN 'places' THEN 'place_embeddings'
WHEN 'posts' THEN 'post_embeddings'
WHEN 'users' THEN 'user_embeddings'
WHEN 'clubs' THEN 'club_embeddings'
WHEN 'groups' THEN 'group_embeddings'
WHEN 'events' THEN 'event_embeddings'
ELSE NULL
END;
IF target_table IS NULL THEN
RAISE EXCEPTION 'Unsupported search resource type: %', p_resource_type;
END IF;
RETURN QUERY EXECUTE format($query$
WITH eligible AS NOT MATERIALIZED (
SELECT e.*
FROM public.%I e
WHERE e.is_active = true
AND COALESCE(($4->>'is_active')::boolean, true) = true
AND (
($4 ? 'city') IS FALSE
OR lower(e.metadata->>'city') = lower($4->>'city')
)
AND (
($4 ? 'state') IS FALSE
OR lower(e.metadata->>'state') = lower($4->>'state')
)
AND (
($4 ? 'categories') IS FALSE
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text($4->'categories') AS allowed(value)
WHERE lower(e.metadata->>'category') = lower(allowed.value)
)
)
AND (
($4 ? 'tags') IS FALSE
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text($4->'tags') AS allowed(value)
WHERE lower(COALESCE(e.metadata->>'tags', ''))
LIKE ('%%' || lower(allowed.value) || '%%')
)
)
AND (
($4 ? 'published_from') IS FALSE
OR NULLIF(e.metadata->>'published_at', '')::timestamptz
>= ($4->>'published_from')::timestamptz
)
AND (
($4 ? 'published_to') IS FALSE
OR NULLIF(e.metadata->>'published_at', '')::timestamptz
<= ($4->>'published_to')::timestamptz
)
AND (
($4 ? 'event_from') IS FALSE
OR NULLIF(e.metadata->>'start_time', '')::timestamptz
>= ($4->>'event_from')::timestamptz
)
AND (
($4 ? 'event_to') IS FALSE
OR NULLIF(e.metadata->>'start_time', '')::timestamptz
<= ($4->>'event_to')::timestamptz
)
AND (
($4 ? 'is_online') IS FALSE
OR COALESCE((e.metadata->>'is_online')::boolean, false)
= ($4->>'is_online')::boolean
)
AND (
($4 ? 'user_roles') IS FALSE
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text($4->'user_roles') AS allowed(value)
WHERE lower(e.metadata->>'role') = lower(allowed.value)
)
)
AND (
($4 ? 'place_ids') IS FALSE
OR e.metadata->>'place_id' IN (
SELECT jsonb_array_elements_text($4->'place_ids')
)
)
AND (
(
COALESCE((e.metadata->>'is_private')::boolean, false) = false
AND COALESCE((e.metadata->>'is_public')::boolean, true) = true
)
OR (
$4 ? 'requester_id'
AND (
e.metadata->>'creator_id' = $4->>'requester_id'
OR COALESCE(e.metadata->'authorized_user_ids', '[]'::jsonb)
? ($4->>'requester_id')
)
)
)
),
vector_results AS (
SELECT
e.external_id,
1 - (e.embedding <=> $1) AS semantic_score,
row_number() OVER (
ORDER BY e.embedding <=> $1, e.external_id ASC
) AS vector_rank
FROM eligible e
ORDER BY e.embedding <=> $1, e.external_id ASC
LIMIT GREATEST($3 * 3, 30)
),
lexical_results AS (
SELECT
e.external_id,
ts_rank_cd(e.textsearch, query.value) AS lexical_score,
row_number() OVER (
ORDER BY ts_rank_cd(e.textsearch, query.value) DESC, e.external_id ASC
) AS lexical_rank
FROM eligible e
CROSS JOIN websearch_to_tsquery('simple', $2) AS query(value)
WHERE e.textsearch @@ query.value
ORDER BY lexical_score DESC, e.external_id ASC
LIMIT GREATEST($3 * 3, 30)
),
fused AS (
SELECT
COALESCE(v.external_id, l.external_id) AS external_id,
v.semantic_score,
l.lexical_score,
COALESCE(1.0 / (60.0 + v.vector_rank), 0.0)
+ COALESCE(1.0 / (60.0 + l.lexical_rank), 0.0) AS rrf_score
FROM vector_results v
FULL OUTER JOIN lexical_results l USING (external_id)
)
SELECT
e.external_id,
e.document,
e.metadata,
LEAST(
1.0,
f.rrf_score / (2.0 / 61.0)
+ CASE
WHEN lower(COALESCE(
e.metadata->>'search_title',
e.metadata->>'name',
e.metadata->>'title',
e.metadata->>'username',
''
)) = lower($2) THEN 0.15
ELSE 0.0
END
)::double precision AS score,
f.semantic_score::double precision,
f.lexical_score::double precision
FROM fused f
JOIN eligible e USING (external_id)
ORDER BY score DESC, f.semantic_score DESC NULLS LAST, e.external_id ASC
LIMIT GREATEST($3, 1)
$query$, target_table)
USING p_query_embedding, p_query_text, p_match_count, p_filters;
END;
$$;
CREATE OR REPLACE FUNCTION get_resource_content_hashes(
p_resource_type TEXT,
p_external_ids TEXT[]
)
RETURNS TABLE (external_id TEXT, content_hash TEXT)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
target_table TEXT;
BEGIN
target_table := CASE p_resource_type
WHEN 'users' THEN 'user_embeddings'
WHEN 'clubs' THEN 'club_embeddings'
WHEN 'groups' THEN 'group_embeddings'
WHEN 'events' THEN 'event_embeddings'
ELSE NULL
END;
IF target_table IS NULL THEN
RAISE EXCEPTION 'Unsupported sync resource type: %', p_resource_type;
END IF;
RETURN QUERY EXECUTE format(
'SELECT e.external_id, e.content_hash FROM public.%I e WHERE e.external_id = ANY($1)',
target_table
) USING p_external_ids;
END;
$$;
CREATE OR REPLACE FUNCTION upsert_resource_embedding(
p_resource_type TEXT,
p_external_id TEXT,
p_document TEXT,
p_metadata JSONB,
p_embedding VECTOR(300),
p_content_hash TEXT,
p_embedding_model TEXT,
p_embedding_version TEXT,
p_is_active BOOLEAN
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
target_table TEXT;
BEGIN
target_table := CASE p_resource_type
WHEN 'users' THEN 'user_embeddings'
WHEN 'clubs' THEN 'club_embeddings'
WHEN 'groups' THEN 'group_embeddings'
WHEN 'events' THEN 'event_embeddings'
ELSE NULL
END;
IF target_table IS NULL THEN
RAISE EXCEPTION 'Unsupported sync resource type: %', p_resource_type;
END IF;
EXECUTE format($query$
INSERT INTO public.%I (
external_id, document, metadata, embedding, content_hash,
embedding_model, embedding_version, is_active, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (external_id) DO UPDATE SET
document = EXCLUDED.document,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding,
content_hash = EXCLUDED.content_hash,
embedding_model = EXCLUDED.embedding_model,
embedding_version = EXCLUDED.embedding_version,
is_active = EXCLUDED.is_active,
updated_at = now()
$query$, target_table)
USING p_external_id, p_document, p_metadata, p_embedding, p_content_hash,
p_embedding_model, p_embedding_version, p_is_active;
END;
$$;
-- Example least-privilege grants. Adjust schema/database/user creation to your setup.
-- GRANT EXECUTE ON FUNCTION match_places(VECTOR, INTEGER, JSONB) TO nlp_reader;
-- GRANT EXECUTE ON FUNCTION match_posts(VECTOR, INTEGER, JSONB) TO nlp_reader;
-- GRANT EXECUTE ON FUNCTION get_place_content_hashes(TEXT[]) TO nlp_writer;
-- GRANT EXECUTE ON FUNCTION get_post_content_hashes(TEXT[]) TO nlp_writer;
-- GRANT EXECUTE ON FUNCTION upsert_place_embedding(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN) TO nlp_writer;
-- GRANT EXECUTE ON FUNCTION upsert_post_embedding(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TIMESTAMPTZ, BIGINT) TO nlp_writer;
-- GRANT EXECUTE ON FUNCTION search_resource_embeddings(TEXT, TEXT, VECTOR, INTEGER, JSONB) TO nlp_reader;
-- GRANT EXECUTE ON FUNCTION get_resource_content_hashes(TEXT, TEXT[]) TO nlp_writer;
-- GRANT EXECUTE ON FUNCTION upsert_resource_embedding(TEXT, TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN) TO nlp_writer;
-- The feed-specific tables and controlled functions are versioned in
-- sql/migrate_post_feed_v1.sql. Apply that companion contract immediately
-- after this base contract. Keeping the feed extension versioned prevents a
-- fresh setup and an upgraded database from drifting apart; the full psql
-- setup includes it automatically with \ir.