Skip to content

חיפוש סמנטי מקומי ב־Go: ‏Ollama, ‏Qdrant והערות סינתטיות

ככל שאוסף הערות טכניות גדל, איתור מידע בו עלול להיעשות קשה. מאמר זה בוחן תכנון חיפוש אחד; התרחיש הלילי והציטוט הפופולרי הנטען מ־Hacker News אינם דיווח מאומת.

חיפוש מילות מפתח וחיפוש שיכונים פותרים בעיות אחזור שונות ויכולים להשלים זה את זה. קושי למצוא הערות אינו אבחון התמכרות או ראיה לאופן שבו מוחם של מפתחים מחווט.

צינור Go להמחשה משתמש בשיכוני Ollama מקומיים וב־REST API מקומי של Qdrant. עיבוד מקומי יכול לצמצם העברת מידע החוצה, אך אינו מבטיח פרטיות: חשיפת שירותים, משתמשים, יומנים, אחסון וגיבויים עדיין חשובים. השתמשו רק בהערות הסינתטיות שלהלן, לא בקבצים אישיים או חסויים. לא הותקן, הורד, אונדקס או הורץ כאן דבר.

הארכיטקטורה של חיפוש סמנטי

חיפושים מילוליים כגון תבניות grep או SQL LIKE עלולים להחמיץ טקסט רלוונטי בניסוח שונה. יש להם גם התנהגות שימושית של התאמה מדויקת; דוגמת הרחבת מסד הנתונים במקור היא להמחשה, לא בדיקת אחזור.

מודל שיכונים ממפה טקסט לווקטורים מספריים. הדמיון משקף את הייצוג הנלמד שלו, לא הבטחה למשמעות זהה או לרלוונטיות. יש להעריך מודל, שפה וחלוקה למקטעים על האוסף המיועד.

הארכיטקטורה שלנו תיראה כך:

[Synthetic Markdown] -> [Go CLI] -> [Ollama on 127.0.0.1]
                              |                 |
                              |            [Embedding]
                              v                 v
                         [Qdrant on 127.0.0.1]

הקטעים המלאים יוצרים קובץ Go אחד שלא הודר: חלוקת פסקאות מוגבלת, בקשות שיכון מקומיות, בדיקת סכמת אוסף, עדכון נקודות באצוות ושאילתה. זו הדגמה סינתטית קבועה, לא מערכת סנכרון מסמכים מלאה: הערות שנמחקו או התקצרו עלולות להשאיר נקודות ישנות, ושדרוג מודל מחייב אינדוקס מחדש מתוכנן בנפרד.

הקמת התשתית

שירות Qdrant מקומי הוא אפשרות אחת למערכת אחורית. הפקודות הן תיעוד בלבד, לא הקמה שבוצעה. סקרו גרסאות, מקום פנוי ובקרות גישה לפני שימוש שיאושר בנפרד.

פקודת Docker להמחשה: קישור יציאת REST בלבד ל־IPv4 loopback ושימוש בכרך הדגמה ייעודי. loopback אינו הגנה מפני משתמשים מקומיים אחרים או קונטיינרים; יש לאמת את התנהגות הרשת בפועל, במיוחד בגרסאות Docker שלפני 28.0.0:

docker run --name qdrant-notes-demo   -p 127.0.0.1:6333:6333   -v qdrant_notes_demo:/qdrant/storage   qdrant/qdrant

הרשומה all-minilm היא מודל שיכונים של Ollama. פקודת ההורדה המתועדת הבאה מורידה נתוני מודל; היא לא הורצה. השתמשו בגרסת מודל מקומית מאושרת, תעדו את ערך הגיבוב (digest) שלה והשאירו את השירות ב־loopback; אין להחליף בדוגמה נקודת קצה מקומית במרוחקת או במודל ענן.

ollama pull all-minilm

הקוד מצפה ל־384 ממדים ודוחה גודל אחר, ערכים שאינם סופיים או וקטור אפס. אורך ההקשר והאיכות תלויים במודל; אין הבטחה שטקסט שרירותי יתאים. הדוגמה אינה מאמתת שאילתות בעברית ואינה קובעת תקציב VRAM.

בניית מנוע הקליטה ב־Go

לסביבה שתיבדק בהמשך, אלו פקודות להמחשת הקמת מודול. לא בוצעו הורדת תלויות או הידור:

mkdir info-vault && cd info-vault
go mod init info-vault
go get github.com/google/uuid

