Skip to content

Commit 4342752

Browse files
Optimize exact int8 late-interaction scoring
1 parent 59376ca commit 4342752

6 files changed

Lines changed: 185 additions & 31 deletions

File tree

‎pkg/conv/actions.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,16 +328,16 @@ func formatContextOutput(result *ContextResult, format string) string {
328328
switch format {
329329
case "markdown":
330330
sb.WriteString("## Previous Session Context\n\n")
331-
sb.WriteString(fmt.Sprintf("**Project:** %s\n", result.Project))
332-
sb.WriteString(fmt.Sprintf("**Date:** %s (%s)\n", result.Date.Format("2006-01-02"), relativeTime(result.Date)))
333-
sb.WriteString(fmt.Sprintf("**Agent:** %s\n", result.Agent))
331+
fmt.Fprintf(&sb, "**Project:** %s\n", result.Project)
332+
fmt.Fprintf(&sb, "**Date:** %s (%s)\n", result.Date.Format("2006-01-02"), relativeTime(result.Date))
333+
fmt.Fprintf(&sb, "**Agent:** %s\n", result.Agent)
334334
if result.Topic != "" {
335-
sb.WriteString(fmt.Sprintf("**Topic:** %s\n", result.Topic))
335+
fmt.Fprintf(&sb, "**Topic:** %s\n", result.Topic)
336336
}
337337
sb.WriteString("\n")
338338

339339
if result.Summary != "" {
340-
sb.WriteString(fmt.Sprintf("### Summary\n%s\n\n", result.Summary))
340+
fmt.Fprintf(&sb, "### Summary\n%s\n\n", result.Summary)
341341
}
342342

343343
if len(result.KeyPoints) > 0 {

‎pkg/index/index.go‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -565,16 +565,6 @@ func (idx *Indexer) prepareFile(ctx context.Context, path string) ([]*store.Docu
565565
return docs, nil
566566
}
567567

568-
// indexFile is a convenience wrapper for single-file indexing (used by Watch).
569-
func (idx *Indexer) indexFile(ctx context.Context, path string) error {
570-
refreshColBERT, err := idx.shouldRefreshColBERT(ctx)
571-
if err != nil {
572-
return err
573-
}
574-
575-
return idx.syncFile(ctx, path, refreshColBERT, true)
576-
}
577-
578568
func (idx *Indexer) syncFile(ctx context.Context, path string, refreshColBERT, rebuildColBERTMMap bool) error {
579569
relPath, err := filepath.Rel(idx.rootPath, path)
580570
if err != nil {

‎pkg/search/colbert.go‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ type ColBERTScorer struct {
2525
cache *segmentCache
2626
}
2727

28+
type preparedQueryTerm struct {
29+
embedding []float32
30+
sum float64
31+
}
32+
2833
// segmentCache caches PRE-NORMALIZED segment embeddings to avoid recomputation.
2934
// Storing normalized vectors enables fast similarity via dot product.
3035
type segmentCache struct {
@@ -228,6 +233,7 @@ func (c *ColBERTScorer) ScoreBatchWithChunkIDs(ctx context.Context, query string
228233
if err != nil {
229234
return nil, err
230235
}
236+
preparedTerms := prepareQueryTerms(queryEmbeddings)
231237

232238
// Batch load pre-computed segment embeddings for all chunks
233239
segmentMap, err := c.segmentStore.GetColBERTSegmentsBatch(ctx, chunkIDs)
@@ -274,8 +280,8 @@ func (c *ColBERTScorer) ScoreBatchWithChunkIDs(ctx context.Context, query string
274280

275281
// Compute MaxSim score using int8 quantized embeddings if available
276282
var totalScore float64
277-
for _, qEmb := range queryEmbeddings {
278-
maxSim := maxSimInt8(qEmb, segments)
283+
for _, term := range preparedTerms {
284+
maxSim := maxSimPreparedInt8(term, segments)
279285
if maxSim > 0 {
280286
totalScore += maxSim
281287
}
@@ -585,3 +591,42 @@ func maxSimInt8(qEmb []float32, segments []store.ColBERTSegment) float64 {
585591
}
586592
return maxSim
587593
}
594+
595+
func prepareQueryTerms(queryEmbeddings [][]float32) []preparedQueryTerm {
596+
terms := make([]preparedQueryTerm, len(queryEmbeddings))
597+
for i, emb := range queryEmbeddings {
598+
terms[i] = preparedQueryTerm{
599+
embedding: emb,
600+
sum: sumFloat32(emb),
601+
}
602+
}
603+
return terms
604+
}
605+
606+
func maxSimPreparedInt8(term preparedQueryTerm, segments []store.ColBERTSegment) float64 {
607+
if len(segments) == 0 {
608+
return -1
609+
}
610+
611+
maxSim := float64(-1)
612+
for _, seg := range segments {
613+
var sim float64
614+
if seg.EmbeddingInt8 != nil {
615+
sim = util.DotProductInt8AffinePrepared(term.embedding, seg.EmbeddingInt8, seg.QuantScale, seg.QuantMin, term.sum)
616+
} else if seg.Embedding != nil {
617+
sim = util.DotProductUnrolled8(term.embedding, seg.Embedding)
618+
}
619+
if sim > maxSim {
620+
maxSim = sim
621+
}
622+
}
623+
return maxSim
624+
}
625+
626+
func sumFloat32(values []float32) float64 {
627+
var sum float64
628+
for _, v := range values {
629+
sum += float64(v)
630+
}
631+
return sum
632+
}

‎pkg/search/colbert_test.go‎

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package search
22

33
import (
4+
"math"
45
"testing"
6+
7+
"github.com/XiaoConstantine/sgrep/pkg/store"
8+
"github.com/XiaoConstantine/sgrep/pkg/util"
59
)
610

711
func TestDecomposeQuery(t *testing.T) {
@@ -29,22 +33,22 @@ func TestDecomposeQuery(t *testing.T) {
2933

3034
func TestDecomposeDocument(t *testing.T) {
3135
tests := []struct {
32-
name string
33-
content string
34-
minSegs int
35-
maxSegs int
36+
name string
37+
content string
38+
minSegs int
39+
maxSegs int
3640
}{
3741
{
38-
name: "empty",
39-
content: "",
40-
minSegs: 0,
41-
maxSegs: 0,
42+
name: "empty",
43+
content: "",
44+
minSegs: 0,
45+
maxSegs: 0,
4246
},
4347
{
44-
name: "single line",
45-
content: "func main() {}",
46-
minSegs: 1,
47-
maxSegs: 1,
48+
name: "single line",
49+
content: "func main() {}",
50+
minSegs: 1,
51+
maxSegs: 1,
4852
},
4953
{
5054
name: "multiple functions",
@@ -221,3 +225,51 @@ func TestSegmentCache(t *testing.T) {
221225
// At least one of the original keys should be evicted
222226
// Note: eviction is simple and clears half, so behavior may vary
223227
}
228+
229+
func TestPrepareQueryTerms(t *testing.T) {
230+
embeddings := [][]float32{
231+
{0.25, -0.5, 0.75},
232+
{-0.25, 0.5, -0.75},
233+
}
234+
235+
terms := prepareQueryTerms(embeddings)
236+
if len(terms) != len(embeddings) {
237+
t.Fatalf("prepareQueryTerms returned %d terms, want %d", len(terms), len(embeddings))
238+
}
239+
240+
for i, term := range terms {
241+
if len(term.embedding) != len(embeddings[i]) {
242+
t.Fatalf("term %d embedding len = %d, want %d", i, len(term.embedding), len(embeddings[i]))
243+
}
244+
if diff := math.Abs(term.sum - sumFloat32(embeddings[i])); diff > 1e-9 {
245+
t.Fatalf("term %d sum diff = %.12f", i, diff)
246+
}
247+
}
248+
}
249+
250+
func TestMaxSimPreparedInt8MatchesCurrent(t *testing.T) {
251+
query := util.NormalizeVector([]float32{0.9, -0.3, 0.2, 0.1})
252+
segments := make([]store.ColBERTSegment, 3)
253+
254+
rawSegments := [][]float32{
255+
util.NormalizeVector([]float32{0.8, -0.25, 0.25, 0.15}),
256+
util.NormalizeVector([]float32{-0.4, 0.9, -0.1, 0.05}),
257+
util.NormalizeVector([]float32{0.2, -0.1, 0.95, -0.05}),
258+
}
259+
260+
for i, emb := range rawSegments {
261+
quantized, scale, min := util.QuantizeInt8(emb)
262+
segments[i] = store.ColBERTSegment{
263+
SegmentIdx: i,
264+
EmbeddingInt8: quantized,
265+
QuantScale: scale,
266+
QuantMin: min,
267+
}
268+
}
269+
270+
current := maxSimInt8(query, segments)
271+
prepared := maxSimPreparedInt8(prepareQueryTerms([][]float32{query})[0], segments)
272+
if diff := math.Abs(current - prepared); diff > 1e-7 {
273+
t.Fatalf("prepared scorer mismatch: current %.12f prepared %.12f diff %.12f", current, prepared, diff)
274+
}
275+
}

‎pkg/util/slab.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,41 @@ func DotProductInt8Unrolled8(query []float32, doc []int8, scale, min float32) fl
364364
return s0 + s1 + s2 + s3 + s4 + s5 + s6 + s7
365365
}
366366

367+
// DotProductInt8AffinePrepared computes the exact same score as DotProductInt8Unrolled8,
368+
// but factors out the affine dequantization so query-specific work can be prepared once:
369+
//
370+
// dot(q, ((doc + 128) * scale + min))
371+
// = scale*dot(q, doc) + (128*scale + min)*sum(q)
372+
func DotProductInt8AffinePrepared(query []float32, doc []int8, scale, min float32, querySum float64) float64 {
373+
n := len(query)
374+
if n != len(doc) {
375+
return 0
376+
}
377+
378+
scale64 := float64(scale)
379+
bias := float64(128*scale + min)
380+
381+
var s0, s1, s2, s3, s4, s5, s6, s7 float64
382+
383+
i := 0
384+
for ; i <= n-8; i += 8 {
385+
s0 += float64(query[i]) * float64(doc[i])
386+
s1 += float64(query[i+1]) * float64(doc[i+1])
387+
s2 += float64(query[i+2]) * float64(doc[i+2])
388+
s3 += float64(query[i+3]) * float64(doc[i+3])
389+
s4 += float64(query[i+4]) * float64(doc[i+4])
390+
s5 += float64(query[i+5]) * float64(doc[i+5])
391+
s6 += float64(query[i+6]) * float64(doc[i+6])
392+
s7 += float64(query[i+7]) * float64(doc[i+7])
393+
}
394+
395+
for ; i < n; i++ {
396+
s0 += float64(query[i]) * float64(doc[i])
397+
}
398+
399+
return scale64*(s0+s1+s2+s3+s4+s5+s6+s7) + bias*querySum
400+
}
401+
367402
// TopKIndices returns indices of k smallest values in distances (partial sort).
368403
// Uses indices buffer for zero allocation.
369404
func TopKIndices(distances []float64, indices []int, k int) []int {

‎pkg/util/slab_test.go‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ func TestL2DistanceBatch(t *testing.T) {
220220
func TestL2DistanceBatch_DimensionMismatch(t *testing.T) {
221221
query := []float32{0.0, 0.0, 0.0}
222222
vectors := [][]float32{
223-
{0.0, 0.0}, // wrong dims
224-
{1.0, 0.0, 0.0}, // correct dims
223+
{0.0, 0.0}, // wrong dims
224+
{1.0, 0.0, 0.0}, // correct dims
225225
{0.0, 3.0, 4.0, 5.0}, // wrong dims
226226
}
227227
distances := make([]float64, len(vectors))
@@ -239,6 +239,38 @@ func TestL2DistanceBatch_DimensionMismatch(t *testing.T) {
239239
}
240240
}
241241

242+
func TestDotProductInt8AffinePreparedMatchesCurrent(t *testing.T) {
243+
rng := rand.New(rand.NewSource(42))
244+
245+
for caseIdx := 0; caseIdx < 128; caseIdx++ {
246+
query := make([]float32, 768)
247+
doc := make([]float32, 768)
248+
for i := range query {
249+
query[i] = rng.Float32()*2 - 1
250+
doc[i] = rng.Float32()*2 - 1
251+
}
252+
query = NormalizeVector(query)
253+
doc = NormalizeVector(doc)
254+
255+
docInt8, scale, min := QuantizeInt8(doc)
256+
querySum := sumFloat32Values(query)
257+
258+
got := DotProductInt8AffinePrepared(query, docInt8, scale, min, querySum)
259+
want := DotProductInt8Unrolled8(query, docInt8, scale, min)
260+
if diff := math.Abs(got - want); diff > 1e-7 {
261+
t.Fatalf("case %d: got %.12f want %.12f diff %.12f", caseIdx, got, want, diff)
262+
}
263+
}
264+
}
265+
266+
func sumFloat32Values(values []float32) float64 {
267+
var sum float64
268+
for _, v := range values {
269+
sum += float64(v)
270+
}
271+
return sum
272+
}
273+
242274
func TestTopKIndices(t *testing.T) {
243275
tests := []struct {
244276
name string

0 commit comments

Comments
 (0)