promptgraph-mcp 2.8.1 → 2.8.3
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 +205 -205
- package/ann.js +33 -33
- package/api.js +202 -202
- package/bundle-counts.js +111 -111
- package/chunker.js +28 -28
- package/cli.js +115 -115
- package/commands/bundle.js +150 -150
- package/commands/doctor.js +15 -15
- package/commands/import.js +7 -7
- package/commands/init.js +37 -37
- package/commands/marketplace.js +146 -146
- package/commands/reindex.js +10 -10
- package/commands/search.js +55 -55
- package/commands/setup.js +19 -19
- package/commands/status.js +110 -110
- package/commands/train.js +18 -18
- package/commands/update.js +49 -49
- package/commands/validate.js +63 -63
- package/config.js +72 -72
- package/db.js +157 -157
- package/doctor.js +48 -48
- package/embedder.js +54 -54
- package/github-import.js +750 -745
- package/indexer.js +310 -310
- package/package.json +61 -61
- package/parser.js +69 -69
- package/pg-hook.js +70 -70
- package/platform.js +120 -120
- package/search.js +216 -216
- package/src/filter/classifier.js +88 -88
- package/src/filter/hard-filter.js +62 -62
- package/src/filter/train.js +66 -66
- package/src/reranker/reranker.js +92 -92
- package/src/store/flat-store.js +61 -61
- package/src/store/hnsw-store.js +187 -187
- package/src/store/index.js +19 -19
- package/src/store/vector-store.js +9 -9
- package/src/utils/rate-limiter.js +33 -33
- package/tui.js +418 -418
- package/validate-repo-action.js +139 -139
- package/watcher.js +84 -84
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
|
|
3
|
-
const SKIP_FILENAMES = new Set([
|
|
4
|
-
'readme', 'changelog', 'license', 'contributing', 'code-of-conduct',
|
|
5
|
-
'security', 'authors', 'credits', 'install', 'installation', 'usage',
|
|
6
|
-
'engagements', 'contributors', 'maintainers', 'acknowledgements',
|
|
7
|
-
'faq', 'glossary', 'index', 'overview', 'summary', 'roadmap', 'todo',
|
|
8
|
-
'notes', 'template', 'example', 'sample', 'demo', 'getting-started',
|
|
9
|
-
'quickstart', 'guide', 'tutorial', 'walkthrough', 'architecture',
|
|
10
|
-
'design', 'spec', 'specification', 'requirements', 'privacy', 'terms',
|
|
11
|
-
'disclaimer', 'notice', 'copying', 'warranty', 'codeofconduct',
|
|
12
|
-
'pull_request_template', 'issue_template', 'funding',
|
|
13
|
-
'claude', 'bugs', 'bug_report', 'bug-report', 'feature_request',
|
|
14
|
-
'feature-request',
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
const SKIP_FILENAME_RE = /^(_|\.)|^v?\d+[\.\-]\d+|^\d{4}[\-_]\d{2}|^readme|^license|^changelog|^contributing|^code.of.conduct|^security|^authors|^credits|^disclaimer|^notice|^copying|^warranty|^promotion|^funding|^claude|^bugs?\b|^feature.?request/i;
|
|
18
|
-
|
|
19
|
-
const SKIP_DIRS = new Set([
|
|
20
|
-
'.github', 'docs', 'doc', 'documentation', 'examples', 'example',
|
|
21
|
-
'tests', 'test', '__tests__', 'spec', 'fixtures', 'assets', 'images',
|
|
22
|
-
'img', 'screenshots', 'media', 'static', 'public', 'dist', 'build',
|
|
23
|
-
'node_modules', 'vendor', 'third_party',
|
|
24
|
-
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
25
|
-
'cheatsheets', 'resources',
|
|
26
|
-
'src', 'cli', 'lib', 'bin',
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
const BADGE_RE = /!\[.*\]\(https?:\/\/(img\.shields\.io|badge\.fury|travis-ci|github\.com\/[^)]+\/badge)/i;
|
|
30
|
-
const README_HEADER_RE = /^#\s*readme\b/i;
|
|
31
|
-
|
|
32
|
-
export function hardFilter(filePath, raw) {
|
|
33
|
-
const parts = filePath.replace(/\\/g, '/').split('/');
|
|
34
|
-
const filename = parts[parts.length - 1];
|
|
35
|
-
const base = filename.replace(/\.md$/i, '').toLowerCase();
|
|
36
|
-
|
|
37
|
-
if (SKIP_FILENAMES.has(base)) {
|
|
38
|
-
return { pass: false, reason: `skip filename: ${base}` };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (SKIP_FILENAME_RE.test(base)) {
|
|
42
|
-
return { pass: false, reason: `skip filename pattern: ${base}` };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (const part of parts.slice(0, -1)) {
|
|
46
|
-
if (SKIP_DIRS.has(part.toLowerCase())) {
|
|
47
|
-
return { pass: false, reason: `skip dir: ${part}` };
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (raw) {
|
|
52
|
-
const firstLines = raw.trimStart().slice(0, 300);
|
|
53
|
-
if (README_HEADER_RE.test(firstLines)) {
|
|
54
|
-
return { pass: false, reason: 'starts with # Readme' };
|
|
55
|
-
}
|
|
56
|
-
if (BADGE_RE.test(firstLines)) {
|
|
57
|
-
return { pass: false, reason: 'badge-only content' };
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return { pass: true };
|
|
62
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
const SKIP_FILENAMES = new Set([
|
|
4
|
+
'readme', 'changelog', 'license', 'contributing', 'code-of-conduct',
|
|
5
|
+
'security', 'authors', 'credits', 'install', 'installation', 'usage',
|
|
6
|
+
'engagements', 'contributors', 'maintainers', 'acknowledgements',
|
|
7
|
+
'faq', 'glossary', 'index', 'overview', 'summary', 'roadmap', 'todo',
|
|
8
|
+
'notes', 'template', 'example', 'sample', 'demo', 'getting-started',
|
|
9
|
+
'quickstart', 'guide', 'tutorial', 'walkthrough', 'architecture',
|
|
10
|
+
'design', 'spec', 'specification', 'requirements', 'privacy', 'terms',
|
|
11
|
+
'disclaimer', 'notice', 'copying', 'warranty', 'codeofconduct',
|
|
12
|
+
'pull_request_template', 'issue_template', 'funding',
|
|
13
|
+
'claude', 'bugs', 'bug_report', 'bug-report', 'feature_request',
|
|
14
|
+
'feature-request',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const SKIP_FILENAME_RE = /^(_|\.)|^v?\d+[\.\-]\d+|^\d{4}[\-_]\d{2}|^readme|^license|^changelog|^contributing|^code.of.conduct|^security|^authors|^credits|^disclaimer|^notice|^copying|^warranty|^promotion|^funding|^claude|^bugs?\b|^feature.?request/i;
|
|
18
|
+
|
|
19
|
+
const SKIP_DIRS = new Set([
|
|
20
|
+
'.github', 'docs', 'doc', 'documentation', 'examples', 'example',
|
|
21
|
+
'tests', 'test', '__tests__', 'spec', 'fixtures', 'assets', 'images',
|
|
22
|
+
'img', 'screenshots', 'media', 'static', 'public', 'dist', 'build',
|
|
23
|
+
'node_modules', 'vendor', 'third_party',
|
|
24
|
+
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
25
|
+
'cheatsheets', 'resources',
|
|
26
|
+
'src', 'cli', 'lib', 'bin',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const BADGE_RE = /!\[.*\]\(https?:\/\/(img\.shields\.io|badge\.fury|travis-ci|github\.com\/[^)]+\/badge)/i;
|
|
30
|
+
const README_HEADER_RE = /^#\s*readme\b/i;
|
|
31
|
+
|
|
32
|
+
export function hardFilter(filePath, raw) {
|
|
33
|
+
const parts = filePath.replace(/\\/g, '/').split('/');
|
|
34
|
+
const filename = parts[parts.length - 1];
|
|
35
|
+
const base = filename.replace(/\.md$/i, '').toLowerCase();
|
|
36
|
+
|
|
37
|
+
if (SKIP_FILENAMES.has(base)) {
|
|
38
|
+
return { pass: false, reason: `skip filename: ${base}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (SKIP_FILENAME_RE.test(base)) {
|
|
42
|
+
return { pass: false, reason: `skip filename pattern: ${base}` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const part of parts.slice(0, -1)) {
|
|
46
|
+
if (SKIP_DIRS.has(part.toLowerCase())) {
|
|
47
|
+
return { pass: false, reason: `skip dir: ${part}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (raw) {
|
|
52
|
+
const firstLines = raw.trimStart().slice(0, 300);
|
|
53
|
+
if (README_HEADER_RE.test(firstLines)) {
|
|
54
|
+
return { pass: false, reason: 'starts with # Readme' };
|
|
55
|
+
}
|
|
56
|
+
if (BADGE_RE.test(firstLines)) {
|
|
57
|
+
return { pass: false, reason: 'badge-only content' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { pass: true };
|
|
62
|
+
}
|
package/src/filter/train.js
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
import { globSync } from 'glob';
|
|
6
|
-
import { embedBatch } from '../../embedder.js';
|
|
7
|
-
|
|
8
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const TRAINING_DIR = path.resolve(__dirname, '../../registry/training');
|
|
10
|
-
const MODEL_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'model.json');
|
|
11
|
-
|
|
12
|
-
function readAllMd(dir) {
|
|
13
|
-
const files = globSync(`${dir}/**/*.md`);
|
|
14
|
-
return files.map(f => fs.readFileSync(f, 'utf8'));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function meanVector(vectors) {
|
|
18
|
-
if (!vectors.length) return null;
|
|
19
|
-
const dim = vectors[0].length;
|
|
20
|
-
const centroid = new Float32Array(dim);
|
|
21
|
-
for (const v of vectors) {
|
|
22
|
-
for (let i = 0; i < dim; i++) centroid[i] += v[i];
|
|
23
|
-
}
|
|
24
|
-
for (let i = 0; i < dim; i++) centroid[i] /= vectors.length;
|
|
25
|
-
return Array.from(centroid);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function train() {
|
|
29
|
-
const goodDir = path.join(TRAINING_DIR, 'good');
|
|
30
|
-
const badDir = path.join(TRAINING_DIR, 'bad');
|
|
31
|
-
|
|
32
|
-
const goodTexts = readAllMd(goodDir);
|
|
33
|
-
const badTexts = readAllMd(badDir);
|
|
34
|
-
|
|
35
|
-
if (goodTexts.length < 1 || badTexts.length < 1) {
|
|
36
|
-
throw new Error(`Need at least 1 good and 1 bad training example (good: ${goodTexts.length}, bad: ${badTexts.length})`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const allTexts = [...goodTexts, ...badTexts];
|
|
40
|
-
const allVecs = await embedBatch(allTexts);
|
|
41
|
-
|
|
42
|
-
const goodVecs = allVecs.slice(0, goodTexts.length);
|
|
43
|
-
const badVecs = allVecs.slice(goodTexts.length);
|
|
44
|
-
|
|
45
|
-
const model = {
|
|
46
|
-
version: 1,
|
|
47
|
-
good: meanVector(goodVecs),
|
|
48
|
-
bad: meanVector(badVecs),
|
|
49
|
-
counts: { good: goodTexts.length, bad: badTexts.length },
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
fs.mkdirSync(path.dirname(MODEL_PATH), { recursive: true });
|
|
53
|
-
fs.writeFileSync(MODEL_PATH, JSON.stringify(model));
|
|
54
|
-
return model;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function loadModel() {
|
|
58
|
-
try {
|
|
59
|
-
const raw = fs.readFileSync(MODEL_PATH, 'utf8');
|
|
60
|
-
return JSON.parse(raw);
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export { MODEL_PATH };
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { globSync } from 'glob';
|
|
6
|
+
import { embedBatch } from '../../embedder.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const TRAINING_DIR = path.resolve(__dirname, '../../registry/training');
|
|
10
|
+
const MODEL_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'model.json');
|
|
11
|
+
|
|
12
|
+
function readAllMd(dir) {
|
|
13
|
+
const files = globSync(`${dir}/**/*.md`);
|
|
14
|
+
return files.map(f => fs.readFileSync(f, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function meanVector(vectors) {
|
|
18
|
+
if (!vectors.length) return null;
|
|
19
|
+
const dim = vectors[0].length;
|
|
20
|
+
const centroid = new Float32Array(dim);
|
|
21
|
+
for (const v of vectors) {
|
|
22
|
+
for (let i = 0; i < dim; i++) centroid[i] += v[i];
|
|
23
|
+
}
|
|
24
|
+
for (let i = 0; i < dim; i++) centroid[i] /= vectors.length;
|
|
25
|
+
return Array.from(centroid);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function train() {
|
|
29
|
+
const goodDir = path.join(TRAINING_DIR, 'good');
|
|
30
|
+
const badDir = path.join(TRAINING_DIR, 'bad');
|
|
31
|
+
|
|
32
|
+
const goodTexts = readAllMd(goodDir);
|
|
33
|
+
const badTexts = readAllMd(badDir);
|
|
34
|
+
|
|
35
|
+
if (goodTexts.length < 1 || badTexts.length < 1) {
|
|
36
|
+
throw new Error(`Need at least 1 good and 1 bad training example (good: ${goodTexts.length}, bad: ${badTexts.length})`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const allTexts = [...goodTexts, ...badTexts];
|
|
40
|
+
const allVecs = await embedBatch(allTexts);
|
|
41
|
+
|
|
42
|
+
const goodVecs = allVecs.slice(0, goodTexts.length);
|
|
43
|
+
const badVecs = allVecs.slice(goodTexts.length);
|
|
44
|
+
|
|
45
|
+
const model = {
|
|
46
|
+
version: 1,
|
|
47
|
+
good: meanVector(goodVecs),
|
|
48
|
+
bad: meanVector(badVecs),
|
|
49
|
+
counts: { good: goodTexts.length, bad: badTexts.length },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
fs.mkdirSync(path.dirname(MODEL_PATH), { recursive: true });
|
|
53
|
+
fs.writeFileSync(MODEL_PATH, JSON.stringify(model));
|
|
54
|
+
return model;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadModel() {
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs.readFileSync(MODEL_PATH, 'utf8');
|
|
60
|
+
return JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { MODEL_PATH };
|
package/src/reranker/reranker.js
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
// Term-overlap reranker with phrase matching and IDF-like weighting.
|
|
2
|
-
// Replace `rerank()` with a cross-encoder (BGE Reranker / MiniLM via ONNX)
|
|
3
|
-
// for deeper semantic reranking. This class is the plug-in point.
|
|
4
|
-
export class Reranker {
|
|
5
|
-
constructor(modelName = 'default', device = 'cpu') {
|
|
6
|
-
this.modelName = modelName
|
|
7
|
-
this.device = device
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
rerank(query, results, topK = 5) {
|
|
11
|
-
if (!results || results.length === 0) return []
|
|
12
|
-
|
|
13
|
-
const rawTerms = (query.toLowerCase().match(/\w+/g) || [])
|
|
14
|
-
if (rawTerms.length === 0) return results.slice(0, topK)
|
|
15
|
-
|
|
16
|
-
const unigrams = rawTerms
|
|
17
|
-
const bigrams = []
|
|
18
|
-
const trigrams = []
|
|
19
|
-
for (let i = 0; i < rawTerms.length - 1; i++) bigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1])
|
|
20
|
-
for (let i = 0; i < rawTerms.length - 2; i++) trigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1] + ' ' + rawTerms[i + 2])
|
|
21
|
-
|
|
22
|
-
const texts = results.map(r => (r.text || '').toLowerCase())
|
|
23
|
-
|
|
24
|
-
// Term doc-frequency
|
|
25
|
-
const termDf = {}
|
|
26
|
-
for (const t of unigrams) {
|
|
27
|
-
termDf[t] = texts.filter(txt => txt.includes(t)).length
|
|
28
|
-
}
|
|
29
|
-
const nResults = results.length
|
|
30
|
-
|
|
31
|
-
// First pass: compute raw tfIdf for each result
|
|
32
|
-
const tfIdfValues = texts.map(text => {
|
|
33
|
-
let sum = 0
|
|
34
|
-
for (const term of unigrams) {
|
|
35
|
-
const df = termDf[term] || 1
|
|
36
|
-
const idf = 1 + Math.log10((nResults + 1) / df)
|
|
37
|
-
let idx = -1
|
|
38
|
-
let count = 0
|
|
39
|
-
while ((idx = text.indexOf(term, idx + 1)) !== -1) count++
|
|
40
|
-
sum += count * idf
|
|
41
|
-
}
|
|
42
|
-
return sum
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
const maxTfIdf = Math.max(...tfIdfValues, 1)
|
|
46
|
-
|
|
47
|
-
const scored = results.map((r, idx) => {
|
|
48
|
-
const text = texts[idx]
|
|
49
|
-
const lines = text.split('\n')
|
|
50
|
-
|
|
51
|
-
// 1. N-gram overlap
|
|
52
|
-
const unigramMatch = unigrams.filter(t => text.includes(t)).length / unigrams.length
|
|
53
|
-
const bigramMatch = bigrams.length > 0 ? bigrams.filter(b => text.includes(b)).length / bigrams.length : 0
|
|
54
|
-
const trigramMatch = trigrams.length > 0 ? trigrams.filter(t => text.includes(t)).length / trigrams.length : 0
|
|
55
|
-
const ngramScore = 0.4 * unigramMatch + 0.35 * bigramMatch + 0.25 * trigramMatch
|
|
56
|
-
|
|
57
|
-
// 2. IDF-weighted TF (normalised by max across results)
|
|
58
|
-
const tfIdfScore = tfIdfValues[idx] / maxTfIdf
|
|
59
|
-
|
|
60
|
-
// 3. Exact phrase proximity — consecutive same-order term matches
|
|
61
|
-
let phraseBoost = 0
|
|
62
|
-
if (rawTerms.length >= 2) {
|
|
63
|
-
const textWords = text.split(/\s+/)
|
|
64
|
-
let bestRun = 0
|
|
65
|
-
for (let i = 0; i < textWords.length - rawTerms.length + 1; i++) {
|
|
66
|
-
let matchLen = 0
|
|
67
|
-
for (let j = 0; j < rawTerms.length; j++) {
|
|
68
|
-
if (textWords[i + j] === rawTerms[j]) matchLen++
|
|
69
|
-
else break
|
|
70
|
-
}
|
|
71
|
-
if (matchLen >= 2) bestRun = Math.max(bestRun, matchLen)
|
|
72
|
-
}
|
|
73
|
-
phraseBoost = bestRun / rawTerms.length
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// 4. Header position boost
|
|
77
|
-
const headerText = lines.slice(0, 3).join(' ').toLowerCase()
|
|
78
|
-
const headerOverlap = unigrams.filter(t => headerText.includes(t)).length / unigrams.length
|
|
79
|
-
|
|
80
|
-
// Blend
|
|
81
|
-
const overlapScore = 0.25 * ngramScore + 0.25 * tfIdfScore + 0.25 * phraseBoost + 0.15 * headerOverlap + 0.10 * unigramMatch
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
id: r.id,
|
|
85
|
-
text: r.text,
|
|
86
|
-
score: 0.70 * (r.score || 0) + 0.30 * overlapScore,
|
|
87
|
-
}
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
return scored.sort((a, b) => b.score - a.score).slice(0, topK)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
1
|
+
// Term-overlap reranker with phrase matching and IDF-like weighting.
|
|
2
|
+
// Replace `rerank()` with a cross-encoder (BGE Reranker / MiniLM via ONNX)
|
|
3
|
+
// for deeper semantic reranking. This class is the plug-in point.
|
|
4
|
+
export class Reranker {
|
|
5
|
+
constructor(modelName = 'default', device = 'cpu') {
|
|
6
|
+
this.modelName = modelName
|
|
7
|
+
this.device = device
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
rerank(query, results, topK = 5) {
|
|
11
|
+
if (!results || results.length === 0) return []
|
|
12
|
+
|
|
13
|
+
const rawTerms = (query.toLowerCase().match(/\w+/g) || [])
|
|
14
|
+
if (rawTerms.length === 0) return results.slice(0, topK)
|
|
15
|
+
|
|
16
|
+
const unigrams = rawTerms
|
|
17
|
+
const bigrams = []
|
|
18
|
+
const trigrams = []
|
|
19
|
+
for (let i = 0; i < rawTerms.length - 1; i++) bigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1])
|
|
20
|
+
for (let i = 0; i < rawTerms.length - 2; i++) trigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1] + ' ' + rawTerms[i + 2])
|
|
21
|
+
|
|
22
|
+
const texts = results.map(r => (r.text || '').toLowerCase())
|
|
23
|
+
|
|
24
|
+
// Term doc-frequency
|
|
25
|
+
const termDf = {}
|
|
26
|
+
for (const t of unigrams) {
|
|
27
|
+
termDf[t] = texts.filter(txt => txt.includes(t)).length
|
|
28
|
+
}
|
|
29
|
+
const nResults = results.length
|
|
30
|
+
|
|
31
|
+
// First pass: compute raw tfIdf for each result
|
|
32
|
+
const tfIdfValues = texts.map(text => {
|
|
33
|
+
let sum = 0
|
|
34
|
+
for (const term of unigrams) {
|
|
35
|
+
const df = termDf[term] || 1
|
|
36
|
+
const idf = 1 + Math.log10((nResults + 1) / df)
|
|
37
|
+
let idx = -1
|
|
38
|
+
let count = 0
|
|
39
|
+
while ((idx = text.indexOf(term, idx + 1)) !== -1) count++
|
|
40
|
+
sum += count * idf
|
|
41
|
+
}
|
|
42
|
+
return sum
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const maxTfIdf = Math.max(...tfIdfValues, 1)
|
|
46
|
+
|
|
47
|
+
const scored = results.map((r, idx) => {
|
|
48
|
+
const text = texts[idx]
|
|
49
|
+
const lines = text.split('\n')
|
|
50
|
+
|
|
51
|
+
// 1. N-gram overlap
|
|
52
|
+
const unigramMatch = unigrams.filter(t => text.includes(t)).length / unigrams.length
|
|
53
|
+
const bigramMatch = bigrams.length > 0 ? bigrams.filter(b => text.includes(b)).length / bigrams.length : 0
|
|
54
|
+
const trigramMatch = trigrams.length > 0 ? trigrams.filter(t => text.includes(t)).length / trigrams.length : 0
|
|
55
|
+
const ngramScore = 0.4 * unigramMatch + 0.35 * bigramMatch + 0.25 * trigramMatch
|
|
56
|
+
|
|
57
|
+
// 2. IDF-weighted TF (normalised by max across results)
|
|
58
|
+
const tfIdfScore = tfIdfValues[idx] / maxTfIdf
|
|
59
|
+
|
|
60
|
+
// 3. Exact phrase proximity — consecutive same-order term matches
|
|
61
|
+
let phraseBoost = 0
|
|
62
|
+
if (rawTerms.length >= 2) {
|
|
63
|
+
const textWords = text.split(/\s+/)
|
|
64
|
+
let bestRun = 0
|
|
65
|
+
for (let i = 0; i < textWords.length - rawTerms.length + 1; i++) {
|
|
66
|
+
let matchLen = 0
|
|
67
|
+
for (let j = 0; j < rawTerms.length; j++) {
|
|
68
|
+
if (textWords[i + j] === rawTerms[j]) matchLen++
|
|
69
|
+
else break
|
|
70
|
+
}
|
|
71
|
+
if (matchLen >= 2) bestRun = Math.max(bestRun, matchLen)
|
|
72
|
+
}
|
|
73
|
+
phraseBoost = bestRun / rawTerms.length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Header position boost
|
|
77
|
+
const headerText = lines.slice(0, 3).join(' ').toLowerCase()
|
|
78
|
+
const headerOverlap = unigrams.filter(t => headerText.includes(t)).length / unigrams.length
|
|
79
|
+
|
|
80
|
+
// Blend
|
|
81
|
+
const overlapScore = 0.25 * ngramScore + 0.25 * tfIdfScore + 0.25 * phraseBoost + 0.15 * headerOverlap + 0.10 * unigramMatch
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
id: r.id,
|
|
85
|
+
text: r.text,
|
|
86
|
+
score: 0.70 * (r.score || 0) + 0.30 * overlapScore,
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, topK)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/store/flat-store.js
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
import { VectorStore } from './vector-store.js';
|
|
2
|
-
|
|
3
|
-
export class FlatVectorStore extends VectorStore {
|
|
4
|
-
constructor() {
|
|
5
|
-
super();
|
|
6
|
-
this._items = [];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
async add(id, vector) {
|
|
10
|
-
this._items.push({ skill_id: id, vec: new Float32Array(vector) });
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
async addBatch(entries) {
|
|
14
|
-
for (const { id, vector } of entries) {
|
|
15
|
-
this._items.push({ skill_id: id, vec: new Float32Array(vector) });
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async remove(id) {
|
|
20
|
-
this._items = this._items.filter(item => item.skill_id !== id);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async search(vector, topK = 20) {
|
|
24
|
-
const qArr = new Float32Array(vector);
|
|
25
|
-
const bestBySkill = new Map();
|
|
26
|
-
for (const entry of this._items) {
|
|
27
|
-
const score = cosineSim(qArr, entry.vec);
|
|
28
|
-
const prev = bestBySkill.get(entry.skill_id);
|
|
29
|
-
if (!prev || score > prev) bestBySkill.set(entry.skill_id, score);
|
|
30
|
-
}
|
|
31
|
-
return [...bestBySkill.entries()]
|
|
32
|
-
.sort((a, b) => b[1] - a[1])
|
|
33
|
-
.slice(0, topK)
|
|
34
|
-
.map(([skill_id, score]) => ({ skill_id, score }));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async build(entries) {
|
|
38
|
-
this._items = [];
|
|
39
|
-
for (const { skill_id, vector } of entries) {
|
|
40
|
-
this._items.push({ skill_id, vec: new Float32Array(vector) });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async clear() {
|
|
45
|
-
this._items = [];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get size() {
|
|
49
|
-
return this._items.length;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function cosineSim(a, b) {
|
|
54
|
-
let dot = 0, na = 0, nb = 0;
|
|
55
|
-
for (let i = 0; i < a.length; i++) {
|
|
56
|
-
dot += a[i] * b[i];
|
|
57
|
-
na += a[i] * a[i];
|
|
58
|
-
nb += b[i] * b[i];
|
|
59
|
-
}
|
|
60
|
-
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
|
|
61
|
-
}
|
|
1
|
+
import { VectorStore } from './vector-store.js';
|
|
2
|
+
|
|
3
|
+
export class FlatVectorStore extends VectorStore {
|
|
4
|
+
constructor() {
|
|
5
|
+
super();
|
|
6
|
+
this._items = [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async add(id, vector) {
|
|
10
|
+
this._items.push({ skill_id: id, vec: new Float32Array(vector) });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async addBatch(entries) {
|
|
14
|
+
for (const { id, vector } of entries) {
|
|
15
|
+
this._items.push({ skill_id: id, vec: new Float32Array(vector) });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async remove(id) {
|
|
20
|
+
this._items = this._items.filter(item => item.skill_id !== id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async search(vector, topK = 20) {
|
|
24
|
+
const qArr = new Float32Array(vector);
|
|
25
|
+
const bestBySkill = new Map();
|
|
26
|
+
for (const entry of this._items) {
|
|
27
|
+
const score = cosineSim(qArr, entry.vec);
|
|
28
|
+
const prev = bestBySkill.get(entry.skill_id);
|
|
29
|
+
if (!prev || score > prev) bestBySkill.set(entry.skill_id, score);
|
|
30
|
+
}
|
|
31
|
+
return [...bestBySkill.entries()]
|
|
32
|
+
.sort((a, b) => b[1] - a[1])
|
|
33
|
+
.slice(0, topK)
|
|
34
|
+
.map(([skill_id, score]) => ({ skill_id, score }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async build(entries) {
|
|
38
|
+
this._items = [];
|
|
39
|
+
for (const { skill_id, vector } of entries) {
|
|
40
|
+
this._items.push({ skill_id, vec: new Float32Array(vector) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async clear() {
|
|
45
|
+
this._items = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get size() {
|
|
49
|
+
return this._items.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cosineSim(a, b) {
|
|
54
|
+
let dot = 0, na = 0, nb = 0;
|
|
55
|
+
for (let i = 0; i < a.length; i++) {
|
|
56
|
+
dot += a[i] * b[i];
|
|
57
|
+
na += a[i] * a[i];
|
|
58
|
+
nb += b[i] * b[i];
|
|
59
|
+
}
|
|
60
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
|
|
61
|
+
}
|