@@ -10018,25 +10018,42 @@ static int vs_build_keyword_vectors(cbm_store_t *s, const char *project, const c
1001810018 return actual_kw ;
1001910019}
1002010020
10021+ /* L2 norm of every quantized keyword vector, computed once per search
10022+ * instead of once per (node, keyword) pair inside the scoring loop. */
10023+ static void vs_keyword_norms (const int8_t (* kw_vecs )[VS_VEC_DIM ], int actual_kw , double * kw_norms ) {
10024+ for (int k = 0 ; k < actual_kw ; k ++ ) {
10025+ int32_t ma = 0 ;
10026+ for (int d = 0 ; d < VS_VEC_DIM ; d ++ ) {
10027+ ma += (int32_t )kw_vecs [k ][d ] * (int32_t )kw_vecs [k ][d ];
10028+ }
10029+ kw_norms [k ] = sqrt ((double )ma );
10030+ }
10031+ }
10032+
1002110033/* Compute the per-keyword min cosine score between a node's int8 vector and
10022- * each of the query vectors. Returns 0.0 if the node vector is unavailable
10023- * or mis-sized. */
10034+ * each of the query vectors. `kw_norms[k]` is the precomputed L2 norm of
10035+ * kw_vecs[k]; the node norm is computed once per node. The integer sums are
10036+ * exact, so factoring the norms out of the pair loop leaves every score
10037+ * bit-identical to the fused form. Returns 0.0 if the node vector is
10038+ * unavailable or mis-sized. */
1002410039static double vs_min_cosine_score (const int8_t * node_vec , int node_vec_len ,
10025- const int8_t (* kw_vecs )[VS_VEC_DIM ], int actual_kw ) {
10040+ const int8_t (* kw_vecs )[VS_VEC_DIM ], const double * kw_norms ,
10041+ int actual_kw ) {
1002610042 if (!node_vec || node_vec_len != VS_VEC_DIM ) {
1002710043 return 0.0 ;
1002810044 }
10045+ int32_t mb = 0 ;
10046+ for (int d = 0 ; d < VS_VEC_DIM ; d ++ ) {
10047+ mb += (int32_t )node_vec [d ] * (int32_t )node_vec [d ];
10048+ }
10049+ double node_norm = sqrt ((double )mb );
1002910050 double min_score = CBM_STORE_UNIT_POS_D ;
1003010051 for (int k = 0 ; k < actual_kw ; k ++ ) {
1003110052 int32_t dot = 0 ;
10032- int32_t ma = 0 ;
10033- int32_t mb = 0 ;
1003410053 for (int d = 0 ; d < VS_VEC_DIM ; d ++ ) {
1003510054 dot += (int32_t )kw_vecs [k ][d ] * (int32_t )node_vec [d ];
10036- ma += (int32_t )kw_vecs [k ][d ] * (int32_t )kw_vecs [k ][d ];
10037- mb += (int32_t )node_vec [d ] * (int32_t )node_vec [d ];
1003810055 }
10039- double denom = sqrt (( double ) ma ) * sqrt (( double ) mb ) ;
10056+ double denom = kw_norms [ k ] * node_norm ;
1004010057 double cos_k = denom > CBM_STORE_DENOM_EPS_D ? (double )dot / denom : 0.0 ;
1004110058 if (cos_k < min_score ) {
1004210059 min_score = cos_k ;
@@ -10045,35 +10062,69 @@ static double vs_min_cosine_score(const int8_t *node_vec, int node_vec_len,
1004510062 return min_score ;
1004610063}
1004710064
10048- /* Append one candidate row read from the scan statement into the result
10049- * vector. Grows the results array geometrically on demand. Returns the
10050- * (possibly grown) results pointer, or NULL on allocation failure. */
10051- static cbm_vector_result_t * vs_append_result (cbm_vector_result_t * results , int * count , int * cap ,
10052- sqlite3_stmt * stmt ,
10053- const int8_t (* kw_vecs )[VS_VEC_DIM ], int actual_kw ) {
10065+ /* Append one candidate row read from the scan statement into `*results`,
10066+ * growing the array geometrically on demand. Returns CBM_STORE_OK, or
10067+ * CBM_STORE_ERR on allocation failure — in which case `*results` still owns
10068+ * exactly `*count` complete rows (a row is only counted once every string
10069+ * copy succeeded), so the caller's usual free path stays valid. */
10070+ static int vs_append_result (cbm_vector_result_t * * results , int * count , int * cap , sqlite3_stmt * stmt ,
10071+ const int8_t (* kw_vecs )[VS_VEC_DIM ], const double * kw_norms ,
10072+ int actual_kw ) {
1005410073 if (* count >= * cap ) {
1005510074 int nc = * cap < CBM_SZ_16 ? CBM_SZ_16 : * cap * ST_COL_2 ;
10056- cbm_vector_result_t * grown = realloc (results , (size_t )nc * sizeof (cbm_vector_result_t ));
10075+ cbm_vector_result_t * grown = realloc (* results , (size_t )nc * sizeof (cbm_vector_result_t ));
1005710076 if (!grown ) {
10058- return NULL ;
10077+ return CBM_STORE_ERR ;
1005910078 }
10060- results = grown ;
10079+ * results = grown ;
1006110080 * cap = nc ;
1006210081 }
10063- int idx = ( * count ) ++ ;
10064- results [ idx ]. node_id = sqlite3_column_int64 (stmt , 0 );
10082+ cbm_vector_result_t * row = & ( * results )[ * count ] ;
10083+ row -> node_id = sqlite3_column_int64 (stmt , 0 );
1006510084 const char * name = (const char * )sqlite3_column_text (stmt , SKIP_ONE );
1006610085 const char * qn = (const char * )sqlite3_column_text (stmt , ST_COL_2 );
1006710086 const char * fp = (const char * )sqlite3_column_text (stmt , ST_COL_3 );
1006810087 const char * label = (const char * )sqlite3_column_text (stmt , ST_COL_4 );
10069- results [idx ].name = name ? strdup (name ) : strdup ("" );
10070- results [idx ].qualified_name = qn ? strdup (qn ) : strdup ("" );
10071- results [idx ].file_path = fp ? strdup (fp ) : strdup ("" );
10072- results [idx ].label = label ? strdup (label ) : strdup ("" );
10088+ row -> name = strdup (name ? name : "" );
10089+ row -> qualified_name = strdup (qn ? qn : "" );
10090+ row -> file_path = strdup (fp ? fp : "" );
10091+ row -> label = strdup (label ? label : "" );
10092+ if (!row -> name || !row -> qualified_name || !row -> file_path || !row -> label ) {
10093+ free (row -> name );
10094+ free (row -> qualified_name );
10095+ free (row -> file_path );
10096+ free (row -> label );
10097+ return CBM_STORE_ERR ;
10098+ }
1007310099 const int8_t * node_vec = (const int8_t * )sqlite3_column_blob (stmt , ST_COL_6 );
1007410100 int node_vec_len = sqlite3_column_bytes (stmt , ST_COL_6 );
10075- results [idx ].score = vs_min_cosine_score (node_vec , node_vec_len , kw_vecs , actual_kw );
10076- return results ;
10101+ row -> score = vs_min_cosine_score (node_vec , node_vec_len , kw_vecs , kw_norms , actual_kw );
10102+ (* count )++ ;
10103+ return CBM_STORE_OK ;
10104+ }
10105+
10106+ /* A lean index carries no node_vectors table at all. That is an empty
10107+ * semantic universe, not a scan failure, and callers must be able to tell
10108+ * the two apart — so probe the schema before preparing the scan. Returns
10109+ * CBM_STORE_OK (present), CBM_STORE_NOT_FOUND (absent) or CBM_STORE_ERR. */
10110+ static int vs_probe_node_vectors_table (cbm_store_t * s ) {
10111+ sqlite3_stmt * probe = NULL ;
10112+ const char * sql =
10113+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_vectors' LIMIT 1;" ;
10114+ if (sqlite3_prepare_v2 (s -> db , sql , SQLITE_AUTO_LEN , & probe , NULL ) != SQLITE_OK ) {
10115+ (void )fprintf (stderr , "vector_search: %s\n" , sqlite3_errmsg (s -> db ));
10116+ return CBM_STORE_ERR ;
10117+ }
10118+ int step_rc = sqlite3_step (probe );
10119+ sqlite3_finalize (probe );
10120+ if (step_rc == SQLITE_ROW ) {
10121+ return CBM_STORE_OK ;
10122+ }
10123+ if (step_rc == SQLITE_DONE ) {
10124+ return CBM_STORE_NOT_FOUND ;
10125+ }
10126+ (void )fprintf (stderr , "vector_search: %s\n" , sqlite3_errmsg (s -> db ));
10127+ return CBM_STORE_ERR ;
1007710128}
1007810129
1007910130static int vs_ranked_result_cmp (const void * lhs , const void * rhs ) {
@@ -10097,11 +10148,18 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
1009710148 return CBM_STORE_ERR ;
1009810149 }
1009910150
10151+ int probe_rc = vs_probe_node_vectors_table (s );
10152+ if (probe_rc != CBM_STORE_OK ) {
10153+ return probe_rc ;
10154+ }
10155+
1010010156 int8_t kw_vecs [VS_MAX_KW ][VS_VEC_DIM ];
1010110157 int actual_kw = vs_build_keyword_vectors (s , project , keywords , keyword_count , kw_vecs );
1010210158 if (actual_kw == 0 ) {
1010310159 return CBM_STORE_OK ;
1010410160 }
10161+ double kw_norms [VS_MAX_KW ];
10162+ vs_keyword_norms (kw_vecs , actual_kw , kw_norms );
1010510163
1010610164 /* Use the first keyword for a cheap candidate ordering, then score each
1010710165 * materialized candidate by min-cosine across every keyword. */
@@ -10141,13 +10199,11 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
1014110199 sqlite3_bind_int (stmt , ST_COL_3 , fetch_limit );
1014210200 while ((step_rc = sqlite3_step (stmt )) == SQLITE_ROW ) {
1014310201 omitted_score_ceiling = sqlite3_column_double (stmt , ST_COL_5 );
10144- cbm_vector_result_t * grown =
10145- vs_append_result (results , & count , & cap , stmt , kw_vecs , actual_kw );
10146- if (!grown ) {
10202+ if (vs_append_result (& results , & count , & cap , stmt , kw_vecs , kw_norms , actual_kw ) !=
10203+ CBM_STORE_OK ) {
1014710204 step_rc = SQLITE_NOMEM ;
1014810205 break ;
1014910206 }
10150- results = grown ;
1015110207 }
1015210208 if (step_rc != SQLITE_DONE ) {
1015310209 char rc_buf [VS_STR_BUF ];
0 commit comments