5 Commits

Author SHA1 Message Date
dierk 26ce44e186 Add lightbox to all three frontends — click photo to view full size
Click any result image to open it in a dark overlay. Click anywhere or
press Escape to close. Score colour matches each frontend's accent colour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:52 +02:00
dierk 048309da8a Add present.sh and document LibreOffice --show flag in README
present.sh launches the slideshow directly without opening the Impress UI.
The script is gitignored as a local convenience helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:56:28 +02:00
dierk 1c5e00d8e4 Add targeted comments explaining non-obvious behaviour
- embedder.py: lazy model load rationale, RGB conversion, shared vector space
- main.py: why vec appears twice, ::vector cast, 1-distance score formula
- main_oracle.py: why array.array("f") is required instead of plain list
- main_oracle_indb.py: no embedder import — embedding done inside Oracle SQL
- index_images_oracle.py: same array.array requirement on indexing path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:39:40 +02:00
dierk 70da90c238 Add LibreOffice lock file pattern to .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:18:28 +02:00
dierk c893c92235 Remove redundant index_indb.html — superseded by frontend/indb/index.html
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:17:25 +02:00
14 changed files with 820 additions and 182 deletions
+2
View File
@@ -1,3 +1,5 @@
.env .env
__pycache__/ __pycache__/
photos/ photos/
.~lock.*
present.sh
+24
View File
@@ -602,3 +602,27 @@ Note: indexing time for backends 1 and 2 is dominated by CLIP inference (CPU),
not database write speed. The in-database backend uses the manually loaded CLIP not database write speed. The in-database backend uses the manually loaded CLIP
models in the `VECTOR` schema; their indexing time is not measured here as it models in the `VECTOR` schema; their indexing time is not measured here as it
was performed separately by the administrator. was performed separately by the administrator.
---
## Presentation
The presentation `Vektoren in der Datenbank.pptx` is generated by `make_presentation.py`:
```bash
python3 make_presentation.py
```
**Start the slideshow directly** (skips the LibreOffice UI):
```bash
libreoffice --impress --show "Vektoren in der Datenbank.pptx"
```
Or use the local helper script (gitignored):
```bash
./present.sh
```
Press `Esc` to exit the presentation.
Binary file not shown.
+614
View File
@@ -0,0 +1,614 @@
"""
Generates "Vektoren in der Datenbank.pptx" — a LibreOffice-compatible presentation.
Run from the project root: python3 make_presentation.py
"""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.oxml.ns import qn
from pptx.oxml import parse_xml
from lxml import etree
_A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
def OxmlElement(tag):
local = tag.split(":")[1]
return etree.fromstring(f'<a:{local} xmlns:a="{_A_NS}"/>')
import copy
# ── Colour palette (dark theme) ──────────────────────────────────────────────
BG = RGBColor(0x1e, 0x1e, 0x2e) # slide background
TITLE_CLR = RGBColor(0xcb, 0xd3, 0xff) # slide titles
BODY_CLR = RGBColor(0xcd, 0xd6, 0xf4) # body text
DIM_CLR = RGBColor(0x6c, 0x70, 0x86) # dimmed / captions
ACCENT_PG = RGBColor(0x89, 0xb4, 0xfa) # pgvector blue
ACCENT_ORA = RGBColor(0xf3, 0x8b, 0xa8) # Oracle red/pink
ACCENT_IDB = RGBColor(0xcb, 0xa6, 0xf7) # in-DB purple
ACCENT_GRN = RGBColor(0xa6, 0xe3, 0xa1) # green for highlights
CODE_BG = RGBColor(0x31, 0x32, 0x44) # code block background
CODE_CLR = RGBColor(0xa6, 0xe3, 0xa1) # code text
W = Inches(13.33) # widescreen 16:9
H = Inches(7.5)
FONT = "Roboto"
prs = Presentation()
prs.slide_width = W
prs.slide_height = H
blank_layout = prs.slide_layouts[6] # completely blank
LOGO_PATH = "/home/dierk/Bilder/Logo/Logo DLC Final.png"
CONFERENCE = "Quest Data Minds Konferenz"
EVENT_DATE = "28. Mai 2026"
EVENT_CITY = "Köln"
_slide_num = [0] # mutable counter so nested calls can increment it
def add_slide(logo=True, footer=True):
slide = prs.slides.add_slide(blank_layout)
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = BG
if logo:
slide.shapes.add_picture(LOGO_PATH,
Inches(11.6), Inches(7.0), Inches(1.6), Inches(0.42))
if footer:
_slide_num[0] += 1
# thin separator line
sep = slide.shapes.add_shape(1, Inches(0.3), Inches(6.95), Inches(11.1), Pt(1))
sep.fill.solid()
sep.fill.fore_color.rgb = DIM_CLR
sep.line.fill.background()
# left: conference info
txb(slide, f"{CONFERENCE} · {EVENT_CITY}, {EVENT_DATE}",
Inches(0.3), Inches(7.02), Inches(9.5), Inches(0.35),
size=11, color=DIM_CLR)
# right: page number (before logo)
txb(slide, str(_slide_num[0]),
Inches(10.9), Inches(7.02), Inches(0.6), Inches(0.35),
size=11, color=DIM_CLR, align=PP_ALIGN.RIGHT)
return slide
def txb(slide, text, x, y, w, h,
size=24, bold=False, color=BODY_CLR,
align=PP_ALIGN.LEFT, italic=False):
box = slide.shapes.add_textbox(x, y, w, h)
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(size)
run.font.bold = bold
run.font.italic = italic
run.font.color.rgb = color
run.font.name = FONT
return box
def title_slide_layout(slide, title, subtitle=None):
txb(slide, title,
Inches(1), Inches(2.8), Inches(11.33), Inches(1.2),
size=48, bold=True, color=TITLE_CLR, align=PP_ALIGN.CENTER)
if subtitle:
txb(slide, subtitle,
Inches(1), Inches(4.1), Inches(11.33), Inches(0.8),
size=24, color=DIM_CLR, align=PP_ALIGN.CENTER)
def section_header(slide, title, accent=ACCENT_PG):
"""Full-width coloured bar at top, then title."""
bar = slide.shapes.add_shape(
1, # MSO_SHAPE_TYPE.RECTANGLE
Inches(0), Inches(0), W, Inches(0.12)
)
bar.fill.solid()
bar.fill.fore_color.rgb = accent
bar.line.fill.background()
txb(slide, title,
Inches(0.5), Inches(0.2), Inches(12.33), Inches(0.8),
size=32, bold=True, color=TITLE_CLR)
def bullet_box(slide, items, x, y, w, h, size=20, color=BODY_CLR, indent=False):
box = slide.shapes.add_textbox(x, y, w, h)
tf = box.text_frame
tf.word_wrap = True
first = True
for item in items:
if first:
p = tf.paragraphs[0]
first = False
else:
p = tf.add_paragraph()
p.space_before = Pt(4)
run = p.add_run()
run.text = (" " if indent else "") + item
run.font.size = Pt(size)
run.font.color.rgb = color
run.font.name = FONT
def code_box(slide, code, x, y, w, h, size=13):
# Background rectangle (no text)
bg = slide.shapes.add_shape(1, x, y, w, h)
bg.fill.solid()
bg.fill.fore_color.rgb = CODE_BG
bg.line.color.rgb = RGBColor(0x58, 0x5b, 0x70)
bg.text_frame.text = ""
# Text box on top — textboxes have predictable left-aligned defaults
pad = Pt(7)
tb = slide.shapes.add_textbox(x + pad, y + pad, w - pad * 2, h - pad * 2)
tf = tb.text_frame
tf.word_wrap = False
tf.margin_left = Pt(0)
tf.margin_right = Pt(0)
tf.margin_top = Pt(0)
tf.margin_bottom = Pt(0)
first = True
for line in code.strip().split("\n"):
if first:
p = tf.paragraphs[0]
first = False
else:
p = tf.add_paragraph()
p.alignment = PP_ALIGN.LEFT
p.space_before = Pt(0)
p.space_after = Pt(0)
# Explicitly zero out left margin, hanging indent, and remove any bullet
pPr = p._p.get_or_add_pPr()
pPr.set("marL", "0")
pPr.set("indent", "0")
for tag in ("a:buClr","a:buClrTx","a:buFont","a:buFontTx","a:buChar","a:buAutoNum","a:buNone"):
for el in pPr.findall(qn(tag)):
pPr.remove(el)
pPr.append(OxmlElement("a:buNone"))
run = p.add_run()
run.text = line
run.font.size = Pt(size)
run.font.color.rgb = CODE_CLR
run.font.name = "Courier New"
def divider(slide, y, color=DIM_CLR):
line = slide.shapes.add_shape(1, Inches(0.5), y, Inches(12.33), Pt(1))
line.fill.solid()
line.fill.fore_color.rgb = color
line.line.fill.background()
# ════════════════════════════════════════════════════════════════════════════
# Slide 1 — Titelfolie
# ════════════════════════════════════════════════════════════════════════════
s = add_slide(logo=False, footer=False) # title slide: custom layout
title_slide_layout(s,
"Vektoren in der Datenbank",
"Semantische Bildsuche mit PostgreSQL/pgvector und Oracle 26ai")
# Conference details
txb(s, CONFERENCE,
Inches(1), Inches(5.0), Inches(11.33), Inches(0.5),
size=20, bold=True, color=ACCENT_PG, align=PP_ALIGN.CENTER)
txb(s, f"{EVENT_DATE} · {EVENT_CITY}",
Inches(1), Inches(5.5), Inches(11.33), Inches(0.45),
size=18, color=DIM_CLR, align=PP_ALIGN.CENTER)
# Larger centred logo
s.shapes.add_picture(LOGO_PATH, Inches(4.67), Inches(6.1), Inches(4.0), Inches(1.06))
# ════════════════════════════════════════════════════════════════════════════
# Slide 2 — Agenda
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Agenda", ACCENT_PG)
bullet_box(s, [
"01 Was ist ein Vektor?",
"02 Semantische Suche — jenseits von Schlüsselwörtern",
"03 Das CLIP-Modell",
"04 Ähnlichkeit messen: Cosinus-Distanz",
"05 PostgreSQL + pgvector",
"06 Oracle 26ai — nativer Vektor-Support",
"07 Oracle 26ai — Embedding in der Datenbank",
"08 Architektur der Demo",
"09 Demo",
"10 Vergleich & Fazit",
], Inches(1.5), Inches(1.3), Inches(10), Inches(5.5), size=20)
# ════════════════════════════════════════════════════════════════════════════
# Slide 3 — Was ist ein Vektor?
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Was ist ein Vektor?", ACCENT_PG)
bullet_box(s, [
"▸ Ein Vektor ist eine geordnete Liste von Zahlen: [0.12, -0.87, 0.44, …]",
"▸ Jede Zahl beschreibt eine Dimension im semantischen Raum",
"▸ Moderne KI-Modelle erzeugen Vektoren mit 512 bis 1536 Dimensionen",
"▸ Ähnliche Inhalte → ähnliche Vektoren → kleiner Abstand im Raum",
"▸ Texte, Bilder, Audio — alles lässt sich in denselben Vektorraum einbetten",
], Inches(0.8), Inches(1.3), Inches(7.5), Inches(4), size=20)
code_box(s, '# 4-dimensionaler Beispielvektor\nvec_hund = [0.91, 0.12, -0.44, 0.72]\nvec_katze = [0.87, 0.18, -0.39, 0.68]\n# ähnlich! Abstand ≈ 0.04\nvec_auto = [-0.3, -0.82, 0.91, -0.11]\n# weit entfernt',
Inches(8.8), Inches(1.5), Inches(4.3), Inches(2.6), size=12)
txb(s, "Vektoren machen Ähnlichkeit berechenbar.",
Inches(0.8), Inches(5.8), Inches(11), Inches(0.7),
size=22, bold=True, color=ACCENT_GRN)
# ════════════════════════════════════════════════════════════════════════════
# Slide 4 — Semantische Suche
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Semantische Suche — jenseits von Schlüsselwörtern", ACCENT_PG)
bullet_box(s, [
"Klassische Suche: \"trees\" findet nur Dokumente mit dem Wort \"trees\"",
"",
"Semantische Suche: \"trees\" findet Bilder von Wäldern, Parks, Natur —",
" ohne dass das Wort irgendwo steht",
], Inches(0.8), Inches(1.3), Inches(11.5), Inches(2.2), size=20)
divider(s, Inches(3.7))
bullet_box(s, [
"▸ Text-Anfrage wird in denselben Vektorraum eingebettet wie die Bilder",
"▸ Datenbankabfrage: finde die k nächsten Nachbarn (k-NN)",
"▸ Ergebnis: Bilder nach semantischer Ähnlichkeit gerankt",
"▸ Kein manuelles Tagging, keine Metadaten nötig",
], Inches(0.8), Inches(3.9), Inches(11.5), Inches(2.8), size=20)
# ════════════════════════════════════════════════════════════════════════════
# Slide 5 — CLIP-Modell
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Das CLIP-Modell (OpenAI)", ACCENT_IDB)
bullet_box(s, [
"CLIP = Contrastive LanguageImage Pretraining",
"▸ Trainiert auf hunderten Millionen Bild-Text-Paaren",
"▸ Bildet sowohl Bilder als auch Text in denselben 512-dimensionalen Raum ab",
"▸ Modell: clip-ViT-B-32 (Vision Transformer, Patch-Größe 32×32)",
"▸ Quell-Gewichte: Hugging Face Hub (sentence-transformers/clip-ViT-B-32)",
], Inches(0.8), Inches(1.3), Inches(7.5), Inches(3.2), size=20)
code_box(s,
'from sentence_transformers import (\n SentenceTransformer)\n\nmodel = SentenceTransformer(\n "clip-ViT-B-32")\n\n# Bild einbetten\nvec = model.encode(image)\n# → 512 floats\n\n# Text einbetten\nvec = model.encode("Bäume")\n# → 512 floats, gleicher Raum!',
Inches(8.8), Inches(1.3), Inches(4.3), Inches(3.8), size=11)
txb(s, "Bild-Vektor und Text-Vektor zeigen in dieselbe Richtung,\nwenn Bild und Text inhaltlich übereinstimmen.",
Inches(0.8), Inches(5.0), Inches(11.5), Inches(1.0),
size=18, italic=True, color=ACCENT_IDB)
# ════════════════════════════════════════════════════════════════════════════
# Slide 6 — Cosinus-Distanz
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Ähnlichkeit messen: Cosinus-Distanz", ACCENT_PG)
bullet_box(s, [
"▸ CLIP-Vektoren haben unterschiedliche Beträge — daher kein euklidischer Abstand",
"▸ Cosinus-Distanz misst nur den Winkel zwischen zwei Vektoren",
"▸ Cosinus-Distanz = 0 → identisch",
"▸ Cosinus-Distanz = 1 → völlig unähnlich",
"▸ Ähnlichkeitswert = 1 Distanz → 1.0 = perfekte Übereinstimmung",
], Inches(0.8), Inches(1.3), Inches(8.5), Inches(3.5), size=20)
code_box(s,
"-- PostgreSQL\n1 - (embedding <=> query_vec)\n\n-- Oracle 26ai\n1 - VECTOR_DISTANCE(embedding, query_vec, COSINE)",
Inches(0.8), Inches(5.0), Inches(6.0), Inches(1.9), size=13)
txb(s, "In der Demo:\nScore 28 % = schwache Übereinstimmung\nScore 75 % = starke Übereinstimmung",
Inches(7.5), Inches(5.0), Inches(5.0), Inches(2.0),
size=18, color=ACCENT_GRN)
# ════════════════════════════════════════════════════════════════════════════
# Slide 7 — PostgreSQL + pgvector: Voraussetzungen
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "PostgreSQL + pgvector", ACCENT_PG)
txb(s, "Was wird benötigt?", Inches(0.8), Inches(1.3), Inches(11), Inches(0.5),
size=22, bold=True, color=ACCENT_PG)
bullet_box(s, [
"▸ PostgreSQL (ab Version 13)",
"▸ pgvector-Extension — docker image: pgvector/pgvector:pg18",
"▸ Extension aktivieren: CREATE EXTENSION vector;",
"▸ Python-Paket: psycopg2-binary",
"▸ KI-Bibliothek: sentence-transformers (auf dem Anwendungsserver)",
], Inches(0.8), Inches(1.9), Inches(11.5), Inches(2.5), size=20)
divider(s, Inches(4.6))
txb(s, "Schema & Index", Inches(0.8), Inches(4.5), Inches(11), Inches(0.5),
size=22, bold=True, color=ACCENT_PG)
code_box(s,
"CREATE TABLE images (\n id SERIAL PRIMARY KEY,\n filename TEXT NOT NULL UNIQUE,\n embedding vector(512) -- pgvector-Typ\n);\n\nCREATE INDEX ON images USING hnsw (embedding vector_cosine_ops);",
Inches(0.8), Inches(5.0), Inches(7.5), Inches(1.85), size=13)
bullet_box(s, [
"HNSW = Hierarchical Navigable Small World",
"Approximativer k-NN Index",
"Sehr schnell bei der Suche",
], Inches(8.8), Inches(5.0), Inches(4.3), Inches(1.85), size=18, color=DIM_CLR)
# ════════════════════════════════════════════════════════════════════════════
# Slide 8 — PostgreSQL: Suchanfrage
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "PostgreSQL: Suchanfrage", ACCENT_PG)
bullet_box(s, [
"1. Text-Anfrage mit CLIP in Python in einen Vektor umwandeln",
"2. Vektor an die SQL-Abfrage übergeben",
"3. PostgreSQL findet die ähnlichsten Bilder via HNSW-Index",
], Inches(0.8), Inches(1.3), Inches(11.5), Inches(1.5), size=20)
code_box(s,
"# Python\nvec = model.encode(\"Bäume\") # → 512 floats\n\n# SQL\nSELECT filename,\n 1 - (embedding <=> %s::vector) AS score\nFROM images\nORDER BY embedding <=> %s::vector\nLIMIT 12;",
Inches(0.8), Inches(3.0), Inches(7.5), Inches(3.5), size=16)
bullet_box(s, [
"<=> Cosinus-Distanz-Operator",
"(pgvector-spezifisch)",
"",
"$1::vector expliziter Cast",
"erforderlich",
"",
"LIMIT statt FETCH FIRST",
], Inches(9.0), Inches(3.0), Inches(4.0), Inches(3.5), size=18, color=DIM_CLR)
# ════════════════════════════════════════════════════════════════════════════
# Slide 9 — Oracle 26ai: Nativer Support
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Oracle 26ai — nativer Vektor-Support", ACCENT_ORA)
txb(s, "Was wird benötigt?", Inches(0.8), Inches(1.3), Inches(11), Inches(0.5),
size=22, bold=True, color=ACCENT_ORA)
bullet_box(s, [
"▸ Oracle AI Database 26ai Free (oder Enterprise)",
"▸ Keine Extension nötig — Vektoren sind eingebaut",
"▸ Vector Memory Area im SGA konfigurieren (für HNSW-Index)",
"▸ Python-Paket: oracledb (Thin Mode — kein Oracle Client nötig)",
"▸ KI-Bibliothek: sentence-transformers (auf dem Anwendungsserver)",
], Inches(0.8), Inches(1.9), Inches(11.5), Inches(2.2), size=20)
divider(s, Inches(4.2))
txb(s, "Schema & Index", Inches(0.8), Inches(4.3), Inches(11), Inches(0.45),
size=20, bold=True, color=ACCENT_ORA)
code_box(s,
"CREATE TABLE images (\n id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n filename VARCHAR2(255) NOT NULL UNIQUE,\n embedding VECTOR(512, FLOAT32) -- Typ + Dimension\n);\nCREATE VECTOR INDEX images_idx ON images(embedding)\n ORGANIZATION INMEMORY NEIGHBOR GRAPH\n WITH DISTANCE COSINE WITH TARGET ACCURACY 95;",
Inches(0.8), Inches(4.8), Inches(8.5), Inches(2.0), size=11)
bullet_box(s, [
"HNSW im SGA",
"(Vector Memory Area)",
"512 MB konfiguriert",
], Inches(9.8), Inches(4.8), Inches(3.3), Inches(2.0), size=17, color=DIM_CLR)
# ════════════════════════════════════════════════════════════════════════════
# Slide 10 — Oracle: Unterschiede zu pgvector
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Oracle vs. pgvector — Schema-Unterschiede", ACCENT_ORA)
rows = [
("Extension", "CREATE EXTENSION vector", "Eingebaut, keine Extension"),
("Vektor-Spalte", "vector(512) — nur Dimension", "VECTOR(512, FLOAT32) — Dim + Typ"),
("Primary Key", "SERIAL", "NUMBER GENERATED ALWAYS AS IDENTITY"),
("Text-Spalte", "TEXT (unbegrenzt)", "VARCHAR2(n) — Länge erforderlich"),
("HNSW-Syntax", "USING hnsw (...ops)", "ORGANIZATION INMEMORY NEIGHBOR GRAPH"),
("Genauigkeit", "Implizit via Index-Parameter", "WITH TARGET ACCURACY 95 (explizit)"),
("Speicher", "Kein Sonder-Speicher nötig", "vector_memory_size im SGA"),
("Abstand-Op", "<=> (Operator)", "VECTOR_DISTANCE(col, vec, COSINE)"),
("Top-N", "LIMIT n", "FETCH FIRST n ROWS ONLY"),
]
# Column header row
y = Inches(1.3)
hdr_bg = s.shapes.add_shape(1, Inches(0.3), y, Inches(12.7), Inches(0.55))
hdr_bg.fill.solid()
hdr_bg.fill.fore_color.rgb = RGBColor(0x18, 0x18, 0x28)
hdr_bg.line.fill.background()
txb(s, "Aspekt", Inches(0.4), y + Pt(6), Inches(2.2), Inches(0.5), size=14, bold=True, color=BODY_CLR)
txb(s, "PostgreSQL + pgvector",Inches(2.7), y + Pt(6), Inches(4.8), Inches(0.5), size=14, bold=True, color=ACCENT_PG)
txb(s, "Oracle 26ai", Inches(7.6), y + Pt(6), Inches(5.4), Inches(0.5), size=14, bold=True, color=ACCENT_ORA)
y += Inches(0.56)
for i, (aspect, pg, ora) in enumerate(rows):
bg_color = RGBColor(0x28, 0x29, 0x3d) if i % 2 == 0 else RGBColor(0x24, 0x25, 0x38)
row_bg = s.shapes.add_shape(1, Inches(0.3), y, Inches(12.7), Inches(0.52))
row_bg.fill.solid()
row_bg.fill.fore_color.rgb = bg_color
row_bg.line.fill.background()
txb(s, aspect, Inches(0.4), y + Pt(5), Inches(2.2), Inches(0.48), size=13, bold=True, color=DIM_CLR)
txb(s, pg, Inches(2.7), y + Pt(5), Inches(4.8), Inches(0.48), size=13, color=ACCENT_PG)
txb(s, ora, Inches(7.6), y + Pt(5), Inches(5.4), Inches(0.48), size=13, color=ACCENT_ORA)
y += Inches(0.53)
# ════════════════════════════════════════════════════════════════════════════
# Slide 11 — Oracle In-Database Embedding
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Oracle 26ai — Embedding in der Datenbank", ACCENT_IDB)
bullet_box(s, [
"▸ Oracle kann ONNX-Modelle direkt in die Datenbank laden",
"▸ VECTOR_EMBEDDING() ruft das Modell innerhalb einer SQL-Abfrage auf",
"▸ Kein Python, keine KI-Bibliothek auf dem Anwendungsserver zur Laufzeit",
"▸ Der Text-String ist der einzige Parameter aus Python",
], Inches(0.8), Inches(1.3), Inches(11.5), Inches(2.2), size=20)
code_box(s,
"-- Gesamte Logik in einem SQL-Statement\nSELECT filename,\n 1 - VECTOR_DISTANCE(\n foto_vek,\n VECTOR_EMBEDDING(CLIP_TXT USING :q AS data),\n COSINE\n ) AS score\nFROM VECTOR.FOTO_VEKTOR\nORDER BY VECTOR_DISTANCE(\n foto_vek,\n VECTOR_EMBEDDING(CLIP_TXT USING :q AS data), COSINE)\nFETCH FIRST 12 ROWS ONLY;",
Inches(0.8), Inches(3.6), Inches(7.5), Inches(3.3), size=13)
bullet_box(s, [
":q = reiner Text aus Python",
"",
"Oracle übernimmt:",
" • Tokenisierung",
" • ONNX-Inferenz",
" • Vektorsuche",
"",
"→ Architektur vereinfacht sich",
], Inches(9.0), Inches(3.6), Inches(4.0), Inches(3.4), size=18, color=DIM_CLR)
# ════════════════════════════════════════════════════════════════════════════
# Slide 12 — ONNX in Oracle: Besonderheit
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "ONNX in Oracle: Was zu beachten ist", ACCENT_IDB)
bullet_box(s, [
"Oracle's ONNX-Validator stellt strenge Anforderungen an das Modell-Graph:",
"",
"▸ input_ids darf nur in einem einzigen Gather-Knoten verwendet werden",
"▸ Standard-CLIP-Export verwendet input_ids auch in ArgMax → wird abgelehnt",
"",
"Lösung: CLIP_TXT mit CLS-Token-Pooling (Position 0) statt EOS-Token-Pooling",
"▸ Einfacherer ONNX-Graph, den Oracle akzeptiert",
"▸ Cosinus-Ähnlichkeit zwischen EOS- und CLS-Variante: ~0,70",
"▸ Modell muss beim Export entsprechend angepasst werden",
], Inches(0.8), Inches(1.3), Inches(11.5), Inches(3.8), size=19)
code_box(s,
"-- Modell laden (einmalig durch Administrator)\nEXEC DBMS_VECTOR.LOAD_ONNX_MODEL(\n 'VEC_DUMP', 'clip_txt.onnx', 'CLIP_TXT',\n JSON('{\"function\":\"embedding\",\"embeddingOutput\":\"output\",\n \"input\":{\"input\":[\"DATA\"]}}'));",
Inches(0.8), Inches(5.2), Inches(11.5), Inches(1.6), size=13)
# ════════════════════════════════════════════════════════════════════════════
# Slide 13 — Architektur der Demo
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Architektur der Demo", ACCENT_GRN)
# Three columns
for i, (label, port, color) in enumerate([
("pgvector", "Port 8000", ACCENT_PG),
("Oracle 26ai\n(Python)", "Port 8001", ACCENT_ORA),
("Oracle 26ai\n(In-DB)", "Port 8002", ACCENT_IDB),
]):
x = Inches(0.5 + i * 4.27)
# Box
box = s.shapes.add_shape(1, x, Inches(1.3), Inches(3.8), Inches(4.8))
box.fill.solid()
box.fill.fore_color.rgb = RGBColor(0x28, 0x29, 0x3d)
box.line.color.rgb = color
txb(s, label, x + Inches(0.1), Inches(1.4), Inches(3.6), Inches(0.8),
size=22, bold=True, color=color, align=PP_ALIGN.CENTER)
txb(s, port, x + Inches(0.1), Inches(2.1), Inches(3.6), Inches(0.4),
size=16, color=DIM_CLR, align=PP_ALIGN.CENTER)
items = {
"pgvector": ["Browser /ui/", "FastAPI", "CLIP (Python)", "PostgreSQL 18", "pgvector 0.8.2"],
"Oracle 26ai\n(Python)": ["Browser /ui/", "FastAPI", "CLIP (Python)", "Oracle 26ai", "HNSW (SGA)"],
"Oracle 26ai\n(In-DB)": ["Browser /ui/", "FastAPI", "(kein CLIP)", "Oracle 26ai", "VECTOR_EMBEDDING()"],
}[label]
for j, item in enumerate(items):
txb(s, "" + item, x + Inches(0.2), Inches(2.65 + j * 0.52), Inches(3.5), Inches(0.48),
size=16, color=BODY_CLR)
txb(s, "116 Street Fotos · CLIP ViT-B/32 · 512-dimensionale Vektoren",
Inches(0.5), Inches(6.6), Inches(12.33), Inches(0.3),
size=16, color=DIM_CLR, align=PP_ALIGN.CENTER)
# ════════════════════════════════════════════════════════════════════════════
# Slide 14 — Demo-Hinweis
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Demo", ACCENT_GRN)
for url, label, color, y in [
("http://localhost:8000/ui/", "pgvector (blau)", ACCENT_PG, Inches(2.2)),
("http://localhost:8001/ui/", "Oracle 26ai (rot)", ACCENT_ORA, Inches(3.5)),
("http://localhost:8002/ui/", "Oracle In-DB (lila)",ACCENT_IDB, Inches(4.8)),
]:
txb(s, url, Inches(1.5), y, Inches(6), Inches(0.5), size=22, bold=True, color=color)
txb(s, label, Inches(7.8), y + Inches(0.05), Inches(4.5), Inches(0.5), size=20, color=DIM_CLR)
txb(s, "Suchbegriffe zum Ausprobieren:",
Inches(1.5), Inches(5.9), Inches(10), Inches(0.5), size=18, color=BODY_CLR)
txb(s, "Bäume · Wasser · Menschen · Gebäude · Himmel · Nacht · Autos",
Inches(1.5), Inches(6.3), Inches(10), Inches(0.6), size=20, bold=True, color=ACCENT_GRN)
# ════════════════════════════════════════════════════════════════════════════
# Slide 15 — Vergleich
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Vergleich", ACCENT_PG)
rows = [
("Merkmal", "PostgreSQL + pgvector", "Oracle 26ai (Python)", "Oracle 26ai (In-DB)"),
("Fotos indiziert", "116", "116", "116"),
("Indizierungszeit", "~26 Sek. (CPU)", "~16 Sek. (CPU)", "— (separat)"),
("Index-Typ", "HNSW (auf Disk)", "HNSW (im Speicher)", "Full Table Scan"),
("RAM-Bedarf", "Keiner", "512 MB SGA", "512 MB SGA"),
("CLIP zur Laufzeit", "Ja (Python)", "Ja (Python)", "Nein"),
("Embedding-Ort", "Python-Prozess", "Python-Prozess", "In der Datenbank"),
("VECTOR_EMBEDDING()", "", "", "Ja"),
("Extension nötig", "CREATE EXTENSION vector", "Nein", "Nein"),
]
y = Inches(1.3)
header = True
for row in rows:
bg_color = RGBColor(0x18, 0x18, 0x28) if header else (RGBColor(0x28, 0x29, 0x3d) if rows.index(row) % 2 == 0 else RGBColor(0x24, 0x25, 0x38))
row_bg = s.shapes.add_shape(1, Inches(0.3), y, Inches(12.7), Inches(0.52))
row_bg.fill.solid()
row_bg.fill.fore_color.rgb = bg_color
row_bg.line.fill.background()
colors = [DIM_CLR, ACCENT_PG, ACCENT_ORA, ACCENT_IDB] if header else [BODY_CLR, ACCENT_PG, ACCENT_ORA, ACCENT_IDB]
widths = [2.5, 3.0, 3.1, 3.1]
xs = [0.4, 2.9, 6.0, 9.15]
for j, (cell, col, w, x) in enumerate(zip(row, colors, widths, xs)):
txb(s, cell, Inches(x), y + Pt(4), Inches(w), Inches(0.48),
size=13, bold=header, color=col)
y += Inches(0.53)
header = False
# ════════════════════════════════════════════════════════════════════════════
# Slide 16 — Fazit
# ════════════════════════════════════════════════════════════════════════════
s = add_slide()
section_header(s, "Fazit", ACCENT_GRN)
bullet_box(s, [
"▸ Beide Datenbanken unterstützen Vektorsuche produktionsreif",
"▸ pgvector: einfach, leichtgewichtig, kein zusätzlicher Speicher nötig",
"▸ Oracle 26ai: vollständig integriert, kein Extension-Management",
"▸ Oracle In-DB Embedding: Architektur ohne ML-Laufzeit im App-Server",
"▸ CLIP ermöglicht Bildersuche per Freitext — ohne Tagging oder Metadaten",
"▸ HNSW liefert schnelle approximative k-NN-Suche in beiden Datenbanken",
], Inches(0.8), Inches(1.3), Inches(11.5), Inches(3.5), size=21)
divider(s, Inches(5.1))
txb(s, "Quellcode & Dokumentation",
Inches(0.8), Inches(5.2), Inches(11), Inches(0.5),
size=20, bold=True, color=BODY_CLR)
txb(s, "https://gitea.dl-cons.de/dierk/vector-search-demo",
Inches(0.8), Inches(5.7), Inches(11), Inches(0.5),
size=20, color=ACCENT_PG)
txb(s, "Programmierung und Folien unterstützt durch Claude (Anthropic)",
Inches(0.8), Inches(6.55), Inches(11.33), Inches(0.35),
size=13, italic=True, color=DIM_CLR, align=PP_ALIGN.CENTER)
# ════════════════════════════════════════════════════════════════════════════
# Save
# ════════════════════════════════════════════════════════════════════════════
OUT = "Vektoren in der Datenbank.pptx"
prs.save(OUT)
print(f"Saved: {OUT} ({prs.slides.__len__()} slides)")
+5
View File
@@ -4,14 +4,19 @@ from PIL import Image
_model = None _model = None
def _get_model(): def _get_model():
# Lazy load: the CLIP model is ~600 MB and takes several seconds to initialise.
# Loading on first call avoids the cost at import time and during indexing warmup.
global _model global _model
if _model is None: if _model is None:
_model = SentenceTransformer("clip-ViT-B-32") _model = SentenceTransformer("clip-ViT-B-32")
return _model return _model
def embed_image(path: str) -> list[float]: def embed_image(path: str) -> list[float]:
# CLIP requires RGB — some JPEGs are stored as CMYK or grayscale.
img = Image.open(path).convert("RGB") img = Image.open(path).convert("RGB")
return _get_model().encode(img).tolist() return _get_model().encode(img).tolist()
def embed_text(text: str) -> list[float]: def embed_text(text: str) -> list[float]:
# Text and images share the same 512-dimensional vector space in CLIP,
# so the returned vector is directly comparable to image embeddings.
return _get_model().encode(text).tolist() return _get_model().encode(text).tolist()
@@ -53,6 +53,7 @@ def main():
if cur.fetchone(): if cur.fetchone():
print(f"[{i}/{len(files)}] Skipping {filename} (already indexed)") print(f"[{i}/{len(files)}] Skipping {filename} (already indexed)")
continue continue
# oracledb requires array.array("f") for VECTOR(512, FLOAT32) — plain list is rejected.
embedding = array.array("f", embed_image(filepath)) embedding = array.array("f", embed_image(filepath))
cur.execute(INSERT, (filename, filepath, embedding)) cur.execute(INSERT, (filename, filepath, embedding))
conn.commit() conn.commit()
+2
View File
@@ -20,6 +20,8 @@ app.mount("/ui", StaticFiles(directory=os.path.abspath(FRONTEND_DIR), html=True)
@app.get("/search") @app.get("/search")
def search(q: str = Query(...), limit: int = Query(12)): def search(q: str = Query(...), limit: int = Query(12)):
# oracledb rejects a plain Python list for a VECTOR column.
# array.array("f") produces a typed 32-bit float buffer that matches VECTOR(512, FLOAT32).
vec = array.array("f", embed_text(q)) vec = array.array("f", embed_text(q))
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
@@ -1,3 +1,5 @@
# No embedder import — text embedding happens inside Oracle via VECTOR_EMBEDDING(CLIP_TXT).
# The only value Python passes to the database is the raw query string (:q).
import os import os
from fastapi import FastAPI, Query from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
+54 -1
View File
@@ -106,6 +106,37 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; } .empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; }
.card img { cursor: pointer; }
.card img:hover { opacity: 0.85; }
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.8rem;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
}
.lightbox-info { color: white; font-size: 0.95rem; text-align: center; }
.lightbox-info .lb-score { color: #cba6f7; font-weight: 700; }
.lightbox-close {
position: fixed;
top: 1rem; right: 1.2rem;
color: white; font-size: 2rem;
cursor: pointer; line-height: 1;
}
</style> </style>
</head> </head>
<body> <body>
@@ -134,6 +165,14 @@
<p class="stats" id="stats"></p> <p class="stats" id="stats"></p>
<div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div> <div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<span class="lightbox-close" onclick="closeLightbox()"></span>
<img id="lb-img" src="" alt="" />
<div class="lightbox-info">
<span id="lb-name"></span> &nbsp;·&nbsp; <span class="lb-score" id="lb-score"></span>
</div>
</div>
<script> <script>
const API = "http://localhost:8002"; const API = "http://localhost:8002";
@@ -166,7 +205,8 @@
} }
grid.innerHTML = results.map(r => ` grid.innerHTML = results.map(r => `
<div class="card"> <div class="card">
<img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy" /> <img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy"
onclick="openLightbox('${encodeURIComponent(r.filename)}','${r.filename}','${(r.score*100).toFixed(1)}%')" />
<div class="card-info"> <div class="card-info">
<div class="score">${(r.score * 100).toFixed(1)}% match</div> <div class="score">${(r.score * 100).toFixed(1)}% match</div>
<div class="name">${r.filename}</div> <div class="name">${r.filename}</div>
@@ -174,6 +214,19 @@
</div> </div>
`).join(""); `).join("");
} }
function openLightbox(encoded, name, score) {
document.getElementById("lb-img").src = `${API}/photos/${encoded}`;
document.getElementById("lb-name").textContent = name;
document.getElementById("lb-score").textContent = score + " match";
document.getElementById("lightbox").classList.add("open");
}
function closeLightbox() {
document.getElementById("lightbox").classList.remove("open");
}
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
</script> </script>
</body> </body>
</html> </html>
+54 -1
View File
@@ -106,6 +106,37 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; } .empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; }
.card img { cursor: pointer; }
.card img:hover { opacity: 0.85; }
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.8rem;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
}
.lightbox-info { color: white; font-size: 0.95rem; text-align: center; }
.lightbox-info .lb-score { color: #f38ba8; font-weight: 700; }
.lightbox-close {
position: fixed;
top: 1rem; right: 1.2rem;
color: white; font-size: 2rem;
cursor: pointer; line-height: 1;
}
</style> </style>
</head> </head>
<body> <body>
@@ -134,6 +165,14 @@
<p class="stats" id="stats"></p> <p class="stats" id="stats"></p>
<div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div> <div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<span class="lightbox-close" onclick="closeLightbox()"></span>
<img id="lb-img" src="" alt="" />
<div class="lightbox-info">
<span id="lb-name"></span> &nbsp;·&nbsp; <span class="lb-score" id="lb-score"></span>
</div>
</div>
<script> <script>
const API = "http://localhost:8001"; const API = "http://localhost:8001";
@@ -166,7 +205,8 @@
} }
grid.innerHTML = results.map(r => ` grid.innerHTML = results.map(r => `
<div class="card"> <div class="card">
<img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy" /> <img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy"
onclick="openLightbox('${encodeURIComponent(r.filename)}','${r.filename}','${(r.score*100).toFixed(1)}%')" />
<div class="card-info"> <div class="card-info">
<div class="score">${(r.score * 100).toFixed(1)}% match</div> <div class="score">${(r.score * 100).toFixed(1)}% match</div>
<div class="name">${r.filename}</div> <div class="name">${r.filename}</div>
@@ -174,6 +214,19 @@
</div> </div>
`).join(""); `).join("");
} }
function openLightbox(encoded, name, score) {
document.getElementById("lb-img").src = `${API}/photos/${encoded}`;
document.getElementById("lb-name").textContent = name;
document.getElementById("lb-score").textContent = score + " match";
document.getElementById("lightbox").classList.add("open");
}
function closeLightbox() {
document.getElementById("lightbox").classList.remove("open");
}
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
</script> </script>
</body> </body>
</html> </html>
-179
View File
@@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vector Image Search — Oracle In-DB</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; }
header {
background: #7b5ea7;
color: white;
padding: 1.2rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header h1 { font-size: 1.4rem; font-weight: 600; }
.badge {
background: white;
color: #7b5ea7;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: 999px;
}
.search-area {
max-width: 700px;
margin: 2rem auto 1rem;
padding: 0 1rem;
}
.search-row {
display: flex;
gap: 0.5rem;
}
input[type="text"] {
flex: 1;
padding: 0.7rem 1rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 6px;
}
button.search-btn {
padding: 0.7rem 1.4rem;
background: #7b5ea7;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
button.search-btn:hover { background: #664e8d; }
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.8rem;
}
.chip {
padding: 0.3rem 0.8rem;
background: white;
border: 1px solid #ccc;
border-radius: 999px;
font-size: 0.85rem;
cursor: pointer;
}
.chip:hover { background: #f3f0f8; border-color: #7b5ea7; }
.stats { text-align: center; color: #666; font-size: 0.85rem; margin-bottom: 1rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.card img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.card-info {
padding: 0.5rem 0.7rem;
font-size: 0.8rem;
}
.card-info .score {
font-weight: 700;
color: #7b5ea7;
}
.card-info .name {
color: #555;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; }
</style>
</head>
<body>
<header>
<h1>Vector Image Search</h1>
<span class="badge">Oracle In-DB</span>
</header>
<div class="search-area">
<div class="search-row">
<input id="query" type="text" placeholder="Search photos, e.g. trees, water, night…" />
<button class="search-btn" onclick="doSearch()">Search</button>
</div>
<div class="chips">
<span class="chip" onclick="setQuery('trees')">trees</span>
<span class="chip" onclick="setQuery('water')">water</span>
<span class="chip" onclick="setQuery('people')">people</span>
<span class="chip" onclick="setQuery('buildings')">buildings</span>
<span class="chip" onclick="setQuery('sky')">sky</span>
<span class="chip" onclick="setQuery('street')">street</span>
<span class="chip" onclick="setQuery('night')">night</span>
<span class="chip" onclick="setQuery('cars')">cars</span>
</div>
</div>
<p class="stats" id="stats"></p>
<div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div>
<script>
const API = "http://localhost:8002";
fetch(`${API}/stats`)
.then(r => r.json())
.then(d => document.getElementById("stats").textContent = `${d.count} photos indexed`);
document.getElementById("query").addEventListener("keydown", e => {
if (e.key === "Enter") doSearch();
});
function setQuery(text) {
document.getElementById("query").value = text;
doSearch();
}
function doSearch() {
const q = document.getElementById("query").value.trim();
if (!q) return;
fetch(`${API}/search?q=${encodeURIComponent(q)}&limit=12`)
.then(r => r.json())
.then(renderResults);
}
function renderResults(results) {
const grid = document.getElementById("grid");
if (!results.length) {
grid.innerHTML = '<p class="empty">No results found.</p>';
return;
}
grid.innerHTML = results.map(r => `
<div class="card">
<img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy" />
<div class="card-info">
<div class="score">${(r.score * 100).toFixed(1)}% match</div>
<div class="name">${r.filename}</div>
</div>
</div>
`).join("");
}
</script>
</body>
</html>
+5
View File
@@ -4,14 +4,19 @@ from PIL import Image
_model = None _model = None
def _get_model(): def _get_model():
# Lazy load: the CLIP model is ~600 MB and takes several seconds to initialise.
# Loading on first call avoids the cost at import time and during indexing warmup.
global _model global _model
if _model is None: if _model is None:
_model = SentenceTransformer("clip-ViT-B-32") _model = SentenceTransformer("clip-ViT-B-32")
return _model return _model
def embed_image(path: str) -> list[float]: def embed_image(path: str) -> list[float]:
# CLIP requires RGB — some JPEGs are stored as CMYK or grayscale.
img = Image.open(path).convert("RGB") img = Image.open(path).convert("RGB")
return _get_model().encode(img).tolist() return _get_model().encode(img).tolist()
def embed_text(text: str) -> list[float]: def embed_text(text: str) -> list[float]:
# Text and images share the same 512-dimensional vector space in CLIP,
# so the returned vector is directly comparable to image embeddings.
return _get_model().encode(text).tolist() return _get_model().encode(text).tolist()
+3
View File
@@ -29,6 +29,9 @@ def search(q: str = Query(...), limit: int = Query(12)):
ORDER BY embedding <=> %s::vector ORDER BY embedding <=> %s::vector
LIMIT %s LIMIT %s
""", """,
# vec appears twice: once for ORDER BY (uses HNSW index), once for the score column.
# ::vector cast is required — psycopg2 passes the list as text without it.
# 1 - distance converts cosine distance (0=identical) to similarity (1=identical).
(vec, vec, limit), (vec, vec, limit),
) )
rows = cur.fetchall() rows = cur.fetchall()
+54 -1
View File
@@ -106,6 +106,37 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; } .empty { text-align: center; color: #999; margin-top: 3rem; font-size: 1rem; }
.card img { cursor: pointer; }
.card img:hover { opacity: 0.85; }
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.8rem;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
}
.lightbox-info { color: white; font-size: 0.95rem; text-align: center; }
.lightbox-info .lb-score { color: #89b4fa; font-weight: 700; }
.lightbox-close {
position: fixed;
top: 1rem; right: 1.2rem;
color: white; font-size: 2rem;
cursor: pointer; line-height: 1;
}
</style> </style>
</head> </head>
<body> <body>
@@ -134,6 +165,14 @@
<p class="stats" id="stats"></p> <p class="stats" id="stats"></p>
<div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div> <div class="grid" id="grid"><p class="empty">Enter a search term above.</p></div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<span class="lightbox-close" onclick="closeLightbox()"></span>
<img id="lb-img" src="" alt="" />
<div class="lightbox-info">
<span id="lb-name"></span> &nbsp;·&nbsp; <span class="lb-score" id="lb-score"></span>
</div>
</div>
<script> <script>
const API = "http://localhost:8000"; const API = "http://localhost:8000";
@@ -166,7 +205,8 @@
} }
grid.innerHTML = results.map(r => ` grid.innerHTML = results.map(r => `
<div class="card"> <div class="card">
<img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy" /> <img src="${API}/photos/${encodeURIComponent(r.filename)}" alt="${r.filename}" loading="lazy"
onclick="openLightbox('${encodeURIComponent(r.filename)}','${r.filename}','${(r.score*100).toFixed(1)}%')" />
<div class="card-info"> <div class="card-info">
<div class="score">${(r.score * 100).toFixed(1)}% match</div> <div class="score">${(r.score * 100).toFixed(1)}% match</div>
<div class="name">${r.filename}</div> <div class="name">${r.filename}</div>
@@ -174,6 +214,19 @@
</div> </div>
`).join(""); `).join("");
} }
function openLightbox(encoded, name, score) {
document.getElementById("lb-img").src = `${API}/photos/${encoded}`;
document.getElementById("lb-name").textContent = name;
document.getElementById("lb-score").textContent = score + " match";
document.getElementById("lightbox").classList.add("open");
}
function closeLightbox() {
document.getElementById("lightbox").classList.remove("open");
}
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
</script> </script>
</body> </body>
</html> </html>