@@ -485,6 +485,147 @@ describe('graphile-search (unified search plugin)', () => {
485485 } ) ;
486486 } ) ;
487487
488+ // ─── orderBy + LIMIT correctness (regression tests) ─────────────────────
489+
490+ describe ( 'orderBy + LIMIT returns top results (not arbitrary rows)' , ( ) => {
491+ it ( 'pgvector: LIMIT 1 with orderBy ASC returns the closest vector' , async ( ) => {
492+ // Query vector [1,0,0] — doc 1 has embedding [1,0,0] (distance ≈ 0),
493+ // so it must be the top result when ordering by distance ASC.
494+ const allResult = await query < AllDocumentsResult > ( `
495+ query {
496+ allDocuments(
497+ where: { vectorEmbedding: { vector: [1, 0, 0], metric: COSINE } }
498+ orderBy: EMBEDDING_VECTOR_DISTANCE_ASC
499+ ) {
500+ nodes { rowId title embeddingVectorDistance }
501+ }
502+ }
503+ ` ) ;
504+ expect ( allResult . errors ) . toBeUndefined ( ) ;
505+ const allNodes = allResult . data ! . allDocuments . nodes ;
506+ expect ( allNodes . length ) . toBeGreaterThan ( 1 ) ;
507+
508+ // Now fetch with LIMIT 1 — should return the same first row
509+ const limitResult = await query < AllDocumentsResult > ( `
510+ query {
511+ allDocuments(
512+ where: { vectorEmbedding: { vector: [1, 0, 0], metric: COSINE } }
513+ orderBy: EMBEDDING_VECTOR_DISTANCE_ASC
514+ first: 1
515+ ) {
516+ nodes { rowId title embeddingVectorDistance }
517+ }
518+ }
519+ ` ) ;
520+ expect ( limitResult . errors ) . toBeUndefined ( ) ;
521+ const limitNodes = limitResult . data ! . allDocuments . nodes ;
522+ expect ( limitNodes ) . toHaveLength ( 1 ) ;
523+
524+ // The LIMIT 1 result must be the closest (smallest distance)
525+ expect ( limitNodes [ 0 ] . rowId ) . toBe ( allNodes [ 0 ] . rowId ) ;
526+ expect ( limitNodes [ 0 ] . embeddingVectorDistance ) . toBe ( allNodes [ 0 ] . embeddingVectorDistance ) ;
527+ } ) ;
528+
529+ it ( 'pgvector: results are actually sorted by distance ASC' , async ( ) => {
530+ const result = await query < AllDocumentsResult > ( `
531+ query {
532+ allDocuments(
533+ where: { vectorEmbedding: { vector: [1, 0, 0], metric: COSINE } }
534+ orderBy: EMBEDDING_VECTOR_DISTANCE_ASC
535+ ) {
536+ nodes { rowId embeddingVectorDistance }
537+ }
538+ }
539+ ` ) ;
540+ expect ( result . errors ) . toBeUndefined ( ) ;
541+ const nodes = result . data ! . allDocuments . nodes ;
542+ expect ( nodes . length ) . toBeGreaterThan ( 1 ) ;
543+
544+ for ( let i = 0 ; i < nodes . length - 1 ; i ++ ) {
545+ expect ( nodes [ i ] . embeddingVectorDistance ) . toBeLessThanOrEqual (
546+ nodes [ i + 1 ] . embeddingVectorDistance !
547+ ) ;
548+ }
549+ } ) ;
550+
551+ it ( 'BM25: LIMIT 2 with orderBy ASC returns the most relevant rows' , async ( ) => {
552+ // Fetch all BM25 results sorted by score ASC (most negative = most relevant)
553+ const allResult = await query < AllDocumentsResult > ( `
554+ query {
555+ allDocuments(
556+ where: { bm25Body: { query: "learning" } }
557+ orderBy: BODY_BM25_SCORE_ASC
558+ ) {
559+ nodes { rowId title bodyBm25Score }
560+ }
561+ }
562+ ` ) ;
563+ expect ( allResult . errors ) . toBeUndefined ( ) ;
564+ const allNodes = allResult . data ! . allDocuments . nodes ;
565+ expect ( allNodes . length ) . toBeGreaterThan ( 2 ) ;
566+
567+ // Now fetch with LIMIT 2
568+ const limitResult = await query < AllDocumentsResult > ( `
569+ query {
570+ allDocuments(
571+ where: { bm25Body: { query: "learning" } }
572+ orderBy: BODY_BM25_SCORE_ASC
573+ first: 2
574+ ) {
575+ nodes { rowId title bodyBm25Score }
576+ }
577+ }
578+ ` ) ;
579+ expect ( limitResult . errors ) . toBeUndefined ( ) ;
580+ const limitNodes = limitResult . data ! . allDocuments . nodes ;
581+ expect ( limitNodes ) . toHaveLength ( 2 ) ;
582+
583+ // The limited results must be the top-2 from the full sorted list
584+ expect ( limitNodes [ 0 ] . rowId ) . toBe ( allNodes [ 0 ] . rowId ) ;
585+ expect ( limitNodes [ 1 ] . rowId ) . toBe ( allNodes [ 1 ] . rowId ) ;
586+ } ) ;
587+
588+ it ( 'fullTextSearch + per-adapter orderBy: LIMIT returns top results' , async ( ) => {
589+ // fullTextSearch dispatches to all text-compatible adapters.
590+ // Per-adapter orderBy (e.g. BM25 score) still works correctly with LIMIT
591+ // because the adapter score is a SQL-level expression.
592+ // (Note: SEARCH_SCORE is a JS-computed composite and does not produce
593+ // SQL-level ORDER BY, so it should not be relied on with LIMIT.)
594+ const allResult = await query < AllDocumentsResult > ( `
595+ query {
596+ allDocuments(
597+ where: { fullTextSearch: "machine learning" }
598+ orderBy: BODY_BM25_SCORE_ASC
599+ ) {
600+ nodes { rowId title bodyBm25Score }
601+ }
602+ }
603+ ` ) ;
604+ expect ( allResult . errors ) . toBeUndefined ( ) ;
605+ const allNodes = allResult . data ! . allDocuments . nodes ;
606+ expect ( allNodes . length ) . toBeGreaterThan ( 1 ) ;
607+
608+ // Now LIMIT 1
609+ const limitResult = await query < AllDocumentsResult > ( `
610+ query {
611+ allDocuments(
612+ where: { fullTextSearch: "machine learning" }
613+ orderBy: BODY_BM25_SCORE_ASC
614+ first: 1
615+ ) {
616+ nodes { rowId title bodyBm25Score }
617+ }
618+ }
619+ ` ) ;
620+ expect ( limitResult . errors ) . toBeUndefined ( ) ;
621+ const limitNodes = limitResult . data ! . allDocuments . nodes ;
622+ expect ( limitNodes ) . toHaveLength ( 1 ) ;
623+
624+ // Must be the most relevant BM25 row
625+ expect ( limitNodes [ 0 ] . rowId ) . toBe ( allNodes [ 0 ] . rowId ) ;
626+ } ) ;
627+ } ) ;
628+
488629 // ─── Hybrid / multi-adapter queries ─────────────────────────────────────
489630
490631 describe ( 'hybrid search (multiple adapters active simultaneously)' , ( ) => {
0 commit comments