הניחו את הקטעים הבאים בקובץ main.go יחיד. קראו רק תיקייה מהימנה בשליטה בלעדית שמכילה Markdown סינתטי; בדיקות הקבצים אינן ארגז חול מפני שינויים מקבילים במערכת הקבצים. המטענים הנשמרים כוללים טקסט ונתיב יחסי, ולכן יש להגן גם על מסד הנתונים עצמו.

שלב 1: ניתוח וחלוקת Markdown למקטעים

פצלו לפי שורות ריקות ואז הגבילו כל חלק ל־500 נקודות קוד Unicode. הדבר עלול לפצל מבני Markdown ואינו מגבלת מספר טוקנים. פסקאות קצרות שאינן ריקות נשמרות. הבקשות מגדירות truncate:false, ולכן קלט שחורג מההקשר נכשל במקום לאבד טקסט בשקט; צמצמו מקטעים או השתמשו בטוקנייזר תואם לפני הערכה נוספת.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "path/filepath"
    "strings"
    "time"
    "unicode/utf8"
    "github.com/google/uuid"
)

const collectionURL = "http://127.0.0.1:6333/collections/notes_demo_minilm384"
const embedURL = "http://127.0.0.1:11434/api/embed"
const modelName = "all-minilm"
const dimensions = 384
const maxFileBytes = 128 * 1024
var client = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{Proxy: nil},
    CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
        return http.ErrUseLastResponse
    },
}

type Chunk struct {
    ID string
    FilePath string
    Content string
    Vector []float32
}

func jsonRequest(method, url string, value any, result any) (int, error) {
    var body []byte
    var err error
    if value != nil {
        body, err = json.Marshal(value)
        if err != nil { return 0, err }
    }
    req, err := http.NewRequest(method, url, bytes.NewReader(body))
    if err != nil { return 0, err }
    req.Header.Set("Content-Type", "application/json")
    resp, err := client.Do(req)
    if err != nil { return 0, err }
    defer resp.Body.Close()
    const maxResponse = 4 * 1024 * 1024
    raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse+1))
    if err != nil { return resp.StatusCode, err }
    if len(raw) > maxResponse { return resp.StatusCode, fmt.Errorf("response too large") }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return resp.StatusCode, fmt.Errorf("HTTP status %d", resp.StatusCode)
    }
    if result != nil { return resp.StatusCode, json.Unmarshal(raw, result) }
    return resp.StatusCode, nil
}

func chunkFile(root, path string) ([]Chunk, error) {
    f, err := os.Open(path)
    if err != nil { return nil, err }
    defer f.Close()
    raw, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1))
    if err != nil { return nil, err }
    if len(raw) > maxFileBytes || !utf8.Valid(raw) {
        return nil, fmt.Errorf("file too large or invalid UTF-8")
    }
    rel, err := filepath.Rel(root, path)
    if err != nil { return nil, err }
    var chunks []Chunk
    text := strings.ReplaceAll(string(raw), "\r\n", "\n")
    for _, para := range strings.Split(text, "\n\n") {
        runes := []rune(strings.TrimSpace(para))
        for len(runes) > 0 {
            n := len(runes)
            if n > 500 { n = 500 }
            part := strings.TrimSpace(string(runes[:n]))
            runes = runes[n:]
            if part == "" { continue }
            key := fmt.Sprintf("notes-demo:%s:%d", filepath.ToSlash(rel), len(chunks))
            id := uuid.NewSHA1(uuid.NameSpaceURL, []byte(key)).String()
            chunks = append(chunks, Chunk{ID: id, FilePath: rel, Content: part})
        }
    }
    return chunks, nil
}

שלב 2: יצירת שיכונים מקומיים עם Ollama

השתמשו בשדות model ו־input של /api/embed הנוכחי, ונתחו את התשובה ברבים, embeddings. המודל חייב להישאר זהה באינדוקס ובשאילתות. בקשת HTTP מוצלחת לבדה אינה ראיה לווקטור תואם:

func validVector(v []float32) bool {
    if len(v) != dimensions { return false }
    nonzero := false
    for _, x := range v {
        if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) { return false }
        if x != 0 { nonzero = true }
    }
    return nonzero
}

