From 66f7db40b06bc1b54e9a130bce4ddec453512d74 Mon Sep 17 00:00:00 2001 From: Dierk Date: Tue, 19 May 2026 11:33:16 +0200 Subject: [PATCH] Initial implementation of pgvector and Oracle 26ai vector search demo Three FastAPI backends comparing PostgreSQL/pgvector and Oracle 26ai for semantic image search using CLIP embeddings: Python-side embedding for both databases, plus Oracle in-database embedding via VECTOR_EMBEDDING(CLIP_TXT). Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + README.md | 466 ++++++++++++++++++ README.odt | Bin 0 -> 17155 bytes oravector-demo/backend/db_oracle.py | 19 + oravector-demo/backend/embedder.py | 17 + oravector-demo/backend/index_images_oracle.py | 66 +++ oravector-demo/backend/main_oracle.py | 49 ++ oravector-demo/backend/main_oracle_indb.py | 55 +++ oravector-demo/frontend/index.html | 179 +++++++ oravector-demo/frontend/index_indb.html | 179 +++++++ pgvector-demo/backend/db.py | 14 + pgvector-demo/backend/embedder.py | 17 + pgvector-demo/backend/index_images.py | 56 +++ pgvector-demo/backend/main.py | 48 ++ pgvector-demo/frontend/index.html | 179 +++++++ 15 files changed, 1347 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README.odt create mode 100644 oravector-demo/backend/db_oracle.py create mode 100644 oravector-demo/backend/embedder.py create mode 100644 oravector-demo/backend/index_images_oracle.py create mode 100644 oravector-demo/backend/main_oracle.py create mode 100644 oravector-demo/backend/main_oracle_indb.py create mode 100644 oravector-demo/frontend/index.html create mode 100644 oravector-demo/frontend/index_indb.html create mode 100644 pgvector-demo/backend/db.py create mode 100644 pgvector-demo/backend/embedder.py create mode 100644 pgvector-demo/backend/index_images.py create mode 100644 pgvector-demo/backend/main.py create mode 100644 pgvector-demo/frontend/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e22d368 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +photos/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f22f3cc --- /dev/null +++ b/README.md @@ -0,0 +1,466 @@ +# Vector Image Search — PostgreSQL/pgvector vs Oracle 26ai + +A comparative demo that vectorizes JPEG photos using the CLIP neural network model +and stores the embeddings in two different databases: **PostgreSQL with pgvector** +and **Oracle AI Database 26ai**. Users search the photo collection by typing +plain-text keywords such as "trees" or "water" and receive results ranked by +semantic similarity. + +Three backends are implemented, demonstrating two fundamental approaches to vector +embedding: + +| Backend | Port | Embedding location | Model | +|---|---|---|---| +| PostgreSQL + pgvector | 8000 | Python (external) | sentence-transformers CLIP | +| Oracle 26ai (Python embedding) | 8001 | Python (external) | sentence-transformers CLIP | +| Oracle 26ai (in-database embedding) | 8002 | Inside Oracle SQL | Oracle native CLIP_TXT | + +The key architectural difference: in the third backend, the text query is embedded +**inside a SQL statement** using Oracle's `VECTOR_EMBEDDING()` function — no Python +ML library is loaded or called at search time. + +--- + +## Architecture overview + +``` + 115 JPEG photos + │ + ▼ + ┌───────────────────────────────┐ + │ CLIP model (clip-ViT-B-32) │ + │ sentence-transformers lib │ + │ → 512-dimensional float vec │ + └──────────────┬────────────────┘ + │ + ┌──────────────┴──────────────┐ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ ┌───────────────────────┐ + │ PostgreSQL 16 │ │ Oracle 26ai │ │ Oracle 26ai │ + │ + pgvector 0.6.0 │ │ (version 23.26.1) │ │ (version 23.26.1) │ + │ database: │ │ PDB: FREEPDB1 │ │ PDB: FREEPDB1 │ + │ vectors_demo │ │ user: vectors_user │ │ schema: VECTOR │ + │ HNSW index │ │ HNSW index │ │ HNSW not needed │ + └────────┬─────────────┘ └──────────┬───────────┘ └──────────┬────────────┘ + │ │ │ + ▼ ▼ │ + Python CLIP encode Python CLIP encode Text stays in Oracle SQL + (search query) (search query) VECTOR_EMBEDDING(CLIP_TXT + USING :q AS data) + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ + │ FastAPI │ │ FastAPI │ │ FastAPI │ + │ main.py │ │ main_oracle │ │ main_oracle_ │ + │ port 8000 │ │ port 8001 │ │ indb.py │ + └──────┬───────┘ └──────┬───────┘ │ port 8002 │ + │ │ └────────┬─────────┘ + ▼ ▼ ▼ + frontend/index.html frontend/index.html frontend/index_indb.html + (badge: pgvector) (badge: Oracle 26ai) (badge: Oracle In-DB) +``` + +--- + +## Project structure + +``` +pgvector-demo/ +├── backend/ +│ ├── .env # PostgreSQL credentials, photo path +│ ├── db.py # PostgreSQL connection factory +│ ├── embedder.py # CLIP model wrapper +│ ├── index_images.py # One-time indexing script +│ └── main.py # FastAPI app (port 8000) +└── frontend/ + └── index.html # Search UI + +oravector-demo/ +├── backend/ +│ ├── .env # Oracle credentials, photo path +│ ├── db_oracle.py # Oracle connection factory (vectors_user) +│ ├── embedder.py # CLIP model wrapper (identical to pgvector) +│ ├── index_images_oracle.py # One-time indexing script (Python embedding) +│ ├── main_oracle.py # FastAPI app — Python embedding (port 8001) +│ └── main_oracle_indb.py # FastAPI app — in-database embedding (port 8002) +└── frontend/ + ├── index.html # Search UI (Oracle 26ai, Python embedding) + └── index_indb.html # Search UI (Oracle 26ai, in-database embedding) +``` + +--- + +## System components installed + +### Operating system packages + +| Package | Version | Purpose | +|---|---|---| +| PostgreSQL | 16.13 (Ubuntu) | Relational database | +| postgresql-16-pgvector | 0.6.0 | Vector data type and indexes for PostgreSQL | +| Python | 3.12.3 | Runtime for all backend code | +| Podman | — | Container runtime for Oracle 26ai | + +**PostgreSQL pgvector installation:** +```bash +sudo apt install postgresql-16-pgvector +``` + +**pgvector extension activation** (requires superuser, run once per database): +```bash +sudo -u postgres psql -d vectors_demo -c "CREATE EXTENSION vector;" +``` + +### Oracle 26ai (Podman container) + +| Property | Value | +|---|---| +| Product | Oracle AI Database 26ai Free | +| Version | 23.26.1.0.0 | +| Container name | `oracle.free` | +| Host port | 37611 (mapped to 1521 inside container) | +| Pluggable Database | FREEPDB1 | +| Schema user | `vectors_user` | + +**Oracle vector memory** — the HNSW index is held entirely in the SGA's Vector +Memory Area. This must be configured before the database starts: + +```sql +-- Connect as SYSDBA to service FREE (CDB root) +ALTER SYSTEM SET vector_memory_size = 512M SCOPE=SPFILE; +``` + +Then restart Oracle inside the container: +```bash +podman exec oracle.free bash -c "sqlplus -s / as sysdba <<'EOF' +SHUTDOWN ABORT; +EXIT; +EOF" + +podman exec oracle.free bash -c "sqlplus -s / as sysdba <<'EOF' +STARTUP; +EXIT; +EOF" +``` + +After restart, the SGA confirms: `Vector Memory Area: 536870912 bytes (512 MB)`. + +### Python packages + +| Package | Version | Used by | Purpose | +|---|---|---|---| +| `sentence-transformers` | 5.3.0 | both | CLIP model loading and inference | +| `torch` | 2.11.0 | both | Neural network runtime for CLIP | +| `Pillow` | 10.2.0 | both | JPEG loading and colour conversion | +| `fastapi` | 0.135.2 | both | REST API framework | +| `uvicorn` | 0.42.0 | both | ASGI server | +| `python-dotenv` | 1.0.1 | both | `.env` file support | +| `psycopg2-binary` | 2.9.11 | pgvector only | PostgreSQL driver | +| `oracledb` | 3.4.2 | Oracle only | Oracle driver (thin mode, no client libs needed) | + +**Install all packages:** +```bash +pip3 install fastapi uvicorn psycopg2-binary oracledb sentence-transformers \ + Pillow python-dotenv --break-system-packages +``` + +--- + +## Vectorization + +### Model: CLIP (clip-ViT-B-32) + +CLIP (Contrastive Language–Image Pretraining) is a neural network model developed +by OpenAI. It was trained on hundreds of millions of image–text pairs and maps both +images and text into the **same 512-dimensional vector space**. This enables +searching images by plain-text query without any manual labelling or tagging. + +| Property | Value | +|---|---| +| Architecture | Vision Transformer ViT-B/32 | +| Output dimension | 512 floats | +| Similarity metric | Cosine similarity | +| Weights source | Hugging Face Hub: `sentence-transformers/clip-ViT-B-32` | +| Downloaded to | `~/.cache/huggingface/hub/` on first run | + +**Why cosine similarity?** CLIP vectors have varying magnitudes. Cosine similarity +normalises for magnitude and measures only the direction — the angle between two +vectors — which reliably captures semantic relatedness regardless of vector scale. + +The `embedder.py` module is identical in both projects. It lazily loads the model +on first call and exposes two functions: + +| Function | Input | Output | +|---|---|---| +| `embed_image(path)` | Filesystem path to a JPEG | `list[float]` — 512 values | +| `embed_text(text)` | Plain-text query string | `list[float]` — 512 values | + +At search time, the text query is embedded into the same vector space as the photos. +The database then finds the photos whose vectors point in the most similar direction. + +--- + +## Database schemas + +### PostgreSQL + pgvector + +```sql +-- database: vectors_demo (PostgreSQL 16) +CREATE EXTENSION vector; -- pgvector 0.6.0 + +CREATE TABLE images ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + filepath TEXT NOT NULL, + embedding vector(512) -- pgvector type, 512 dimensions +); + +CREATE INDEX images_embedding_idx + ON images USING hnsw (embedding vector_cosine_ops); +``` + +### Oracle 26ai + +```sql +-- PDB: FREEPDB1, user: vectors_user + +CREATE TABLE images ( + id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + filename VARCHAR2(255) NOT NULL UNIQUE, + filepath VARCHAR2(1000) NOT NULL, + embedding VECTOR(512, FLOAT32) -- native Oracle type, typed at definition +); + +CREATE VECTOR INDEX images_embedding_idx + ON images(embedding) + ORGANIZATION INMEMORY NEIGHBOR GRAPH -- HNSW (in-memory) + WITH DISTANCE COSINE + WITH TARGET ACCURACY 95 + PARAMETERS (type HNSW, neighbors 32, efconstruction 200); +``` + +**Key schema differences:** + +| Aspect | PostgreSQL/pgvector | Oracle 26ai | +|---|---|---| +| Extension needed | `CREATE EXTENSION vector` | Built-in, no extension | +| Vector column | `vector(512)` — dimension only | `VECTOR(512, FLOAT32)` — dimension + element type | +| Primary key | `SERIAL` (auto-increment) | `NUMBER GENERATED ALWAYS AS IDENTITY` | +| Text columns | `TEXT` (unlimited) | `VARCHAR2(n)` (length required) | +| HNSW syntax | `USING hnsw (col vector_cosine_ops)` | `ORGANIZATION INMEMORY NEIGHBOR GRAPH` | +| IVF syntax | `USING ivfflat (col vector_cosine_ops)` | `ORGANIZATION NEIGHBOR PARTITIONS` | +| Accuracy target | Implicit (set via index params) | `WITH TARGET ACCURACY 95` (explicit %) | +| Memory prereq | None | `vector_memory_size > 0` in SGA | + +--- + +## Backend modules + +### Connection factories + +**`db.py` (PostgreSQL):** +Reads `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` from `.env` and +returns a `psycopg2` connection. + +**`db_oracle.py` (Oracle):** +Reads `ORA_HOST`, `ORA_PORT`, `ORA_SERVICE`, `ORA_USER`, `ORA_PASSWORD` from `.env` +and returns an `oracledb` connection. The DSN is assembled as `host:port/service`. +Runs in **thin mode** — no Oracle Instant Client installation is required on the host. + +--- + +### Indexing scripts + +Both scripts are idempotent: they check for existing rows and skip already-indexed +photos. Each photo is committed individually so a crash does not lose prior work. + +| | `index_images.py` | `index_images_oracle.py` | +|---|---|---| +| Run command | `python3 index_images.py` | `python3 index_images_oracle.py` | +| Vector bind | Python `list` passed directly | `array.array("f", embedding)` required | +| Bind style | `%s` placeholders (psycopg2) | `:1`, `:2`, `:3` positional (oracledb) | +| Runtime (115 photos, CPU) | **26 seconds** | **16 seconds** | + +**Why `array.array` for Oracle?** +The `python-oracledb` driver does not accept a plain Python list for a `VECTOR` +column. The data must be a Python `array.array` with typecode `"f"` (32-bit float), +matching the `FLOAT32` declaration in the Oracle column type. + +--- + +### FastAPI applications + +Both apps expose identical endpoints at different ports: + +| Endpoint | Description | +|---|---| +| `GET /search?q=&limit=` | Embed query, run nearest-neighbour search, return ranked results | +| `GET /stats` | Return count of indexed photos | +| `GET /photos/` | Serve original JPEG from the photos directory | + +**Search query comparison:** + +PostgreSQL (`main.py`, port 8000): +```sql +SELECT filename, 1 - (embedding <=> $1::vector) AS score +FROM images +ORDER BY embedding <=> $1::vector +LIMIT $2 +``` + +Oracle 26ai (`main_oracle.py`, port 8001): +```sql +SELECT filename, + 1 - VECTOR_DISTANCE(embedding, :vec, COSINE) AS score +FROM images +ORDER BY VECTOR_DISTANCE(embedding, :vec, COSINE) +FETCH FIRST :lim ROWS ONLY +``` + +**Key query differences:** + +| Aspect | PostgreSQL/pgvector | Oracle 26ai | +|---|---|---| +| Distance operator | `<=>` (cosine distance operator) | `VECTOR_DISTANCE(col, val, COSINE)` | +| Cast required | `$1::vector` — explicit cast | No cast, column type is enforced | +| Top-N clause | `LIMIT n` | `FETCH FIRST n ROWS ONLY` | +| Bind style | `$1`, `$2` positional (psycopg2) | `:name` named binds (dict) | +| Repeated param | `$1` can appear multiple times | Same `:name` can appear multiple times; positional `:1` cannot be reused | +| Score formula | `1 - (embedding <=> val)` | `1 - VECTOR_DISTANCE(...)` | + +In both cases `1 − distance` converts cosine distance (0 = identical) into a +similarity score (1.0 = identical), displayed as a percentage in the frontend. + +--- + +## Frontend + +Both frontends are identical single HTML files with no build step. Open directly +in a browser. + +| | pgvector frontend | Oracle 26ai frontend | +|---|---|---| +| File | `pgvector-demo/frontend/index.html` | `oravector-demo/frontend/index.html` | +| Badge label | pgvector | Oracle 26ai | +| API base URL | `http://localhost:8000` | `http://localhost:8001` | + +Features: search box, Enter-key support, suggestion chips (trees, water, people, +buildings, sky, street, night, cars), result grid with thumbnails and similarity +scores in percent. + +--- + +## Running the applications + +**Start PostgreSQL backend** (Python embedding): +```bash +cd pgvector-demo/backend +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +**Start Oracle backend — Python embedding:** +```bash +cd oravector-demo/backend +uvicorn main_oracle:app --host 0.0.0.0 --port 8001 +``` + +**Start Oracle backend — in-database embedding:** +```bash +cd oravector-demo/backend +uvicorn main_oracle_indb:app --host 0.0.0.0 --port 8002 +``` + +Open the matching `frontend/index.html` (ports 8000/8001) or +`frontend/index_indb.html` (port 8002) in a browser. All three can run +simultaneously. + +**Re-index after adding photos:** +```bash +# PostgreSQL +cd pgvector-demo/backend && python3 index_images.py + +# Oracle (Python embedding) +cd oravector-demo/backend && python3 index_images_oracle.py + +# Oracle in-database: re-indexing is done in SQL directly +# (the VECTOR schema's FOTO_VEKTOR table is managed by Oracle) +``` + +--- + +## Oracle in-database embedding + +The `VECTOR` schema, its ONNX models, and the `FOTO_VEKTOR` table were manually +set up by the administrator — they are **not** part of a standard Oracle 26ai +installation. The setup involved: + +1. Creating a `VECTOR` database user +2. Exporting CLIP (ViT-B/32) to ONNX format and loading the models via + `DBMS_VECTOR.LOAD_ONNX_MODEL` +3. Creating and populating the `FOTO_VEKTOR` table with images and their vectors + +The resulting models and table are: + +| Object | Type | Input | Output | Purpose | +|---|---|---|---|---| +| `VECTOR.CLIP_TXT` | ONNX model | `VARCHAR2` text | `VECTOR(512)` | Embed text queries | +| `VECTOR.CLIP_IMG` | ONNX model | `BLOB` image | `VECTOR(512)` | Embed image data | +| `VECTOR.FOTO_VEKTOR` | Table | — | — | Stores filenames, image BLOBs, and vectors | + +These are called with the `VECTOR_EMBEDDING()` SQL function. The table +`VECTOR.FOTO_VEKTOR` stores images as BLOBs alongside their CLIP_IMG-computed +embeddings. + +**The complete in-database search query:** +```sql +SELECT filename, + 1 - VECTOR_DISTANCE( + foto_vek, + VECTOR_EMBEDDING(CLIP_TXT USING :q AS data), + COSINE + ) AS score +FROM VECTOR.FOTO_VEKTOR +ORDER BY VECTOR_DISTANCE( + foto_vek, + VECTOR_EMBEDDING(CLIP_TXT USING :q AS data), + COSINE + ) +FETCH FIRST 12 ROWS ONLY +``` + +The Python FastAPI backend (`main_oracle_indb.py`) passes only the raw text string +to Oracle via a bind variable `:q`. Oracle tokenizes the text, runs the CLIP_TXT +ONNX model internally, produces the 512-dim vector, and performs the similarity +search — all within one SQL statement. No Python ML library is involved at +query time. + +**Why Oracle can ship CLIP as an in-database ONNX model:** +Oracle's `DBMS_VECTOR.LOAD_ONNX_MODEL` requires the model's ONNX graph to use +`input_ids` in a single `Gather` node (embedding lookup only). CLIP's standard +export uses `input_ids` additionally in `ArgMax` for EOS-token pooling, which +Oracle's validator rejects. The manually loaded CLIP_TXT model in the `VECTOR` +schema uses CLS-token pooling (position 0) instead, which produces a simpler +graph that Oracle accepts. The +cosine similarity between EOS-pooling and CLS-pooling variants is ~0.70. + +--- + +## Performance comparison + +Measured on this installation (CPU only, no GPU): + +| Metric | PostgreSQL + pgvector | Oracle 26ai (Python embed) | Oracle 26ai (in-DB embed) | +|---|---|---|---| +| Photos indexed | 115 | 115 | 116 (manually indexed) | +| Indexing time | 26 seconds | 16 seconds | 0 (indexed separately by admin) | +| Index type | HNSW (on disk) | HNSW (in-memory) | Full table scan (116 rows) | +| Memory required | None | 512 MB SGA | 512 MB SGA | +| Python CLIP at query time | Yes | Yes | **No** | +| Embedding location | Python process | Python process | Inside Oracle SQL | +| `VECTOR_EMBEDDING()` used | No | No | **Yes** | + +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 +models in the `VECTOR` schema; their indexing time is not measured here as it +was performed separately by the administrator. diff --git a/README.odt b/README.odt new file mode 100644 index 0000000000000000000000000000000000000000..d7736bc105617a2ee3d3fd14ff3a0dc116708a58 GIT binary patch literal 17155 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 list[float]: + img = Image.open(path).convert("RGB") + return _get_model().encode(img).tolist() + +def embed_text(text: str) -> list[float]: + return _get_model().encode(text).tolist() diff --git a/oravector-demo/backend/index_images_oracle.py b/oravector-demo/backend/index_images_oracle.py new file mode 100644 index 0000000..bebdf27 --- /dev/null +++ b/oravector-demo/backend/index_images_oracle.py @@ -0,0 +1,66 @@ +import os +import array +from dotenv import load_dotenv +from db_oracle import get_connection +from embedder import embed_image + +load_dotenv() + +PHOTOS_DIR = os.getenv("PHOTOS_DIR") + +CREATE_TABLE = """ +CREATE TABLE images ( + id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + filename VARCHAR2(255) NOT NULL UNIQUE, + filepath VARCHAR2(1000) NOT NULL, + embedding VECTOR(512, FLOAT32) +) +""" + +CREATE_INDEX = """ +CREATE VECTOR INDEX images_embedding_idx + ON images(embedding) + ORGANIZATION INMEMORY NEIGHBOR GRAPH + WITH DISTANCE COSINE + WITH TARGET ACCURACY 95 + PARAMETERS (type HNSW, neighbors 32, efconstruction 200) +""" + +INSERT = "INSERT INTO images (filename, filepath, embedding) VALUES (:1, :2, :3)" + +def table_exists(cur): + cur.execute("SELECT COUNT(*) FROM user_tables WHERE table_name = 'IMAGES'") + return cur.fetchone()[0] > 0 + +def main(): + conn = get_connection() + cur = conn.cursor() + + if not table_exists(cur): + cur.execute(CREATE_TABLE) + cur.execute(CREATE_INDEX) + conn.commit() + print("Table and index created.") + else: + print("Table already exists, skipping creation.") + + files = [f for f in os.listdir(PHOTOS_DIR) if f.lower().endswith((".jpg", ".jpeg"))] + print(f"Found {len(files)} photos in {PHOTOS_DIR}") + + for i, filename in enumerate(files, 1): + filepath = os.path.join(PHOTOS_DIR, filename) + cur.execute("SELECT 1 FROM images WHERE filename = :1", (filename,)) + if cur.fetchone(): + print(f"[{i}/{len(files)}] Skipping {filename} (already indexed)") + continue + embedding = array.array("f", embed_image(filepath)) + cur.execute(INSERT, (filename, filepath, embedding)) + conn.commit() + print(f"[{i}/{len(files)}] Indexed {filename}") + + cur.close() + conn.close() + print("Done.") + +if __name__ == "__main__": + main() diff --git a/oravector-demo/backend/main_oracle.py b/oravector-demo/backend/main_oracle.py new file mode 100644 index 0000000..da7bb64 --- /dev/null +++ b/oravector-demo/backend/main_oracle.py @@ -0,0 +1,49 @@ +import os +import array +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from dotenv import load_dotenv +from db_oracle import get_connection +from embedder import embed_text + +load_dotenv() + +PHOTOS_DIR = os.getenv("PHOTOS_DIR") + +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.get("/search") +def search(q: str = Query(...), limit: int = Query(12)): + vec = array.array("f", embed_text(q)) + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + SELECT filename, 1 - VECTOR_DISTANCE(embedding, :vec, COSINE) AS score + FROM images + ORDER BY VECTOR_DISTANCE(embedding, :vec, COSINE) + FETCH FIRST :lim ROWS ONLY + """, + {"vec": vec, "lim": limit}, + ) + rows = cur.fetchall() + cur.close() + conn.close() + return [{"filename": r[0], "score": round(r[1], 4)} for r in rows] + +@app.get("/stats") +def stats(): + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM images") + count = cur.fetchone()[0] + cur.close() + conn.close() + return {"count": count} + +@app.get("/photos/{filename}") +def get_photo(filename: str): + path = os.path.join(PHOTOS_DIR, filename) + return FileResponse(path, media_type="image/jpeg") diff --git a/oravector-demo/backend/main_oracle_indb.py b/oravector-demo/backend/main_oracle_indb.py new file mode 100644 index 0000000..6fb8af6 --- /dev/null +++ b/oravector-demo/backend/main_oracle_indb.py @@ -0,0 +1,55 @@ +import os +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from dotenv import load_dotenv +from db_oracle import get_connection_indb + +load_dotenv() + +PHOTOS_DIR = os.getenv("PHOTOS_DIR") + +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.get("/search") +def search(q: str = Query(...), limit: int = Query(12)): + conn = get_connection_indb() + cur = conn.cursor() + cur.execute( + """ + SELECT filename, + 1 - VECTOR_DISTANCE( + foto_vek, + VECTOR_EMBEDDING(CLIP_TXT USING :q AS data), + COSINE + ) AS score + FROM VECTOR.FOTO_VEKTOR + ORDER BY VECTOR_DISTANCE( + foto_vek, + VECTOR_EMBEDDING(CLIP_TXT USING :q AS data), + COSINE + ) + FETCH FIRST :lim ROWS ONLY + """, + {"q": q, "lim": limit}, + ) + rows = cur.fetchall() + cur.close() + conn.close() + return [{"filename": r[0], "score": round(r[1], 4)} for r in rows] + +@app.get("/stats") +def stats(): + conn = get_connection_indb() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM VECTOR.FOTO_VEKTOR") + count = cur.fetchone()[0] + cur.close() + conn.close() + return {"count": count} + +@app.get("/photos/{filename}") +def get_photo(filename: str): + path = os.path.join(PHOTOS_DIR, filename) + return FileResponse(path, media_type="image/jpeg") diff --git a/oravector-demo/frontend/index.html b/oravector-demo/frontend/index.html new file mode 100644 index 0000000..7375f23 --- /dev/null +++ b/oravector-demo/frontend/index.html @@ -0,0 +1,179 @@ + + + + + + Vector Image Search — Oracle 26ai + + + +
+

