@drewswiredin/backstage-plugin-assistants-backend-module-semantic-search 0.2.0 → 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 +119 -18
- package/config.schema.json +33 -0
- package/dist/action.cjs.js +78 -26
- package/dist/action.cjs.js.map +1 -1
- package/dist/alpha.cjs.js +8 -0
- package/dist/alpha.cjs.js.map +1 -0
- package/dist/alpha.d.ts +78 -0
- package/dist/chunk.cjs.js +6 -1
- package/dist/chunk.cjs.js.map +1 -1
- package/dist/config.cjs.js +45 -1
- package/dist/config.cjs.js.map +1 -1
- package/dist/documents.cjs.js +37 -1
- package/dist/documents.cjs.js.map +1 -1
- package/dist/feed.cjs.js +51 -4
- package/dist/feed.cjs.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/indexer.cjs.js +17 -5
- package/dist/indexer.cjs.js.map +1 -1
- package/dist/module.cjs.js +32 -4
- package/dist/module.cjs.js.map +1 -1
- package/dist/rank.cjs.js +14 -6
- package/dist/rank.cjs.js.map +1 -1
- package/dist/ranking.cjs.js +61 -0
- package/dist/ranking.cjs.js.map +1 -0
- package/dist/searchModule.cjs.js +8 -1
- package/dist/searchModule.cjs.js.map +1 -1
- package/dist/store.cjs.js +241 -41
- package/dist/store.cjs.js.map +1 -1
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -100,12 +100,53 @@ assistants:
|
|
|
100
100
|
On Azure OpenAI, deploy an embedding model and name the deployment:
|
|
101
101
|
`embeddingModel: azure:<embedding-deployment>`.
|
|
102
102
|
|
|
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
|
+
|
|
103
131
|
The index fills as search runs: each document type arrives when its collator
|
|
104
132
|
next runs, on the schedule you gave that collator. A source's first run embeds
|
|
105
133
|
all of it; later runs embed only new and changed sections and delete removed
|
|
106
134
|
ones. Changing `embeddingModel` empties the index, and each type refills on its
|
|
107
135
|
next search run.
|
|
108
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.
|
|
149
|
+
|
|
109
150
|
## Give an assistant the tool
|
|
110
151
|
|
|
111
152
|
Add `search-knowledge` to the assistant's tools in the assistant editor. It
|
|
@@ -149,17 +190,24 @@ document gated by a catalog entity (its `authorization.resourceRef`) keeps that
|
|
|
149
190
|
entity for the permission check; a document gated by any other kind of
|
|
150
191
|
resource is skipped, since the index can only check entity read permission.
|
|
151
192
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
158
203
|
data.
|
|
159
204
|
|
|
160
|
-
**Retrieval.** `search-knowledge` takes the question
|
|
161
|
-
phrasings and
|
|
162
|
-
|
|
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:
|
|
163
211
|
|
|
164
212
|
| List | Matches | Postgres |
|
|
165
213
|
| --------- | --------------------------------------------- | ----------------------------------------------------------- |
|
|
@@ -171,18 +219,71 @@ list, and a fuzzy list:
|
|
|
171
219
|
Query words include adjacent pairs written as one word, so "tally up" also
|
|
172
220
|
matches "TallyUp". Reciprocal rank fusion merges the lists with one vote per
|
|
173
221
|
method: the semantic lists share one vote, so extra phrasings broaden recall
|
|
174
|
-
without outvoting an exact match.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
text is shown
|
|
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.
|
|
180
275
|
|
|
181
276
|
## Reference
|
|
182
277
|
|
|
183
|
-
| Key
|
|
184
|
-
|
|
|
185
|
-
| `assistants.semanticSearch.embeddingModel`
|
|
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. |
|
|
186
287
|
|
|
187
288
|
The index refreshes when search does; set each collator's schedule in its own
|
|
188
289
|
config.
|
package/config.schema.json
CHANGED
|
@@ -11,6 +11,39 @@
|
|
|
11
11
|
"embeddingModel": {
|
|
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
|
+
},
|
|
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": {
|
|
20
|
+
"type": "object",
|
|
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
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
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."
|
|
14
47
|
}
|
|
15
48
|
},
|
|
16
49
|
"required": [
|
package/dist/action.cjs.js
CHANGED
|
@@ -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 =
|
|
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 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
|
|
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,19 +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, a location, 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.`,
|
|
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
|
-
|
|
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
49
|
title: z.string(),
|
|
41
50
|
location: z.string(),
|
|
51
|
+
type: z.string().optional(),
|
|
42
52
|
entityRef: z.string().optional(),
|
|
53
|
+
metadata: z.record(
|
|
54
|
+
z.string(),
|
|
55
|
+
z.union([z.string(), z.number(), z.boolean()])
|
|
56
|
+
).optional(),
|
|
43
57
|
excerpt: z.string()
|
|
44
58
|
})
|
|
45
59
|
)
|
|
@@ -51,12 +65,17 @@ Each result has a title (its heading path), an excerpt, a location, and, for cat
|
|
|
51
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."
|
|
52
66
|
);
|
|
53
67
|
}
|
|
68
|
+
const filter = ranking.selectTypes(types, await store.types(), input.types);
|
|
54
69
|
const phrasings = [input.query, ...input.alternatives ?? []];
|
|
55
70
|
const words = rank.queryWords(phrasings);
|
|
56
71
|
const joined = rank.joinedWords(phrasings).filter((w) => !words.includes(w));
|
|
57
72
|
const [{ embeddings }, frequencies] = await Promise.all([
|
|
58
73
|
ai.embedMany({ model: embeddingModel, values: phrasings }),
|
|
59
|
-
store.documentFrequencies(
|
|
74
|
+
store.documentFrequencies(
|
|
75
|
+
[...words, ...joined],
|
|
76
|
+
RARE_MAX_CHUNKS + 1,
|
|
77
|
+
filter
|
|
78
|
+
)
|
|
60
79
|
]);
|
|
61
80
|
const known = [...words, ...joined].filter(
|
|
62
81
|
(w) => (frequencies.get(w) ?? 0) > 0
|
|
@@ -65,48 +84,80 @@ Each result has a title (its heading path), an excerpt, a location, and, for cat
|
|
|
65
84
|
const unknown = words.filter((w) => frequencies.get(w) === 0);
|
|
66
85
|
const lists = await Promise.all([
|
|
67
86
|
...embeddings.map(async (embedding) => ({
|
|
68
|
-
|
|
87
|
+
name: "semantic",
|
|
88
|
+
hits: await store.nearest(embedding, CANDIDATES, filter),
|
|
69
89
|
weight: 1 / embeddings.length
|
|
70
90
|
})),
|
|
71
|
-
store.keyword(known, CANDIDATES).then((
|
|
72
|
-
store.keyword(rare, CANDIDATES).then((
|
|
73
|
-
store.fuzzy(unknown, CANDIDATES).then((
|
|
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 }))
|
|
74
94
|
]);
|
|
75
|
-
const
|
|
95
|
+
const settingsOf = ranking.typeSettingsOf(types);
|
|
96
|
+
const fused = rank.fuseRankings(lists, (type) => settingsOf(type).weight).slice(
|
|
97
|
+
0,
|
|
98
|
+
FUSED
|
|
99
|
+
);
|
|
76
100
|
const byId = new Map(
|
|
77
|
-
(await store.chunks(
|
|
101
|
+
(await store.chunks(fused.map((hit) => hit.id))).map((chunk) => [
|
|
102
|
+
chunk.id,
|
|
103
|
+
chunk
|
|
104
|
+
])
|
|
78
105
|
);
|
|
79
|
-
const
|
|
106
|
+
const found = fused.flatMap((hit) => {
|
|
107
|
+
const chunk = byId.get(hit.id);
|
|
108
|
+
return chunk ? [{ hit, chunk }] : [];
|
|
109
|
+
});
|
|
80
110
|
const readable = await readableEntityRefs(catalog, auth, credentials, [
|
|
81
111
|
...new Set(
|
|
82
|
-
|
|
83
|
-
(chunk) => chunk.entityRef ? [chunk.entityRef] : []
|
|
112
|
+
found.flatMap(
|
|
113
|
+
({ chunk }) => chunk.entityRef ? [chunk.entityRef] : []
|
|
84
114
|
)
|
|
85
115
|
)
|
|
86
116
|
]);
|
|
87
117
|
const seen = /* @__PURE__ */ new Set();
|
|
88
|
-
const
|
|
89
|
-
for (const chunk of
|
|
118
|
+
const candidates = [];
|
|
119
|
+
for (const { hit, chunk } of found) {
|
|
90
120
|
const key = `${chunk.location}|${chunk.title}`;
|
|
91
121
|
if (chunk.entityRef && !readable.has(chunk.entityRef) || seen.has(key) || seen.has(chunk.content)) {
|
|
92
122
|
continue;
|
|
93
123
|
}
|
|
94
124
|
seen.add(key);
|
|
95
125
|
seen.add(chunk.content);
|
|
96
|
-
|
|
97
|
-
title: chunk.title,
|
|
98
|
-
location: chunk.location,
|
|
99
|
-
...chunk.entityRef ? { entityRef: chunk.entityRef } : {},
|
|
100
|
-
excerpt: chunk.content.slice(0, EXCERPT_CHARS)
|
|
101
|
-
});
|
|
102
|
-
if (results.length === input.limit) {
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
126
|
+
candidates.push({ ...chunk, score: hit.score, ranks: hit.ranks });
|
|
105
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
|
+
}));
|
|
106
144
|
return { output: { results } };
|
|
107
145
|
}
|
|
108
146
|
});
|
|
109
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
|
+
}
|
|
110
161
|
async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
|
|
111
162
|
if (entityRefs.length === 0) {
|
|
112
163
|
return /* @__PURE__ */ new Set();
|
|
@@ -123,4 +174,5 @@ async function readableEntityRefs(catalog, auth, credentials, entityRefs) {
|
|
|
123
174
|
}
|
|
124
175
|
|
|
125
176
|
exports.registerSearchKnowledgeAction = registerSearchKnowledgeAction;
|
|
177
|
+
exports.sourcesDescription = sourcesDescription;
|
|
126
178
|
//# sourceMappingURL=action.cjs.js.map
|
package/dist/action.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.cjs.js","sources":["../src/action.ts"],"sourcesContent":["import type {\n AuthService,\n BackstageCredentials,\n} from '@backstage/backend-plugin-api';\nimport type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport type { CatalogApi } from '@backstage/catalog-client';\nimport { embedMany, type EmbeddingModel } from 'ai';\nimport { fuseRankings, joinedWords, queryWords, type RankedList } from './rank';\nimport type { KnowledgeStore, StoredChunk } from './store';\n\n/** Candidates per retrieval list, before fusion and permission filtering. */\nconst CANDIDATES = 30;\n/** Fused candidates checked for permission; over-fetched so filtering rarely empties the page. */\nconst FUSED = 40;\nconst EXCERPT_CHARS = 1500;\n/** A word in this many chunks or fewer is rare: strong evidence of what the user means. */\nconst RARE_MAX_CHUNKS = 3;\n\nexport interface SearchKnowledgeDeps {\n actionsRegistry: ActionsRegistryService;\n store: KnowledgeStore;\n catalog: CatalogApi;\n auth: AuthService;\n embeddingModel: EmbeddingModel;\n}\n\n/**\n * Registers `search-knowledge`: hybrid retrieval over the knowledge index. Each\n * phrasing is embedded and matched by meaning; the query words are matched\n * exactly (full-text, any word) and, for words absent from the index, by\n * near spelling (trigram). Words found in only a few chunks get a list of\n * their own, so a rare name or acronym buried in a paragraph about something\n * else is not outvoted by sections that are merely near the question.\n *\n * The lists are fused by reciprocal rank with one vote per method: the\n * phrasings share the meaning vote, so extra alternatives broaden recall\n * without drowning out exact matches. Results gated by a catalog entity are\n * filtered to entities the caller can read; the rest are visible to every\n * assistant user, as they are in Backstage search. Repeated text is shown\n * once.\n */\nexport function registerSearchKnowledgeAction({\n actionsRegistry,\n store,\n catalog,\n auth,\n embeddingModel,\n}: SearchKnowledgeDeps): void {\n actionsRegistry.register({\n name: 'search-knowledge',\n title: 'Search Knowledge',\n attributes: { destructive: false, readOnly: true, idempotent: true },\n 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 or source.\n\nHow to search the organization's docs and catalog:\n1. Start here, with the user's question.\n2. If the results don't clearly answer, also run a keyword search: the query tool (catalog and TechDocs at once) when it is available, otherwise search-techdocs and search-catalog. Keyword search finds rare names, acronyms and jargon that ranking by meaning can miss. For a bare name, term or acronym, run both searches from the start.\n3. Report something as missing only after both searches come back empty.\n\nPass the user's question as the \"query\" parameter. Pass up to three \"alternatives\": other phrasings, synonyms, or the internal terms the docs might use.\n\nEach result has a title (its heading path), an excerpt, a location, 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.`,\n schema: {\n input: z =>\n z.object({\n query: z.string().min(1).describe('The question or topic to find'),\n alternatives: z\n .array(z.string())\n .max(3)\n .optional()\n .describe('Other phrasings or terms for the same thing'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(20)\n .optional()\n .default(8)\n .describe('Number of results to return'),\n }),\n output: z =>\n z.object({\n results: z.array(\n z.object({\n title: z.string(),\n location: z.string(),\n entityRef: z.string().optional(),\n excerpt: z.string(),\n }),\n ),\n }),\n },\n action: async ({ input, credentials }) => {\n if (!(await store.isReady())) {\n throw new Error(\n 'The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog.',\n );\n }\n\n const phrasings = [input.query, ...(input.alternatives ?? [])];\n const words = queryWords(phrasings);\n const joined = joinedWords(phrasings).filter(w => !words.includes(w));\n const [{ embeddings }, frequencies] = await Promise.all([\n embedMany({ model: embeddingModel, values: phrasings }),\n store.documentFrequencies([...words, ...joined], RARE_MAX_CHUNKS + 1),\n ]);\n const known = [...words, ...joined].filter(\n w => (frequencies.get(w) ?? 0) > 0,\n );\n const rare = known.filter(w => frequencies.get(w)! <= RARE_MAX_CHUNKS);\n const unknown = words.filter(w => frequencies.get(w) === 0);\n\n const lists: RankedList[] = await Promise.all([\n ...embeddings.map(async embedding => ({\n ids: await store.nearest(embedding, CANDIDATES),\n weight: 1 / embeddings.length,\n })),\n store.keyword(known, CANDIDATES).then(ids => ({ ids, weight: 1 })),\n store.keyword(rare, CANDIDATES).then(ids => ({ ids, weight: 1 })),\n store.fuzzy(unknown, CANDIDATES).then(ids => ({ ids, weight: 1 })),\n ]);\n\n const ranked = fuseRankings(lists).slice(0, FUSED);\n const byId = new Map(\n (await store.chunks(ranked)).map(chunk => [chunk.id, chunk]),\n );\n const candidates = ranked\n .map(id => byId.get(id))\n .filter((chunk): chunk is StoredChunk => Boolean(chunk));\n\n const readable = await readableEntityRefs(catalog, auth, credentials, [\n ...new Set(\n candidates.flatMap(chunk =>\n chunk.entityRef ? [chunk.entityRef] : [],\n ),\n ),\n ]);\n\n const seen = new Set<string>();\n const results = [];\n for (const chunk of candidates) {\n const key = `${chunk.location}|${chunk.title}`;\n if (\n (chunk.entityRef && !readable.has(chunk.entityRef)) ||\n seen.has(key) ||\n seen.has(chunk.content)\n ) {\n continue;\n }\n seen.add(key);\n seen.add(chunk.content);\n results.push({\n title: chunk.title,\n location: chunk.location,\n ...(chunk.entityRef ? { entityRef: chunk.entityRef } : {}),\n excerpt: chunk.content.slice(0, EXCERPT_CHARS),\n });\n if (results.length === input.limit) {\n break;\n }\n }\n return { output: { results } };\n },\n });\n}\n\n/**\n * The subset of entity refs the caller can read, asked of the catalog as the\n * caller — so the catalog's own permission policy decides, and a deleted\n * entity's leftover chunks never surface.\n */\nasync function readableEntityRefs(\n catalog: CatalogApi,\n auth: AuthService,\n credentials: BackstageCredentials,\n entityRefs: string[],\n): Promise<Set<string>> {\n if (entityRefs.length === 0) {\n return new Set();\n }\n const { token } = await auth.getPluginRequestToken({\n onBehalfOf: credentials,\n targetPluginId: 'catalog',\n });\n const { items } = await catalog.getEntitiesByRefs(\n { entityRefs, fields: ['kind', 'metadata.namespace', 'metadata.name'] },\n { token },\n );\n return new Set(entityRefs.filter((_, i) => items[i]));\n}\n"],"names":["queryWords","joinedWords","embedMany","fuseRankings"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"action.cjs.js","sources":["../src/action.ts"],"sourcesContent":["import type {\n AuthService,\n BackstageCredentials,\n} from '@backstage/backend-plugin-api';\nimport type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport type { CatalogApi } from '@backstage/catalog-client';\nimport { embedMany, type EmbeddingModel } from 'ai';\nimport type { KnowledgeTypeSettings } from './config';\nimport { fuseRankings, joinedWords, queryWords, type RankedList } from './rank';\nimport {\n selectTypes,\n typeSettingsOf,\n type KnowledgeCandidate,\n type KnowledgeRanker,\n} from './ranking';\nimport type { KnowledgeStore } from './store';\n\n/** Candidates per type in each retrieval list, before fusion and permission filtering. */\nconst CANDIDATES = 30;\n/** Fused candidates checked for permission and ranked; over-fetched so filtering and type caps rarely empty the page. */\nconst FUSED = 60;\n/** The most results a caller may ask for, unless `maxResults` is configured higher. */\nconst MAX_LIMIT = 20;\nconst EXCERPT_CHARS = 1500;\n/** A word in this many chunks or fewer is rare: strong evidence of what the user means. */\nconst RARE_MAX_CHUNKS = 3;\n\nexport interface SearchKnowledgeDeps {\n actionsRegistry: ActionsRegistryService;\n store: KnowledgeStore;\n catalog: CatalogApi;\n auth: AuthService;\n embeddingModel: EmbeddingModel;\n types: Map<string, KnowledgeTypeSettings>;\n maxResults: number;\n /** The ranker in effect: the default, or one a module set. */\n ranker: () => KnowledgeRanker;\n}\n\n/**\n * Registers `search-knowledge`: hybrid retrieval over the knowledge index. Each\n * phrasing is embedded and matched by meaning; the query words are matched\n * exactly (full-text, any word) and, for words absent from the index, by\n * near spelling (trigram). Words found in only a few chunks get a list of\n * their own, so a rare name or acronym buried in a paragraph about something\n * else is not outvoted by sections that are merely near the question.\n *\n * Each list keeps its best chunks of every document type, so a large source\n * cannot fill a list on its own. The lists are fused by reciprocal rank with\n * one vote per method, each chunk's score multiplied by its type's weight:\n * the phrasings share the meaning vote, so extra alternatives broaden recall\n * without drowning out exact matches. Results gated by a catalog entity are\n * filtered to entities the caller can read; the rest are visible to every\n * assistant user, as they are in Backstage search. Repeated text is shown\n * once, and the ranker picks the results from what remains.\n */\nexport function registerSearchKnowledgeAction({\n actionsRegistry,\n store,\n catalog,\n auth,\n embeddingModel,\n types,\n maxResults,\n ranker,\n}: SearchKnowledgeDeps): void {\n actionsRegistry.register({\n name: 'search-knowledge',\n title: 'Search Knowledge',\n attributes: { destructive: false, readOnly: true, idempotent: true },\n 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.\n${sourcesDescription(types)}\n\nHow to search the organization's docs and catalog:\n1. Start here, with the user's question.\n2. If the results don't clearly answer, also run a keyword search: the query tool (catalog and TechDocs at once) when it is available, otherwise search-techdocs and search-catalog. Keyword search finds rare names, acronyms and jargon that ranking by meaning can miss. For a bare name, term or acronym, run both searches from the start.\n3. Report something as missing only after both searches come back empty.\n\nPass the user's question as the \"query\" parameter. Pass up to three \"alternatives\": other phrasings, synonyms, or the internal terms the docs might use.\n\nEach 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.`,\n schema: {\n input: z =>\n z.object({\n query: z.string().min(1).describe('The question or topic to find'),\n alternatives: z\n .array(z.string())\n .max(3)\n .optional()\n .describe('Other phrasings or terms for the same thing'),\n types: z\n .array(z.string())\n .optional()\n .describe(\n 'Search only these sources (search document types); omit to search the default ones',\n ),\n limit: z\n .number()\n .int()\n .min(1)\n .max(Math.max(MAX_LIMIT, maxResults))\n .optional()\n .default(maxResults)\n .describe('Number of results to return'),\n }),\n output: z =>\n z.object({\n results: z.array(\n z.object({\n title: z.string(),\n location: z.string(),\n type: z.string().optional(),\n entityRef: z.string().optional(),\n metadata: z\n .record(\n z.string(),\n z.union([z.string(), z.number(), z.boolean()]),\n )\n .optional(),\n excerpt: z.string(),\n }),\n ),\n }),\n },\n action: async ({ input, credentials }) => {\n if (!(await store.isReady())) {\n throw new Error(\n 'The knowledge index is still being built; meanwhile use keyword search: the query tool when it is available, otherwise search-techdocs and search-catalog.',\n );\n }\n\n const filter = selectTypes(types, await store.types(), input.types);\n const phrasings = [input.query, ...(input.alternatives ?? [])];\n const words = queryWords(phrasings);\n const joined = joinedWords(phrasings).filter(w => !words.includes(w));\n const [{ embeddings }, frequencies] = await Promise.all([\n embedMany({ model: embeddingModel, values: phrasings }),\n store.documentFrequencies(\n [...words, ...joined],\n RARE_MAX_CHUNKS + 1,\n filter,\n ),\n ]);\n const known = [...words, ...joined].filter(\n w => (frequencies.get(w) ?? 0) > 0,\n );\n const rare = known.filter(w => frequencies.get(w)! <= RARE_MAX_CHUNKS);\n const unknown = words.filter(w => frequencies.get(w) === 0);\n\n const lists: RankedList[] = await Promise.all([\n ...embeddings.map(async embedding => ({\n name: 'semantic' as const,\n hits: await store.nearest(embedding, CANDIDATES, filter),\n weight: 1 / embeddings.length,\n })),\n store\n .keyword(known, CANDIDATES, filter)\n .then(hits => ({ name: 'keyword' as const, hits, weight: 1 })),\n store\n .keyword(rare, CANDIDATES, filter)\n .then(hits => ({ name: 'rareWord' as const, hits, weight: 1 })),\n store\n .fuzzy(unknown, CANDIDATES, filter)\n .then(hits => ({ name: 'fuzzy' as const, hits, weight: 1 })),\n ]);\n\n const settingsOf = typeSettingsOf(types);\n const fused = fuseRankings(lists, type => settingsOf(type).weight).slice(\n 0,\n FUSED,\n );\n const byId = new Map(\n (await store.chunks(fused.map(hit => hit.id))).map(chunk => [\n chunk.id,\n chunk,\n ]),\n );\n const found = fused.flatMap(hit => {\n const chunk = byId.get(hit.id);\n return chunk ? [{ hit, chunk }] : [];\n });\n\n const readable = await readableEntityRefs(catalog, auth, credentials, [\n ...new Set(\n found.flatMap(({ chunk }) =>\n chunk.entityRef ? [chunk.entityRef] : [],\n ),\n ),\n ]);\n\n const seen = new Set<string>();\n const candidates: KnowledgeCandidate[] = [];\n for (const { hit, chunk } of found) {\n const key = `${chunk.location}|${chunk.title}`;\n if (\n (chunk.entityRef && !readable.has(chunk.entityRef)) ||\n seen.has(key) ||\n seen.has(chunk.content)\n ) {\n continue;\n }\n seen.add(key);\n seen.add(chunk.content);\n candidates.push({ ...chunk, score: hit.score, ranks: hit.ranks });\n }\n\n const ids = new Set(candidates.map(candidate => candidate.id));\n const ranked = await ranker()({\n query: input.query,\n phrasings,\n types: filter.types,\n candidates,\n maxResults: input.limit,\n });\n const results = ranked\n .filter(candidate => ids.delete(candidate.id))\n .slice(0, input.limit)\n .map(candidate => ({\n title: candidate.title,\n location: candidate.location,\n ...(candidate.type ? { type: candidate.type } : {}),\n ...(candidate.entityRef ? { entityRef: candidate.entityRef } : {}),\n ...(candidate.metadata ? { metadata: candidate.metadata } : {}),\n excerpt: candidate.content.slice(0, EXCERPT_CHARS),\n }));\n return { output: { results } };\n },\n });\n}\n\n/**\n * The tool description's paragraph on sources: the configured types, which\n * ones are searched only when named, and what each holds.\n */\nexport function sourcesDescription(\n types: Map<string, KnowledgeTypeSettings>,\n): string {\n const listed = [...types].filter(\n ([, settings]) => settings.enabled && settings.weight > 0,\n );\n const lines = listed.map(\n ([type, settings]) =>\n `- ${type}${settings.default ? '' : ' (only when named in \"types\")'}${\n settings.description ? `: ${settings.description}` : ''\n }`,\n );\n return [\n '',\n `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.`,\n ...lines,\n ].join('\\n');\n}\n\n/**\n * The subset of entity refs the caller can read, asked of the catalog as the\n * caller — so the catalog's own permission policy decides, and a deleted\n * entity's leftover chunks never surface.\n */\nasync function readableEntityRefs(\n catalog: CatalogApi,\n auth: AuthService,\n credentials: BackstageCredentials,\n entityRefs: string[],\n): Promise<Set<string>> {\n if (entityRefs.length === 0) {\n return new Set();\n }\n const { token } = await auth.getPluginRequestToken({\n onBehalfOf: credentials,\n targetPluginId: 'catalog',\n });\n const { items } = await catalog.getEntitiesByRefs(\n { entityRefs, fields: ['kind', 'metadata.namespace', 'metadata.name'] },\n { token },\n );\n return new Set(entityRefs.filter((_, i) => items[i]));\n}\n"],"names":["selectTypes","queryWords","joinedWords","embedMany","typeSettingsOf","fuseRankings"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"alpha.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;"}
|
package/dist/alpha.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One `search-knowledge` result candidate, after fusion and the permission
|
|
5
|
+
* check.
|
|
6
|
+
*
|
|
7
|
+
* @alpha
|
|
8
|
+
*/
|
|
9
|
+
interface KnowledgeCandidate {
|
|
10
|
+
/** The chunk's stable id. */
|
|
11
|
+
id: string;
|
|
12
|
+
/** The Backstage search document type; absent for types not named in `assistants.semanticSearch.types`. */
|
|
13
|
+
type?: string;
|
|
14
|
+
/** The catalog entity gating it, for catalog and TechDocs results. */
|
|
15
|
+
entityRef?: string;
|
|
16
|
+
/** The search result location: a TechDocs path or a link. */
|
|
17
|
+
location: string;
|
|
18
|
+
/** Heading path, e.g. `Pylon › Messaging › Dead-letter handling`. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** The chunk's text, up to ~1600 characters. */
|
|
21
|
+
content: string;
|
|
22
|
+
/** The chunk's 1-based rank in each retrieval list it appears in. */
|
|
23
|
+
ranks: {
|
|
24
|
+
semantic?: number;
|
|
25
|
+
keyword?: number;
|
|
26
|
+
rareWord?: number;
|
|
27
|
+
fuzzy?: number;
|
|
28
|
+
};
|
|
29
|
+
/** Reciprocal rank fusion score, after the type's configured weight. */
|
|
30
|
+
score: number;
|
|
31
|
+
/** The short fields the collator attached (status, space, updated, …). */
|
|
32
|
+
metadata?: Record<string, string | number | boolean>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What a {@link KnowledgeRanker} picks from.
|
|
36
|
+
*
|
|
37
|
+
* @alpha
|
|
38
|
+
*/
|
|
39
|
+
interface KnowledgeRankerInput {
|
|
40
|
+
/** The caller's question. */
|
|
41
|
+
query: string;
|
|
42
|
+
/** The query and the caller's alternatives. */
|
|
43
|
+
phrasings: string[];
|
|
44
|
+
/** The types searched: those the caller named, or the default ones. */
|
|
45
|
+
types: string[];
|
|
46
|
+
/** Candidates the caller may read, best fused score first, with repeated text removed. */
|
|
47
|
+
candidates: KnowledgeCandidate[];
|
|
48
|
+
/** How many results the caller gets. */
|
|
49
|
+
maxResults: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Picks and orders the results from the candidates. Results it returns that
|
|
53
|
+
* are not among the candidates are dropped, and the list is cut to
|
|
54
|
+
* `maxResults`.
|
|
55
|
+
*
|
|
56
|
+
* @alpha
|
|
57
|
+
*/
|
|
58
|
+
type KnowledgeRanker = (input: KnowledgeRankerInput) => Promise<KnowledgeCandidate[]> | KnowledgeCandidate[];
|
|
59
|
+
/**
|
|
60
|
+
* Replaces how `search-knowledge` picks its results.
|
|
61
|
+
*
|
|
62
|
+
* @alpha
|
|
63
|
+
*/
|
|
64
|
+
interface KnowledgeRankingExtensionPoint {
|
|
65
|
+
/** Replaces the default ranker (type caps, then `maxResults`). */
|
|
66
|
+
setRanker(ranker: KnowledgeRanker): void;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lets a backend module replace how `search-knowledge` picks its results,
|
|
70
|
+
* for example to favour recent tickets or the caller's team. The ranker sees
|
|
71
|
+
* only candidates the caller may read.
|
|
72
|
+
*
|
|
73
|
+
* @alpha
|
|
74
|
+
*/
|
|
75
|
+
declare const knowledgeRankingExtensionPoint: _backstage_backend_plugin_api.ExtensionPoint<KnowledgeRankingExtensionPoint>;
|
|
76
|
+
|
|
77
|
+
export { knowledgeRankingExtensionPoint };
|
|
78
|
+
export type { KnowledgeCandidate, KnowledgeRanker, KnowledgeRankerInput, KnowledgeRankingExtensionPoint };
|
package/dist/chunk.cjs.js
CHANGED
|
@@ -5,13 +5,18 @@ var node_crypto = require('node:crypto');
|
|
|
5
5
|
const MAX_CHARS = 1600;
|
|
6
6
|
const OVERLAP_CHARS = 200;
|
|
7
7
|
function chunkDocument(doc) {
|
|
8
|
+
const metadataHash = sha256(
|
|
9
|
+
JSON.stringify(Object.entries(doc.metadata ?? {}).sort())
|
|
10
|
+
);
|
|
8
11
|
return splitText(doc.text).map((content, index) => ({
|
|
9
12
|
id: sha256(`${doc.location}|${index}`),
|
|
10
13
|
...doc.entityRef ? { entityRef: doc.entityRef } : {},
|
|
11
14
|
location: doc.location,
|
|
12
15
|
title: doc.title,
|
|
13
16
|
content,
|
|
14
|
-
hash: sha256(embeddingInput(doc.title, content))
|
|
17
|
+
hash: sha256(embeddingInput(doc.title, content)),
|
|
18
|
+
...doc.metadata ? { metadata: doc.metadata } : {},
|
|
19
|
+
metadataHash
|
|
15
20
|
}));
|
|
16
21
|
}
|
|
17
22
|
function embeddingInput(title, content) {
|
package/dist/chunk.cjs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunk.cjs.js","sources":["../src/chunk.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { SourceDocument } from './documents';\n\n/** A stored, embedded unit of a {@link SourceDocument}. */\nexport interface Chunk {\n id: string;\n entityRef?: string;\n location: string;\n title: string;\n content: string;\n /** Hash of what gets embedded; unchanged hash = no re-embedding. */\n hash: string;\n}\n\nconst MAX_CHARS = 1600;\nconst OVERLAP_CHARS = 200;\n\n/**\n * Splits a document into chunks of at most ~1600 characters on word\n * boundaries, overlapping by ~200 so a sentence cut at a boundary survives in\n * one piece. TechDocs sections are usually one chunk already.\n */\nexport function chunkDocument(doc: SourceDocument): Chunk[] {\n return splitText(doc.text).map((content, index) => ({\n id: sha256(`${doc.location}|${index}`),\n ...(doc.entityRef ? { entityRef: doc.entityRef } : {}),\n location: doc.location,\n title: doc.title,\n content,\n hash: sha256(embeddingInput(doc.title, content)),\n }));\n}\n\n/** The text a chunk is embedded as: heading path first, so a terse section keeps its context. */\nexport function embeddingInput(title: string, content: string): string {\n return `${title}\\n\\n${content}`;\n}\n\nexport function splitText(text: string): string[] {\n if (text.length <= MAX_CHARS) {\n return [text];\n }\n const pieces: string[] = [];\n let start = 0;\n while (start < text.length) {\n let end = Math.min(start + MAX_CHARS, text.length);\n if (end < text.length) {\n const space = text.lastIndexOf(' ', end);\n if (space > start + MAX_CHARS / 2) {\n end = space;\n }\n }\n pieces.push(text.slice(start, end).trim());\n if (end >= text.length) {\n break;\n }\n const restart = text.indexOf(' ', end - OVERLAP_CHARS);\n start = restart > start && restart < end ? restart + 1 : end;\n }\n return pieces.filter(Boolean);\n}\n\nfunction sha256(value: string): string {\n return createHash('sha256').update(value).digest('hex');\n}\n"],"names":["createHash"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"chunk.cjs.js","sources":["../src/chunk.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport type { KnowledgeMetadata, SourceDocument } from './documents';\n\n/** A stored, embedded unit of a {@link SourceDocument}. */\nexport interface Chunk {\n id: string;\n entityRef?: string;\n location: string;\n title: string;\n content: string;\n /** Hash of what gets embedded; unchanged hash = no re-embedding. */\n hash: string;\n metadata?: KnowledgeMetadata;\n /** Hash of the metadata; a change alone rewrites the row without re-embedding. */\n metadataHash: string;\n}\n\nconst MAX_CHARS = 1600;\nconst OVERLAP_CHARS = 200;\n\n/**\n * Splits a document into chunks of at most ~1600 characters on word\n * boundaries, overlapping by ~200 so a sentence cut at a boundary survives in\n * one piece. TechDocs sections are usually one chunk already.\n */\nexport function chunkDocument(doc: SourceDocument): Chunk[] {\n const metadataHash = sha256(\n JSON.stringify(Object.entries(doc.metadata ?? {}).sort()),\n );\n return splitText(doc.text).map((content, index) => ({\n id: sha256(`${doc.location}|${index}`),\n ...(doc.entityRef ? { entityRef: doc.entityRef } : {}),\n location: doc.location,\n title: doc.title,\n content,\n hash: sha256(embeddingInput(doc.title, content)),\n ...(doc.metadata ? { metadata: doc.metadata } : {}),\n metadataHash,\n }));\n}\n\n/** The text a chunk is embedded as: heading path first, so a terse section keeps its context. */\nexport function embeddingInput(title: string, content: string): string {\n return `${title}\\n\\n${content}`;\n}\n\nexport function splitText(text: string): string[] {\n if (text.length <= MAX_CHARS) {\n return [text];\n }\n const pieces: string[] = [];\n let start = 0;\n while (start < text.length) {\n let end = Math.min(start + MAX_CHARS, text.length);\n if (end < text.length) {\n const space = text.lastIndexOf(' ', end);\n if (space > start + MAX_CHARS / 2) {\n end = space;\n }\n }\n pieces.push(text.slice(start, end).trim());\n if (end >= text.length) {\n break;\n }\n const restart = text.indexOf(' ', end - OVERLAP_CHARS);\n start = restart > start && restart < end ? restart + 1 : end;\n }\n return pieces.filter(Boolean);\n}\n\nfunction sha256(value: string): string {\n return createHash('sha256').update(value).digest('hex');\n}\n"],"names":["createHash"],"mappings":";;;;AAiBA,MAAM,SAAA,GAAY,IAAA;AAClB,MAAM,aAAA,GAAgB,GAAA;AAOf,SAAS,cAAc,GAAA,EAA8B;AAC1D,EAAA,MAAM,YAAA,GAAe,MAAA;AAAA,IACnB,IAAA,CAAK,SAAA,CAAU,MAAA,CAAO,OAAA,CAAQ,GAAA,CAAI,YAAY,EAAE,CAAA,CAAE,IAAA,EAAM;AAAA,GAC1D;AACA,EAAA,OAAO,UAAU,GAAA,CAAI,IAAI,EAAE,GAAA,CAAI,CAAC,SAAS,KAAA,MAAW;AAAA,IAClD,IAAI,MAAA,CAAO,CAAA,EAAG,IAAI,QAAQ,CAAA,CAAA,EAAI,KAAK,CAAA,CAAE,CAAA;AAAA,IACrC,GAAI,IAAI,SAAA,GAAY,EAAE,WAAW,GAAA,CAAI,SAAA,KAAc,EAAC;AAAA,IACpD,UAAU,GAAA,CAAI,QAAA;AAAA,IACd,OAAO,GAAA,CAAI,KAAA;AAAA,IACX,OAAA;AAAA,IACA,MAAM,MAAA,CAAO,cAAA,CAAe,GAAA,CAAI,KAAA,EAAO,OAAO,CAAC,CAAA;AAAA,IAC/C,GAAI,IAAI,QAAA,GAAW,EAAE,UAAU,GAAA,CAAI,QAAA,KAAa,EAAC;AAAA,IACjD;AAAA,GACF,CAAE,CAAA;AACJ;AAGO,SAAS,cAAA,CAAe,OAAe,OAAA,EAAyB;AACrE,EAAA,OAAO,GAAG,KAAK;;AAAA,EAAO,OAAO,CAAA,CAAA;AAC/B;AAEO,SAAS,UAAU,IAAA,EAAwB;AAChD,EAAA,IAAI,IAAA,CAAK,UAAU,SAAA,EAAW;AAC5B,IAAA,OAAO,CAAC,IAAI,CAAA;AAAA,EACd;AACA,EAAA,MAAM,SAAmB,EAAC;AAC1B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,OAAO,KAAA,GAAQ,KAAK,MAAA,EAAQ;AAC1B,IAAA,IAAI,MAAM,IAAA,CAAK,GAAA,CAAI,KAAA,GAAQ,SAAA,EAAW,KAAK,MAAM,CAAA;AACjD,IAAA,IAAI,GAAA,GAAM,KAAK,MAAA,EAAQ;AACrB,MAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,WAAA,CAAY,GAAA,EAAK,GAAG,CAAA;AACvC,MAAA,IAAI,KAAA,GAAQ,KAAA,GAAQ,SAAA,GAAY,CAAA,EAAG;AACjC,QAAA,GAAA,GAAM,KAAA;AAAA,MACR;AAAA,IACF;AACA,IAAA,MAAA,CAAO,KAAK,IAAA,CAAK,KAAA,CAAM,OAAO,GAAG,CAAA,CAAE,MAAM,CAAA;AACzC,IAAA,IAAI,GAAA,IAAO,KAAK,MAAA,EAAQ;AACtB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,OAAA,CAAQ,GAAA,EAAK,MAAM,aAAa,CAAA;AACrD,IAAA,KAAA,GAAQ,OAAA,GAAU,KAAA,IAAS,OAAA,GAAU,GAAA,GAAM,UAAU,CAAA,GAAI,GAAA;AAAA,EAC3D;AACA,EAAA,OAAO,MAAA,CAAO,OAAO,OAAO,CAAA;AAC9B;AAEA,SAAS,OAAO,KAAA,EAAuB;AACrC,EAAA,OAAOA,uBAAW,QAAQ,CAAA,CAAE,OAAO,KAAK,CAAA,CAAE,OAAO,KAAK,CAAA;AACxD;;;;;;"}
|