Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d1333b2
hybrid semantic search: embeddings + KNN + RRF fusion (#1)
arreyder Jun 10, 2026
ce9c731
embed: truncate input to model context limit (EMBED_MAX_CHARS, defaul…
arreyder Jun 10, 2026
8dffd4c
knn: force defType=lucene so {!knn} isn't parsed as edismax text
arreyder Jun 10, 2026
e5e5173
fuse: NumFound reflects returned docs (semantic-only hits aren't in l…
arreyder Jun 10, 2026
5294b3a
embed: nomic task prefixes (search_document/search_query) for asymmet…
arreyder Jun 10, 2026
e4c62ec
semantic search: upgrade to mxbai-embed-large (1024d) via new embeddi…
arreyder Jun 10, 2026
5020757
revert to nomic-embed-text: mxbai's 512-tok context truncated our cor…
arreyder Jun 10, 2026
fa38b5a
fix: stop EnsureCollection from destroying a corrupt core
arreyder Aug 13, 2026
bb782b2
fix(parser): resolve physical line numbers, not //line-adjusted ones
arreyder Aug 17, 2026
1a025b3
feat: add isolated OMP session archive core
arreyder Sep 1, 2026
fe3a7ab
fix: mount Solr config for session archive core
arreyder Sep 1, 2026
a266476
fix: expose archive configset to Solr home
arreyder Sep 1, 2026
4525030
fix: enable Solr update log for archive core
arreyder Sep 1, 2026
8e8ad83
fix: add archive core default query field
arreyder Sep 1, 2026
048f70a
fix: satisfy archive core query configuration
arreyder Sep 1, 2026
c15a7c6
fix: satisfy archive core facet defaults
arreyder Sep 1, 2026
f657388
fix: complete archive core compatibility fields
arreyder Sep 1, 2026
4213b11
feat: expose isolated session archive search
arreyder Sep 1, 2026
d1e0889
fix: route archive MCP tool to session core
arreyder Sep 1, 2026
21189d8
chore: log Solr MCP tool registration
arreyder Sep 2, 2026
99b73f3
test: cover archive MCP registration
arreyder Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,55 @@ The indexer manages its own clones in `~/solr-mem-repos/` and polls for new comm

Logs: `/tmp/solr-mem-server.log` and `/tmp/solr-mem-indexer.log`

## Recovering a corrupt core

An unclean shutdown (host sleep, `colima stop`, OOM kill) can truncate segment
files mid-flush and leave a core that will not load. Solr reports it as
`SolrCore 'code' is not available due to init failure: Error opening new
searcher`, and `admin/cores?action=STATUS` lists the core under `initFailures`.

**Do not run CoreAdmin CREATE against a core in this state.** Solr deletes the
`core.properties` of a failed CREATE, which unregisters the core and orphans a
perfectly recoverable index on disk. `EnsureCollection` checks STATUS and
refuses for this reason; a hand-run `curl` has no such guard.

Diagnose which segments are broken (read-only, safe to run anytime):

```bash
docker exec solr-mem bash -lc 'cd /opt/solr && java \
-cp "server/solr-webapp/webapp/WEB-INF/lib/*:server/lib/ext/*" \
org.apache.lucene.index.CheckIndex /var/solr/data/code/data/index -fast'
```

If it reports broken segments, drop them. This loses only the documents in
those segments — the indexer re-adds them on its next pass:

```bash
# Stop writers first so nothing holds the index lock.
launchctl bootout gui/$UID/com.solr-mem.indexer

docker exec solr-mem bash -lc 'cd /opt/solr && java \
-cp "server/solr-webapp/webapp/WEB-INF/lib/*:server/lib/ext/*" \
org.apache.lucene.index.CheckIndex /var/solr/data/code/data/index -exorcise'

# Re-register the core. instanceDir has its own conf/, so pass no configSet.
curl "http://localhost:8983/solr/admin/cores?action=CREATE&name=code&instanceDir=code"

launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.solr-mem.indexer.plist
```

Back up the memories core before any repair work — it is the only collection
that cannot be rebuilt from source:

```bash
curl "http://localhost:8983/solr/memories/replication?command=backup&location=/var/solr/data&name=safety"
```

Note that a core's config lives in its own `instanceDir/conf`, copied there
once at creation. Editing `solr/*.xml` in this repo does **not** reach an
existing core — copy the files in and reload the core, or the core keeps
running the config it was born with.

## Architecture

```
Expand Down
88 changes: 88 additions & 0 deletions cmd/solr-mem-backfill/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Command solr-mem-backfill embeds existing memories so semantic search can
// find them. It re-embeds every memory (idempotent) — title + content via the
// configured embedder — and atomically sets the `embedding` field.
//
// Env: SOLR_URL (default http://localhost:8983/solr/memories), EMBED_URL,
// EMBED_MODEL, EMBED_DIM (same as the server).
package main

import (
"context"
"flag"
"log"
"os"
"strings"
"time"

"github.com/arreyder/solr-mem/internal/embed"
"github.com/arreyder/solr-mem/internal/solr"
)

func main() {
batch := flag.Int("batch", 100, "docs per page / update batch")
flag.Parse()

solrURL := os.Getenv("SOLR_URL")
if solrURL == "" {
solrURL = "http://localhost:8983/solr/memories"
}
client := solr.NewClient(solrURL)

emb := embed.FromEnv()
if !emb.Enabled() {
log.Fatal("EMBED_URL not set — nothing to backfill (embeddings disabled)")
}

ctx := context.Background()
start := 0
total, embedded, failed := 0, 0, 0

for {
resp, err := client.Query(ctx, solr.QueryParams{
Query: "*:*",
Rows: *batch,
Start: start,
Sort: "id asc", // stable paging
Fields: []string{"id", "title", "content"},
Highlight: false,
})
if err != nil {
log.Fatalf("query at start=%d: %v", start, err)
}
if len(resp.Docs) == 0 {
break
}

var updates []map[string]any
for _, d := range resp.Docs {
total++
id, _ := d["id"].(string)
title, _ := d["title"].(string)
content, _ := d["content"].(string)
text := strings.TrimSpace(title + "\n\n" + content)
if id == "" || text == "" {
continue
}
vec, err := emb.EmbedDocument(ctx, text)
if err != nil || len(vec) == 0 {
log.Printf("embed failed id=%s: %v", id, err)
failed++
continue
}
updates = append(updates, map[string]any{
"id": id,
"embedding": map[string]any{"set": vec},
})
embedded++
}

if err := client.BulkUpdate(ctx, updates); err != nil {
log.Fatalf("bulk update at start=%d: %v", start, err)
}
log.Printf("progress: %d seen, %d embedded, %d failed", total, embedded, failed)
start += *batch
time.Sleep(50 * time.Millisecond) // be gentle on the embed service
}

log.Printf("DONE: %d memories, %d embedded, %d failed", total, embedded, failed)
}
1 change: 1 addition & 0 deletions cmd/solr-mem-server/bulk_store_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func bulkStoreMemoriesTool(ctx context.Context, args map[string]any) (any, error
SessionID: getString(m, "session_id"),
RelatedIDs: getStringSlice(m, "related_ids"),
Format: format,
Embedding: embedMemoryText(ctx, scrubbedTitle, scrubbedContent),
})
}

Expand Down
47 changes: 47 additions & 0 deletions cmd/solr-mem-server/embedding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"context"
"fmt"
"log"
"strings"

"github.com/arreyder/solr-mem/internal/solr"
)

// embedMemoryText embeds a memory's semantic text (title + content). Returns
// nil when embeddings are disabled or on error, so store/update degrade to
// lexical-only instead of failing the write.
func embedMemoryText(ctx context.Context, title, content string) []float32 {
if !embedder.Enabled() {
return nil
}
text := strings.TrimSpace(title + "\n\n" + content)
if text == "" {
return nil
}
vec, err := embedder.EmbedDocument(ctx, text)
if err != nil {
log.Printf("embedding failed (proceeding without vector): %v", err)
return nil
}
return vec
}

// currentTitleContent fetches a memory's stored title and content. Used when
// re-embedding on update where only one of the two was supplied, so the vector
// still reflects both fields.
func currentTitleContent(ctx context.Context, id string) (title, content string) {
resp, err := solrClient.Query(ctx, solr.QueryParams{
Query: fmt.Sprintf("id:%q", id),
Rows: 1,
Fields: []string{"title", "content"},
Highlight: false,
})
if err != nil || resp == nil || len(resp.Docs) == 0 {
return "", ""
}
title, _ = resp.Docs[0]["title"].(string)
content, _ = resp.Docs[0]["content"].(string)
return title, content
}
84 changes: 84 additions & 0 deletions cmd/solr-mem-server/fuse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"sort"

"github.com/arreyder/solr-mem/internal/solr"
)

// rrfK is the reciprocal-rank-fusion constant. 60 is the widely-used default
// from the original RRF paper; it damps the influence of any single ranker's
// top positions so the two lists combine smoothly.
const rrfK = 60

func docID(d map[string]any) string {
s, _ := d["id"].(string)
return s
}

// fuseResponses combines lexical and semantic (KNN) result lists with
// reciprocal rank fusion: score(d) = Σ 1/(rrfK + rank) across the lists it
// appears in. Returns a response with docs ordered by fused score (desc),
// de-duplicated by id and capped to limit, with highlighting merged from both.
// Ties break by lexical order first, then semantic — deterministic regardless
// of map iteration.
func fuseResponses(lexical, semantic *solr.QueryResponse, limit int) *solr.QueryResponse {
scores := map[string]float64{}
docByID := map[string]map[string]any{}
var order []string // deterministic seed order: lexical first, then semantic-only
seen := map[string]bool{}

accumulate := func(resp *solr.QueryResponse) {
if resp == nil {
return
}
for rank, d := range resp.Docs {
id := docID(d)
if id == "" {
continue
}
scores[id] += 1.0 / float64(rrfK+rank+1) // rank is 0-based
if !seen[id] {
seen[id] = true
order = append(order, id)
docByID[id] = d
}
}
}
accumulate(lexical)
accumulate(semantic)

sort.SliceStable(order, func(i, j int) bool {
return scores[order[i]] > scores[order[j]]
})
if limit > 0 && len(order) > limit {
order = order[:limit]
}

docs := make([]map[string]any, 0, len(order))
for _, id := range order {
docs = append(docs, docByID[id])
}

hl := map[string]map[string][]string{}
for _, resp := range []*solr.QueryResponse{lexical, semantic} {
if resp == nil {
continue
}
for k, v := range resp.Highlighting {
hl[k] = v
}
}

out := &solr.QueryResponse{Docs: docs, Highlighting: hl}
if lexical != nil {
out.Facets = lexical.Facets
out.NumFound = lexical.NumFound
}
// Don't report fewer than we're actually returning — semantic-only hits
// aren't counted in the lexical NumFound.
if len(docs) > out.NumFound {
out.NumFound = len(docs)
}
return out
}
61 changes: 61 additions & 0 deletions cmd/solr-mem-server/fuse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"testing"

"github.com/arreyder/solr-mem/internal/solr"
)

func resp(ids ...string) *solr.QueryResponse {
docs := make([]map[string]any, len(ids))
for i, id := range ids {
docs[i] = map[string]any{"id": id}
}
return &solr.QueryResponse{NumFound: len(ids), Docs: docs}
}

func order(r *solr.QueryResponse) []string {
out := make([]string, len(r.Docs))
for i, d := range r.Docs {
out[i] = docID(d)
}
return out
}

func TestFuseResponses_RRF(t *testing.T) {
// b ranks high in both lists -> should win. d is semantic-only -> still included.
lexical := resp("a", "b", "c")
semantic := resp("b", "d", "a")

fused := fuseResponses(lexical, semantic, 10)
got := order(fused)

// b: 1/61 + 1/61 (rank0 both) = highest.
if got[0] != "b" {
t.Fatalf("expected 'b' first, got %v", got)
}
// All four unique ids present, deduped.
if len(got) != 4 {
t.Fatalf("expected 4 unique docs, got %v", got)
}
// d (semantic-only) is included.
if !contains(got, "d") {
t.Errorf("semantic-only 'd' missing: %v", got)
}
}

func TestFuseResponses_LimitAndNilSemantic(t *testing.T) {
fused := fuseResponses(resp("a", "b", "c"), nil, 2)
if got := order(fused); len(got) != 2 || got[0] != "a" {
t.Fatalf("limit/nil-semantic: got %v", got)
}
}

func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
Loading