Skip to content

Commit 400c12b

Browse files
author
Ariel Vernaza
committed
UI
1 parent 5463ee6 commit 400c12b

20 files changed

Lines changed: 892 additions & 19 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,8 @@ logs/
5959

6060
# Docker local (optional; uncomment if you want to ignore)
6161
# docker-compose.override.yml
62+
63+
# Frontend (built in Docker; no local node)
64+
frontend/node_modules
65+
frontend/dist
66+
frontend/package-lock.json

Dockerfile

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,14 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
66

77
WORKDIR /app
88

9-
# Set to 1 to install Sentence Transformers in the image (large: ~2GB+ for torch). Default 0 = small image, use cohere:// for ingest-pdf.
10-
ARG INSTALL_TRANSFORMER=0
11-
# Set to 1 to add Image embedder (CLIP/ViT). Requires INSTALL_TRANSFORMER or separate install.
9+
# Set to 1 to add Image embedder (CLIP/ViT). Default 0 = no image embedder.
1210
ARG INSTALL_EMBEDDING_EXTRAS=0
1311

14-
# Install deps from lockfile (reproducible). PDF extra for POST /libraries/{id}/ingest-pdf.
12+
# Install deps: pdf (ingest-pdf) + embedding-transformer (Sentence Transformers) so UI works with Cohere or Transformer.
1513
COPY pyproject.toml uv.lock ./
1614
COPY app ./app
17-
RUN uv sync --frozen --no-dev --extra pdf
15+
RUN uv sync --frozen --no-dev --extra pdf --extra embedding-transformer
1816

