Skip to content

Commit 57fe2b6

Browse files
feat: add vector search UI
1 parent c55950b commit 57fe2b6

8 files changed

Lines changed: 485 additions & 189 deletions

File tree

client/app/components/SearchMovieModal/SearchMovieModal.tsx

Lines changed: 248 additions & 145 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { default } from './SearchMovieModal';
2-
export type { SearchParams } from './SearchMovieModal';
2+
export type { SearchParams, SearchType } from './SearchMovieModal';

client/app/lib/api.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export async function deleteMovie(id: string): Promise<{ success: boolean; error
183183
*/
184184
export async function createMovie(movieData: Omit<Movie, '_id'>): Promise<{ success: boolean; error?: string; movieId?: string }> {
185185
try {
186-
const response = await fetch(`${API_BASE_URL}/api/movies/`, {
186+
const response = await fetch(`${API_BASE_URL}/api/movies`, {
187187
method: 'POST',
188188
headers: {
189189
'Content-Type': 'application/json',
@@ -276,7 +276,7 @@ export async function deleteMoviesBatch(movieIds: string[]): Promise<{ success:
276276
}
277277
};
278278

279-
const response = await fetch(`${API_BASE_URL}/api/movies/`, {
279+
const response = await fetch(`${API_BASE_URL}/api/movies`, {
280280
method: 'DELETE',
281281
headers: {
282282
'Content-Type': 'application/json',
@@ -326,7 +326,7 @@ export async function updateMoviesBatch(movieIds: string[], updateData: Partial<
326326
}
327327
};
328328

329-
const response = await fetch(`${API_BASE_URL}/api/movies/`, {
329+
const response = await fetch(`${API_BASE_URL}/api/movies`, {
330330
method: 'PATCH',
331331
headers: {
332332
'Content-Type': 'application/json',
@@ -437,3 +437,82 @@ export async function searchMovies(searchParams: {
437437
};
438438
}
439439
}
440+
441+
/**
442+
* Search movies using MongoDB Vector Search to find movies with similar plots
443+
*/
444+
export async function vectorSearchMovies(searchParams: {
445+
q: string;
446+
limit?: number;
447+
}): Promise<{ success: boolean; error?: string; movies?: Movie[]; results?: any[] }> {
448+
try {
449+
const limit = searchParams.limit || 10;
450+
451+
const queryParams = new URLSearchParams();
452+
queryParams.append('q', searchParams.q);
453+
queryParams.append('limit', limit.toString());
454+
455+
const response = await fetch(`${API_BASE_URL}/api/movies/vector-search?${queryParams}`, {
456+
method: 'GET',
457+
headers: {
458+
'Content-Type': 'application/json',
459+
},
460+
});
461+
462+
const result = await response.json();
463+
464+
if (!response.ok) {
465+
return {
466+
success: false,
467+
error: result.error || `Failed to perform vector search: ${response.status}`
468+
};
469+
}
470+
471+
if (!result.success) {
472+
return {
473+
success: false,
474+
error: result.error || 'API returned error response'
475+
};
476+
}
477+
478+
// Transform VectorSearchResult objects to Movie objects for backend compatibility
479+
const movies: Movie[] = (result.data || []).map((item: any) => {
480+
// Convert VectorSearchResult to Movie format
481+
return {
482+
_id: item._id || item.id, // Handle both _id (Python) and id (Java) field names
483+
title: item.title || '',
484+
plot: item.plot || '',
485+
poster: item.poster,
486+
year: item.year,
487+
genres: item.genres || [],
488+
directors: item.directors || [],
489+
cast: item.cast || [],
490+
// Add default values for fields not included in VectorSearchResult
491+
fullplot: undefined,
492+
released: undefined,
493+
runtime: undefined,
494+
writers: [],
495+
countries: [],
496+
languages: [],
497+
rated: undefined,
498+
awards: undefined,
499+
imdb: undefined,
500+
tomatoes: undefined,
501+
metacritic: undefined,
502+
type: undefined
503+
} as Movie;
504+
});
505+
506+
return {
507+
success: true,
508+
movies,
509+
results: result.data || []
510+
};
511+
} catch (error) {
512+
console.error('Error performing vector search:', error);
513+
return {
514+
success: false,
515+
error: 'Network error occurred while performing vector search'
516+
};
517+
}
518+
}

client/app/movies/page.tsx

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import pageStyles from "./page.module.css";
66
import movieStyles from "./movies.module.css";
77
import { MovieCard, Pagination, PageSizeSelector, AddMovieForm, BatchEditMovieForm, SearchMovieModal } from "../components";
88
import { ErrorDisplay, LoadingSpinner } from "../components/ui";
9-
import { fetchMovies, createMovie, createMoviesBatch, deleteMoviesBatch, updateMoviesBatch, searchMovies } from "../lib/api";
9+
import { fetchMovies, createMovie, createMoviesBatch, deleteMoviesBatch, updateMoviesBatch, searchMovies, vectorSearchMovies } from "../lib/api";
1010
import { Movie } from "../types/movie";
1111
import { APP_CONFIG, ROUTES } from "../lib/constants";
1212
import type { SearchParams } from "../components/SearchMovieModal";
@@ -221,38 +221,67 @@ export default function Movies() {
221221
setError(null);
222222
setSuccessMessage(null);
223223

224-
// For new searches, start from page 1
225-
const searchSkip = 0;
226-
const searchLimitToUse = searchParams.limit || 20;
227-
228-
const searchParamsWithPagination = {
229-
...searchParams,
230-
limit: searchLimitToUse,
231-
skip: searchSkip,
232-
};
224+
try {
225+
let result;
226+
227+
if (searchParams.searchType === 'vector-search') {
228+
// Handle Vector Search
229+
const vectorSearchParams = {
230+
q: searchParams.q!,
231+
limit: searchParams.limit || 10,
232+
};
233+
result = await vectorSearchMovies(vectorSearchParams);
234+
235+
if (result.success) {
236+
setSearchResults(result.movies || []);
237+
setSearchHasNextPage(false); // Vector search doesn't support pagination
238+
setSearchHasPrevPage(false);
239+
setSearchTotalCount(result.movies?.length || 0);
240+
}
241+
} else {
242+
// Handle MongoDB Search
243+
const searchSkip = 0; // For new searches, start from page 1
244+
const searchLimitToUse = searchParams.limit || 20;
245+
246+
const searchParamsWithPagination = {
247+
...searchParams,
248+
limit: searchLimitToUse,
249+
skip: searchSkip,
250+
};
233251

234-
const result = await searchMovies(searchParamsWithPagination);
252+
result = await searchMovies(searchParamsWithPagination);
253+
254+
if (result.success) {
255+
setSearchResults(result.movies || []);
256+
setSearchHasNextPage(result.hasNextPage || false);
257+
setSearchHasPrevPage(result.hasPrevPage || false);
258+
setSearchTotalCount(result.totalCount || 0);
259+
}
260+
}
235261

236-
if (result.success) {
237-
setSearchResults(result.movies || []);
238-
setSearchHasNextPage(result.hasNextPage || false);
239-
setSearchHasPrevPage(result.hasPrevPage || false);
240-
setSearchTotalCount(result.totalCount || 0);
241-
setIsSearchMode(true);
242-
setSearchPage(1);
243-
setSearchLimit(searchLimitToUse);
244-
setCurrentSearchParams(searchParams);
245-
setShowSearchModal(false);
246-
setSelectedMovies(new Set()); // Clear selection when switching to search mode
247-
248-
const totalCount = result.totalCount || 0;
249-
if (totalCount === 0) {
250-
setSuccessMessage('Search completed, but no movies matched your criteria. Try different search terms.');
262+
if (result.success) {
263+
setIsSearchMode(true);
264+
setSearchPage(1);
265+
setSearchLimit(searchParams.limit || 20);
266+
setCurrentSearchParams(searchParams);
267+
setShowSearchModal(false);
268+
setSelectedMovies(new Set()); // Clear selection when switching to search mode
269+
270+
const totalCount = searchParams.searchType === 'vector-search'
271+
? result.movies?.length || 0
272+
: (result as any).totalCount || 0;
273+
274+
if (totalCount === 0) {
275+
setSuccessMessage('Search completed, but no movies matched your criteria. Try different search terms.');
276+
} else {
277+
const searchTypeLabel = searchParams.searchType === 'vector-search' ? 'vector search' : 'text search';
278+
setSuccessMessage(`Found ${totalCount} movies using ${searchTypeLabel}.`);
279+
}
251280
} else {
252-
setSuccessMessage(`Found ${totalCount} total movies matching your search criteria.`);
281+
setError(result.error || 'Failed to search movies');
253282
}
254-
} else {
255-
setError(result.error || 'Failed to search movies');
283+
} catch (err) {
284+
setError('An unexpected error occurred while searching');
256285
}
257286

258287
setIsSearching(false);
@@ -278,6 +307,11 @@ export default function Movies() {
278307
const handleSearchPageChange = async (newPage: number) => {
279308
if (!currentSearchParams) return;
280309

310+
// Vector search doesn't support pagination
311+
if (currentSearchParams.searchType === 'vector-search') {
312+
return;
313+
}
314+
281315
setIsSearching(true);
282316
setError(null);
283317

@@ -468,7 +502,7 @@ export default function Movies() {
468502
</div>
469503

470504
{/* Show pagination based on current mode */}
471-
{isSearchMode ? (
505+
{isSearchMode && currentSearchParams?.searchType === 'mongodb-search' ? (
472506
<nav className={movieStyles.pagination} aria-label="Search results pagination">
473507
<div className={movieStyles.paginationContainer}>
474508
{/* Previous Button */}

server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/VectorSearchResult.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ public class VectorSearchResult {
3232
*/
3333
private String plot;
3434

35+
/**
36+
* Movie poster URL.
37+
*/
38+
private String poster;
39+
40+
/**
41+
* Movie release year.
42+
*/
43+
private Integer year;
44+
45+
/**
46+
* Movie genres.
47+
*/
48+
private java.util.List<String> genres;
49+
50+
/**
51+
* Movie directors.
52+
*/
53+
private java.util.List<String> directors;
54+
55+
/**
56+
* Movie cast members.
57+
*/
58+
private java.util.List<String> cast;
59+
3560
/**
3661
* Vector search similarity score (0.0 to 1.0, higher = more similar).
3762
*/

server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import java.net.http.HttpResponse;
1818
import java.util.ArrayList;
1919
import java.util.Collection;
20+
import java.util.HashMap;
2021
import java.util.List;
2122
import java.util.Map;
2223
import java.util.regex.Pattern;
@@ -807,6 +808,18 @@ public List<Movie> findSimilarMovies(String movieId, Integer limit) {
807808
}
808809
}
809810

811+
/**
812+
* Performs vector search on movie plots using MongoDB Vector Search.
813+
*
814+
* This method uses a two-step process:
815+
* 1. Query the embedded_movies collection (which has vector embeddings) to get movie IDs and similarity scores
816+
* 2. Fetch complete movie data from the movies collection using those IDs
817+
*
818+
* This approach ensures that:
819+
* - Vector search works correctly with the embedded data
820+
* - Returned movie objects are compatible with CRUD operations on the movies collection
821+
* - Complete movie metadata is available in the response
822+
*/
810823
@Override
811824
public List<VectorSearchResult> vectorSearchMovies(String query, Integer limit) {
812825
// Validate query parameter
@@ -838,29 +851,61 @@ public List<VectorSearchResult> vectorSearchMovies(String query, Integer limit)
838851
.append("limit", resultLimit)
839852
);
840853

841-
// Project the fields we need in the response
854+
// Project only the fields we need from embedded_movies: _id and score
842855
Document projectStage = new Document("$project", new Document()
843856
.append("_id", 1)
844-
.append("title", 1)
845-
.append("plot", 1)
846857
.append("score", new Document("$meta", "vectorSearchScore"))
847858
);
848859

849860
// Execute the aggregation pipeline on the embedded_movies collection
850861
List<Document> aggregationPipeline = List.of(vectorSearchStage, projectStage);
851862

852-
List<VectorSearchResult> results = new ArrayList<>();
863+
// Step 1: Get movie IDs and scores from embedded_movies (which has the vector embeddings)
864+
List<ObjectId> movieIds = new ArrayList<>();
865+
Map<String, Double> scoreMap = new HashMap<>();
866+
853867
mongoTemplate.getCollection("embedded_movies")
854868
.aggregate(aggregationPipeline)
855869
.forEach(doc -> {
870+
ObjectId movieId = doc.getObjectId("_id");
871+
movieIds.add(movieId);
872+
scoreMap.put(movieId.toString(), doc.getDouble("score"));
873+
});
874+
875+
// Step 2: Fetch complete movie data from the movies collection (for CRUD compatibility)
876+
List<VectorSearchResult> results = new ArrayList<>();
877+
if (!movieIds.isEmpty()) {
878+
Query movieQuery = new Query(Criteria.where("_id").in(movieIds));
879+
List<Movie> movies = mongoTemplate.find(movieQuery, Movie.class);
880+
881+
// Log if there's a mismatch between collections
882+
if (movies.size() != movieIds.size()) {
883+
System.out.println("Warning: Found " + movieIds.size() +
884+
" movies in embedded_movies but only " + movies.size() +
885+
" in movies collection for vector search");
886+
}
887+
888+
// Convert to VectorSearchResult with scores preserved
889+
for (Movie movie : movies) {
890+
String movieIdStr = movie.getId().toString();
891+
Double score = scoreMap.get(movieIdStr);
892+
893+
if (score != null) { // Only include movies that have vector scores
856894
VectorSearchResult result = VectorSearchResult.builder()
857-
.id(doc.getObjectId("_id").toString())
858-
.title(doc.getString("title"))
859-
.plot(doc.getString("plot"))
860-
.score(doc.getDouble("score"))
861-
.build();
895+
.id(movieIdStr)
896+
.title(movie.getTitle())
897+
.plot(movie.getPlot())
898+
.poster(movie.getPoster())
899+
.year(movie.getYear())
900+
.genres(movie.getGenres())
901+
.directors(movie.getDirectors())
902+
.cast(movie.getCast())
903+
.score(score)
904+
.build();
862905
results.add(result);
863-
});
906+
}
907+
}
908+
}
864909

865910
return results;
866911

server/python/src/models/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ class VectorSearchResult(BaseModel):
120120
id: Optional[str] = Field(alias="_id")
121121
title: str
122122
plot: Optional[str] = None
123+
poster: Optional[str] = None
124+
year: Optional[int] = None
125+
genres: Optional[list[str]] = None
126+
directors: Optional[list[str]] = None
127+
cast: Optional[list[str]] = None
123128
score: float
124129

125130
model_config = {

server/python/src/routers/movies.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,11 @@ async def vector_search_movies(
340340
"_id": 1,
341341
"title": 1,
342342
"plot": 1,
343+
"poster": 1,
344+
"year": 1,
345+
"genres": 1,
346+
"directors": 1,
347+
"cast": 1,
343348
"score": {
344349
"$meta": "vectorSearchScore"
345350
}

0 commit comments

Comments
 (0)