16 Commits
24 changed files with 363 additions and 105 deletions
+23 -5
View File
@@ -50,7 +50,7 @@ Package-by-feature layout. Server context path is `/api`. Main endpoints:
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
- `POST /api/search` — paged search against the requested service; returns 401 if no active session
Five packages:
Six packages:
**`shared/`** — cross-cutting types used by more than one feature package
@@ -72,12 +72,24 @@ Five packages:
- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation
**`search/`** — querying connected services
**`ai/`** — shared AI infrastructure used by multiple features (search, future image analysis)
- `EmbeddingService`: wraps Spring AI's `EmbeddingModel`; reused by any feature that needs to generate vectors
- `VectorStoreConfig`: Spring bean configuration for `PgVectorStore` (pgvector-backed `VectorStore`)
- All classes here are provider-agnostic — the OpenAI starter is pointed at LiteLLM, so the underlying model is configurable without code changes
**`search/`** — querying connected services (keyword and AI)
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
- `SearchService`: auto-discovers providers via Spring injection, dispatches by `ServiceType`
- `SearchService`: maintains two provider maps — keyword providers and AI providers; routes based on `SearchRequest.aiSearch` flag
- `SearchController`: guards with session check before delegating to `SearchService`
- `HomeboxSearchProvider`: Homebox-specific search implementation using bearer token from session
- `SearchRequest`: includes `aiSearch: boolean` — when true, routes to AI provider instead of keyword provider
- `PagedSearchResponse`: includes nullable `summary` field — populated only for AI search results; null for keyword search
- `HomeboxSearchProvider`: keyword search via Homebox API; unchanged from original implementation
- `HomeboxAiSearchProvider`: AI search via pgvector similarity; returns ranked items + generated summary
- `HomeboxSyncService`: fetches all Homebox items page by page, embeds them via `EmbeddingService`, stores in `VectorStore`; triggered on connection login (background sync)
**AI search flow:** on login → background sync indexes all Homebox items into pgvector. On AI search → embed query → pgvector similarity search → top N results passed to `ChatClient` for summary generation → return list + summary. Sync is idempotent (delete-then-reindex per connection).
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
@@ -97,9 +109,15 @@ React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components
### Data & AI
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it`OPENAI_BASE_URL` points to LiteLLM, not OpenAI directly, keeping the underlying model provider configurable
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`; configured in `ai/VectorStoreConfig`
- Embedding dimensions must stay consistent with the configured LiteLLM embedding model — changing models requires re-syncing all indexed items
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
### Testing Strategy
Integration tests spin up a **mirrored PostgreSQL container** on port 5434 (same schema as production). WireMock mocks external HTTP APIs (Homebox, WikiJS). Do not mock the database in integration tests — the mirrored container strategy exists specifically to catch schema/migration divergence.
## Chat Operations
Don't make code suggestions and changes unless explicitly asked. Treat every prompt as a discussion of latest best practice coding approaches.
+5
View File
@@ -1,4 +1,5 @@
val wiremockVersion = "3.12.0"
val postgresqlVersion = "42.7.11"
plugins {
java
@@ -46,6 +47,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.ai:spring-ai-starter-model-openai")
implementation("org.postgresql:postgresql:$postgresqlVersion")
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
compileOnly("org.projectlombok:lombok")
@@ -11,15 +11,15 @@ import lombok.Setter;
@DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity {
public class HomeboxConnectionEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
public static HomeboxConnectionEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
HomeboxConnectionEntity he = new HomeboxConnectionEntity();
he.setAppUrl(request.appUrl());
he.setUsername(request.username());
@@ -12,22 +12,17 @@ import org.springframework.web.client.RestClient;
import com.vaessl.app.exception.EmptyCredentialsException;
import com.vaessl.app.exception.RemoteApiException;
import com.vaessl.app.shared.ServiceType;
import lombok.RequiredArgsConstructor;
import static com.vaessl.app.shared.Endpoint.*;
@Component
@RequiredArgsConstructor
public class HomeboxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeboxConnectionProvider(RestClient.Builder restClientBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
public void checkCredentials(ConnectionRequest request) {
if (request.username() == null || request.password() == null) {
@@ -82,13 +77,13 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request,
ConnectionResponse response) {
return HomeboxEntity.from(request, response);
return HomeboxConnectionEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
if (existing instanceof HomeboxEntity hbE) {
if (existing instanceof HomeboxConnectionEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
@@ -100,7 +95,7 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
@Override
public Instant getTokenExpiry(ConnectionEntity entity) {
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
return (entity instanceof HomeboxConnectionEntity he) ? he.getExpiresAt() : null;
}
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
@@ -0,0 +1,22 @@
package com.vaessl.app.search;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
@Component
public class HomeboxAiSearchProvider implements SearchProvider {
@Override
public ServiceType getServiceType() {
return ServiceType.HOMEBOX;
}
@Override
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'getSearchResults'");
}
}
@@ -11,9 +11,10 @@ import org.springframework.web.client.RestClient;
import com.vaessl.app.connection.ConnectionEntity;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.connection.HomeboxEntity;
import com.vaessl.app.connection.HomeboxConnectionEntity;
import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
import static com.vaessl.app.shared.Endpoint.*;
@@ -37,12 +38,12 @@ public class HomeboxSearchProvider implements SearchProvider {
}
@Override
public Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable) {
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
ConnectionEntity entity =
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
if (!(entity instanceof HomeboxEntity hbEntity)) {
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
throw new ConnectionNotFoundException();
}
@@ -58,12 +59,12 @@ public class HomeboxSearchProvider implements SearchProvider {
throw new RemoteApiException(request.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
}
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
List<ServiceItem> items = hbResponse.items().stream().map(i -> {
String id = i.id();
String title = i.name();
String description = i.description();
Map<String, Object> extraSearchResponseData = Map.of("location", i.parent());
return new SearchResponse(id, title, description, extraSearchResponseData);
Map<String, Object> extraSearchResponseData = Map.of("parent", i.parent());
return new ServiceItem(id, title, description, extraSearchResponseData);
}).toList();
return new PageImpl<>(items, pageable, hbResponse.total());
@@ -73,9 +74,10 @@ public class HomeboxSearchProvider implements SearchProvider {
List<HomeboxItem> items) {
}
private record HomeboxItem(String id, String name, String description, HomeboxLocation parent) {
private record HomeboxItem(String id, String name, String description,
HomeboxExtraData parent) {
}
private record HomeboxLocation(String name, String description) {
private record HomeboxExtraData(String name, String description) {
}
}
@@ -4,11 +4,11 @@ import java.util.List;
import org.springframework.data.domain.Page;
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
boolean first, boolean last, String sort) {
boolean first, boolean last, String sort, String summary) {
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
pageResult.isLast(), pageResult.getSort().toString());
pageResult.isLast(), pageResult.getSort().toString(), null);
}
}
@@ -8,7 +8,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.SessionKeys;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@@ -26,7 +26,7 @@ public class SearchController {
* there is no active session.
*/
@PostMapping("/search")
public ResponseEntity<PagedSearchResponse<SearchResponse>> search(
public ResponseEntity<PagedSearchResponse<ServiceItem>> search(
@Valid @RequestBody SearchRequest request,
@PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false);
@@ -35,7 +35,7 @@ public class SearchController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Page<SearchResponse> result = searchService.search(request, pageable);
Page<ServiceItem> result = searchService.search(request, pageable);
return ResponseEntity.ok(PagedSearchResponse.from(result));
}
@@ -2,7 +2,7 @@ package com.vaessl.app.search;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceProvider;
/**
@@ -17,5 +17,5 @@ public interface SearchProvider extends ServiceProvider {
* @param pageable the Pageable interface
* @return a list of Page<SearchResponse> items matching the query
*/
Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable);
Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable);
}
@@ -5,5 +5,5 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
@NotNull ServiceType serviceType) {
@NotNull ServiceType serviceType, boolean aiSearch) {
}
@@ -7,6 +7,7 @@ import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
import com.vaessl.app.exception.WrongServiceTypeException;
@@ -21,7 +22,6 @@ public class SearchService {
registry.put(provider.getServiceType(), provider);
}
this.providerRegistry = registry;
}
/**
@@ -33,7 +33,7 @@ public class SearchService {
* @return results returned by the matching provider
* @throws WrongServiceTypeException if no provider is registered for the given service type
*/
public Page<SearchResponse> search(SearchRequest request, Pageable pageable) {
public Page<ServiceItem> search(SearchRequest request, Pageable pageable) {
SearchProvider provider = providerRegistry.get(request.serviceType());
@@ -1,8 +1,9 @@
package com.vaessl.app.search;
package com.vaessl.app.shared;
import java.util.Map;
import jakarta.validation.constraints.NotNull;
public record SearchResponse(String id, String title, String description,
public record ServiceItem(String id, @NotNull String title, String description,
Map<String, Object> extraData) {
public String getExtra(String key) {
@@ -0,0 +1,27 @@
package com.vaessl.app.sync;
import org.springframework.stereotype.Service;
import com.vaessl.app.shared.ServiceType;
import com.vaessl.app.vector.EmbeddingService;
@Service
public class HomeboxSyncProvider implements SyncProvider {
private final EmbeddingService embeddingService;
public HomeboxSyncProvider(EmbeddingService embeddingService) {
this.embeddingService = embeddingService;
}
@Override
public ServiceType getServiceType() {
return ServiceType.HOMEBOX;
}
@Override
public void syncVectorStore() {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'syncVectorStore'");
}
}
@@ -0,0 +1,7 @@
package com.vaessl.app.sync;
import com.vaessl.app.shared.ServiceProvider;
public interface SyncProvider extends ServiceProvider{
public void syncVectorStore();
}
@@ -0,0 +1,83 @@
package com.vaessl.app.vector;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
@Service
public class EmbeddingService {
private static final float THRESHOLD = 0.7f;
private static final int LIMIT = 2;
private final VectorStore vectorStore;
public EmbeddingService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId) {
List<Document> documents = new ArrayList<>();
for (ServiceItem item : items) {
documents.add(toDocument(item, serviceType, connectionId));
}
vectorStore.add(documents);
}
private Document toDocument(ServiceItem item, ServiceType serviceType, Long connectionId) {
String extraData = buildExtraData(item.extraData());
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId, extraData);
String content = buildContent(item, extraData);
return new Document(connectionId + ":" + item.id(), content, metadata);
}
private String buildExtraData(Map<String, Object> extraData) {
if (extraData == null) {
return "";
}
StringBuilder data = new StringBuilder();
for (Map.Entry<String, Object> entry : extraData.entrySet()) {
if (entry.getValue() == null || entry.getValue().toString().isEmpty()) {
continue;
}
if (!data.isEmpty()) {
data.append(" \n");
}
data.append(entry.getKey()).append(": ").append(entry.getValue());
}
return data.toString();
}
private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType,
Long connectionId, String extraData) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("connectionId", connectionId);
metadata.put("serviceType", serviceType.name());
metadata.put("itemId", item.id());
metadata.put("title", item.title());
if (item.description() != null && !item.description().isEmpty()) {
metadata.put("description", item.description());
}
if (!extraData.isEmpty()) {
metadata.put("extraData", extraData);
}
return metadata;
}
private String buildContent(ServiceItem item, String extraData) {
StringBuilder content = new StringBuilder("Title: ").append(item.title());
if (item.description() != null && !item.description().isEmpty()) {
content.append("\n Description: ").append(item.description());
}
if (!extraData.isEmpty()) {
content.append("\n Extradata: ").append(extraData);
}
return content.toString();
}
}
@@ -21,6 +21,13 @@ spring:
api-key: ${OPENAI_KEY}
chat:
model: gpt-4o-mini
vectorstore:
pgvector:
id-type: text
dimensions: 1536
distance-type: COSINE_DISTANCE
index-type: HNSW
initialize-schema: true
session:
store-type: jdbc
jdbc:
@@ -180,7 +180,7 @@ class HomeboxIntegrationTest {
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
if (dbEntry instanceof HomeboxEntity hbE) {
if (dbEntry instanceof HomeboxConnectionEntity hbE) {
assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
assertThat(hbE.getExpiresAt().toString())
@@ -29,7 +29,8 @@ class HomeboxSearchProviderTest {
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX);
SearchRequest request =
new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX, false);
Pageable pageable = PageRequest.of(0, 10);
assertThrows(ConnectionNotFoundException.class,
() -> provider.getSearchResults(request, pageable));
@@ -63,11 +63,11 @@ class SearchControllerTest {
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
"createdAt": "2026-05-13T19:55:55.817576Z",
"updatedAt": "2026-05-14T12:37:24.396651Z"
},
"tags": [],
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
"soldTime": "0001-01-01T00:00:00Z"
},
"tags": [],
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
"soldTime": "0001-01-01T00:00:00Z"
}
]
}
@@ -93,8 +93,7 @@ class SearchControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].extraData.location.name")
.value("Server Schrank Ikea weiß"));
.andExpect(jsonPath("$.content[0].extraData.parent.name").value("Server Schrank Ikea weiß"));
}
@Test
@@ -104,7 +103,8 @@ class SearchControllerTest {
"appUrl": "http://irrelevant",
"query": "Item",
"serviceType": "HOMEBOX",
"username": "irrelevant"
"username": "irrelevant",
"aiSearch": false
}
""")).andExpect(status().isUnauthorized());
}
@@ -117,22 +117,23 @@ class SearchControllerTest {
}
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
return searchRequestBody(wm.getHttpBaseUrl(), serviceType);
return searchRequestBody(wm.getHttpBaseUrl(), serviceType, false);
}
private String searchRequestBody(String serviceType) {
return searchRequestBody("http://irrelevant", serviceType);
return searchRequestBody("http://irrelevant", serviceType, false);
}
private String searchRequestBody(String appUrl, String serviceType) {
private String searchRequestBody(String appUrl, String serviceType, boolean aiSearch) {
return """
{
"appUrl": "%s",
"query": "Item",
"serviceType": "%s",
"username": "%s"
"username": "%s",
"aiSearch": "%b"
}
""".formatted(appUrl, serviceType, MOCK_USER);
""".formatted(appUrl, serviceType, MOCK_USER, aiSearch);
}
private String connectionRequestBody(WireMockRuntimeInfo wm) {
@@ -4,12 +4,13 @@ import static com.vaessl.app.Mockdata.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
import com.vaessl.app.shared.ServiceItem;
class SearchResponseTest {
@Test
void shouldReturnNullWhenExtraDataIsNull() {
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null);
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null);
assertThat(response.getExtra(null)).isNull();
}
@@ -17,7 +18,7 @@ class SearchResponseTest {
@Test
void shouldReturnNullWhenExtraDataKeyIsMissing() {
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
assertThat(response.getExtra("missing")).isNull();
}
@@ -25,7 +26,7 @@ class SearchResponseTest {
@Test
void shouldReturnExtraDataValue() {
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
assertThat(response.id()).isEqualTo(MOCK_ID);
assertThat(response.getExtra("key")).contains("value");
@@ -165,43 +165,124 @@ services:
restart: unless-stopped
```
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. Just add databases via SQL or PgAdmin and install the pgvector extension to each database manually. There is an offical ready-made pgvector docker image but if you already host a PostGreSQL database you need to add the extension yourself.
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. Just add databases via SQL or PgAdmin and install the pgvector extension to each database manually. There is an official ready-made pgvector docker image but if you already host a PostgreSQL database you need to add the extension yourself.
Check the name of your PostGreSQL container:
```
docker ps
## Installing pgvector on a self-hosted PostgreSQL container
pgvector is a PostgreSQL extension that adds a `vector` data type. It does not create a new database — it adds a `vector_store` table to your existing database. The shared library (`vector.so`) must be installed on the PostgreSQL server itself.
**Do not install it manually inside a running container.** Manual installations do not survive container recreation (e.g. after a `docker compose up --force-recreate` or image update). Instead, bake it into a custom Docker image.
### Step 1: Create a custom Dockerfile for PostgreSQL
Create a `Dockerfile.postgres` next to your `docker-compose.yaml`:
```dockerfile
FROM postgres:18.4
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git build-essential postgresql-server-dev-18 \
&& git clone --branch v0.8.3 https://github.com/pgvector/pgvector.git \
&& cd pgvector && make && make install \
&& cd .. && rm -rf pgvector \
&& apt-get purge -y ca-certificates git build-essential postgresql-server-dev-18 \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
```
Enter your container via bash:
- `FROM postgres:18.4` — this is still the standard postgres image, not the pgvector image
- `ca-certificates` — required for git to verify GitHub's SSL certificate during build
- `postgresql-server-dev-18` — provides the PostgreSQL header files needed to compile pgvector
```
docker exec -it 876fb382969f bash
```
Before working on your database backup your databases:
```
su - postgres -c "pg_dumpall > /tmp/backup200526.sql"
### Step 2: Update docker-compose.yaml to use the custom image
#exit the container and copy the backup file to local file system
docker cp 876fb382969f:/tmp/backup200526.sql .
```yaml
services:
db:
container_name: postgres
labels:
- "com.centurylinklabs.watchtower.enable=false"
build:
context: .
dockerfile: Dockerfile.postgres
network: host
restart: always
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
networks:
- pg_network
volumes:
- /home/pi/docker/postgresql:/var/lib/postgresql
ports:
- "5432:5432"
networks:
pg_network:
external: true
```
Install dependencies, build and install pgvector:
apt-get update
apt-get install -y build-essential git postgresql-server-dev-all
```
git clone https://github.com/pgvector/pgvector.git
cd pgvector
make
make install
docker restart 876fb382969f
```
Enter PostGreSQL container and create pgvector extension for each databse:
```
docker exec -it <container-name> psql -h localhost -U <db-user> -d <db-name>
### Step 3: Build the image and recreate the container
CREATE EXTENSION vector;
Before recreating, back up your databases:
```bash
docker exec -it <container-name> bash
su - postgres -c "pg_dumpall > /tmp/backup.sql"
exit
docker cp <container-name>:/tmp/backup.sql .
```
Build the image. Use `--network=host` to ensure the build container can reach GitHub:
```bash
docker build --network=host -t postgres-pgvector -f Dockerfile.postgres .
```
Recreate the container
```bash
docker compose up -d --force-recreate
```
### Step 4: Enable the extensions in each database
Run this once per database that needs pgvector:
```bash
docker exec -it <container-name> psql -U <db-user> -d <db-name>
```
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
```
`uuid-ossp` is also required — Spring AI's `vector_store` table uses `uuid_generate_v4()` for its primary key.
### Step 5: Add pgvector config to application.yaml
Spring AI's pgvector auto-configuration requires these properties:
```yaml
spring:
ai:
vectorstore:
pgvector:
dimensions: 1536 # must match your embedding model output size
distance-type: COSINE_DISTANCE
index-type: HNSW
initialize-schema: true # auto-creates the vector_store table on startup
```
`dimensions` depends on the embedding model:
- `text-embedding-ada-002` / `text-embedding-3-small` → 1536
- `text-embedding-3-large` → 3072
`initialize-schema: true` means Spring AI will create the `vector_store` table automatically on first startup — no manual SQL needed.
# Appendix: Additional config for developing in Code-Server
When using the code-server container there are additional config steps to mind:
+6 -2
View File
@@ -1,8 +1,12 @@
import { apiFetch } from "./client";
import type { PagedSearchResponse, SearchRequest, SearchResponse } from "../types/search";
import type {
PagedSearchResponse,
SearchRequest,
ServiceItem,
} from "../types/search";
export const search = (req: SearchRequest) =>
apiFetch<PagedSearchResponse<SearchResponse>>("/search", {
apiFetch<PagedSearchResponse<ServiceItem>>("/search", {
method: "POST",
body: JSON.stringify(req),
});
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import '../ui/Modal.scss'
import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search'
import { type PagedSearchResponse, type ServiceItem, type SearchRequest } from '../../types/search'
import { search } from '../../api/searches'
import type { ServiceType } from '../../types/connection'
@@ -14,11 +14,13 @@ interface Props {
export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly<Props>) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<PagedSearchResponse<SearchResponse> | null>(null)
const [results, setResults] = useState<PagedSearchResponse<ServiceItem> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
//TODO: implement aiSearch
const [aiSearch, setAiSearch] = useState(false);
const firstInputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
@@ -41,7 +43,7 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
setError(null)
setLoading(true)
try {
const req: SearchRequest = { appUrl, serviceType, username, query}
const req: SearchRequest = { appUrl, serviceType, username, query, aiSearch}
const res = await search(req)
console.log(req)
setResults(res)
+17 -16
View File
@@ -1,25 +1,26 @@
import type { ServiceType } from "./connection";
export interface SearchRequest {
appUrl: string
serviceType: ServiceType
username: string
query: string | null
appUrl: string;
serviceType: ServiceType;
username: string;
query: string | null;
aiSearch: boolean;
}
export interface SearchResponse {
id: string
title: string
description: string | null
extraData: Record<string, unknown>
export interface ServiceItem {
id: string;
title: string;
description: string | null;
extraData: Record<string, unknown>;
}
export interface PagedSearchResponse<T> {
content: T[]
page: number
pageSize: number
totalElements: number
first: boolean
last: boolean
sort: string
content: T[];
page: number;
pageSize: number;
totalElements: number;
first: boolean;
last: boolean;
sort: string;
}