@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search 0.1.4 → 0.3.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/README.md CHANGED
@@ -1,16 +1,24 @@
1
1
  # @drewswiredin/backstage-plugin-assistants-backend-module-semantic-search
2
2
 
3
- Knowledge index for AI Assistants for Backstage. It embeds every published
4
- TechDocs section and every catalog entity except `Location` into the assistants plugin's Postgres database and
5
- gives assistants a `search-knowledge` tool that finds them by meaning, by near
6
- spelling, and by exact words:
3
+ Knowledge index for AI Assistants for Backstage. It copies everything your
4
+ Backstage search indexes (TechDocs, the catalog, and any other source a
5
+ collator brings in, such as Confluence or a wiki), embeds it into the
6
+ assistants plugin's Postgres database, and gives assistants a
7
+ `search-knowledge` tool that finds it by meaning, by near spelling, and by
8
+ exact words:
7
9
 
8
10
  - "how do I fix errored-out messages" finds _Dead-letter handling › Replaying
9
11
  dead-lettered messages_.
10
12
  - "Pilon deployment" finds the _Pylon Gateway › Deployment runbook_.
11
13
  - "dlq-replay" finds the page that names the job.
12
14
 
13
- Results are filtered to entities the user can read in the catalog.
15
+ It reads no source itself. Each time a search collator finishes a run, the
16
+ module copies that run's documents, so the index follows your search schedule
17
+ and a rate-limited source such as Confluence is read once, by search.
18
+
19
+ Catalog and TechDocs results are filtered to entities the user can read in the
20
+ catalog. Results from other sources are visible to every assistant user, as
21
+ they are in Backstage search.
14
22
 
15
23
  Adds to the backend plugin