func getEmbedding(text string) ([]float32, error) {
    if strings.TrimSpace(text) == "" { return nil, fmt.Errorf("empty text") }
    var result struct { Embeddings [][]float32 `json:"embeddings"` }
    payload := map[string]any{"model": modelName, "input": text, "truncate": false}
    _, err := jsonRequest(http.MethodPost, embedURL, payload, &result)
    if err != nil { return nil, err }
    if len(result.Embeddings) != 1 || !validVector(result.Embeddings[0]) {
        return nil, fmt.Errorf("incompatible embedding")
    }
    return result.Embeddings[0], nil
}

שלב 3: שמירת וקטורים ב־Qdrant

קראו תחילה את האוסף ואמתו את גודל הווקטור ללא שם ואת מדד המרחק. צרו אותו רק אחרי 404 מפורש; אין לראות בכל התנגשות הצלחה או למחוק אוסף קיים. הבדיקה אינה מוכיחה שווקטורים קיימים נוצרו באותו ערך גיבוב (digest) של המודל — השתמשו באוסף הדגמה ייעודי עם מודל קבוע.

func initQdrantCollection() error {
    var info struct {
        Result struct {
            Config struct {
                Params struct {
                    Vectors struct {
                        Size int `json:"size"`
                        Distance string `json:"distance"`
                    } `json:"vectors"`
                } `json:"params"`
            } `json:"config"`
        } `json:"result"`
    }
    status, err := jsonRequest(http.MethodGet, collectionURL, nil, &info)
    if status == http.StatusNotFound {
        payload := map[string]any{"vectors": map[string]any{
            "size": dimensions, "distance": "Cosine",
        }}
        if _, err = jsonRequest(http.MethodPut, collectionURL, payload, nil); err != nil {
            return err
        }
        _, err = jsonRequest(http.MethodGet, collectionURL, nil, &info)
    }
    if err != nil { return err }
    v := info.Result.Config.Params.Vectors
    if v.Size != dimensions || v.Distance != "Cosine" {
        return fmt.Errorf("collection schema mismatch")
    }
    return nil
}

עדכנו נקודות באמצעות PUT, באצוות של עד 32, עם wait=true. מזהים יציבים מעדכנים מיקומי נתיב ומקטע תואמים; הם אינם מסירים נקודות שהתיישנו ואינם הופכים את כל הקליטה לאטומית.

type QdrantPoint struct {
    ID string `json:"id"`
    Vector []float32 `json:"vector"`
    Payload map[string]string `json:"payload"`
}
func upsertToQdrant(chunks []Chunk) error {
    for start := 0; start < len(chunks); start += 32 {
        end := start + 32
        if end > len(chunks) { end = len(chunks) }
        points := make([]QdrantPoint, 0, end-start)
        for _, c := range chunks[start:end] {
            if !validVector(c.Vector) { return fmt.Errorf("invalid point vector") }
            points = append(points, QdrantPoint{ID: c.ID, Vector: c.Vector,
                Payload: map[string]string{"file_path": c.FilePath, "content": c.Content}})
        }
        var ack struct { Result struct { Status string `json:"status"` } `json:"result"` }
        _, err := jsonRequest(http.MethodPut, collectionURL+"/points?wait=true",
            map[string]any{"points": points}, &ack)
        if err != nil { return err }
        if ack.Result.Status != "completed" { return fmt.Errorf("upsert not completed") }
    }
    return nil
}

שלב 4: חיבור צינור העיבוד

נקודת הכניסה דורשת תיקייה סינתטית מפורשת במקום לסרוק ./vault כברירת מחדל. היא מדלגת על קישורים סמליים וקבצים לא רגילים, מגבילה את הסריקה ל־256 קובצי Markdown ו־4096 מקטעים, ומחזירה שגיאות בלי להדפיס טקסט הערות. היא אינה מגינה מפני שינויים עוינים מקבילים במערכת הקבצים ואינה מספקת עסקאות ניתנות לחידוש.

func run() error {
    if len(os.Args) != 3 {
        return fmt.Errorf("usage: vault-search index SYNTHETIC_DIR | search QUERY")
    }
    switch os.Args[1] {
    case "index":
        root := os.Args[2]
        info, err := os.Lstat(root)
        if err != nil { return err }
        if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
            return fmt.Errorf("expected a real directory")
        }
        var all []Chunk
        files := 0
        err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
            if err != nil { return err }
            if d.IsDir() { return nil }
            if !d.Type().IsRegular() || !strings.HasSuffix(d.Name(), ".md") { return nil }
            files++
            if files > 256 { return fmt.Errorf("too many files") }
            chunks, err := chunkFile(root, path)
            if err != nil { return err }
            if len(all)+len(chunks) > 4096 { return fmt.Errorf("too many chunks") }
            all = append(all, chunks...)
            return nil
        })
        if err != nil { return err }
        if len(all) == 0 { return fmt.Errorf("no nonempty Markdown chunks") }
        for i := range all {
            all[i].Vector, err = getEmbedding(all[i].Content)
            if err != nil { return err }
        }
        if err := initQdrantCollection(); err != nil { return err }
        if err := upsertToQdrant(all); err != nil { return err }
        fmt.Printf("Updated %d synthetic chunks; stale-point cleanup is not implemented.\n", len(all))
        return nil
    case "search":
        return performSearch(os.Args[2])
    default:
        return fmt.Errorf("unknown command")
    }
}
func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

