13 KiB
Semantic search
Default-on. A baked GloVe-50d table (~3.8 MB embedded in the binary, top 20k tokens) gives every install hybrid BM25 + vector search out of the box — no flag, no model download, no native dependency. Reciprocal Rank Fusion blends the two channels, and the BM25↔vector balance is scored continuously from the query's shape (identifier density, separators, stopwords) rather than bucketed into a discrete class — so a half-identifier query lands between the symbol and natural-language blends instead of jumping a whole tier. After ranking, an optional pure-cosine refinement pass re-scores the top results with the exact embedding distance the rank-based fusion discards.
Configuration
Switch or tune providers in .gortex.yaml:
embedding:
enabled: true # default — pass `false` to disable
provider: static # static | local | api (default: static)
variant: "" # optional named local model (e.g. a Hugot variant); empty = the provider default
api_url: http://localhost:11434
api_model: nomic-embed-text
chunk_threshold_lines: 60 # symbols longer than this get split
chunk_window_lines: 40 # AST-aware window size
api_concurrency: 4 # bounded worker pool for hosted providers
Selecting a different embedding model (variant, or GORTEX_EMBEDDINGS_VARIANT) that changes vector dimensionality re-embeds the graph on next index; the persisted index guards against a dimension mismatch.
Where the config lives — and what wins
The embedding: block can sit in more than one place; precedence, highest first:
--embeddings/--embeddings-url/--embeddings-modelflags and theGORTEX_EMBEDDINGS*env vars — one-shot overrides. A URL forces theapiprovider;GORTEX_EMBEDDINGS=0/1toggles the vector channel;GORTEX_EMBEDDINGS_VARIANTpins a local model.- Repo-local
.gortex.yaml— the per-projectembedding:block (loaded by viper, soGORTEX_EMBEDDING_*env keys also merge here). - Global
~/.gortex/config.yaml— a user-levelembedding:block layered under the repo-local one: every field the repo leaves unset inherits the global value (the tri-stateenabled:too), so one block can serve every repo. An unrecognised top-level key in this file is ignored with a startup warning (contains keys gortex does not recognize) rather than silently dropped — the usual cause of anembedding:block that "does nothing" is placing it under the wrong key here.
| Provider | Quality | Offline | Native deps | Notes |
|---|---|---|---|---|
static (default) |
Good for identifier-shaped queries | Yes | None | Baked GloVe-50d table, CPU-only, zero setup |
local (Hugot MiniLM-L6-v2) |
Better for NL queries | After first run | None | Auto-downloads ~90 MB to ~/.gortex/models/ |
api (Ollama / OpenAI) |
Best | No | None | Bounded concurrent worker pool — tune via api_concurrency |
AST sub-chunking
Symbols longer than chunk_threshold_lines are split into AST-aware windows (block statements, case clauses, field groups) before embedding; each window is vectorised independently and de-duplicated back to the parent symbol at query time, so a large function lands as one hit grounded in the specific chunk that matched — chunk IDs never leak into results.
Input truncation
Every embedding input is capped at the model's positional budget — max_position_embeddings from the model's config.json minus the two special-token slots (510 for MiniLM/BERT; larger for wide-context variants) — before it reaches inference. A transformer cannot attend past that window, so trimming the tail is lossless by construction, and it is load-bearing: the pure-Go tokenizer path does not enforce the limit itself, so a single over-budget input would otherwise reach inference at full length and abort the entire vector-index build with a tensor shape mismatch — dropping the daemon to text-only search. AST chunking already splits long symbols into sub-budget windows, so truncation only ever trims a pathological single chunk. If the model directory lacks a readable config.json the budget falls back to 510; if the tokenizer itself can't load, truncation degrades to a rune clamp rather than disabling the backend.
Persistent index
The vector index and the chunk → symbol map are persisted in the daemon snapshot; restarts re-warm in milliseconds without re-embedding the graph. Daemon snapshot schema is forward-compatible — older snapshots load with an empty vector layer and rebuild incrementally.
Vocabulary bridging without an LLM
A curated equivalence table (auth ↔ authentication ↔ login, delete ↔ remove ↔ destroy, …) plus per-repo auto-concept mining from symbol-name token co-occurrence expands queries deterministically — runs alongside (and dedup against) any LLM expansion. Toggle via search.equivalence_classes. A weighted concept-relatedness layer sits on top of the flat classes (e.g. auth pulls in token / session at lower priority) without merging the distinct concepts. When an LLM expander is configured, vocab_anchored: true constrains its invented terms to tokens that actually occur in the repo's symbol vocabulary.
HITS reranking
A hubs-and-authorities pass over the reference/call graph contributes a hits signal to the rerank pipeline — heavily-referenced symbols outrank shallow utility nodes, and the hub penalty (authority / (1 + hub)) demotes called-by-everything infra so it doesn't drown the result page.
Edge-provenance attenuation
Centrality (HITS + PageRank) and a dedicated rerank signal weight call/reference edges by how they were resolved: the abundant LSP-dispatch / framework-wiring tier — and the weak name-only tier — are attenuated relative to the structurally-unambiguous tier. Dense LSP enrichment otherwise inflates the apparent centrality of utility and framework code over genuine domain authorities. The weighting is a no-op on graphs with no resolution provenance recorded, so it never changes ranking where the data is absent.
Other rerank refinements
- Generated-file demotion — a generated file (
*.pb.go,mock_*.go,*_pb2.py, …) is ranked below a real same-named hand-written implementation, but only when one exists. - Source over test — when a query surfaces both an implementation and its test, the implementation is lifted above the test (only when both co-occur, so it never shifts the rest of the page).
Sparse sub-word tokenization (opt-in)
An optional tokenizer stage emits sub-word n-grams whose split points come from a per-repo boundary table learned from symbol names at index time, trading exact-identifier precision for recall on typo/fragment queries. Off by default (it is reindex-required and precision-sensitive); enable with GORTEX_SPARSE_NGRAM=1. Applies to the BM25 backend.
Keyword-soup defense
Boolean / OR-soup queries (A OR B OR 'no access' OR …) — and operator-free keyword lists (parse decode unmarshal token jwt cache) and comma-enumerations — defeat embedding retrieval. The query classifier detects all three, skips wasted LLM expansion, and splits the soup into terms fused via the existing BM25 expansion path; a query_advice nudge rides on the response. Genuine natural-language questions stay classified as concept. Tune via search.keyword_soup_rewrite: split | nudge | off.
Prose corpus
Markdown headings + section bodies become first-class searchable nodes (KindDoc) — search_symbols corpus: "docs" returns ranked README / ADR / design-doc sections; corpus: "all" mixes them with code hits. A docs query runs its own retrieval channel (a parallel doc-biased fetch, not merely a post-filter over the code fetch) and applies a prose weight profile that suppresses code-structural rerank signals (API/type-signature, definition-bias) which are meaningless for prose. Section node IDs are derived from the heading path, so incremental reindex of a touched markdown file produces stable IDs.
Per-keyword TaskMemory
The combo store now keys symbol associations both on the whole query and per keyword, so a new task with similar keywords inherits learned ranking from prior searches even when the exact phrasing differs. Exact-query matches still dominate; per-keyword evidence is the lower-confidence generalisation.
Build-tag backends
Opt-in faster local backends via build tags:
go build -tags embeddings_onnx ./cmd/gortex/ # needs: brew install onnxruntime
go build -tags "embeddings_gomlx XLA" ./cmd/gortex/ # needs libtokenizers.a on the linker path — use `make build-gomlx` (see below)
The embeddings_onnx backend (GTE-small) never auto-downloads: place model.onnx and vocab.txt in ~/.gortex/models/gte-small/ yourself and install the ONNX Runtime native library (brew install onnxruntime, or the distro equivalent). Without both, the backend reports "ONNX model not found" and the local chain falls through to the pure-Go Hugot backend.
The GoMLX/XLA backend requires both tags — embeddings_gomlx alone links a disabled XLA stub and always falls through to the pure-Go backend; the XLA tag is what compiles the real XLA session. It also statically links the rust tokenizer, so the build needs libtokenizers.a on the linker path (a prebuilt archive from daulet/tokenizers releases, at /usr/lib or /usr/local/lib); make build-gomlx downloads it for you. At runtime the XLA/PJRT plugin auto-downloads (~100 MB). XLA/PJRT runtime viability is platform-dependent and still experimental — if the plugin fails to load, the local chain degrades to the pure-Go Hugot backend and the startup log names the failed backend (see Troubleshooting). The default pure-Go backend needs no tags and no native libraries, and is the reliable path.
| Build tag | Backend | Model | Extra dependency | Status |
|---|---|---|---|---|
| (none) | Hugot pure-Go | MiniLM-L6-v2 (auto-download) | none | default — reliable path |
embeddings_gomlx XLA |
Hugot + XLA/GoMLX | MiniLM-L6-v2 (auto-download) | libtokenizers.a (build) + PJRT plugin (runtime download) | experimental — XLA/PJRT runtime is platform-dependent |
embeddings_onnx |
ONNX Runtime | GTE-small (manual placement) | libonnxruntime + hand-placed model | manual setup — never auto-downloads |
The legacy --embeddings / --embeddings-url / --embeddings-model CLI flags and the GORTEX_EMBEDDINGS* env vars still take precedence over the config block — useful for one-shot overrides without editing .gortex.yaml.
Troubleshooting
Semantic search degrading to text-only (BM25 / FTS5) is always logged — match the daemon log line to the cause:
embeddings enabled ... provider: local (hugot/fp32), dim: 384— working as intended: the transformer backend is active at its true width.embeddings enabled ... provider: local → static fallback, dim: 50— alocalconfig could not construct any transformer backend and fell back to static GloVe. The precedingembedding backend unavailable — degraded to static fallbackwarnings name each backend and why (an uncached model with downloads disabled, a missingembeddings_onnxmodel, …). Fix the named backend or accept static; the width and provider name now tell the truth rather than echoing the configured name.vector index aborted on chunk failure— an embedding call failed (API timeout / auth, or an over-long input on a build without truncation) and the whole vector index was dropped to avoid a half-embedded, mis-scoring index. Text search stays live.gortex eval embeddersreports the concrete cause asvector build failed: …instead of a bare "no vector data".vector index built ... dropped: N(N > 0) — N malformed vectors (nil / wrong width) were skipped; thesample_idswarning names the first few. A non-zerodroppedfrom a healthy provider is worth investigating.vector index disabled — embedding text count exceeds threshold— the corpus is larger thanembedding.max_symbols; raise it if you have the memory headroom.~/.gortex/config.yaml contains keys gortex does not recognize— a top-level key (commonly anembedding:block nested one level too deep, or a typo) is being ignored; move it to a recognised key.
search_symbols assist: modes
auto(default) — skips LLM for identifier queries, expands NL querieson— forces expansion + rerankoff— pure BM25deep— adds a body-grounded verification pass; +1.5–4 s; quality is highly model-dependent — unreliable on 3B local models, fine on 7B+ or hosted
See llm.md for provider configuration.