16
24
  [`@drewswiredin/backstage-plugin-assistants-backend`](https://www.npmjs.com/package/@drewswiredin/backstage-plugin-assistants-backend).
@@ -18,6 +26,8 @@ Adds to the backend plugin
18
26
  ## Requirements
19
27
 
20
28
  - The assistants backend plugin, with its database on Postgres 13 or later.
29
+ - Backstage search (`@backstage/plugin-search-backend`) in the same backend,
30
+ with the collators for the sources you want: any search engine works.
21
31
  - The `vector` ([pgvector](https://github.com/pgvector/pgvector)) and
22
32
  `pg_trgm` extensions in the assistants plugin's database.
23
33
  - An embedding model from an `openai`, `azure`, or `openai-compatible`
@@ -34,6 +44,7 @@ yarn --cwd packages/backend add @drewswiredin/backstage-plugin-assistants-backen
34
44
  ```ts
35
45
  // packages/backend/src/index.ts
36
46
  backend.add(import('@drewswiredin/backstage-plugin-assistants-backend'));
47
+ backend.add(import('@backstage/plugin-search-backend'));
37
48
  backend.add(
38
49
  import(
39
50
  '@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search'
@@ -41,6 +52,13 @@ backend.add(
41
52
  );
42
53
  ```
43
54
 
55
+ The one `backend.add` installs two modules: the index and `search-knowledge`
56
+ under the `assistants` plugin, and a document decorator under the `search`
57
+ plugin that copies what each search run collected. To add them separately,
58
+ import `assistantsModuleSemanticSearch` and `searchModuleAssistantsKnowledge`
59
+ by name. Without the search plugin the module logs a warning at startup and
60
+ the index stays empty.
61
+
44
62
  ## Enable the extensions
45
63
 
46
64
  The module enables both extensions itself when its database role may. `pg_trgm`
@@ -82,14 +100,59 @@ assistants:
82
100
  On Azure OpenAI, deploy an embedding model and name the deployment:
83
101
  `embeddingModel: azure:<embedding-deployment>`.
84
102
 
85
- The first run embeds everything; later runs embed only new and changed
86
- sections and delete removed ones. Changing `embeddingModel` rebuilds the index.
103
+ ### Rank by source
104
+
105
+ Every source shares one index, so a large one (thousands of tickets that all
106
+ mention the same system) can crowd out the one runbook section that answers
107
+ the question. Name the search document types to weigh them:
108
+
109
+ ```yaml
110
+ assistants:
111
+ semanticSearch:
112
+ embeddingModel: openai:text-embedding-3-small
113
+ maxResults: 8
114
+ types:
115
+ software-catalog: { weight: 1.5 }
116
+ techdocs: { weight: 1.2 }
117
+ confluence: { weight: 1.0 }
118
+ jira:
119
+ weight: 0.5
120
+ maxResults: 2
121
+ description: ticket history, decisions and status
122
+ ```
123
+
124
+ The keys are the type names Backstage search uses: `software-catalog`,
125
+ `techdocs`, or a collator's own type. Types left out are copied and ranked
126
+ with weight 1. Set `default: false` to make a source opt-in: the model then
127
+ searches it only when it passes `types: ['jira']`, and the tool description
128
+ says so. Set `enabled: false` to stop copying a source; its chunks are
129
+ deleted on its next search run.
130
+
131
+ The index fills as search runs: each document type arrives when its collator
132
+ next runs, on the schedule you gave that collator. A source's first run embeds
133
+ all of it; later runs embed only new and changed sections and delete removed
134
+ ones. Changing `embeddingModel` empties the index, and each type refills on its
135
+ next search run.
136
+
137
+ A type added to `types` takes its name, weight, and metadata on its next run,
138
+ keeping the embeddings it already has; 0.2.0 indexes upgrade the same way. To
139
+ run a collator now rather than wait for its schedule, trigger its search task
140
+ with a user or service token:
141
+
142
+ ```bash
143
+ curl -X POST -H "Authorization: Bearer $TOKEN" \
144
+ https://backstage.example.com/api/search/.backstage/scheduler/v1/tasks/search_index_jira/trigger
145
+ ```
146
+
147
+ The task id is `search_index_<type>`; the backend logs `Knowledge index
148
+ refreshed` with the type and counts when the copy finishes.
87
149
 
88
150
  ## Give an assistant the tool
89
151
 
90
152
  Add `search-knowledge` to the assistant's tools in the assistant editor. It
91
153
  pairs with `read-techdocs` (from `builtinActions: true`), which opens the full
92
- page behind a TechDocs result. Until the first index run finishes, the tool
154
+ page behind a TechDocs result; other results carry a link. Until the first
155
+ search run is copied, the tool
93
156
  answers with an error that points the model at keyword search: Backstage's
94
157
  `query` action when the assistant has it, otherwise `search-techdocs` and
95
158
  `search-catalog`.
@@ -113,17 +176,38 @@ backend:
113
176
 
114
177
  ## How it works
115
178
 
116
- **Indexing.** A scheduled task (one replica at a time) reads each published
117
- TechDocs site's MkDocs search index (the same source Backstage's TechDocs
118
- search uses) and every catalog entity except `Location`. Each TechDocs section
119
- becomes one or more chunks of up to ~1600 characters, titled with its heading
120
- path (`Site › Page › Section`) and embedded with that title. Each row stores
121
- the embedding, a generated full-text vector, and a trigram index over its text.
122
- The table is a rebuildable cache, not migrated data.
123
-
124
- **Retrieval.** `search-knowledge` takes the question and up to three alternative
125
- phrasings and builds one semantic list per phrasing, a keyword list, a rare-word
126
- list, and a fuzzy list:
179
+ **Indexing.** A search decorator sits on every document type's pipeline. It
180
+ passes each document on to the search engine unchanged and keeps it; when the
181
+ collator finishes, it hands the run's documents to the index. A failed run
182
+ hands over nothing, and a run with no documents changes nothing. Runs are
183
+ copied one at a time, in the backend that ran the collator.
184
+
185
+ Each document becomes one or more chunks of up to ~1600 characters, titled
186
+ with its heading path (`Site › Page › Section` for TechDocs) and embedded with
187
+ that title. The facts collators attach (entity ref, type, owner, lifecycle)
188
+ join the text, so a catalog entity with no description is still found. A
189
+ document gated by a catalog entity (its `authorization.resourceRef`) keeps that
190
+ entity for the permission check; a document gated by any other kind of
191
+ resource is skipped, since the index can only check entity read permission.
192
+
193
+ Search never tells a decorator which document type it is decorating, so the
194
+ module adds a pass-through decorator to each type named in `types`. Search
195
+ builds a run's pipeline in one pass, so that decorator names the run's type.
196
+ A named type's run is stored under its name; a run of any other type joins
197
+ the stored collection that already holds most of its locations. Chunks the
198
+ run no longer has are deleted. A collection no search run has refreshed in
199
+ 30 days (a source removed from search, or an unnamed one whose locations all
200
+ changed at once) is dropped. Each row stores the embedding, a generated
201
+ full-text vector, a trigram index over its text, its type, and the short
202
+ fields its collator attached. The table is a rebuildable copy, not migrated
203
+ data.
204
+
205
+ **Retrieval.** `search-knowledge` takes the question, up to three alternative
206
+ phrasings, and optionally the `types` to search (otherwise every type that is
207
+ on by default, plus unnamed sources). It builds one semantic list per
208
+ phrasing, a keyword list, a rare-word list, and a fuzzy list, each keeping
209
+ its best 30 chunks of every type, so a large source cannot fill a list on its
210
+ own:
127
211
 
128
212
  | List | Matches | Postgres |
129
213
  | --------- | --------------------------------------------- | ----------------------------------------------------------- |
@@ -135,18 +219,74 @@ list, and a fuzzy list:
135
219
  Query words include adjacent pairs written as one word, so "tally up" also
136
220
  matches "TallyUp". Reciprocal rank fusion merges the lists with one vote per
137
221
  method: the semantic lists share one vote, so extra phrasings broaden recall
138
- without outvoting an exact match. The rare-word list keeps a name or acronym
139
- mentioned once in a paragraph about something else from being outranked by
140
- sections that are only near the question in meaning. The top candidates'
141
- entities are fetched from the catalog as the user; results for entities the
142
- user cannot read are dropped, and repeated text is shown once.
222
+ without outvoting an exact match. Each chunk's fused score is multiplied by
223
+ its type's weight. The rare-word list keeps a name or acronym mentioned once
224
+ in a paragraph about something else from being outranked by sections that
225
+ are only near the question in meaning. The top candidates' entities are
226
+ fetched from the catalog as the user; results for entities the user cannot
227
+ read are dropped, results with no entity are kept, and repeated text is shown
228
+ once. The ranker then picks the results: by default best first, with at most
229
+ `maxResults` of a capped type while other types can fill the slots.
230
+
231
+ Each result carries its title, location, type, excerpt, the entity ref for
232
+ catalog and TechDocs results, and `metadata`: the short fields its collator
233
+ attached (strings up to 200 characters, numbers, booleans), such as a
234
+ ticket's status or a page's last update.
235
+
236
+ ## Custom ranking
237
+
238
+ A backend module can replace the ranker, for example to favour recently
239
+ updated tickets:
240
+
241
+ ```ts
242
+ import { createBackendModule } from '@backstage/backend-plugin-api';
243
+ import { knowledgeRankingExtensionPoint } from '@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search/alpha';
244
+
245
+ export default createBackendModule({
246
+ pluginId: 'assistants',
247
+ moduleId: 'knowledge-recency',
248
+ register(env) {
249
+ env.registerInit({
250
+ deps: { ranking: knowledgeRankingExtensionPoint },
251
+ async init({ ranking }) {
252
+ ranking.setRanker(({ candidates, maxResults }) =>
253
+ candidates
254
+ .map(candidate => {
255
+ const updated = Date.parse(String(candidate.metadata?.updated));
256
+ const ageDays = (Date.now() - updated) / 86_400_000;
257
+ const boost = ageDays < 90 ? 1.2 : 1;
258
+ return { candidate, score: candidate.score * boost };
259
+ })
260
+ .sort((a, b) => b.score - a.score)
261
+ .slice(0, maxResults)
262
+ .map(({ candidate }) => candidate),
263
+ );
264
+ },
265
+ });
266
+ },
267
+ });
268
+ ```
269
+
270
+ The ranker receives the query, its phrasings, the types searched, and up to
271
+ 60 candidates the caller may read. Each candidate has its fused score after
272
+ weights, its rank in each retrieval list, its type, entity ref, and metadata.
273
+ It runs after the permission check, so it cannot surface what the caller
274
+ cannot read; results it returns that were not candidates are dropped.
143
275
 
144
276
  ## Reference
145
277
 
146
- | Key | Default | Meaning |
147
- | ------------------------------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
148
- | `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
149
- | `assistants.semanticSearch.schedule` | every 30 minutes, 1 hour timeout, 15 s initial delay | Backstage task schedule for refreshing the index; a configured schedule replaces the default. |
278
+ | Key | Default | Meaning |
279
+ | ---------------------------------------------------- | ---------- | ----------------------------------------------------------------------------- |
280
+ | `assistants.semanticSearch.embeddingModel` | (required) | `<providerId>:<model>` from `assistants.providers`. |
281
+ | `assistants.semanticSearch.maxResults` | `8` | Results per answer unless the model asks for a number (it may ask for 20). |
282
+ | `assistants.semanticSearch.types.<type>.weight` | `1` | Multiplies the type's fused score; `0` never returns it. |
283
+ | `assistants.semanticSearch.types.<type>.default` | `true` | Searched when the model names no types; `false` makes the type opt-in. |
284
+ | `assistants.semanticSearch.types.<type>.maxResults` | | At most this many of the type's results while other types can fill the slots. |
285
+ | `assistants.semanticSearch.types.<type>.enabled` | `true` | `false` stops copying the type and deletes its chunks on its next run. |
286
+ | `assistants.semanticSearch.types.<type>.description` | | What the source holds, shown to the model in the tool description. |
287
+
288
+ The index refreshes when search does; set each collator's schedule in its own
289
+ config.
150
290
 
151
291
  ## License
152
292
 
@@ -12,167 +12,44 @@
12
12
  "type": "string",
13
13
  "description": "Embedding model in `<providerId>:<model>` format, resolved against `assistants.providers`, e.g. `openai:text-embedding-3-small`. The provider must be `openai`, `azure`, or `openai-compatible`. Changing it rebuilds the index."
14
14
  },
15
- "schedule": {
15
+ "maxResults": {
16
+ "type": "number",
17
+ "description": "Results per `search-knowledge` answer unless the caller asks for a number. Defaults to 8."
18
+ },
19
+ "types": {
16
20
  "type": "object",
17
- "properties": {
18
- "frequency": {
19
- "anyOf": [
20
- {
21
- "type": "object",
22
- "properties": {
23
- "cron": {
24
- "type": "string",
25
- "description": "A crontab style string."
26
- }
27
- },
28
- "required": [
29
- "cron"
30
- ]
31
- },
32
- {
33
- "type": "string"
34
- },
35
- {
36
- "type": "object",
37
- "properties": {
38
- "years": {
39
- "type": "number"
40
- },
41
- "months": {
42
- "type": "number"
43
- },
44
- "weeks": {
45
- "type": "number"
46
- },
47
- "days": {
48
- "type": "number"
49
- },
50
- "hours": {
51
- "type": "number"
52
- },
53
- "minutes": {
54
- "type": "number"
55
- },
56
- "seconds": {
57
- "type": "number"
58
- },
59
- "milliseconds": {
60
- "type": "number"
61
- }
62
- },
63
- "description": "Human friendly durations object."
64
- },
65
- {
66
- "type": "object",
67
- "properties": {
68
- "trigger": {
69
- "type": "string",
70
- "const": "manual"
71
- }
72
- },
73
- "required": [
74
- "trigger"
75
- ]
76
- }
77
- ],
78
- "description": "How often you want the task to run. The system does its best to avoid overlapping invocations."
79
- },
80
- "timeout": {
81
- "anyOf": [
82
- {
83
- "type": "string"
84
- },
85
- {
86
- "type": "object",
87
- "properties": {
88
- "years": {
89
- "type": "number"
90
- },
91
- "months": {
92
- "type": "number"
93
- },
94
- "weeks": {
95
- "type": "number"
96
- },
97
- "days": {
98
- "type": "number"
99
- },
100
- "hours": {
101
- "type": "number"
102
- },
103
- "minutes": {
104
- "type": "number"
105
- },
106
- "seconds": {
107
- "type": "number"
108
- },
109
- "milliseconds": {
110
- "type": "number"
111
- }
112
- },
113
- "description": "Human friendly durations object."
114
- }
115
- ],
116
- "description": "The maximum amount of time that a single task invocation can take, before it's considered timed out and gets \"released\" such that a new invocation is permitted to take place (possibly, then, on a different worker)."
117
- },
118
- "initialDelay": {
119
- "anyOf": [
120
- {
121
- "type": "string"
122
- },
123
- {
124
- "type": "object",
125
- "properties": {
126
- "years": {
127
- "type": "number"
128
- },
129
- "months": {
130
- "type": "number"
131
- },
132
- "weeks": {
133
- "type": "number"
134
- },
135
- "days": {
136
- "type": "number"
137
- },
138
- "hours": {
139
- "type": "number"
140
- },
141
- "minutes": {
142
- "type": "number"
143
- },
144
- "seconds": {
145
- "type": "number"
146
- },
147
- "milliseconds": {
148
- "type": "number"
149
- }
150
- },
151
- "description": "Human friendly durations object."
152
- }
153
- ],
154
- "description": "The amount of time that should pass before the first invocation happens."
155
- },
156
- "scope": {
157
- "type": "string",
158
- "enum": [
159
- "global",
160
- "local"
161
- ],
162
- "description": "Sets the scope of concurrency control / locking to apply for invocations of this task."
21
+ "additionalProperties": {
22
+ "type": "object",
23
+ "properties": {
24
+ "weight": {
25
+ "type": "number",
26
+ "description": "Multiplies the type's fused score. Defaults to 1; 0 means its chunks are never returned."
27
+ },
28
+ "default": {
29
+ "type": "boolean",
30
+ "description": "Searched when the caller names no types. Defaults to true; false makes the type opt-in (the model passes it in `types`)."
31
+ },
32
+ "maxResults": {
33
+ "type": "number",
34
+ "description": "At most this many of the type's results in one answer."
35
+ },
36
+ "enabled": {
37
+ "type": "boolean",
38
+ "description": "Copied into the index. Defaults to true; false stops copying it and deletes its chunks on its next search run."
39
+ },
40
+ "description": {
41
+ "type": "string",
42
+ "description": "What the source holds, shown to the model in the tool description, e.g. \"ticket history, decisions and status\"."
43
+ }
163
44
  }
164
45
  },
165
- "required": [
166
- "frequency",
167
- "timeout"
168
- ],
169
- "description": "How often the index refreshes. Each run embeds only new and changed content. Default: every 30 minutes, 1 hour timeout, 15 s initial delay; a configured schedule replaces it."
46
+ "description": "Settings per Backstage search document type, keyed by the type name search uses (`software-catalog`, `techdocs`, or a collator's own type). Types left out are copied and ranked with the defaults."
170
47
  }
171
48
  },
172
49
  "required": [
173
50
  "embeddingModel"
174
51
  ],
175
- "description": "The knowledge index: published TechDocs sections and catalog entities (except `Location`) embedded into the assistants plugin's Postgres database and served to assistants as the `search-knowledge` action. Requires the `vector` and `pg_trgm` extensions."
52
+ "description": "The knowledge index: a copy of everything Backstage search indexes (TechDocs, the catalog, and any other collator's documents), embedded into the assistants plugin's Postgres database as each search run finishes and served to assistants as the `search-knowledge` action. Requires the search plugin in the same backend, and the `vector` and `pg_trgm` extensions."
176
53
  }
177
54
  }
178
55
  }
@@ -2,9 +2,11 @@
2
2
 
3
3
  var ai = require('ai');
4
4
  var rank = require('./rank.cjs.js');
5
+ var ranking = require('./ranking.cjs.js');
5
6
 
6
7
  const CANDIDATES = 30;
7
- const FUSED = 40;
8
+ const FUSED = 60;
9
+ const MAX_LIMIT = 20;
8
10
  const EXCERPT_CHARS = 1500;
9
11
  const RARE_MAX_CHUNKS = 3;
10
12
  function registerSearchKnowledgeAction({
@@ -12,13 +14,17 @@ function registerSearchKnowledgeAction({
12
14
  store,
13
15
  catalog,
14
16
  auth,
15
- embeddingModel
17
+ embeddingModel,
18
+ types,
19
+ maxResults,
20
+ ranker
16
21
  }) {
17
22
  actionsRegistry.register({
18
23
  name: "search-knowledge",
19
24
  title: "Search Knowledge",
20
25
  attributes: { destructive: false, readOnly: true, idempotent: true },
21
- description: `Search the organization's TechDocs and software catalog by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it ("fix errored messages" finds "Retry dead-letter messages") and tolerates misspelled names ("Pilon" finds "Pylon"). It returns the most relevant sections, not every match, and cannot filter by entity kind.
26
+ description: `Search everything the organization's Backstage search indexes (TechDocs, the software catalog, and any other source it collects, such as Confluence or wiki pages) by meaning, not only by exact words. Best for questions in the user's own words: it finds a page even when the question is worded differently from it ("fix errored messages" finds "Retry dead-letter messages") and tolerates misspelled names ("Pilon" finds "Pylon"). It returns the most relevant sections, not every match, and cannot filter by entity kind.
27
+ ${sourcesDescription(types)}
22
28
 
23
29
  How to search the organization's docs and catalog:
24
30
  1. Start here, with the user's question.
@@ -27,20 +33,27 @@ How to search the organization's docs and catalog:
27
33
 
28
34
  Pass the user's question as the "query" parameter. Pass up to three "alternatives": other phrasings, synonyms, or the internal terms the docs might use.
29
35
 
30
- Each result has a title (its heading path), an excerpt, and a location. For TechDocs results, pass the location to read-techdocs to read the full page. For catalog results, the location is the entity ref.`,
36
+ Each result has a title (its heading path), an excerpt, a location, its source type, the fields the source attached (such as status, space, or last update: use them to judge how current it is), and, for catalog and TechDocs results, the entity ref. A location starting with /docs/ is a TechDocs page: pass it to read-techdocs to read the full page. Any other location is a link to the page or entity; cite it.`,
31
37
  schema: {
32
38
  input: (z) => z.object({
33
39
  query: z.string().min(1).describe("The question or topic to find"),
34
40
  alternatives: z.array(z.string()).max(3).optional().describe("Other phrasings or terms for the same thing"),
35
- limit: z.number().int().min(1).max(20).optional().default(8).describe("Number of results to return")
41
+ types: z.array(z.string()).optional().describe(
42
+ "Search only these sources (search document types); omit to search the default ones"
43
+ ),
44
+ limit: z.number().int().min(1).max(Math.max(MAX_LIMIT, maxResults)).optional().default(maxResults).describe("Number of results to return")
36
45
  }),
37
46
  output: (z) => z.object({
38
47
  results: z.array(
39
48
  z.object({
40
- source: z.enum(["techdocs", "catalog"]),
41
49
  title: z.string(),
42
50
  location: z.string(),
43
- entityRef: z.string(),
51
+ type: z.string().optional(),
52
+ entityRef: z.string().optional(),
53
+ metadata: z.record(
54
+ z.string(),
55
+ z.union([z.string(), z.number(), z.boolean()])
56
+ ).optional(),
44
57
  excerpt: z.string()
45
58
  })
46
59
  )
@@ -52,12 +65,17 @@ Each result has a title (its heading path), an excerpt, and a location. For Tech
52
65
  "The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog."
53
66
  );
54
67
  }
68
+ const filter = ranking.selectTypes(types, await store.types(), input.types);
55
69
  const phrasings = [input.query, ...input.alternatives ?? []];
56
70
  const words = rank.queryWords(phrasings);
57
71
  const joined = rank.joinedWords(phrasings).filter((w) => !words.includes(w));
58
72
  const [{ embeddings }, frequencies] = await Promise.all([
59
73
  ai.embedMany({ model: embeddingModel, values: phrasings }),
60
- store.documentFrequencies([...words, ...joined], RARE_MAX_CHUNKS + 1)
74
+ store.documentFrequencies(
75
+ [...words, ...joined],
76
+ RARE_MAX_CHUNKS + 1,
77
+ filter
78
+ )
61
79
  ]);
62
80
  const known = [...words, ...joined].filter(
63
81
  (w) => (frequencies.get(w) ?? 0) > 0
@@ -66,45 +84,80 @@ Each result has a title (its heading path), an excerpt, and a location. For Tech
66
84
  const unknown = words.filter((w) => frequencies.get(w) === 0);
67
85
  const lists = await Promise.all([
68
86
  ...embeddings.map(async (embedding) => ({
69
- ids: await store.nearest(embedding, CANDIDATES),
87
+ name: "semantic",
88
+ hits: await store.nearest(embedding, CANDIDATES, filter),
70
89
  weight: 1 / embeddings.length
71
90
  })),
72
- store.keyword(known, CANDIDATES).then((ids) => ({ ids, weight: 1 })),
73
- store.keyword(rare, CANDIDATES).then((ids) => ({ ids, weight: 1 })),
74
- store.fuzzy(unknown, CANDIDATES).then((ids) => ({ ids, weight: 1 }))
91
+ store.keyword(known, CANDIDATES, filter).then((hits) => ({ name: "keyword", hits, weight: 1 })),
92
+ store.keyword(rare, CANDIDATES, filter).then((hits) => ({ name: "rareWord", hits, weight: 1 })),
93
+ store.fuzzy(unknown, CANDIDATES, filter).then((hits) => ({ name: "fuzzy", hits, weight: 1 }))
75
94
  ]);
76
- const ranked = rank.fuseRankings(lists).slice(0, FUSED);
95
+ const settingsOf = ranking.typeSettingsOf(types);
96
+ const fused = rank.fuseRankings(lists, (type) => settingsOf(type).weight).slice(
97
+ 0,
98
+ FUSED
99
+ );
77
100
  const byId = new Map(
78
- (await store.chunks(ranked)).map((chunk) => [chunk.id, chunk])
101
+ (await store.chunks(fused.map((hit) => hit.id))).map((chunk) => [
102
+ chunk.id,
103
+ chunk
104
+ ])
79
105
  );
80
- const candidates = ranked.map((id) => byId.get(id)).filter((chunk) => Boolean(chunk));
106
+ const found = fused.flatMap((hit) => {
107
+ const chunk = byId.get(hit.id);
108
+ return chunk ? [{ hit, chunk }] : [];
109
+ });
81
110
  const readable = await readableEntityRefs(catalog, auth, credentials, [
82
- ...new Set(candidates.map((chunk) => chunk.entityRef))
111
+ ...new Set(
112
+ found.flatMap(
113
+ ({ chunk }) => chunk.entityRef ? [chunk.entityRef] : []
114
+ )
115
+ )
83
116
  ]);
84
117
  const seen = /* @__PURE__ */ new Set();
85
- const results = [];
86
- for (const chunk of candidates) {
118
+ const candidates = [];
119
+ for (const { hit, chunk } of found) {
87
120
  const key = `${chunk.location}|${chunk.title}`;
88
- if (!readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
121
+ if (chunk.entityRef && !readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
89
122
  continue;
90
123
  }
91
124
  seen.add(key);
92
125
  seen.add(chunk.content);
93
- results.push({
94
- source: chunk.source,
95
- title: chunk.title,
96
- location: chunk.location,
97
- entityRef: chunk.entityRef,
98
- excerpt: chunk.content.slice(0, EXCERPT_CHARS)
99
- });
100
- if (results.length === input.limit) {
101
- break;
102
- }
126
+ candidates.push({ ...chunk, score: hit.score, ranks: hit.ranks });
103
127
  }
128
+ const ids = new Set(candidates.map((candidate) => candidate.id));
129
+ const ranked = await ranker()({
130
+ query: input.query,
131
+ phrasings,
132
+ types: filter.types,
133
+ candidates,
134
+ maxResults: input.limit
135
+ });
136
+ const results = ranked.filter((candidate) => ids.delete(candidate.id)).slice(0, input.limit).map((candidate) => ({
137
+ title: candidate.title,
138
+ location: candidate.location,
139
+ ...candidate.type ? { type: candidate.type } : {},
140
+ ...candidate.entityRef ? { entityRef: candidate.entityRef } : {},
141
+ ...candidate.metadata ? { metadata: candidate.metadata } : {},
142
+ excerpt: candidate.content.slice(0, EXCERPT_CHARS)
143
+ }));
104
144
  return { output: { results } };
105
145
  }
106
146
  });
107
147
  }
148
+ function sourcesDescription(types) {
149
+ const listed = [...types].filter(
150
+ ([, settings]) => settings.enabled && settings.weight > 0
151
+ );
152
+ const lines = listed.map(
153
+ ([type, settings]) => `- ${type}${settings.default ? "" : ' (only when named in "types")'}${settings.description ? `: ${settings.description}` : ""}`
154
+ );
155
+ return [
156
+ "",
157
+ `Sources: pass "types" to search only the named search document types; without it the search covers every source except those marked as searched only when named. An unknown name returns the list of types.`,
158
+ ...lines
159
+ ].join("\n");
160
+ }
108
161
  async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
109
162
  if (entityRefs.length === 0) {
110
163
  return /* @__PURE__ */ new Set();
@@ -121,4 +174,5 @@ async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
121
174
  }
122
175
 
123
176
  exports.registerSearchKnowledgeAction = registerSearchKnowledgeAction;
177
+ exports.sourcesDescription = sourcesDescription;
124
178
  //# sourceMappingURL=action.cjs.js.map