תשאול מנוע הידע האישי

הפונקציה performSearch יוצרת שיכון לשאילתה מוגבלת באותו מודל מקומי ומבקשת וקטורים קרובים. ציון קוסינוס אינו הסתברות לרלוונטיות או הוכחה שההערה עונה על השאלה.

הוסיפו את פונקציית החיפוש הבאה ל־main.go:

func performSearch(query string) error {
    if !utf8.ValidString(query) || len([]rune(query)) > 500 {
        return fmt.Errorf("query too long or invalid UTF-8")
    }
    vector, err := getEmbedding(query)
    if err != nil { return err }
    var result struct {
        Result struct {
            Points []struct {
                Score float64 `json:"score"`
                Payload map[string]string `json:"payload"`
            } `json:"points"`
        } `json:"result"`
    }
    payload := map[string]any{"query": vector, "limit": 3, "with_payload": true}
    _, err = jsonRequest(http.MethodPost, collectionURL+"/points/query", payload, &result)
    if err != nil { return err }
    for i, point := range result.Result.Points {
        fmt.Printf("%d cosine=%.4f source=%q content=%q\n", i+1, point.Score,
            point.Payload["file_path"], point.Payload["content"])
    }
    return nil
}

בדיקה מוצעת בנתונים סינתטיים, לא בדיקה שבוצעה

מערך הנתונים הבא מכיל הערות בדיוניות בלבד. פקודת שינוי הבעלות הרקורסיבית וטענת UID אוניברסלי של PostgreSQL הוסרו; אין להשתמש במערך כהוראה לשנות הרשאות אחסון אמיתי. הערת Redis אינה טוענת לאירוע אמיתי או לתיקון תצורה מובטח.

mkdir synthetic_vault
cat > synthetic_vault/storage.md <<'EOF'
# Fictional storage investigation
The demo team would inspect a container image's documented user and directory permissions before proposing a change. This note contains no permission-changing command.
EOF
cat > synthetic_vault/cache.md <<'EOF'
# Fictional cache investigation
The demo team would compare cache memory limits and eviction policies in a test environment. No production crash or successful fix is reported.
EOF

אם סביבת בדיקה עתידית מורשית נבדקה, הפקודות האלה יבנו את הקובץ ויאנדקסו רק את התיקייה הסינתטית שנקובה בשם. הן לא הורצו כאן:

go build -o vault-search main.go
./vault-search index ./synthetic_vault

שאילתה אפשרית לבדיקה עתידית מוצגת להלן. השוו הערות מוחזרות לשיפוט אנושי, לרבות החמצות ותוצאות לא רלוונטיות; לא נצפה דירוג:

./vault-search search "database out of memory crash mitigation"

הערת המטמון היא מועמדת סבירה לשאילתה, לא תוצאה ראשונה מובטחת. גרסת המודל, החלוקה למקטעים ותוכן האוסף יכולים לשנות דירוג; השתמשו בשאילתות הערכה לפני הסתמכות על אחזור.

סיכום: צמצום הרעש

חיפוש סמנטי מקומי הוא כלי ארגון אחד, לא טיפול בהתמכרות ולא הבטחת פרטיות. מימוש שימושי דורש שירותים מקומיים מוגנים, מודל וסכמה עקביים, טיפול במחזור חיי מסמכים ואיכות אחזור מדודה. הדוגמה המצורפת נותרת הדגמה סינתטית שלא הורצה.

איך אתם מנהלים עומס מידע? האם שילבתם מודלי שפה מקומיים בתהליך הפיתוח היומיומי? ספרו לי בתגובות למטה!

תכנות מהנה, ולכו לסגור כמה מהלשוניות הפתוחות האלה!

תיעוד ראשוני

Post a Comment

Previous Post Next Post