Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ce44e186 | |||
| 048309da8a | |||
| 1c5e00d8e4 |
@@ -2,3 +2,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
photos/
|
photos/
|
||||||
.~lock.*
|
.~lock.*
|
||||||
|
present.sh
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> · <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>
|
||||||
|
|||||||
@@ -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> · <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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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> · <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>
|
||||||
|
|||||||
Reference in New Issue
Block a user