Skip to content

Commit dbb1dbf

Browse files
fix vector search implementation
1 parent 3ee207f commit dbb1dbf

6 files changed

Lines changed: 210 additions & 84 deletions

File tree

client/app/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const APP_CONFIG = {
77
description: 'Browse movies from the sample MFlix database',
88
defaultMovieLimit: 20,
99
maxMovieLimit: 100,
10+
vectorSearchPageSize: 20, // Fixed page size for vector search results display
1011
imageFormats: ['image/avif', 'image/webp'],
1112
} as const;
1213

client/app/movies/page.tsx

Lines changed: 64 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,18 @@ export default function Movies() {
239239
let result;
240240

241241
if (searchParams.searchType === 'vector-search') {
242-
// Vector Search: Fetch all results and implement client-side pagination
242+
// Vector Search: Use the limit from search params as the fetch limit
243243
const vectorSearchParams = {
244244
q: searchParams.q!,
245-
limit: searchParams.limit || 50, // Get more results for better pagination experience
245+
limit: searchParams.limit || 50, // This is how many results to fetch from backend
246246
};
247+
247248
result = await vectorSearchMovies(vectorSearchParams);
248249

249250
if (result.success) {
250251
const allResults = result.movies || [];
251-
const pageSize = searchParams.limit || 20;
252+
const pageSize = APP_CONFIG.vectorSearchPageSize; // Fixed page size for UI display
253+
252254
setAllVectorSearchResults(allResults);
253255
setVectorSearchPage(1);
254256
setVectorSearchPageSize(pageSize);
@@ -257,10 +259,7 @@ export default function Movies() {
257259
const firstPageResults = allResults.slice(0, pageSize);
258260
setSearchResults(firstPageResults);
259261

260-
// Set pagination state based on total results
261-
const totalPages = Math.ceil(allResults.length / pageSize);
262-
setSearchHasNextPage(totalPages > 1);
263-
setSearchHasPrevPage(false);
262+
// Set pagination state for vector search
264263
setSearchTotalCount(allResults.length);
265264
}
266265
} else {
@@ -352,15 +351,19 @@ export default function Movies() {
352351
return { paginatedResults: [], hasNext: false, hasPrev: false, totalPages: 0 };
353352
}
354353

354+
const totalResults = allVectorSearchResults.length;
355+
const totalPages = Math.ceil(totalResults / vectorSearchPageSize);
356+
const hasNext = vectorSearchPage < totalPages;
357+
const hasPrev = vectorSearchPage > 1;
358+
355359
const startIndex = (vectorSearchPage - 1) * vectorSearchPageSize;
356360
const endIndex = startIndex + vectorSearchPageSize;
357361
const paginatedResults = allVectorSearchResults.slice(startIndex, endIndex);
358-
const totalPages = Math.ceil(allVectorSearchResults.length / vectorSearchPageSize);
359362

360363
return {
361364
paginatedResults,
362-
hasNext: vectorSearchPage < totalPages,
363-
hasPrev: vectorSearchPage > 1,
365+
hasNext,
366+
hasPrev,
364367
totalPages
365368
};
366369
};
@@ -653,55 +656,60 @@ export default function Movies() {
653656
/* Vector search results with client-side pagination */
654657
(() => {
655658
const { hasNext, hasPrev, totalPages } = getVectorSearchPageData();
656-
return totalPages > 1 ? (
657-
<nav className={movieStyles.pagination} aria-label="Vector search results pagination">
658-
<div className={movieStyles.paginationContainer}>
659-
{/* Previous Button */}
660-
{hasPrev ? (
661-
<button
662-
onClick={() => handleVectorSearchPageChange(vectorSearchPage - 1)}
663-
className={movieStyles.pageButton}
664-
aria-label="Go to previous page"
665-
>
666-
← Previous
667-
</button>
668-
) : (
669-
<span className={`${movieStyles.pageButton} ${movieStyles.disabled}`}>
670-
← Previous
671-
</span>
672-
)}
673-
674-
{/* Current Page Info */}
675-
<div className={movieStyles.pageInfo}>
676-
Page {vectorSearchPage} of {totalPages}
659+
660+
if (totalPages > 1) {
661+
return (
662+
<nav className={movieStyles.pagination} aria-label="Vector search results pagination">
663+
<div className={movieStyles.paginationContainer}>
664+
{/* Previous Button */}
665+
{hasPrev ? (
666+
<button
667+
onClick={() => handleVectorSearchPageChange(vectorSearchPage - 1)}
668+
className={movieStyles.pageButton}
669+
aria-label="Go to previous page"
670+
>
671+
← Previous
672+
</button>
673+
) : (
674+
<span className={`${movieStyles.pageButton} ${movieStyles.disabled}`}>
675+
← Previous
676+
</span>
677+
)}
678+
679+
{/* Current Page Info */}
680+
<div className={movieStyles.pageInfo}>
681+
Page {vectorSearchPage} of {totalPages}
682+
</div>
683+
684+
{/* Next Button */}
685+
{hasNext ? (
686+
<button
687+
onClick={() => handleVectorSearchPageChange(vectorSearchPage + 1)}
688+
className={movieStyles.pageButton}
689+
aria-label="Go to next page"
690+
>
691+
Next →
692+
</button>
693+
) : (
694+
<span className={`${movieStyles.pageButton} ${movieStyles.disabled}`}>
695+
Next →
696+
</span>
697+
)}
677698
</div>
678699

679-
{/* Next Button */}
680-
{hasNext ? (
681-
<button
682-
onClick={() => handleVectorSearchPageChange(vectorSearchPage + 1)}
683-
className={movieStyles.pageButton}
684-
aria-label="Go to next page"
685-
>
686-
Next →
687-
</button>
688-
) : (
689-
<span className={`${movieStyles.pageButton} ${movieStyles.disabled}`}>
690-
Next →
691-
</span>
692-
)}
693-
</div>
694-
695-
{/* Additional Info */}
696-
<div className={movieStyles.additionalInfo}>
697-
{vectorSearchPageSize} movies per page • {allVectorSearchResults.length} total results
700+
{/* Additional Info */}
701+
<div className={movieStyles.additionalInfo}>
702+
{vectorSearchPageSize} movies per page • {allVectorSearchResults.length} total results
703+
</div>
704+
</nav>
705+
);
706+
} else {
707+
return (
708+
<div className={movieStyles.searchInfo}>
709+
Showing {allVectorSearchResults.length} results (vector search)
698710
</div>
699-
</nav>
700-
) : (
701-
<div className={movieStyles.searchInfo}>
702-
Showing {allVectorSearchResults.length} results (vector search)
703-
</div>
704-
);
711+
);
712+
}
705713
})()
706714
)
707715
) : (

server/express/src/controllers/movieController.ts

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -726,11 +726,11 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise<v
726726
// Generate embedding using Voyage AI REST API
727727
const queryVector = await generateVoyageEmbedding(q.trim(), process.env.VOYAGE_API_KEY);
728728

729-
// Get embedded movies collection
729+
// Get embedded movies collection for vector search
730730
const embeddedMoviesCollection = getCollection("embedded_movies");
731731

732-
// Build the $vectorSearch aggregation pipeline
733-
const pipeline = [
732+
// Step 1: Build the $vectorSearch aggregation pipeline for embedded_movies
733+
const vectorSearchPipeline = [
734734
{
735735
$vectorSearch: {
736736
index: "vector_index",
@@ -743,27 +743,94 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise<v
743743
{
744744
$project: {
745745
_id: 1,
746-
title: 1,
747-
plot: 1,
748746
score: { $meta: "vectorSearchScore" },
749747
},
750748
},
751749
];
752750

753-
const results = await embeddedMoviesCollection.aggregate(pipeline).toArray();
751+
// Execute vector search to get movie IDs and scores
752+
const vectorResults = await embeddedMoviesCollection.aggregate(vectorSearchPipeline).toArray();
753+
754+
if (vectorResults.length === 0) {
755+
res.json(
756+
createSuccessResponse(
757+
[],
758+
`No similar movies found for query: '${q}'`
759+
)
760+
);
761+
return;
762+
}
763+
764+
// Extract movie IDs and create score mapping
765+
const movieIds = vectorResults.map(doc => doc._id);
766+
const scoreMap = new Map();
767+
vectorResults.forEach(doc => {
768+
scoreMap.set(doc._id.toString(), doc.score);
769+
});
770+
771+
// Step 2: Fetch complete movie data from the movies collection
772+
const moviesCollection = getCollection("movies");
773+
774+
// Build aggregation pipeline to safely handle year field and get complete movie data
775+
const moviesPipeline = [
776+
{
777+
$match: {
778+
_id: { $in: movieIds }
779+
}
780+
},
781+
{
782+
$project: {
783+
_id: 1,
784+
title: 1,
785+
plot: 1,
786+
poster: 1,
787+
genres: 1,
788+
directors: 1,
789+
cast: 1,
790+
// Safely convert year to integer, handling strings and dirty data
791+
year: {
792+
$cond: {
793+
if: {
794+
$and: [
795+
{ $ne: ["$year", null] },
796+
{ $eq: [{ $type: "$year" }, "int"] }
797+
]
798+
},
799+
then: "$year",
800+
else: null
801+
}
802+
}
803+
}
804+
}
805+
];
806+
807+
const movieResults = await moviesCollection.aggregate(moviesPipeline).toArray();
808+
809+
// Step 3: Combine movie data with vector search scores
810+
const finalResults: VectorSearchResult[] = movieResults.map(movie => {
811+
const movieIdStr = movie._id.toString();
812+
const score = scoreMap.get(movieIdStr) || 0;
813+
814+
return {
815+
_id: movieIdStr,
816+
title: movie.title || '',
817+
plot: movie.plot,
818+
poster: movie.poster,
819+
year: movie.year,
820+
genres: movie.genres || [],
821+
directors: movie.directors || [],
822+
cast: movie.cast || [],
823+
score: score,
824+
};
825+
});
754826

755-
// Convert ObjectId to string for each result
756-
const vectorResults: VectorSearchResult[] = results.map((doc) => ({
757-
_id: doc._id.toString(),
758-
title: doc.title,
759-
plot: doc.plot,
760-
score: doc.score,
761-
}));
827+
// Sort results by score (highest first) to maintain relevance order
828+
finalResults.sort((a, b) => b.score - a.score);
762829

763830
res.json(
764831
createSuccessResponse(
765-
vectorResults,
766-
`Found ${vectorResults.length} similar movies for query: '${q}'`
832+
finalResults,
833+
`Found ${finalResults.length} similar movies for query: '${q}'`
767834
)
768835
);
769836
} catch (error) {

server/express/src/types/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ export interface VectorSearchResult {
193193
_id: string;
194194
title: string;
195195
plot?: string;
196+
poster?: string;
197+
year?: number;
198+
genres?: string[];
199+
directors?: string[];
200+
cast?: string[];
196201
score: number;
197202
}
198203

server/express/tests/controllers/movieController.test.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SAMPLE_MOVIES,
1616
SAMPLE_SEARCH_RESULTS,
1717
SAMPLE_VECTOR_RESULTS,
18+
SAMPLE_VECTOR_MOVIES,
1819
SAMPLE_COMMENTS_AGGREGATION,
1920
SAMPLE_YEARS_AGGREGATION,
2021
SAMPLE_DIRECTORS_AGGREGATION,
@@ -785,8 +786,17 @@ describe("Movie Controller Tests", () => {
785786
json: () => Promise.resolve(MOCK_VOYAGE_RESPONSE),
786787
} as any);
787788

788-
// Mock database response
789-
mockToArray.mockResolvedValue(SAMPLE_VECTOR_RESULTS);
789+
// Ensure SAMPLE_VECTOR_RESULTS and SAMPLE_VECTOR_MOVIES have matching IDs
790+
const vectorResultsWithMatchingIds = SAMPLE_VECTOR_RESULTS.map((result, index) => ({
791+
...result,
792+
_id: SAMPLE_VECTOR_MOVIES[index]._id,
793+
}));
794+
795+
// Mock database responses - first call for embedded_movies collection (vector search)
796+
mockToArray
797+
.mockResolvedValueOnce(vectorResultsWithMatchingIds)
798+
// Second call for movies collection (complete movie data)
799+
.mockResolvedValueOnce(SAMPLE_VECTOR_MOVIES);
790800

791801
mockRequest.query = { q: "space adventure", limit: "3" };
792802

@@ -803,14 +813,22 @@ describe("Movie Controller Tests", () => {
803813
})
804814
);
805815

816+
// Should call embedded_movies collection first, then movies collection
806817
expect(mockGetCollection).toHaveBeenCalledWith("embedded_movies");
807-
expect(mockAggregate).toHaveBeenCalled();
808-
809-
const expectedResults = SAMPLE_VECTOR_RESULTS.map((result) => ({
810-
_id: result._id.toString(),
811-
title: result.title,
812-
plot: result.plot,
813-
score: result.score,
818+
expect(mockGetCollection).toHaveBeenCalledWith("movies");
819+
expect(mockAggregate).toHaveBeenCalledTimes(2);
820+
821+
// Verify the final result structure includes all movie fields
822+
const expectedResults = SAMPLE_VECTOR_MOVIES.map((movie, index) => ({
823+
_id: movie._id.toString(),
824+
title: movie.title,
825+
plot: movie.plot,
826+
poster: movie.poster,
827+
year: movie.year,
828+
genres: movie.genres,
829+
directors: movie.directors,
830+
cast: movie.cast,
831+
score: SAMPLE_VECTOR_RESULTS[index].score,
814832
}));
815833

816834
expect(mockCreateSuccessResponse).toHaveBeenCalledWith(
@@ -888,20 +906,28 @@ describe("Movie Controller Tests", () => {
888906
json: () => Promise.resolve(MOCK_VOYAGE_RESPONSE),
889907
} as any);
890908

891-
mockToArray.mockResolvedValue([]);
909+
// Mock empty results from vector search
910+
mockToArray
911+
.mockResolvedValueOnce([]) // empty vector search results
912+
.mockResolvedValueOnce([]); // empty movie results
892913

893914
mockRequest.query = { q: "test" };
894915

895916
await vectorSearchMovies(mockRequest as Request, mockResponse as Response);
896917

897918
expect(mockCreateSuccessResponse).toHaveBeenCalledWith(
898919
[],
899-
"Found 0 similar movies for query: 'test'"
920+
"No similar movies found for query: 'test'"
900921
);
901922
});
902923
});
903924

904925
describe("findSimilarMovies", () => {
926+
beforeEach(() => {
927+
// Reset mocks specifically for findSimilarMovies tests
928+
mockToArray.mockReset();
929+
});
930+
905931
it("should successfully find similar movies", async () => {
906932
const targetMovie = {
907933
_id: new ObjectId(TEST_MOVIE_ID),

0 commit comments

Comments
 (0)