diff --git a/chrome-extension/src/description_generation.js b/chrome-extension/src/description_generation.js
index 82955b1..9f0e033 100644
--- a/chrome-extension/src/description_generation.js
+++ b/chrome-extension/src/description_generation.js
@@ -59,7 +59,6 @@ Document: {{content}}`;
document.getElementById("descLoading").style.display = "none";
console.error("Error generating description with Ollama:", error);
- updateStatus(`Error generating description: ${error.message}`);
return [];
}
}
diff --git a/cmd/root.go b/cmd/root.go
index 3d91212..bde2207 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -76,11 +76,20 @@ func (m rootAppModel) Init() tea.Cmd { return nil }
func (m rootAppModel) updateTable() rootAppModel {
bmCount := m.rowsCount
-
- bookmarks, err := store.SearchBookmarks(m.db, m.input.Value())
- if err != nil {
- log.Panicln(err)
- return m
+ var bookmarks []store.Bookmark
+ var err error
+ if m.input.Value() != "" {
+ bookmarks, err = store.SearchBookmarks(m.db, m.input.Value())
+ if err != nil {
+ log.Panicln(err)
+ return m
+ }
+ } else {
+ bookmarks, err = store.GetBookmarks(m.db)
+ if err != nil {
+ log.Panicln(err)
+ return m
+ }
}
if len(bookmarks) == bmCount {
@@ -269,6 +278,7 @@ file sync service. This is sort-of explained the following blog post:
input.Placeholder = "Search / Filter"
m := rootAppModel{db: db, table: t, input: input, currentIndex: 1, rowsCount: 0, mode: NORMAL}
+ m = m.updateTable()
prog := tea.NewProgram(m, tea.WithAltScreen())
diff --git a/cmd/sql.go b/cmd/sql.go
index e90bc27..1618429 100644
--- a/cmd/sql.go
+++ b/cmd/sql.go
@@ -14,6 +14,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/charmbracelet/x/term"
+ "github.com/lukasmwerner/mark/search"
"github.com/lukasmwerner/mark/store"
"github.com/spf13/cobra"
)
@@ -92,30 +93,25 @@ var sqlCmd = &cobra.Command{
query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
} else if input == ".schema" {
query = "SELECT sql FROM sqlite_master WHERE type='table' ORDER BY name;"
- } else if search, ok := strings.CutPrefix(input, ".web-search"); ok {
- results, err := WebSearchBookmarks(db, search, ftsRank)
+ } else if s, ok := strings.CutPrefix(input, ".web-search"); ok {
+ results, err := search.Search(db, s)
if err != nil {
fmt.Println("err: " + err.Error())
continue
}
- renderBookmarks(results)
+ renderSearchResults(results)
query = ""
continue
- } else if search, ok := strings.CutPrefix(input, ".semantic "); ok {
- fmt.Printf("semantic: '%s'\n", search)
- rows, err := db.Query(`SELECT b.url, b.title, b.description, b.tags, distance
- FROM bookmark_embeddings e
- JOIN Bookmarks b ON e.document_id = b.id
- WHERE e.embedding MATCH embed('embeddinggemma', concat_ws(' ', 'task: search result | query: ', ?)) and k = 100 and distance <= 1.21
- ORDER BY distance;`, search)
+ } else if s, ok := strings.CutPrefix(input, ".semantic "); ok {
+ fmt.Printf("semantic: '%s'\n", s)
+ results, err := search.Semantic(db, s)
if err != nil {
fmt.Println("err: " + err.Error())
continue
}
- renderResults(rows)
+ renderBookmarks(results)
query = ""
- rows.Close()
continue
} else if search, ok := strings.CutPrefix(input, ".search "); ok {
fmt.Printf("search: '%s'\n", search)
@@ -162,6 +158,15 @@ func init() {
// sqlCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
+func renderSearchResults(results []search.Result) {
+ bookmarks := make([]store.Bookmark, len(results))
+ for i, r := range results {
+ bookmarks[i] = r.Bookmark
+ }
+ renderBookmarks(bookmarks)
+
+}
+
func renderBookmarks(bookmarks []store.Bookmark) {
if bookmarks == nil {
return
diff --git a/cmd/web-search.go b/cmd/web-search.go
index 64ef62d..4601ccc 100644
--- a/cmd/web-search.go
+++ b/cmd/web-search.go
@@ -7,167 +7,17 @@ import (
"fmt"
"log"
"net/http"
- "sort"
- "strings"
_ "embed"
"github.com/a-h/templ"
+ "github.com/lukasmwerner/mark/search"
"github.com/lukasmwerner/mark/store"
"github.com/lukasmwerner/mark/web"
"github.com/lukasmwerner/mark/web/static"
"github.com/spf13/cobra"
)
-type RankingMethod string
-
-const (
- recency RankingMethod = "rowid DESC, rank DESC" // rank based on rowid will be an issue when going across users
- ftsRank RankingMethod = "rank DESC, rowid DESC"
-)
-
-func WebSearchBookmarks(db *store.DB, query string, ranker RankingMethod) ([]store.Bookmark, error) {
- bookmarks := []store.Bookmark{}
-
- partials := ""
- for _, field := range strings.Fields(query) {
- partial := ""
- switch field {
- case "NOT":
- partial = field
- case "OR":
- partial = field
- case "AND":
- partial = field
- default:
- partial = field + "*"
-
- }
- partials += " " + partial
- }
-
- rows, err := db.Query(
- `
- SELECT
- url,
- title,
- description,
- rowid,
- tags
- FROM (
- SELECT
- url,
- title,
- description,
- tags,
- rowid,
- rank,
- 1 AS priority
- FROM Bookmarks_fts
- WHERE Bookmarks_fts MATCH ?
-
- UNION
-
- SELECT
- url,
- title,
- description,
- tags,
- rowid,
- rank,
- 2 AS priority
- FROM Bookmarks_fts
- WHERE Bookmarks_fts MATCH ?)
-
- GROUP BY url
- ORDER BY MIN(priority) ASC, `+string(ranker)+`;`,
- query, partials)
-
- // rows, err := db.Query(
- // `SELECT url, title, description, tags FROM Bookmarks_fts WHERE Bookmarks_fts MATCH ? ORDER BY rowid DESC, rank DESC;`,
- // query)
- if err != nil {
- return bookmarks, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var b store.Bookmark
- var tags string
- var id int
- err := rows.Scan(&b.Url, &b.Title, &b.Description, &id, &tags)
- if err != nil {
- return bookmarks, err
- }
- b.Tags = strings.Split(tags, ", ")
- bookmarks = append(bookmarks, b)
- }
-
- return bookmarks, nil
-}
-
-func mergeResults(results ...[]store.Bookmark) []store.Bookmark {
- type rankedBookmark struct {
- bookmark store.Bookmark
- score float64
- bestRank int
- firstSet int
- }
-
- merged := map[string]*rankedBookmark{}
- for setIndex, resultSet := range results {
- for rank, bm := range resultSet {
- key := bm.Url
- if key == "" {
- key = bm.Title
- }
-
- // Reciprocal rank fusion: bookmarks that rank highly in one or more
- // result sets bubble toward the front of the merged list.
- score := 1.0 / float64(rank+1)
- if existing, ok := merged[key]; ok {
- existing.score += score
- if rank < existing.bestRank {
- existing.bestRank = rank
- }
- continue
- }
-
- merged[key] = &rankedBookmark{
- bookmark: bm,
- score: score,
- bestRank: rank,
- firstSet: setIndex,
- }
- }
- }
-
- outputResults := make([]rankedBookmark, 0, len(merged))
- for _, bm := range merged {
- outputResults = append(outputResults, *bm)
- }
-
- sort.SliceStable(outputResults, func(i, j int) bool {
- if outputResults[i].score != outputResults[j].score {
- return outputResults[i].score > outputResults[j].score
- }
- if outputResults[i].bestRank != outputResults[j].bestRank {
- return outputResults[i].bestRank < outputResults[j].bestRank
- }
- if outputResults[i].firstSet != outputResults[j].firstSet {
- return outputResults[i].firstSet < outputResults[j].firstSet
- }
- return outputResults[i].bookmark.Url < outputResults[j].bookmark.Url
- })
-
- bookmarks := make([]store.Bookmark, len(outputResults))
- for i, bm := range outputResults {
- bookmarks[i] = bm.bookmark
- }
-
- return bookmarks
-}
-
var searchCmd = &cobra.Command{
Use: "search",
Short: `[EXPERIMENTAL] google search like interface`,
@@ -188,21 +38,13 @@ var searchCmd = &cobra.Command{
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
- fts_results, err := WebSearchBookmarks(db, q, ftsRank)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- fmt.Fprintln(w, "oops we had something go wrong.")
- fmt.Fprintln(w, err.Error())
- return
- }
- semantic_results, err := store.SemanticSearchBookmarks(db, q)
+ results, err := search.Search(db, q)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "oops we had something go wrong.")
fmt.Fprintln(w, err.Error())
return
}
- results := mergeResults(fts_results, semantic_results)
templ.Handler(web.ResultsPage("lukaswerner.com", q, "(FTS + Embeddings) RRF", results)).ServeHTTP(w, r)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
diff --git a/go.mod b/go.mod
index d7c78a1..d479993 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.24.1
require (
github.com/PuerkitoBio/goquery v1.9.2
- github.com/a-h/templ v0.3.960
+ github.com/a-h/templ v0.3.1001
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.5.2
diff --git a/go.sum b/go.sum
index 2c42498..cc5be1d 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
+github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
+github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww=
diff --git a/search/data.go b/search/data.go
new file mode 100644
index 0000000..d9c9051
--- /dev/null
+++ b/search/data.go
@@ -0,0 +1,9 @@
+package search
+
+import "github.com/lukasmwerner/mark/store"
+
+type Result struct {
+ store.Bookmark
+ Source Source
+ Rank float64
+}
diff --git a/search/fts.go b/search/fts.go
new file mode 100644
index 0000000..4c964a0
--- /dev/null
+++ b/search/fts.go
@@ -0,0 +1,84 @@
+package search
+
+import (
+ "strings"
+
+ "github.com/lukasmwerner/mark/store"
+)
+
+func FullText5(db *store.DB, query string) ([]store.Bookmark, error) {
+ bookmarks := []store.Bookmark{}
+
+ rows, err := db.Query(`SELECT url, title, description, tags
+ FROM Bookmarks_fts
+ WHERE Bookmarks_fts MATCH ?
+ ORDER BY bm25(Bookmarks_fts) DESC;`, query)
+ if err != nil {
+ return bookmarks, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var b store.Bookmark
+ var tags string
+ err := rows.Scan(&b.Url, &b.Title, &b.Description, &tags)
+ if err != nil {
+ return bookmarks, err
+ }
+ b.Tags = strings.Split(tags, ", ")
+ bookmarks = append(bookmarks, b)
+ }
+
+ return bookmarks, nil
+}
+
+func PartialText(db *store.DB, query string) ([]store.Bookmark, error) {
+ bookmarks := []store.Bookmark{}
+
+ fuzzy_query := ""
+ for _, field := range strings.Fields(query) {
+ fragment := ""
+ switch field {
+ case "NOT":
+ fragment = field
+ case "OR":
+ fragment = field
+ case "AND":
+ fragment = field
+ default:
+ fragment = field + "*"
+
+ }
+ fuzzy_query += " " + fragment
+ }
+
+ rows, err := db.Query(`SELECT
+ url,
+ title,
+ description,
+ rowid,
+ tags
+ FROM Bookmarks_fts
+ WHERE Bookmarks_fts MATCH ?
+ ORDER BY bm25(Bookmarks_fts) DESC;`,
+ fuzzy_query)
+
+ if err != nil {
+ return bookmarks, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var b store.Bookmark
+ var tags string
+ var id int
+ err := rows.Scan(&b.Url, &b.Title, &b.Description, &id, &tags)
+ if err != nil {
+ return bookmarks, err
+ }
+ b.Tags = strings.Split(tags, ", ")
+ bookmarks = append(bookmarks, b)
+ }
+
+ return bookmarks, nil
+}
diff --git a/search/merge.go b/search/merge.go
new file mode 100644
index 0000000..6983b0c
--- /dev/null
+++ b/search/merge.go
@@ -0,0 +1,83 @@
+package search
+
+import (
+ "sort"
+
+ "github.com/lukasmwerner/mark/store"
+)
+
+type Source string
+
+const (
+ FTS = Source("FTS")
+ PartialFTS = Source("Partial FTS")
+ Embedding = Source("Embedding")
+)
+
+func MergeResults(sources []Source, results ...[]store.Bookmark) []Result {
+ type rankedBookmark struct {
+ bookmark store.Bookmark
+ score float64
+ bestRank int
+ firstSet int
+ source Source
+ }
+
+ merged := map[string]*rankedBookmark{}
+ for setIndex, resultSet := range results {
+ for rank, bm := range resultSet {
+ key := bm.Url
+ if key == "" {
+ key = bm.Title
+ }
+
+ // Reciprocal rank fusion: bookmarks that rank highly in one or more
+ // result sets bubble toward the front of the merged list.
+ score := 1.0 / float64(rank+1)
+ if existing, ok := merged[key]; ok {
+ existing.score += score
+ if rank < existing.bestRank {
+ existing.bestRank = rank
+ }
+ continue
+ }
+
+ merged[key] = &rankedBookmark{
+ bookmark: bm,
+ score: score,
+ bestRank: rank,
+ firstSet: setIndex,
+ source: sources[setIndex],
+ }
+ }
+ }
+
+ outputResults := make([]rankedBookmark, 0, len(merged))
+ for _, bm := range merged {
+ outputResults = append(outputResults, *bm)
+ }
+
+ sort.SliceStable(outputResults, func(i, j int) bool {
+ if outputResults[i].score != outputResults[j].score {
+ return outputResults[i].score > outputResults[j].score
+ }
+ if outputResults[i].bestRank != outputResults[j].bestRank {
+ return outputResults[i].bestRank < outputResults[j].bestRank
+ }
+ if outputResults[i].firstSet != outputResults[j].firstSet {
+ return outputResults[i].firstSet < outputResults[j].firstSet
+ }
+ return outputResults[i].bookmark.Url < outputResults[j].bookmark.Url
+ })
+
+ bookmarks := make([]Result, len(outputResults))
+ for i, bm := range outputResults {
+ bookmarks[i] = Result{
+ Bookmark: bm.bookmark,
+ Source: bm.source,
+ Rank: bm.score,
+ }
+ }
+
+ return bookmarks
+}
diff --git a/search/search.go b/search/search.go
new file mode 100644
index 0000000..4d9e4fe
--- /dev/null
+++ b/search/search.go
@@ -0,0 +1,24 @@
+package search
+
+import "github.com/lukasmwerner/mark/store"
+
+func Search(db *store.DB, query string) ([]Result, error) {
+ results := []Result{}
+ // TODO: make these searches concurrent?
+ fts5, err := FullText5(db, query)
+ if err != nil {
+ return results, err
+ }
+ partials, err := PartialText(db, query)
+ if err != nil {
+ return results, err
+ }
+ semantic, err := Semantic(db, query)
+ if err != nil {
+ return results, err
+ }
+
+ results = MergeResults([]Source{FTS, Embedding, PartialFTS}, fts5, semantic, partials)
+
+ return results, nil
+}
diff --git a/search/semantic.go b/search/semantic.go
new file mode 100644
index 0000000..499d4f3
--- /dev/null
+++ b/search/semantic.go
@@ -0,0 +1,33 @@
+package search
+
+import (
+ "strings"
+
+ "github.com/lukasmwerner/mark/store"
+)
+
+func Semantic(db *store.DB, query string) ([]store.Bookmark, error) {
+ bookmarks := []store.Bookmark{}
+ rows, err := db.Query(`SELECT b.url, b.title, b.description, b.tags
+ FROM bookmark_embeddings e
+ JOIN Bookmarks b ON e.document_id = b.id
+ WHERE e.embedding MATCH embed('embeddinggemma', concat_ws(' ', 'task: search result | query: ', ?)) and k = 100 and distance <= 1.21
+ ORDER BY distance;`, query)
+ if err != nil {
+ return bookmarks, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var b store.Bookmark
+ var tags string
+ err := rows.Scan(&b.Url, &b.Title, &b.Description, &tags)
+ if err != nil {
+ return bookmarks, err
+ }
+ b.Tags = strings.Split(tags, ", ")
+ bookmarks = append(bookmarks, b)
+ }
+
+ return bookmarks, nil
+}
diff --git a/store/database.go b/store/database.go
index 18bc6bd..54161ac 100644
--- a/store/database.go
+++ b/store/database.go
@@ -304,6 +304,32 @@ func InsertBookmark(db *DB, bookmark Bookmark) (BookmarkId, error) {
return BookmarkId(id), err
}
+func SearchBookmarks(db *DB, query string) ([]Bookmark, error) {
+ bookmarks := []Bookmark{}
+
+ rows, err := db.Query(`SELECT url, title, description, tags
+ FROM Bookmarks_fts
+ WHERE Bookmarks_fts MATCH ?
+ ORDER BY bm25(Bookmarks_fts) DESC;`, query)
+ if err != nil {
+ return bookmarks, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var b Bookmark
+ var tags string
+ err := rows.Scan(&b.Url, &b.Title, &b.Description, &tags)
+ if err != nil {
+ return bookmarks, err
+ }
+ b.Tags = strings.Split(tags, ", ")
+ bookmarks = append(bookmarks, b)
+ }
+
+ return bookmarks, nil
+}
+
func GetBookmark(db *DB, query_url string) (Bookmark, error) {
var b Bookmark
var tags string
@@ -354,39 +380,12 @@ func GetBookmark(db *DB, query_url string) (Bookmark, error) {
return b, nil
}
-func SemanticSearchBookmarks(db *DB, query string) ([]Bookmark, error) {
- bookmarks := []Bookmark{}
- rows, err := db.Query(`SELECT b.url, b.title, b.description, b.tags
- FROM bookmark_embeddings e
- JOIN Bookmarks b ON e.document_id = b.id
- WHERE e.embedding MATCH embed('embeddinggemma', concat_ws(' ', 'task: search result | query: ', ?)) and k = 100 and distance <= 1.21
- ORDER BY distance;`, query)
- if err != nil {
- return bookmarks, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var b Bookmark
- var tags string
- err := rows.Scan(&b.Url, &b.Title, &b.Description, &tags)
- if err != nil {
- return bookmarks, err
- }
- b.Tags = strings.Split(tags, ", ")
- bookmarks = append(bookmarks, b)
- }
+func GetBookmarks(db *DB) ([]Bookmark, error) {
- return bookmarks, nil
-}
-
-func SearchBookmarks(db *DB, query string) ([]Bookmark, error) {
bookmarks := []Bookmark{}
- query = strings.Join(strings.Fields(query), "* ") + "*"
- rows, err := db.Query(`SELECT url, title, description, tags
- FROM Bookmarks_fts
- WHERE Bookmarks_fts MATCH ?
- ORDER BY bm25(Bookmarks_fts) DESC;`, query)
+ rows, err := db.Query(`SELECT url, title, description, tags
+ FROM Bookmarks
+ ORDER BY rowid DESC;`)
if err != nil {
return bookmarks, err
}
diff --git a/web/results.templ b/web/results.templ
index c51f15a..85c9117 100644
--- a/web/results.templ
+++ b/web/results.templ
@@ -1,7 +1,7 @@
package web
import (
- "github.com/lukasmwerner/mark/store"
+ "github.com/lukasmwerner/mark/search"
"net/url"
)
@@ -14,7 +14,7 @@ func DomainOnly(u string) string {
}
-templ ResultsPage(markHostname string, query string, orderBy string, results []store.Bookmark) {
+templ ResultsPage(markHostname string, query string, orderBy string, results []search.Result) {
@Page() {
@Head(query + " - mark: " + markHostname)
@Body() {
@@ -52,6 +52,7 @@ templ ResultsPage(markHostname string, query string, orderBy string, results []s
{ result.Title }
{ DomainOnly(result.Url) }
+ { result.Source }
{ result.Description }
} diff --git a/web/static/style.css b/web/static/style.css index 0d6dd6e..1732c9a 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -137,6 +137,18 @@ a:visited { margin-bottom: 0.5em; display: inline-flex; } + .algorithm { + color: #D3C6AA; + background: #3D484D; + border-radius: 50px; + font-size: 0.9rem; + padding: 0.5em; + padding-left: 1em; + padding-right: 1em; + margin-top: 0.5em; + margin-bottom: 0.5em; + display: inline-flex; + } p { color: #C4C0B9; margin-top: 0.5em