Vector Image Search

+ Oracle 26ai +
+ +
+
+ + +
+
+ trees + water + people + buildings + sky + street + night + cars +
+
+ +

+

Enter a search term above.

+ + + + diff --git a/oravector-demo/frontend/index_indb.html b/oravector-demo/frontend/index_indb.html new file mode 100644 index 0000000..f18e2ea --- /dev/null +++ b/oravector-demo/frontend/index_indb.html @@ -0,0 +1,179 @@ + + + + + + Vector Image Search — Oracle In-DB + + + +
+

Vector Image Search

+ Oracle In-DB +
+ +
+
+ + +
+
+ trees + water + people + buildings + sky + street + night + cars +
+
+ +

+

Enter a search term above.

+ + + + diff --git a/pgvector-demo/backend/db.py b/pgvector-demo/backend/db.py new file mode 100644 index 0000000..58e96e0 --- /dev/null +++ b/pgvector-demo/backend/db.py @@ -0,0 +1,14 @@ +import os +import psycopg2 +from dotenv import load_dotenv + +load_dotenv() + +def get_connection(): + return psycopg2.connect( + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT"), + dbname=os.getenv("DB_NAME"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD"), + ) diff --git a/pgvector-demo/backend/embedder.py b/pgvector-demo/backend/embedder.py new file mode 100644 index 0000000..4e1f34b --- /dev/null +++ b/pgvector-demo/backend/embedder.py @@ -0,0 +1,17 @@ +from sentence_transformers import SentenceTransformer +from PIL import Image + +_model = None + +def _get_model(): + global _model + if _model is None: + _model = SentenceTransformer("clip-ViT-B-32") + return _model + +def embed_image(path: str) -> list[float]: + img = Image.open(path).convert("RGB") + return _get_model().encode(img).tolist() + +def embed_text(text: str) -> list[float]: + return _get_model().encode(text).tolist() diff --git a/pgvector-demo/backend/index_images.py b/pgvector-demo/backend/index_images.py new file mode 100644 index 0000000..42976ab --- /dev/null +++ b/pgvector-demo/backend/index_images.py @@ -0,0 +1,56 @@ +import os +from dotenv import load_dotenv +from db import get_connection +from embedder import embed_image + +load_dotenv() + +PHOTOS_DIR = os.getenv("PHOTOS_DIR") + +CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS images ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + filepath TEXT NOT NULL, + embedding vector(512) +); +""" + +CREATE_INDEX = """ +CREATE INDEX IF NOT EXISTS images_embedding_idx + ON images USING hnsw (embedding vector_cosine_ops); +""" + +INSERT = """ +INSERT INTO images (filename, filepath, embedding) +VALUES (%s, %s, %s) +ON CONFLICT (filename) DO NOTHING; +""" + +def main(): + conn = get_connection() + cur = conn.cursor() + cur.execute(CREATE_TABLE) + cur.execute(CREATE_INDEX) + conn.commit() + + files = [f for f in os.listdir(PHOTOS_DIR) if f.lower().endswith((".jpg", ".jpeg"))] + print(f"Found {len(files)} photos in {PHOTOS_DIR}") + + for i, filename in enumerate(files, 1): + filepath = os.path.join(PHOTOS_DIR, filename) + cur.execute("SELECT 1 FROM images WHERE filename = %s", (filename,)) + if cur.fetchone(): + print(f"[{i}/{len(files)}] Skipping {filename} (already indexed)") + continue + embedding = embed_image(filepath) + cur.execute(INSERT, (filename, filepath, embedding)) + conn.commit() + print(f"[{i}/{len(files)}] Indexed {filename}") + + cur.close() + conn.close() + print("Done.") + +if __name__ == "__main__": + main() diff --git a/pgvector-demo/backend/main.py b/pgvector-demo/backend/main.py new file mode 100644 index 0000000..bf268a1 --- /dev/null +++ b/pgvector-demo/backend/main.py @@ -0,0 +1,48 @@ +import os +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from dotenv import load_dotenv +from db import get_connection +from embedder import embed_text + +load_dotenv() + +PHOTOS_DIR = os.getenv("PHOTOS_DIR") + +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.get("/search") +def search(q: str = Query(...), limit: int = Query(12)): + vec = embed_text(q) + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + SELECT filename, 1 - (embedding <=> %s::vector) AS score + FROM images + ORDER BY embedding <=> %s::vector + LIMIT %s + """, + (vec, vec, limit), + ) + rows = cur.fetchall() + cur.close() + conn.close() + return [{"filename": r[0], "score": round(r[1], 4)} for r in rows] + +@app.get("/stats") +def stats(): + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM images") + count = cur.fetchone()[0] + cur.close() + conn.close() + return {"count": count} + +@app.get("/photos/{filename}") +def get_photo(filename: str): + path = os.path.join(PHOTOS_DIR, filename) + return FileResponse(path, media_type="image/jpeg") diff --git a/pgvector-demo/frontend/index.html b/pgvector-demo/frontend/index.html new file mode 100644 index 0000000..dac6008 --- /dev/null +++ b/pgvector-demo/frontend/index.html @@ -0,0 +1,179 @@ + + + + + + Vector Image Search — pgvector + + + +
+

Vector Image Search

+ pgvector +
+ +
+
+ + +
+
+ trees + water + people + buildings + sky + street + night + cars +
+
+ +

+

Enter a search term above.

+ + + +