@dpesch/mantisbt-mcp-server 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,25 @@ This project adheres to [Semantic Versioning](https://semver.org/).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.16.0] – 2026-11-02
15
+
16
+ ### Added
17
+
18
+ - Search benchmark `npm run benchmark:search` (`scripts/benchmark-search.ts`): measures recall@10 of `search_issues` on an existing index for exact titles, first sentences, sentences from deep in long descriptions, rare identifiers and — from `BENCHMARK_PARAPHRASES` — hand-written paraphrases, ranked by vectors only and hybrid. Reads the index only; refuses one built with another embedding signature.
19
+
20
+ ### Changed
21
+
22
+ - Semantic search: `search_issues` now combines the semantic ranking with a keyword ranking (BM25 over summary and description, summary counted twice) by Reciprocal Rank Fusion. Embeddings blur exact terms; a query for an identifier that occurs in at most three issues found its issue among the top 10 in 26 % of the cases, now in 100 % (7,739 issues). Literal queries gain as well — exact titles 97 % → 99.8 %, first sentences 83 % → 99 %, sentences from deep in a long description 58 % → 97 % — while paraphrased queries stay level (38 % → 37 %, 60 queries). `score` is still the semantic similarity, but results are now ordered by the combined ranking, so a keyword match can precede a higher `score`. The keyword index is built in memory on the first search (about 0.1 s and 7 MB for 7,739 issues) and adds about 2 ms per search.
23
+ - Semantic search: a long description is now split into up to 8 chunks of the model's token limit, each embedded together with the summary, and an issue scores by its best chunk. Details deep in a long description become findable (measured without the keyword ranking above): a sentence from the middle of a long description found its issue among the top 10 in 58 % of the cases instead of 13 % (7,739 issues); exact titles 97 % (before 96 %), first sentences 83 % (before 84 %). The index grows to about 1.7 vectors per issue — `index.json` from 67 to 113 MB for 7,739 issues, held in memory by every server process with search enabled. Further chunks are stored in a new optional `chunks` field next to `vector`, so a server of an older version can still read the index.
24
+
25
+ ### Fixed
26
+
27
+ - Semantic search: `search_issues` often missed issues with a longer description, even when queried with their exact title. The default model is trained on 128 tokens, but the text was embedded with up to 512, so the vectors of long issues collapsed into one another and short issues won almost every query. Text is now cut at the model's training length. On an index of 7,930 issues, an exact-title query found the issue among the top 10 in 95 % of the cases instead of 47 %, a query with the first sentence of the description in 84 % instead of 38 %. Text beyond that limit goes into further chunks (see Changed).
28
+ - Semantic search: the index is rebuilt automatically when it was made with another embedding model or token limit, including the first start after this update. Before, changing `MANTIS_SEARCH_MODEL` kept the old vectors, which cannot be compared with the new ones, until a `rebuild_search_index` with `full: true`. The model and token limit are stored in `embedding_signature.txt` in the search directory. The rebuild runs in the background sync of the first server start and takes about 6 minutes for 7,700 issues with the default of 1 thread (see below); until it is done, `search_issues` finds only the issues indexed so far. It also drops entries of issues that were deleted or are no longer visible, which an incremental sync never removes.
29
+ - Semantic search: `MANTIS_SEARCH_THREADS` had no effect since it was introduced in 1.5.1 — the embedding model always used every CPU core. The thread options were passed in snake_case (`intra_op_num_threads`), which onnxruntime silently ignores; it expects `intraOpNumThreads`. The documented default of 1 thread now applies, as intended against CPU saturation on WSL and multi-core machines. Embedding is about three times slower with 1 thread than with all cores; a full rebuild of 7,700 issues takes about 6 minutes instead of 2.5. With `MANTIS_SEARCH_THREADS=4` the rebuild took 160 s instead of 377 s with 1 thread; 4 is a good value on a machine with 4 or more cores.
30
+
31
+ ---
32
+
14
33
  ## [1.15.0] – 2026-10-31
15
34
 
16
35
  ### Deprecated
package/README.de.md CHANGED
@@ -80,8 +80,8 @@ npm run build
80
80
  | `MANTIS_SEARCH_ENABLED` | – | `false` | Auf `true` setzen, um die semantische Suche zu aktivieren |
81
81
  | `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vektorspeicher: `vectra` (reines JS) oder `sqlite-vec` (manuelle Installation erforderlich) |
82
82
  | `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Verzeichnis für den Suchindex |
83
- | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding-Modell (wird beim ersten Start einmalig heruntergeladen, ~80 MB) |
84
- | `MANTIS_SEARCH_THREADS` | – | `1` | Anzahl der ONNX-Intra-Op-Threads für das Embedding-Modell. Standard ist 1, um CPU-Sättigung auf Mehrkernsystemen und in WSL zu verhindern. Nur erhöhen, wenn die Indexierungsgeschwindigkeit kritisch ist und der Host ausschließlich für diese Last vorgesehen ist. |
83
+ | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding-Modell (wird beim ersten Start einmalig heruntergeladen, ~80 MB). Nach einem Wechsel baut der nächste Sync den Index von selbst neu auf. |
84
+ | `MANTIS_SEARCH_THREADS` | – | `1` | Anzahl der ONNX-Intra-Op-Threads für das Embedding-Modell. Standard ist 1, um CPU-Sättigung auf Mehrkernsystemen und in WSL zu verhindern. Auf Rechnern mit 4 oder mehr Kernen ist `4` ein guter Wert: Ein vollständiger Neuaufbau von 7.700 Issues dauerte damit 160 s statt 377 s mit 1 Thread. Die Einstellung beschleunigt vor allem das Indexieren; eine Suchanfrage ist ein einzelner kurzer Text und profitiert kaum. Wirksam ab 1.16.0 – frühere Versionen haben die Einstellung ignoriert und immer alle Kerne genutzt. |
85
85
  | `MANTIS_UPLOAD_DIR` | – | – | Schränkt den `file_path` von `upload_file` auf dieses Verzeichnis ein (Pfad-Traversal via `../` wird blockiert). Im **stdio-Modus** ist `file_path` ohne diese Variable unbeschränkt. Im **HTTP-Modus** liest `file_path` aus dem Dateisystem des Servers und ist deshalb **deaktiviert, sofern diese Variable nicht gesetzt ist** – HTTP-Clients sollten stattdessen den Parameter `content` (Base64) verwenden. |
86
86
  | `MANTIS_TOOLSETS` | – | – (alle) | Kommagetrennte Liste der anzubietenden Tool-Gruppen: `issues`, `notes`, `links`, `files`, `projects`, `versions`, `meta`, `search` (Groß-/Kleinschreibung egal). Nicht gesetzt oder leer bedeutet alle Gruppen. Die Gruppe `search` erfordert zusätzlich `MANTIS_SEARCH_ENABLED=true`; ohne die Gruppe wird das Such-Modul gar nicht gestartet. Ein unbekannter Gruppenname stoppt den Server beim Start mit einer Meldung, die die gültigen Namen auflistet. Siehe [Angebotene Tools einschränken](#angebotene-tools-einschränken) und das [Cookbook](docs/cookbook.de.md#nur-bestimmte-tool-gruppen-anbieten). |
87
87
  | `MANTIS_DISABLE_DESTRUCTIVE` | – | `false` | Auf `true` setzen, um alle destruktiven Tools auszublenden – `delete_issue`, `delete_note`, `delete_version`, `remove_relationship`, `remove_monitor` – unabhängig von den aktiven Gruppen. Ausgeblendete Tools werden nicht aufgelistet und liefern »Tool … not found«, wenn sie aufgerufen werden. |
@@ -214,19 +214,19 @@ Veraltete Tools gehören immer zur gleichen Gruppe wie ihr Ersatz – eine Grupp
214
214
 
215
215
  ### Semantische Suche *(optional)*
216
216
 
217
- Statt einfachem Keyword-Matching versteht die semantische Suche die *Bedeutung* einer Anfrage. Formuliere in natürlicher Sprache – die Suche findet konzeptuell verwandte Issues, auch wenn die genauen Begriffe nicht übereinstimmen:
217
+ Die semantische Suche versteht die *Bedeutung* einer Anfrage, nicht nur ihre Stichwörter. Formuliere in natürlicher Sprache – die Suche findet konzeptuell verwandte Issues, auch wenn die genauen Begriffe nicht übereinstimmen:
218
218
 
219
219
  - *„Login funktioniert nach Passwort-Reset nicht"* – findet Issues rund um Authentifizierungsgrenzfälle
220
220
  - *„Performance-Probleme auf der Checkout-Seite"* – liefert verwandte Meldungen unabhängig von der verwendeten Terminologie
221
221
  - *„doppelte Einträge in der Rechnungsliste"* – erkennt auch Issues, die als „zweifach angezeigt", „dupliziert" o.ä. beschrieben sind
222
222
 
223
- Das Embedding-Modell (~80 MB) läuft vollständig **offline** – kein OpenAI-Key, keine externe API. Es wird beim ersten Start einmalig heruntergeladen und lokal gecacht. Issues werden bei jedem Serverstart inkrementell indexiert (nur neue und geänderte Issues werden neu verarbeitet). Starten mehrere Client-Sitzungen gleichzeitig einen Server, synchronisiert nur einer davon den gemeinsamen Index. Das Modell wird bei der ersten Nutzung geladen und nach 10 Minuten ohne Einbetten wieder entladen, damit ruhende Sitzungen seinen Speicher (~0,9 GB pro Serverprozess) nicht dauerhaft belegen.
223
+ Das Embedding-Modell (~80 MB) läuft vollständig **offline** – kein OpenAI-Key, keine externe API. Es wird beim ersten Start einmalig heruntergeladen und lokal gecacht. Issues werden bei jedem Serverstart inkrementell indexiert (nur neue und geänderte Issues werden neu verarbeitet). Starten mehrere Client-Sitzungen gleichzeitig einen Server, synchronisiert nur einer davon den gemeinsamen Index. Das Modell wird bei der ersten Nutzung geladen und nach 10 Minuten ohne Einbetten wieder entladen, damit ruhende Sitzungen seinen Speicher (~0,9 GB pro Serverprozess) nicht dauerhaft belegen. Lange Beschreibungen werden in bis zu 8 Abschnitte geteilt, damit auch Details weiter unten gefunden werden; der Index enthält dadurch etwa 1,7 Vektoren pro Issue (rund 115 MB bei 7.700 Issues), die jeder Serverprozess mit aktivierter Suche im Speicher hält. Die Rangfolge berücksichtigt zusätzlich exakte Stichworttreffer, sodass eine Suche nach einem Bezeichner, Fehlercode oder einer Versionsnummer die Issues findet, die ihn enthalten.
224
224
 
225
225
  Aktivierung mit `MANTIS_SEARCH_ENABLED=true`.
226
226
 
227
227
  | Tool | Beschreibung |
228
228
  |---|---|
229
- | `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – liefert Top-N-Ergebnisse mit Cosine-Similarity-Score; optionales `select` (kommagetrennte Feldnamen) reichert jedes Ergebnis mit den angeforderten Issue-Feldern an; optionales `highlight` (boolean, Standard `false`) fügt je Ergebnis ein `highlights`-Feld mit keyword-basierten Ausschnitten aus `summary` und `description` hinzu (Treffer werden in `**fett**` dargestellt) |
229
+ | `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – kombiniert semantische Ähnlichkeit mit Stichwortsuche (BM25), damit auch exakte Begriffe wie Bezeichner oder Fehlercodes gefunden werden; liefert Top-N-Ergebnisse mit der Cosine-Similarity als `score`, sortiert nach der kombinierten Rangfolge; optionales `select` (kommagetrennte Feldnamen) reichert jedes Ergebnis mit den angeforderten Issue-Feldern an; optionales `highlight` (boolean, Standard `false`) fügt je Ergebnis ein `highlights`-Feld mit keyword-basierten Ausschnitten aus `summary` und `description` hinzu (Treffer werden in `**fett**` dargestellt) |
230
230
  | `rebuild_search_index` | Suchindex aufbauen oder aktualisieren; `full: true` löscht und baut ihn vollständig neu |
231
231
  | `get_search_index_status` | **Veraltet:** stattdessen `get_metadata` (Feld `search_index`) verwenden – wird in 2.0.0 entfernt |
232
232
 
@@ -341,6 +341,7 @@ npm test # Tests ausführen (vitest)
341
341
  npm run test:watch # Tests im Watch-Modus
342
342
  npm run test:coverage # Coverage-Report
343
343
  npm run benchmark # Token-Benchmark gegen eine Live-MantisBT-Instanz (siehe Token-Verbrauch oben)
344
+ npm run benchmark:search # Such-Trefferquote auf einem bestehenden Index (MANTIS_SEARCH_DIR), Vektor vs. hybrid
344
345
  ```
345
346
 
346
347
  ## Lizenz
package/README.md CHANGED
@@ -80,8 +80,8 @@ npm run build
80
80
  | `MANTIS_SEARCH_ENABLED` | – | `false` | Set to `true` to enable semantic search |
81
81
  | `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vector store backend: `vectra` (pure JS) or `sqlite-vec` (requires manual install) |
82
82
  | `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Directory for the search index |
83
- | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding model name (downloaded once on first use, ~80 MB) |
84
- | `MANTIS_SEARCH_THREADS` | – | `1` | Number of ONNX intra-op threads for the embedding model. Default is 1 to prevent CPU saturation on multi-core machines and WSL. Increase only if index rebuild speed matters and the host is dedicated to this workload. |
83
+ | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding model name (downloaded once on first use, ~80 MB). After a change the index is rebuilt automatically on the next sync. |
84
+ | `MANTIS_SEARCH_THREADS` | – | `1` | Number of ONNX intra-op threads for the embedding model. Default is 1 to prevent CPU saturation on multi-core machines and WSL. On a machine with 4 or more cores, `4` is a good value: a full rebuild of 7,700 issues took 160 s instead of 377 s with 1 thread. The setting mainly speeds up indexing; a search query is a single short text and hardly benefits. Effective since 1.16.0 — earlier versions ignored this setting and always used every core. |
85
85
  | `MANTIS_UPLOAD_DIR` | – | – | Restrict `upload_file`'s `file_path` to files within this directory (path traversal via `../` is blocked). In **stdio mode** `file_path` is unrestricted unless this is set. In **HTTP mode** `file_path` reads from the server's filesystem, so it is **disabled unless this variable is set** — HTTP clients should upload via the `content` (Base64) parameter instead. |
86
86
  | `MANTIS_TOOLSETS` | – | – (all) | Comma-separated list of tool groups to offer: `issues`, `notes`, `links`, `files`, `projects`, `versions`, `meta`, `search` (case-insensitive). Unset or empty means all groups. The `search` group additionally requires `MANTIS_SEARCH_ENABLED=true`; without the group the search module is not started at all. An unknown group name stops the server at startup with a message listing the valid names. See [Limit the offered tools](#limit-the-offered-tools) and the [cookbook](docs/cookbook.md#offer-only-some-tool-groups). |
87
87
  | `MANTIS_DISABLE_DESTRUCTIVE` | – | `false` | Set to `true` to hide every destructive tool — `delete_issue`, `delete_note`, `delete_version`, `remove_relationship`, `remove_monitor` — regardless of the active groups. Hidden tools are not listed and return "Tool … not found" when called. |
@@ -214,19 +214,19 @@ Deprecated tools always belong to the same group as their replacement — disabl
214
214
 
215
215
  ### Semantic search *(optional)*
216
216
 
217
- Instead of exact keyword matching, semantic search understands the *meaning* behind a query. Ask in plain language — the search engine finds conceptually related issues even when the wording doesn't match:
217
+ Semantic search understands the *meaning* behind a query, not just its keywords. Ask in plain language — the search engine finds conceptually related issues even when the wording doesn't match:
218
218
 
219
219
  - *"login fails after password reset"* — finds issues about authentication edge cases
220
220
  - *"performance problems on the checkout page"* — surfaces related reports regardless of the exact terminology used
221
221
  - *"duplicate entries in the invoice list"* — catches issues described as "shown twice", "double records", etc.
222
222
 
223
- The embedding model (~80 MB) runs entirely **offline** — no OpenAI key, no external API. It is downloaded once on first start and cached locally. Issues are indexed incrementally on every server start (only new and updated issues are re-indexed). When several client sessions start a server at the same time, only one of them syncs the shared index. The model is loaded on first use and unloaded again after 10 minutes without embedding, so idle sessions do not hold on to its memory (~0.9 GB per server process).
223
+ The embedding model (~80 MB) runs entirely **offline** — no OpenAI key, no external API. It is downloaded once on first start and cached locally. Issues are indexed incrementally on every server start (only new and updated issues are re-indexed). When several client sessions start a server at the same time, only one of them syncs the shared index. The model is loaded on first use and unloaded again after 10 minutes without embedding, so idle sessions do not hold on to its memory (~0.9 GB per server process). Long descriptions are split into up to 8 chunks so that details further down are found as well; the index then holds about 1.7 vectors per issue (around 115 MB for 7,700 issues), which every server process with search enabled keeps in memory. The ranking also takes exact keyword matches into account, so a query for an identifier, error code or version number finds the issues that contain it.
224
224
 
225
225
  Activate with `MANTIS_SEARCH_ENABLED=true`.
226
226
 
227
227
  | Tool | Description |
228
228
  |---|---|
229
- | `search_issues` | Natural language search over all indexed issues — returns top-N results with cosine similarity score; optional `select` (comma-separated field names) enriches each result with the requested issue fields; optional `highlight` (boolean, default `false`) adds a `highlights` field per result with keyword-matched excerpts from `summary` and `description` (matched terms shown in `**bold**`) |
229
+ | `search_issues` | Natural language search over all indexed issues — combines semantic similarity with keyword matching (BM25), so exact terms such as identifiers or error codes are found as well; returns top-N results with the cosine similarity as `score`, ordered by the combined ranking; optional `select` (comma-separated field names) enriches each result with the requested issue fields; optional `highlight` (boolean, default `false`) adds a `highlights` field per result with keyword-matched excerpts from `summary` and `description` (matched terms shown in `**bold**`) |
230
230
  | `rebuild_search_index` | Build or update the search index; `full: true` clears and rebuilds from scratch |
231
231
  | `get_search_index_status` | **Deprecated:** use `get_metadata` (field `search_index`) instead — will be removed in 2.0.0 |
232
232
 
@@ -341,6 +341,7 @@ npm test # Run tests (vitest)
341
341
  npm run test:watch # Run tests in watch mode
342
342
  npm run test:coverage # Coverage report
343
343
  npm run benchmark # Token benchmark against a live MantisBT (see Token usage above)
344
+ npm run benchmark:search # Search recall on an existing index (MANTIS_SEARCH_DIR), vectors vs. hybrid
344
345
  ```
345
346
 
346
347
  ## License
package/dist/config.js CHANGED
@@ -40,6 +40,7 @@ async function readDotEnvLocal() {
40
40
  // ---------------------------------------------------------------------------
41
41
  // Non-credential config (safe to read at startup without credentials)
42
42
  // ---------------------------------------------------------------------------
43
+ export const DEFAULT_SEARCH_MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
43
44
  function readNonCredentialConfig() {
44
45
  const defaultCacheDir = join(homedir(), '.cache', 'mantisbt-mcp');
45
46
  const cacheDir = process.env.MANTIS_CACHE_DIR ?? defaultCacheDir;
@@ -53,8 +54,7 @@ function readNonCredentialConfig() {
53
54
  }
54
55
  const searchBackend = searchBackendRaw === 'sqlite-vec' ? 'sqlite-vec' : 'vectra';
55
56
  const searchDir = process.env.MANTIS_SEARCH_DIR ?? join(cacheDir, 'search');
56
- const searchModelName = process.env.MANTIS_SEARCH_MODEL ??
57
- 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
57
+ const searchModelName = process.env.MANTIS_SEARCH_MODEL ?? DEFAULT_SEARCH_MODEL;
58
58
  const searchNumThreads = Math.max(1, parseInt(process.env.MANTIS_SEARCH_THREADS ?? '', 10) || 1);
59
59
  return {
60
60
  cacheDir,
@@ -6,18 +6,49 @@
6
6
  // @huggingface/transformers is an optional dependency. The import is
7
7
  // guarded with a try/catch so the server starts without it.
8
8
  // ---------------------------------------------------------------------------
9
+ import { DEFAULT_SEARCH_MODEL } from '../config.js';
10
+ // Sequence length a model was trained on (max_seq_length in its
11
+ // sentence_bert_config.json). transformers.js does not read that file and
12
+ // truncates at the tokenizer's model_max_length instead — 512 for the default
13
+ // model, which is trained on 128. Longer input is out of distribution: the
14
+ // vectors of long issues collapse into one another (#8539). Models not listed
15
+ // keep the tokenizer's limit.
16
+ const MODEL_MAX_TOKENS = {
17
+ [DEFAULT_SEARCH_MODEL]: 128,
18
+ };
19
+ // Vectors per issue for models with a known token limit: the first covers
20
+ // summary and the start of the description, each further one the next part of
21
+ // the description, prefixed with the summary (#8541). 8 chunks reach about
22
+ // 1,000 description tokens and grow the index by a factor of about 1.7.
23
+ const MAX_CHUNKS = 8;
24
+ // Special tokens the tokenizer adds ([CLS], [SEP]) plus the newline.
25
+ const CHUNK_OVERHEAD_TOKENS = 3;
26
+ const MIN_CHUNK_TOKENS = 32;
27
+ // Characters per token kept before tokenizing: tokens average about 4
28
+ // characters, so this keeps well beyond MAX_CHUNKS windows without tokenizing
29
+ // descriptions of 17,000+ tokens in full.
30
+ const MAX_CHARS_PER_TOKEN = 16;
31
+ // Texts per model call; caps the activation memory of a batch.
32
+ const MODEL_BATCH_SIZE = 32;
9
33
  // A loaded model takes ~0.9 GB RAM, and every client session runs its own
10
34
  // server process. An idle process releases the model; the next use reloads it.
11
35
  const IDLE_UNLOAD_MS = 10 * 60 * 1000;
12
36
  export class Embedder {
13
37
  modelName;
14
38
  numThreads;
39
+ /** Identifies how vectors are made; a different signature means the index must be rebuilt. */
40
+ signature;
15
41
  pipe = null;
42
+ maxTokens;
16
43
  running = 0;
17
44
  idleTimer = null;
18
45
  constructor(modelName, numThreads = 1) {
19
46
  this.modelName = modelName;
20
47
  this.numThreads = numThreads;
48
+ this.maxTokens = MODEL_MAX_TOKENS[modelName];
49
+ this.signature = this.maxTokens === undefined
50
+ ? `${modelName}|max_tokens=default`
51
+ : `${modelName}|max_tokens=${this.maxTokens}|chunks=${MAX_CHUNKS}`;
21
52
  }
22
53
  async withPipeline(fn) {
23
54
  this.running++;
@@ -54,56 +85,74 @@ export class Embedder {
54
85
  process.stderr.write(`[mantisbt-search] Loading embedding model ${this.modelName} (threads: ${this.numThreads})...\n`);
55
86
  let transformers;
56
87
  try {
57
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
58
- transformers = (await import('@huggingface/transformers'));
88
+ transformers = await import('@huggingface/transformers');
59
89
  }
60
90
  catch (err) {
61
91
  const msg = err instanceof Error ? err.message : String(err);
62
92
  throw new Error(`Failed to load @huggingface/transformers: ${msg}`);
63
93
  }
94
+ // onnxruntime takes camelCase keys and silently ignores unknown ones
95
+ // (snake_case had no effect until #8540).
64
96
  return transformers.pipeline('feature-extraction', this.modelName, {
65
97
  session_options: {
66
- intra_op_num_threads: this.numThreads,
67
- inter_op_num_threads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
98
+ intraOpNumThreads: this.numThreads,
99
+ interOpNumThreads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
68
100
  },
69
101
  });
70
102
  }
71
- async embed(text) {
72
- return this.withPipeline(async (extractor) => {
73
- const output = await extractor(text, { pooling: 'mean', normalize: true });
74
- return extractVector(output);
103
+ /** One or more vectors per document; see MAX_CHUNKS. */
104
+ async embedDocuments(docs) {
105
+ if (docs.length === 0)
106
+ return [];
107
+ return this.withPipeline(async ({ tokenizer }) => {
108
+ const texts = docs.map(doc => this.chunkTexts(doc, tokenizer));
109
+ const vectors = await this.embedBatch(texts.flat());
110
+ let start = 0;
111
+ return texts.map(chunks => {
112
+ const own = vectors.slice(start, start + chunks.length);
113
+ start += chunks.length;
114
+ return own;
115
+ });
75
116
  });
76
117
  }
118
+ chunkTexts({ summary, description: fullDescription }, tokenizer) {
119
+ const description = this.maxTokens === undefined
120
+ ? fullDescription
121
+ : fullDescription?.slice(0, MAX_CHUNKS * this.maxTokens * MAX_CHARS_PER_TOKEN);
122
+ const texts = [`${summary}\n${description ?? ''}`.trim()];
123
+ if (this.maxTokens === undefined || !description)
124
+ return texts;
125
+ const encode = (text) => tokenizer.encode(text, { add_special_tokens: false });
126
+ const window = Math.max(MIN_CHUNK_TOKENS, this.maxTokens - CHUNK_OVERHEAD_TOKENS - encode(summary).length);
127
+ const tokens = encode(description);
128
+ // The first text already covers about the first window of the description.
129
+ for (let start = window; start < tokens.length && texts.length < MAX_CHUNKS; start += window) {
130
+ const part = tokenizer.decode(tokens.slice(start, start + window), { skip_special_tokens: true });
131
+ texts.push(`${summary}\n${part}`);
132
+ }
133
+ return texts;
134
+ }
135
+ async embed(text) {
136
+ return (await this.embedBatch([text]))[0];
137
+ }
138
+ // Same steps as the feature-extraction pipeline with pooling 'mean' and
139
+ // normalize — but the pipeline offers no way to pass max_length.
77
140
  async embedBatch(texts) {
78
141
  if (texts.length === 0)
79
142
  return [];
80
- return this.withPipeline(async (extractor) => {
81
- const output = await extractor(texts, { pooling: 'mean', normalize: true });
82
- if (Array.isArray(output)) {
83
- return output.map(extractVector);
143
+ return this.withPipeline(async ({ tokenizer, model }) => {
144
+ // Already loaded by load(), so this resolves from the module cache.
145
+ const { mean_pooling } = await import('@huggingface/transformers');
146
+ const vectors = [];
147
+ for (let i = 0; i < texts.length; i += MODEL_BATCH_SIZE) {
148
+ const inputs = tokenizer(texts.slice(i, i + MODEL_BATCH_SIZE), { padding: true, truncation: true, max_length: this.maxTokens });
149
+ const outputs = (await model(inputs));
150
+ const tokenVectors = outputs['last_hidden_state'] ?? outputs['logits'] ?? outputs['token_embeddings'];
151
+ if (!tokenVectors)
152
+ throw new Error('Embedding model returned no token vectors');
153
+ vectors.push(...mean_pooling(tokenVectors, inputs.attention_mask).normalize(2, -1).tolist());
84
154
  }
85
- // Some versions return a single tensor for batch input
86
- return extractBatchVectors(output, texts.length);
155
+ return vectors;
87
156
  });
88
157
  }
89
158
  }
90
- // ---------------------------------------------------------------------------
91
- // Helpers
92
- // ---------------------------------------------------------------------------
93
- function extractVector(output) {
94
- const data = output.data;
95
- return Array.from(data);
96
- }
97
- function extractBatchVectors(output, batchSize) {
98
- const data = Array.from(output.data);
99
- // dims is typically [batchSize, sequenceLen, hiddenSize] or [batchSize, hiddenSize]
100
- const vecSize = data.length / batchSize;
101
- if (!Number.isInteger(vecSize)) {
102
- throw new Error(`Unexpected batch output shape: ${data.length} elements for batch size ${batchSize}`);
103
- }
104
- const result = [];
105
- for (let i = 0; i < batchSize; i++) {
106
- result.push(data.slice(i * vecSize, (i + 1) * vecSize));
107
- }
108
- return result;
109
- }
@@ -0,0 +1,88 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Keyword index (BM25) for hybrid search (#8542)
3
+ //
4
+ // Embeddings capture meaning but blur exact terms: identifiers, error codes,
5
+ // version numbers. search_issues therefore fuses the vector ranking with this
6
+ // keyword ranking. Built in memory from the summaries and descriptions the
7
+ // vector index already stores — no extra API calls.
8
+ // ---------------------------------------------------------------------------
9
+ // Standard BM25 parameters.
10
+ const K1 = 1.2;
11
+ const B = 0.75;
12
+ // A summary term counts twice: titles name what an issue is about.
13
+ const SUMMARY_WEIGHT = 2;
14
+ /** Lowercased words of letters, digits and underscores, at least 2 characters. */
15
+ export function tokenize(text) {
16
+ return (text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter(word => word.length >= 2);
17
+ }
18
+ export class LexicalIndex {
19
+ // Postings in compressed sparse rows: the documents of term t are
20
+ // docs[offsets[t] .. offsets[t + 1]], with their term frequencies in tfs.
21
+ // Typed arrays keep 340,000 postings (7,700 issues) at about 7 MB instead of
22
+ // 30 MB as one object per posting — in every server process.
23
+ termIds = new Map();
24
+ offsets;
25
+ docs;
26
+ tfs;
27
+ ids;
28
+ lengths;
29
+ avgLength;
30
+ constructor(documents) {
31
+ this.ids = Uint32Array.from(documents, doc => doc.id);
32
+ this.lengths = new Uint32Array(documents.length);
33
+ const counts = documents.map((doc, index) => {
34
+ const terms = new Map();
35
+ const add = (text, weight) => {
36
+ for (const term of tokenize(text ?? '')) {
37
+ let id = this.termIds.get(term);
38
+ if (id === undefined)
39
+ this.termIds.set(term, (id = this.termIds.size));
40
+ terms.set(id, (terms.get(id) ?? 0) + weight);
41
+ }
42
+ };
43
+ add(doc.summary, SUMMARY_WEIGHT);
44
+ add(doc.description, 1);
45
+ for (const tf of terms.values())
46
+ this.lengths[index] += tf;
47
+ return terms;
48
+ });
49
+ const df = new Uint32Array(this.termIds.size);
50
+ for (const terms of counts)
51
+ for (const term of terms.keys())
52
+ df[term]++;
53
+ this.offsets = new Uint32Array(df.length + 1);
54
+ for (let t = 0; t < df.length; t++)
55
+ this.offsets[t + 1] = this.offsets[t] + df[t];
56
+ this.docs = new Uint32Array(this.offsets[df.length]);
57
+ this.tfs = new Uint16Array(this.docs.length);
58
+ const cursor = this.offsets.slice(0, df.length);
59
+ counts.forEach((terms, doc) => {
60
+ for (const [term, tf] of terms) {
61
+ const at = cursor[term]++;
62
+ this.docs[at] = doc;
63
+ this.tfs[at] = Math.min(tf, 0xffff);
64
+ }
65
+ });
66
+ this.avgLength = this.lengths.reduce((sum, length) => sum + length, 0) / (documents.length || 1);
67
+ }
68
+ /** BM25 score per document id; documents without a query term are absent. */
69
+ scores(query) {
70
+ const byDoc = new Map();
71
+ const total = this.ids.length;
72
+ for (const word of new Set(tokenize(query))) {
73
+ const term = this.termIds.get(word);
74
+ if (term === undefined)
75
+ continue;
76
+ const start = this.offsets[term];
77
+ const end = this.offsets[term + 1];
78
+ const idf = Math.log(1 + (total - (end - start) + 0.5) / (end - start + 0.5));
79
+ for (let at = start; at < end; at++) {
80
+ const doc = this.docs[at];
81
+ const tf = this.tfs[at];
82
+ const norm = K1 * (1 - B + B * this.lengths[doc] / this.avgLength);
83
+ byDoc.set(doc, (byDoc.get(doc) ?? 0) + idf * tf * (K1 + 1) / (tf + norm));
84
+ }
85
+ }
86
+ return new Map([...byDoc].map(([doc, score]) => [this.ids[doc], score]));
87
+ }
88
+ }
@@ -1,13 +1,20 @@
1
1
  import { readFile, writeFile, rename, mkdir, unlink, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { LexicalIndex } from './lexical.js';
3
4
  // ---------------------------------------------------------------------------
4
5
  // VectraStore
5
6
  // ---------------------------------------------------------------------------
7
+ // Reciprocal Rank Fusion constant. Measured on 7,739 issues (#8542): 10 keeps
8
+ // literal queries (titles, sentences, identifiers) at 97–100 % recall@10 and
9
+ // paraphrased ones level with vector-only search; the common 60 lost up to
10
+ // 8 points on literal queries.
11
+ const RRF_K = 10;
6
12
  export class VectraStore {
7
13
  dir;
8
14
  vectraDir;
9
15
  lastSyncFile;
10
16
  lastTotalFile;
17
+ signatureFile;
11
18
  items = new Map();
12
19
  loaded = false;
13
20
  // Version of index.json the in-memory items correspond to (null: no file).
@@ -16,11 +23,15 @@ export class VectraStore {
16
23
  loadedVersion = null;
17
24
  // Unflushed addBatch() changes — never discard them by reloading.
18
25
  dirty = false;
26
+ // Keyword index over the current items, built on the first search with
27
+ // query text and dropped whenever the items change.
28
+ lexical = null;
19
29
  constructor(dir) {
20
30
  this.dir = dir;
21
31
  this.vectraDir = join(dir, 'vectra');
22
32
  this.lastSyncFile = join(dir, 'last_sync.txt');
23
33
  this.lastTotalFile = join(dir, 'last_total.txt');
34
+ this.signatureFile = join(dir, 'embedding_signature.txt');
24
35
  }
25
36
  get indexFile() {
26
37
  return join(this.vectraDir, 'index.json');
@@ -50,6 +61,7 @@ export class VectraStore {
50
61
  // No index yet — start empty
51
62
  this.items = new Map();
52
63
  }
64
+ this.lexical = null;
53
65
  this.loaded = true;
54
66
  this.loadedVersion = version;
55
67
  }
@@ -64,6 +76,7 @@ export class VectraStore {
64
76
  async add(item) {
65
77
  await this.ensureLoaded();
66
78
  this.items.set(item.id, item);
79
+ this.lexical = null;
67
80
  await this.persist();
68
81
  }
69
82
  async addBatch(items) {
@@ -71,21 +84,35 @@ export class VectraStore {
71
84
  for (const item of items) {
72
85
  this.items.set(item.id, item);
73
86
  }
87
+ this.lexical = null;
74
88
  this.dirty = items.length > 0 || this.dirty;
75
89
  // No persist() here — call flush() once after all batches are processed.
76
90
  }
77
91
  async flush() {
78
92
  await this.persist();
79
93
  }
80
- async search(vector, topN) {
94
+ async search(vector, topN, queryText) {
81
95
  await this.ensureLoaded();
82
96
  const results = [];
83
97
  for (const item of this.items.values()) {
84
- const score = cosineSimilarity(vector, item.vector);
98
+ let score = cosineSimilarity(vector, item.vector);
99
+ if (item.chunks)
100
+ for (const chunk of item.chunks)
101
+ score = Math.max(score, cosineSimilarity(vector, chunk));
85
102
  results.push({ id: item.id, score });
86
103
  }
87
104
  results.sort((a, b) => b.score - a.score);
88
- return results.slice(0, topN);
105
+ if (!queryText)
106
+ return results.slice(0, topN);
107
+ // Reciprocal Rank Fusion of the vector ranking and the keyword ranking.
108
+ this.lexical ??= new LexicalIndex([...this.items.values()].map(item => ({ id: item.id, ...item.metadata })));
109
+ const keywordRank = new Map([...this.lexical.scores(queryText)].sort((a, b) => b[1] - a[1]).map(([id], rank) => [id, rank]));
110
+ const fused = results.map((result, vectorRank) => {
111
+ const rank = keywordRank.get(result.id);
112
+ return { result, fused: 1 / (RRF_K + vectorRank) + (rank === undefined ? 0 : 1 / (RRF_K + rank)) };
113
+ });
114
+ fused.sort((a, b) => b.fused - a.fused);
115
+ return fused.slice(0, topN).map(f => f.result);
89
116
  }
90
117
  async getItem(id) {
91
118
  await this.ensureLoaded();
@@ -94,6 +121,7 @@ export class VectraStore {
94
121
  async delete(id) {
95
122
  await this.ensureLoaded();
96
123
  this.items.delete(id);
124
+ this.lexical = null;
97
125
  await this.persist();
98
126
  }
99
127
  async count() {
@@ -103,6 +131,7 @@ export class VectraStore {
103
131
  async clear() {
104
132
  await this.ensureLoaded();
105
133
  this.items.clear();
134
+ this.lexical = null;
106
135
  await this.persist();
107
136
  }
108
137
  async getLastSyncedAt() {
@@ -141,6 +170,18 @@ export class VectraStore {
141
170
  await mkdir(this.dir, { recursive: true });
142
171
  await writeFile(this.lastTotalFile, String(total), 'utf-8');
143
172
  }
173
+ async getEmbeddingSignature() {
174
+ try {
175
+ return (await readFile(this.signatureFile, 'utf-8')).trim() || null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ async setEmbeddingSignature(signature) {
182
+ await mkdir(this.dir, { recursive: true });
183
+ await writeFile(this.signatureFile, signature, 'utf-8');
184
+ }
144
185
  }
145
186
  // ---------------------------------------------------------------------------
146
187
  // Cosine similarity helper
@@ -41,12 +41,24 @@ export class SearchSyncService {
41
41
  }
42
42
  }
43
43
  async syncUnlocked(projectId, full) {
44
- if (full) {
44
+ // Vectors made with another model or token limit are not comparable with
45
+ // new query vectors, and an incremental sync never replaces them — their
46
+ // issues are unchanged. Such an index is rebuilt, for all projects, since
47
+ // it is useless as it stands. An empty index only takes the signature.
48
+ const signature = this.embedder.signature;
49
+ const mismatch = (await this.store.getEmbeddingSignature()) !== signature;
50
+ const outdated = mismatch && (await this.store.count()) > 0;
51
+ const scope = outdated ? undefined : projectId;
52
+ if (full || outdated) {
45
53
  await this.store.clear();
46
54
  await this.store.resetLastSyncedAt();
47
55
  }
56
+ // Stored before indexing: an interrupted rebuild then resumes on the next
57
+ // sync instead of starting over.
58
+ if (full || mismatch)
59
+ await this.store.setEmbeddingSignature(signature);
48
60
  const lastSyncedAt = await this.store.getLastSyncedAt();
49
- const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
61
+ const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, scope);
50
62
  let indexed = 0;
51
63
  let skipped = 0;
52
64
  let indexedSinceCheckpoint = 0;
@@ -62,21 +74,20 @@ export class SearchSyncService {
62
74
  if (issue.updated_at && (await this.store.getItem(issue.id))?.metadata.updated_at === issue.updated_at) {
63
75
  continue; // already indexed in this exact version
64
76
  }
65
- const text = `${issue.summary}\n${issue.description ?? ''}`.trim();
66
- toEmbed.push({ issue, text });
77
+ toEmbed.push({ issue, doc: { summary: issue.summary, description: issue.description } });
67
78
  }
68
79
  if (toEmbed.length === 0)
69
80
  continue;
70
- const vectors = await this.embedder.embedBatch(toEmbed.map(e => e.text));
71
- const batchItems = toEmbed.map((e, j) => ({
72
- id: e.issue.id,
73
- vector: vectors[j],
74
- metadata: {
75
- summary: e.issue.summary,
76
- description: e.issue.description,
77
- updated_at: e.issue.updated_at,
78
- },
79
- }));
81
+ const vectors = await this.embedder.embedDocuments(toEmbed.map(e => e.doc));
82
+ const batchItems = toEmbed.map(({ issue, doc }, j) => {
83
+ const [vector, ...chunks] = vectors[j];
84
+ return {
85
+ id: issue.id,
86
+ vector: vector,
87
+ ...(chunks.length > 0 && { chunks }),
88
+ metadata: { ...doc, updated_at: issue.updated_at },
89
+ };
90
+ });
80
91
  await this.store.addBatch(batchItems);
81
92
  indexed += batchItems.length;
82
93
  indexedSinceCheckpoint += batchItems.length;
@@ -96,7 +107,7 @@ export class SearchSyncService {
96
107
  // changes for good. A project-scoped sync must not move the shared watermark,
97
108
  // since changes in the other projects were not looked at.
98
109
  const newestUpdatedAt = allIssues.find(issue => issue.updated_at)?.updated_at;
99
- if (projectId === undefined && newestUpdatedAt && newestUpdatedAt !== lastSyncedAt) {
110
+ if (scope === undefined && newestUpdatedAt && newestUpdatedAt !== lastSyncedAt) {
100
111
  await this.store.setLastSyncedAt(newestUpdatedAt);
101
112
  }
102
113
  // Persist the best known total for get_search_index_status.
@@ -46,7 +46,10 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
46
46
  server.registerTool('search_issues', {
47
47
  title: 'Semantic Issue Search',
48
48
  description: 'Search MantisBT issues using natural language. Returns the most relevant issues ' +
49
- 'by semantic similarity. The search index must be populated first via rebuild_search_index.',
49
+ 'by combining semantic similarity with keyword matching, so exact terms such as identifiers, ' +
50
+ 'error codes or version numbers are found as well. "score" is the semantic similarity; results ' +
51
+ 'are ordered by the combined ranking, so a keyword match can precede a higher score. ' +
52
+ 'The search index must be populated first via rebuild_search_index.',
50
53
  inputSchema: z.object({
51
54
  query: z.string().describe('Natural language search query'),
52
55
  top_n: z
@@ -92,7 +95,7 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
92
95
  embedder.embed(query),
93
96
  client.getBaseUrl(),
94
97
  ]);
95
- const results = await store.search(queryVector, top_n);
98
+ const results = await store.search(queryVector, top_n, query);
96
99
  if (!select) {
97
100
  // For filtering or highlighting we need store metadata per result
98
101
  if (!filterActive && !terms.length) {
@@ -1584,6 +1584,8 @@ Deabonniert einen Benutzer von Benachrichtigungen zu einem Issue.
1584
1584
 
1585
1585
  Baut den vollständigen Vektor-Suchindex von Grund auf neu auf. Einmalig nach der Aktivierung der semantischen Suche ausführen.
1586
1586
 
1587
+ Der Server baut den Index außerdem von selbst neu auf, wenn er mit einem anderen Embedding-Modell oder auf andere Weise erstellt wurde – nach einer Änderung von `MANTIS_SEARCH_MODEL` oder nach einem Update, das die Art der Einbettung ändert. Ein vollständiger Neuaufbau von 7.700 Issues dauerte mit dem Standard von 1 Thread etwa 6 Minuten, mit `MANTIS_SEARCH_THREADS=4` keine 3 Minuten.
1588
+
1587
1589
  **Tool:** `rebuild_search_index`
1588
1590
 
1589
1591
  **Parameter:**
@@ -1614,7 +1616,7 @@ Baut den vollständigen Vektor-Suchindex von Grund auf neu auf. Einmalig nach de
1614
1616
 
1615
1617
  Aktualisiert den Index mit Issues, die seit der letzten Synchronisation hinzugekommen oder geändert wurden. Schneller als ein vollständiger Neuaufbau: Nur die seitdem geänderten Issues werden abgerufen und neu eingebettet.
1616
1618
 
1617
- Es synchronisiert immer nur ein Serverprozess den gemeinsamen Index. Läuft bereits eine Synchronisation in einem anderen Prozess (etwa einer anderen Client-Sitzung), gibt das Tool einen Fehler zurück – nach deren Ende einfach erneut aufrufen. Eine auf `project_id` beschränkte Synchronisation verschiebt `last_synced_at` nicht, damit Änderungen in anderen Projekten beim nächsten Sync trotzdem erfasst werden.
1619
+ Es synchronisiert immer nur ein Serverprozess den gemeinsamen Index. Läuft bereits eine Synchronisation in einem anderen Prozess (etwa einer anderen Client-Sitzung), gibt das Tool einen Fehler zurück – nach deren Ende einfach erneut aufrufen. Eine auf `project_id` beschränkte Synchronisation verschiebt `last_synced_at` nicht, damit Änderungen in anderen Projekten beim nächsten Sync trotzdem erfasst werden. Wurde der Index mit einem anderen Embedding-Modell oder einer anderen Version erstellt, baut der Sync ihn für alle Projekte neu auf, auch mit `project_id`.
1618
1620
 
1619
1621
  **Tool:** `rebuild_search_index`
1620
1622
 
@@ -1682,7 +1684,7 @@ Gibt die Anzahl der indizierten Issues, die Gesamtanzahl der Issues und den Zeit
1682
1684
 
1683
1685
  ### Nach Bedeutung suchen
1684
1686
 
1685
- Findet Issues, die einem natürlichsprachigen Suchbegriff semantisch ähnlich sind. Gibt Issue-IDs und Relevanz-Scores zurück.
1687
+ Findet Issues, die einem natürlichsprachigen Suchbegriff semantisch ähnlich sind, kombiniert mit einer Stichwortsuche, damit auch exakte Begriffe (Bezeichner, Fehlercodes, Versionsnummern) gefunden werden. Gibt Issue-IDs und Relevanz-Scores zurück. `score` ist die semantische Ähnlichkeit; die Reihenfolge folgt der kombinierten Rangfolge, ein Ergebnis mit exaktem Stichworttreffer kann also vor einem mit höherem `score` stehen.
1686
1688
 
1687
1689
  **Tool:** `search_issues`
1688
1690
 
package/docs/cookbook.md CHANGED
@@ -1584,6 +1584,8 @@ Unsubscribes a user from notifications for an issue.
1584
1584
 
1585
1585
  Builds the full vector search index from scratch. Run this once after enabling semantic search.
1586
1586
 
1587
+ The server also rebuilds the index by itself when it was made with another embedding model or in another way — after `MANTIS_SEARCH_MODEL` was changed, or after an update that changes how issues are embedded. A full rebuild of 7,700 issues took about 6 minutes with the default of 1 thread and less than 3 minutes with `MANTIS_SEARCH_THREADS=4`.
1588
+
1587
1589
  **Tool:** `rebuild_search_index`
1588
1590
 
1589
1591
  **Parameters:**
@@ -1614,7 +1616,7 @@ Builds the full vector search index from scratch. Run this once after enabling s
1614
1616
 
1615
1617
  Updates the index with issues added or changed since the last sync. Faster than a full rebuild: only issues changed since then are fetched and re-embedded.
1616
1618
 
1617
- Only one server process syncs the shared index at a time. If another process (for example another client session) is already syncing, the tool returns an error ("A search index sync is already running") — try again once that sync has finished. A sync limited to `project_id` does not advance `last_synced_at`, so changes in other projects are still picked up by the next sync.
1619
+ Only one server process syncs the shared index at a time. If another process (for example another client session) is already syncing, the tool returns an error ("A search index sync is already running") — try again once that sync has finished. A sync limited to `project_id` does not advance `last_synced_at`, so changes in other projects are still picked up by the next sync. If the index was made with another embedding model or version, the sync rebuilds it for all projects, even with `project_id`.
1618
1620
 
1619
1621
  **Tool:** `rebuild_search_index`
1620
1622
 
@@ -1682,7 +1684,7 @@ Returns the number of indexed issues, total issue count, and the timestamp of th
1682
1684
 
1683
1685
  ### Search by meaning
1684
1686
 
1685
- Finds issues semantically similar to a natural language query. Returns issue IDs and relevance scores.
1687
+ Finds issues semantically similar to a natural language query, combined with keyword matching so that exact terms (identifiers, error codes, version numbers) are found as well. Returns issue IDs and relevance scores. `score` is the semantic similarity; the order follows the combined ranking, so a result with an exact keyword match can come before one with a higher `score`.
1686
1688
 
1687
1689
  **Tool:** `search_issues`
1688
1690
 
@@ -199,6 +199,16 @@ Die semantische Suche versteht die *Bedeutung* deiner Frage – nicht nur einzel
199
199
 
200
200
  ---
201
201
 
202
+ ### Exakte Begriffe
203
+
204
+ Die Suche berücksichtigt auch exakte Stichwörter, daher funktionieren Bezeichner, Fehlercodes und Versionsnummern ebenfalls als Suchanfrage.
205
+
206
+ > »Welche Issues erwähnen `MANTIS_SEARCH_DIR`?«
207
+
208
+ > »Finde Issues zum Fehler `ERR_CERT_DATE_INVALID`.«
209
+
210
+ ---
211
+
202
212
  ### Keyword-Highlights
203
213
 
204
214
  > »Suche nach Login-Fehlern und zeige mir, welche Stelle im Ticket relevant ist.«
package/docs/examples.md CHANGED
@@ -199,6 +199,16 @@ Semantic search understands the *meaning* of your question — not just keywords
199
199
 
200
200
  ---
201
201
 
202
+ ### Exact terms
203
+
204
+ The search also matches exact keywords, so identifiers, error codes and version numbers work as queries too.
205
+
206
+ > "Which issues mention `MANTIS_SEARCH_DIR`?"
207
+
208
+ > "Find issues about the error `ERR_CERT_DATE_INVALID`."
209
+
210
+ ---
211
+
202
212
  ### Keyword highlights
203
213
 
204
214
  > "Search for login errors and show me which part of each ticket is relevant."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "mcpName": "io.github.dpesch/mantisbt-mcp-server",
5
5
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
6
6
  "author": "Dominik Pesch",
@@ -42,6 +42,7 @@
42
42
  "test:coverage": "vitest run --coverage",
43
43
  "test:record": "tsx scripts/record-fixtures.ts",
44
44
  "benchmark": "tsx scripts/benchmark-tokens.ts",
45
+ "benchmark:search": "tsx scripts/benchmark-search.ts",
45
46
  "init": "node scripts/init.mjs"
46
47
  },
47
48
  "dependencies": {
package/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.dpesch/mantisbt-mcp-server",
4
4
  "title": "MantisBT MCP Server",
5
5
  "description": "MantisBT MCP server – manage issues, notes, files, tags, and relationships. With semantic search.",
6
- "version": "1.15.0",
6
+ "version": "1.16.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@dpesch/mantisbt-mcp-server",
11
- "version": "1.15.0",
11
+ "version": "1.16.0",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"