19-
# Optional: Sentence Transformers (torch) for embedding_transformer:// without Cohere. Build with: --build-arg INSTALL_TRANSFORMER=1
20-
RUN if [ "$INSTALL_TRANSFORMER" = "1" ]; then uv sync --no-dev --extra embedding-transformer; fi
2117
# Optional: Image embedder (CLIP/ViT)
2218
RUN if [ "$INSTALL_EMBEDDING_EXTRAS" = "1" ]; then uv sync --no-dev --extra embedding-image; fi
2319

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,11 +415,14 @@ The project is **container-first** and uses **Compose** to build and run (Docker
415415
From the project root (use `podman` or `docker` as you have):
416416

417417
```bash
418-
# Start (builds the image if needed and runs the service)
418+
# Start API + UI (builds images if needed; nothing runs on the host except containers)
419419
podman compose up
420420
# or: docker compose up
421421
```
422422

423+
- **API:** http://localhost:8000**UI:** http://localhost:3000
424+
The UI (React) runs in its own container and shows the full pipeline: create library → ingest PDF → build index → search by text. Styling is inspired by Qdrant. The UI is built and served from Docker; no local Node/npm required.
425+
423426
In **another terminal** test the API:
424427

425428
```bash
@@ -454,7 +457,7 @@ podman build --target test -t vector-db-api-test .
454457
podman run --rm vector-db-api-test
455458
```
456459

457-
The **default API image** includes only the `pdf` extra (small image). Use `embedder=cohere://` for ingest-pdf (set `COHERE_API_KEY` in `.env`). To include **Sentence Transformers** in the API image (larger, ~2GB+ for torch): `podman build --build-arg INSTALL_TRANSFORMER=1 -t vector-db-api .`. To add the **Image embedder**: `--build-arg INSTALL_EMBEDDING_EXTRAS=1`.
460+
The **default API image** includes the `pdf` and **embedding-transformer** extras (pypdf + Sentence Transformers), so the UI can use Cohere or Sentence Transformers for ingest and search. Image is larger (~2GB for torch). Optional: add Image embedder with `--build-arg INSTALL_EMBEDDING_EXTRAS=1`.
458461

459462
### Local development (optional)
460463

@@ -490,6 +493,7 @@ The API is REST-style. All IDs are UUIDs. Base URL when running locally: `http:/
490493
| DELETE | `/libraries/{library_id}/documents/{document_id}/chunks/{chunk_id}` | Delete chunk |
491494
| POST | `/libraries/{library_id}/index` | Build or rebuild vector index |
492495
| POST | `/libraries/{library_id}/search` | k-NN search by embedding |
496+
| POST | `/libraries/{library_id}/search/by-query` | k-NN search by text (server embeds query) |
493497

494498
### Status codes
495499

app/api/schemas.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,12 @@ class SearchRequest(BaseModel):
7171

7272

7373
class SearchResultItem(BaseModel):
74-
"""One nearest-neighbor result: chunk id and L2 distance to the query."""
74+
"""One nearest-neighbor result: chunk id, distance, and optional chunk text/name (for by-query)."""
7575

7676
chunk_id: UUID = Field(..., description="Chunk UUID.")
7777
distance: float = Field(..., description="L2 (Euclidean) distance to the query vector.")
78+
text: str | None = Field(default=None, description="Chunk text (included by search/by-query).")
79+
name: str | None = Field(default=None, description="Chunk name (included by search/by-query).")
7880

7981

8082
class SearchResponse(BaseModel):
@@ -85,6 +87,17 @@ class SearchResponse(BaseModel):
8587
)
8688

8789

90+
class SearchByQueryRequest(BaseModel):
91+
"""Request body for POST /libraries/{id}/search-by-query. Text query; server embeds it then runs k-NN."""
92+
93+
query: str = Field(..., min_length=1, description="Search query text.")
94+
k: int = Field(default=5, ge=1, le=100, description="Number of nearest neighbors to return.")
95+
embedder: str = Field(
96+
default="cohere://",
97+
description="Embedder URI (must match the one used when indexing).",
98+
)
99+
100+
88101
class IngestPdfResponse(BaseModel):
89102
"""Response for POST /libraries/{id}/ingest-pdf. Created document and chunks."""
90103

app/api/search.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@
99

1010
from uuid import UUID
1111

12-
from fastapi import APIRouter, Depends
12+
from fastapi import APIRouter, Depends, HTTPException, status
1313

1414
from app.api.deps import AppServices, get_services
15-
from app.api.schemas import SearchRequest, SearchResponse, SearchResultItem
15+
from app.api.schemas import (
16+
SearchByQueryRequest,
17+
SearchRequest,
18+
SearchResponse,
19+
SearchResultItem,
20+
)
21+
from app.core.embedding.registry import get_embedder
1622

1723
router = APIRouter(prefix="/libraries/{library_id}/search", tags=["search"])
1824

@@ -29,3 +35,31 @@ def search(
2935
return SearchResponse(
3036
results=[SearchResultItem(chunk_id=uid, distance=dist) for uid, dist in results],
3137
)
38+
39+
40+
@router.post("/by-query", response_model=SearchResponse)
41+
def search_by_query(
42+
library_id: UUID,
43+
body: SearchByQueryRequest,
44+
services: AppServices = Depends(get_services),
45+
) -> SearchResponse:
46+
"""Run k-NN search by text: embed the query, return k nearest chunks with text/name for display."""
47+
try:
48+
embedder = get_embedder(body.embedder)
49+
query_embedding = embedder.embed_queries([body.query])[0]
50+
except ValueError as e:
51+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
52+
with services.lock.read_lock():
53+
results = services.search.search(library_id, query_embedding, body.k)
54+
out = []
55+
for chunk_id, dist in results:
56+
chunk = services.chunk.get(chunk_id)
57+
out.append(
58+
SearchResultItem(
59+
chunk_id=chunk_id,
60+
distance=dist,
61+
text=chunk.text,
62+
name=chunk.name,
63+
)
64+
)
65+
return SearchResponse(results=out)

app/core/embedding/transformer.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ def __init__(
4646
def _get_model(self) -> object:
4747
"""Load model on first use to avoid startup cost when embedder is not used."""
4848
if self._model is None:
49-
# Use HF token from env so Hub uses it (avoids "unauthenticated requests" warning)
49+
# Use HF token from env if valid; invalid token must not break the flow (fall back to unauthenticated).
5050
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
5151
if hf_token:
5252
try:
5353
from huggingface_hub import login
5454

5555
login(token=hf_token)
56-
except ImportError:
56+
except Exception:
5757
pass
5858
try:
5959
from transformers.utils import logging as tf_logging
@@ -64,7 +64,16 @@ def _get_model(self) -> object:
6464
SentenceTransformer, _ = _lazy_import()
6565
# Suppress LOAD REPORT and other loading output (logging + print)
6666
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
67-
self._model = SentenceTransformer(self._model_name, device=self._device)
67+
try:
68+
self._model = SentenceTransformer(self._model_name, device=self._device)
69+
except Exception as e:
70+
err_msg = str(e).lower()
71+
if "invalid" in err_msg and "token" in err_msg:
72+
os.environ.pop("HF_TOKEN", None)
73+
os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)
74+
self._model = SentenceTransformer(self._model_name, device=self._device)
75+
else:
76+
raise
6877
return self._model
6978

7079
@property

app/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
from fastapi import FastAPI, Request, status
11+
from fastapi.middleware.cors import CORSMiddleware
1112
from fastapi.responses import JSONResponse
1213

1314
from app.api import (
@@ -29,6 +30,14 @@
2930
version="0.1.0",
3031
)
3132

33+
app.add_middleware(
34+
CORSMiddleware,
35+
allow_origins=["*"],
36+
allow_credentials=False,
37+
allow_methods=["*"],
38+
allow_headers=["*"],
39+
)
40+
3241
app.include_router(libraries_router)
3342
app.include_router(documents_router)
3443
app.include_router(chunks_router)

docker-compose.yml

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
# Run the Vector DB API in an isolated container.
2-
# Use: docker compose up OR podman compose up
1+
# Run the Vector DB API and UI in containers. Nothing runs on the host.
2+
# Use: podman compose up OR docker compose up
33

4-
# Default API image is small (pdf only). For ingest-pdf use embedder=cohere:// and COHERE_API_KEY.
5-
# To include Sentence Transformers (larger image): podman compose build --build-arg INSTALL_TRANSFORMER=1
4+
# Default API image includes pdf + Sentence Transformers (Cohere and Transformer work in UI).
65
services:
76
api:
87
build: .
@@ -23,6 +22,20 @@ services:
2322
retries: 3
2423
start_period: 5s
2524

25+
ui:
26+
build:
27+
context: ./frontend
28+
dockerfile: Dockerfile
29+
args:
30+
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
31+
image: vector-db-ui:latest
32+
container_name: vector-db-ui
33+
ports:
34+
- "3000:80"
35+
depends_on:
36+
api:
37+
condition: service_healthy
38+
2639
# Lint (Ruff) and tests using the test image. Mounts source so you lint/tests current code.
2740
# Build: docker compose build lint OR podman compose build lint
2841
# Lint: docker compose run --rm lint OR podman compose run --rm lint

frontend/.dockerignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
dist
3+
.git
4+
*.log
5+
.env*
6+
.DS_Store

frontend/Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Stage 1: build React app (runs only inside Docker, never on host)
2+
FROM node:20-alpine AS builder
3+
4+
WORKDIR /app
5+
6+
# API URL the browser will use (default: same host, API on port 8000)
7+
ARG VITE_API_URL=http://localhost:8000
8+
ENV VITE_API_URL=$VITE_API_URL
9+
10+
COPY package.json ./
11+
RUN npm install
12+
13+
COPY . .
14+
RUN npm run build
15+
16+
# Stage 2: serve static files with nginx
17+
FROM nginx:alpine
18+
19+
RUN rm /usr/share/nginx/html/* || true
20+
COPY --from=builder /app/dist /usr/share/nginx/html
21+
COPY nginx.conf /etc/nginx/conf.d/default.conf
22+
EXPOSE 80
23+
CMD ["nginx", "-g", "daemon off;"]

0 commit comments

Comments
 (0)