Files
hyperguild/ingestion/internal/mcp/tools_answer.go
Mathias 57462b52ff
All checks were successful
CI / Lint / Test / Vet (push) Successful in 15s
CI / Mirror to GitHub (push) Successful in 3s
feat(brain): hybrid BM25 + pgvector retrieval (opt-in)
Wires nomic-embed-text (iguana ollama) + pgvector on the shared
postgres18 into brain_query / brain_answer via Reciprocal Rank Fusion.
Pure BM25 stays the default; setting BRAIN_PG_DSN and BRAIN_EMBED_URL
together opts in. Setting one without the other is misconfiguration →
exit 1.

New packages:

- internal/embed
  Client.Embed(ctx, text) → []float32 via POST {URL}/api/embed.
  Defaults to nomic-embed-text:latest (768 dim). nil-on-empty-URL so
  callers gate on a single nil check.

- internal/vectorstore
  PGStore wraps a pgxpool against postgres18. Init creates
  brain_embeddings(path PK, vector(768), updated_at) + HNSW cosine
  index idempotently. Upsert / Delete / Search / KnownPaths.
  Sync(brainDir, store, embedder) diffs brain/wiki/ against the store
  and upserts new files / deletes removed ones; StartSync runs it on
  a ticker (default 300s). Integration tests gated by BRAIN_PG_TEST_DSN.

- scripts/brain-embeddings-init.sql
  One-time DBA setup: brain DB, brain_app role, vector extension,
  GRANTs. Idempotent.

Search layer:

- search.QueryOptions gains Vector + Embedder fields.
- QueryContext is the cancellable variant; Query stays for callers.
- When both are set, BM25 (top-N) and pgvector (top-4N) candidates
  merge via Reciprocal Rank Fusion (k=60, Cormack et al. 2009 — no
  tuning knob, robust to scale differences between rankers).
- Vector-only hits are hydrated from disk so callers see uniform
  Result records (path, title, excerpt, wing, hall, score).
- Wing/hall filters still apply to vector candidates via path-prefix.
- On embedder/vector errors the search falls back to BM25 — embedding
  outage degrades quality but doesn't take the brain offline.

MCP wiring:

- mcp.Server.WithHybridRetrieval(v, e) opt-in setter, same shape as
  WithReranker.
- brainQuery and brainAnswer pass the wired vector/embedder through
  to search.QueryContext.

REST:

- POST /backfill-embeddings drives Sync synchronously. Returns
  {added, deleted, errors[]}. 503 when feature is unconfigured.

cmd/server/main.go:

- BRAIN_PG_DSN + BRAIN_EMBED_URL together enable hybrid; one alone
  → exit 1.
- vectorAdapter bridges *PGStore (returns []Hit) to
  search.VectorSearcher (which takes []VectorHit) without either
  package importing the other.
- BRAIN_EMBED_SYNC_INTERVAL (default 300s) controls the background
  Sync ticker.

Backend pivot from Qdrant to pgvector recorded in DECISIONS.md
2026-05-18 (supersedes 2026-04-08): postgres18 already runs in
databases/ ns, Qdrant was never deployed, one engine beats two.

Dependency: github.com/jackc/pgx/v5 — modern, native pgvector via
parametric vector literals.

Tests:
- embed.Client: empty-URL nil, request shape, dimension, upstream
  error propagation, empty-text rejection.
- vectorstore.PGStore: dimension validation (unit); upsert/search/
  KnownPaths (integration, BRAIN_PG_TEST_DSN-gated).
- vectorstore.Sync: adds new files, skips known, deletes
  disappeared, skips _index.md, no-op when nil, collects embedder
  errors.
- search.Query: hybrid promotes vector-only hits via RRF; falls
  back to BM25 on embedder error.

Closes hyperguild#8.
2026-05-18 23:11:25 +02:00

158 lines
4.3 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
)
// rerankResults scores each candidate's excerpt against the query and
// returns up to top results whose score is positive, preserving the
// caller's input order (BM25 rank) within the kept set. The reranker is
// a filter: ties are broken by BM25, not by the reranker's binary score.
func rerankResults(ctx context.Context, rr *reranker.Client, query string, results []search.Result, top int) ([]search.Result, error) {
docs := make([]string, len(results))
for i, r := range results {
docs[i] = r.Excerpt
}
scores, err := rr.Score(ctx, query, docs)
if err != nil {
return nil, err
}
kept := make([]search.Result, 0, top)
for i, r := range results {
if scores[i] > 0 {
kept = append(kept, r)
}
if len(kept) == top {
break
}
}
return kept, nil
}
const (
answerSystemPrompt = `You are a knowledge assistant. Answer the question using ONLY the provided sources.
Cite source file paths inline when referencing specific content.
If the context does not contain enough information to answer, say so clearly.`
classifySystemPrompt = `Classify the document. Respond with JSON only, no markdown fences.
{"type":"...","title":"...","tags":["..."]}
Valid types: spec, plan, decision, note, wiki, log, code, unknown.`
)
type brainAnswerArgs struct {
Query string `json:"query"`
}
func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
if s.answerLLM == nil {
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
}
var a brainAnswerArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Query == "" {
return nil, fmt.Errorf("query is required")
}
// With reranker disabled: BM25 top-10 straight to the LLM.
// With reranker enabled: BM25 top-20 → cross-encoder filter → top-5.
bm25Limit := 10
if s.reranker != nil {
bm25Limit = 20
}
results, err := search.QueryContext(ctx, s.brainDir, search.QueryOptions{
Query: a.Query,
Limit: bm25Limit,
Vector: s.vector,
Embedder: s.embedder,
})
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if s.reranker != nil && len(results) > 0 {
results, err = rerankResults(ctx, s.reranker, a.Query, results, 5)
if err != nil {
return nil, fmt.Errorf("rerank: %w", err)
}
}
if len(results) == 0 {
return json.Marshal(map[string]any{
"answer": "No relevant content found in brain.",
"sources": []string{},
})
}
var sb strings.Builder
sources := make([]string, 0, len(results))
for _, r := range results {
fmt.Fprintf(&sb, "<source path=%q>\n%s\n</source>\n\n", r.Path, r.Excerpt)
sources = append(sources, r.Path)
}
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
if err != nil {
return nil, fmt.Errorf("llm: %w", err)
}
return json.Marshal(map[string]any{
"answer": answer,
"sources": sources,
})
}
type brainClassifyArgs struct {
Text string `json:"text"`
}
type classifyResult struct {
Type string `json:"type"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
func (s *Server) brainClassify(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
if s.answerLLM == nil {
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
}
var a brainClassifyArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Text == "" {
return nil, fmt.Errorf("text is required")
}
text := a.Text
if len(text) > 3000 {
text = text[:3000]
}
raw, err := s.answerLLM(ctx, classifySystemPrompt, text)
if err != nil {
return nil, fmt.Errorf("llm: %w", err)
}
// Strip markdown fences if model adds them despite the instruction.
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var cr classifyResult
if err := json.Unmarshal([]byte(raw), &cr); err != nil {
return nil, fmt.Errorf("parse classify response %q: %w", raw, err)
}
if cr.Tags == nil {
cr.Tags = []string{}
}
return json.Marshal(cr)
}