@andespindola/brainlink 1.0.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -1
- package/README.md +63 -2
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/build-context.js +14 -7
- package/dist/application/get-graph-node.js +2 -2
- package/dist/application/get-graph-summary.js +2 -2
- package/dist/application/get-graph.js +2 -2
- package/dist/application/index-vault-phases.js +1 -0
- package/dist/application/index-vault.js +16 -2
- package/dist/application/list-agents.js +2 -2
- package/dist/application/list-links.js +3 -3
- package/dist/application/ports/knowledge-store.js +1 -0
- package/dist/application/provision-vault.js +74 -0
- package/dist/application/ranking/run-search.js +212 -0
- package/dist/application/search-graph-node-ids.js +3 -2
- package/dist/application/search-knowledge.js +9 -17
- package/dist/application/server/routes.js +6 -1
- package/dist/application/vault-encryption.js +64 -0
- package/dist/application/vault-git-core.js +168 -0
- package/dist/application/vault-git.js +75 -0
- package/dist/application/vault-snapshot.js +87 -0
- package/dist/cli/commands/read-commands.js +9 -5
- package/dist/cli/commands/vault-sync-commands.js +137 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/infrastructure/config.js +16 -1
- package/dist/infrastructure/context-packs.js +57 -18
- package/dist/infrastructure/file-index.js +22 -409
- package/dist/infrastructure/index-signature.js +27 -0
- package/dist/infrastructure/index-state.js +6 -0
- package/dist/infrastructure/knowledge-store/binary-store.js +333 -0
- package/dist/infrastructure/knowledge-store/index.js +37 -0
- package/dist/infrastructure/knowledge-store/stored-index.js +216 -0
- package/dist/infrastructure/vault-crypto.js +78 -0
- package/dist/mcp/server.js +46 -1
- package/dist/mcp/tools/maintenance-tools.js +2 -2
- package/dist/mcp/tools/read-tools.js +8 -4
- package/dist/mcp/tools/vault-tools.js +163 -0
- package/dist/mcp/tools.js +1 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
- **Reciprocal Rank Fusion for hybrid search.** Hybrid mode now fuses the lexical (BM25) and semantic rankings by rank position (`Σ 1/(k+rank)`, k=60) instead of a fixed `0.6/0.4` min-max blend, which is robust to the two components' very different score scales. Gated by a new `hybridFusion` config (`rrf` default | `linear`); `fts`/`semantic` modes and stored data are unchanged.
|
|
6
|
+
- **Per-document CAG invalidation.** Persisted context packs now record the documents they were assembled from and are fresh only while each cited document is unchanged, so editing one note refreshes only the packs that cite it instead of the whole cache. A new optional `cagPackTtlMs` bounds pack lifetime as a safety net for newly added notes. Freshness is evaluated with a few `stat`s and no index load.
|
|
7
|
+
- **int8 ANN with partial exact-vector reads (binary backend).** Search loads a compact int8 scan index and reads the exact Float32 vectors by offset only for the shortlist it reranks (embedding-bucket coarse prune → int8 scan → exact Float32 rerank), so the exact vectors never become fully resident on a large vault. Small vaults rerank every candidate exactly, staying bit-identical to a full scan; `fts` reads no vectors.
|
|
8
|
+
- **The binary storage backend is now the default** (`storageBackend: "binary"`). Existing `json` vaults auto-migrate on first use (no re-embedding), and switching backends forces a one-time full reindex so the index is rewritten in the active format. The in-memory search/context caches key on a backend-agnostic commit signature (fixing a stale-read window on the binary backend). The `json` backend remains available.
|
|
9
|
+
- **Vault versioning over MCP.** `brainlink_vault_status` / `_init` / `_commit` / `_history` / `_pull` / `_restore` / `_export` / `_import` expose the git and `.blvault` versioning to agents (previously CLI-only); pull/restore/import reindex automatically.
|
|
10
|
+
- **Self-managed versioning repository.** `brainlink_vault_provision` / `blink vault-provision` create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as origin, push the first snapshot, and enable `autoVersion` — after which a successful index that changed Markdown commits and pushes automatically (best-effort; a git/network failure never breaks indexing).
|
|
11
|
+
- **Encrypted vault versioning.** With `vaultEncryption` (enabled by `vault-provision` by default), the git repository holds AES-256-GCM ciphertext (`<note>.md.enc`) and git-ignores the plaintext Markdown, so the repository is opaque to anyone without the per-vault key (in `$BRAINLINK_HOME/keys`, never committed, or via `BRAINLINK_VAULT_KEY`). Encryption is deterministic per note (no git churn); pull/restore/clone decrypt back to Markdown before reindexing.
|
|
12
|
+
|
|
13
|
+
## 1.1.0
|
|
14
|
+
|
|
15
|
+
- Added an optional **binary storage backend** (`storageBackend: "binary"`) that stores chunk embeddings as dense little-endian Float32 (`vectors.f32`) and document/chunk/link metadata as Brotli-compressed JSON, cutting the on-disk index ~75% on a typical vault with much cheaper loads than parsing decimal-text vectors. The graph/search/link reads now run through a shared query core so the JSON and binary backends are behavior-identical for the same candidates. Switching to `binary` converts an existing `index.json` in place on first use (no re-embedding) and falls back to it for reads until the binary store is materialized. The canonical Markdown is unaffected and the index stays fully regenerable.
|
|
16
|
+
- Introduced a `KnowledgeStore` port and a shared, backend-agnostic ranker, so the index storage layout can evolve without touching the application layer and every backend produces an identical ranking.
|
|
17
|
+
- Added **vault versioning** for the canonical Markdown. Git: `vault-init-git`, `vault-commit`, `vault-status`, `vault-history`, `vault-push`, `vault-pull`, `vault-restore`, `vault-clone` (driven through the system `git` binary; the derived `.brainlink/` is git-ignored). Portable snapshots: `vault-export`/`vault-import` produce and restore a self-contained gzip `.blvault` with per-file SHA-256. Every operation that changes Markdown reindexes automatically so the vault is immediately usable, merge conflicts abort cleanly instead of leaving markers in Markdown, conflicting local edits are preserved as `*.conflict-<ts>.md`, and remote URLs with inline credentials are rejected.
|
|
18
|
+
- Pinned the transitive `fast-uri` dependency to `>=4.1.0` to clear a high-severity Snyk advisory affecting the entire 3.x line.
|
|
4
19
|
|
|
5
20
|
- Added `brainlink_similar_notes` MCP tool: find the notes most similar to a given note (selected by title or path), using the note's own content as the retrieval query and excluding the note itself.
|
|
6
21
|
- Added batch write tools `brainlink_add_notes` and `brainlink_delete_notes` that write or delete up to 100 notes in a single call and reindex once for the whole batch; batch deletion reports per-note failures without aborting the batch.
|
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
71
71
|
- Full-text, semantic and hybrid retrieval on a local file index.
|
|
72
72
|
- Middle-out context assembly around the strongest chunk per document.
|
|
73
73
|
- In-process index and context caching with automatic invalidation on index updates.
|
|
74
|
-
- Optional CAG context packs at `.brainlink/context-packs/*.json`,
|
|
74
|
+
- Optional CAG context packs at `.brainlink/context-packs/*.json`, invalidated per cited document (editing one note only refreshes the packs that cite it) and reusable across repeated context calls.
|
|
75
75
|
- HTTP graph server caches generated frontend assets and graph-layout JSON payloads by signature, and skips layout serialization when ETag returns `304`.
|
|
76
76
|
- Compressed-space prefiltering for `.blpk` packs before decryption and scan.
|
|
77
77
|
- Incremental indexing that reprocesses only changed markdown files and reuses existing chunks/embeddings for unchanged notes.
|
|
@@ -560,6 +560,9 @@ Available tools:
|
|
|
560
560
|
- `brainlink_doctor_actions`: return vault checks plus prioritized executable next actions.
|
|
561
561
|
- `brainlink_validate`: validate broken links and orphan notes.
|
|
562
562
|
- `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
|
|
563
|
+
- `brainlink_vault_provision`: create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as `origin`, push the first snapshot, and enable `autoVersion` so later indexed changes are committed and pushed automatically. By default the repository is **encrypted** (`vaultEncryption`): it holds AES-256-GCM ciphertext, decryptable only by a Brainlink instance with the local key.
|
|
564
|
+
- `brainlink_vault_status` / `brainlink_vault_init` / `brainlink_vault_commit` / `brainlink_vault_history` / `brainlink_vault_pull` / `brainlink_vault_restore`: version the vault's canonical Markdown with git (the derived index is git-ignored). Pull and restore reindex changed files automatically so the vault stays immediately searchable.
|
|
565
|
+
- `brainlink_vault_export` / `brainlink_vault_import`: write and read a portable `.blvault` snapshot (gzip envelope of the Markdown with per-file SHA-256). Import verifies checksums, preserves divergent local files as `*.conflict-<ts>.md`, and reindexes.
|
|
563
566
|
- `brainlink_graph`: read indexed graph nodes and weighted links.
|
|
564
567
|
- `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
|
|
565
568
|
- `brainlink_broken_links`: list unresolved wiki links.
|
|
@@ -759,6 +762,30 @@ When the configured default vault is changed manually in config files, Brainlink
|
|
|
759
762
|
Use `--global` to write to `$BRAINLINK_HOME/brainlink.config.json`, `--no-migrate` to skip migration, and `--no-index` to skip post-migration indexing.
|
|
760
763
|
`config doctor` is dry-run by default; use `--fix` to apply safe config normalization and allowlist fixes.
|
|
761
764
|
|
|
765
|
+
#### Storage backend
|
|
766
|
+
|
|
767
|
+
`storageBackend` selects how the derived index is stored (the canonical Markdown is unaffected):
|
|
768
|
+
|
|
769
|
+
- `binary` (default) — Float32 vectors plus Brotli-compressed metadata; a smaller, faster-to-load index (~75% smaller on a typical vault). Switching is seamless: an existing `index.json` is converted in place on first use (no re-embedding), and changing the backend forces a one-time full reindex so the index is rewritten in the active format. On large vaults, semantic/hybrid search uses an approximate-nearest-neighbour path — a compact int8 vector scan (with embedding-bucket coarse pruning) shortlists candidates, and only that shortlist's exact Float32 vectors are read from disk and reranked, so the exact vectors never become fully resident. Small vaults rerank every candidate exactly, staying bit-identical to a full scan.
|
|
770
|
+
- `json` — the original single `index.json`. Larger and loaded whole, but a simple, human-readable artifact.
|
|
771
|
+
|
|
772
|
+
```json
|
|
773
|
+
{ "storageBackend": "json" }
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
#### Hybrid fusion
|
|
777
|
+
|
|
778
|
+
`hybridFusion` selects how the lexical (BM25) and semantic (cosine) rankings are combined in `hybrid` search mode:
|
|
779
|
+
|
|
780
|
+
- `rrf` (default) — Reciprocal Rank Fusion. Combines the two rankings by rank position (`Σ 1/(k + rank)`), so a document both retrievers agree on ranks highly regardless of the very different score scales of BM25 and cosine. No weight tuning required.
|
|
781
|
+
- `linear` — the legacy min-max normalized weighted blend (`0.6·lexical + 0.4·semantic`).
|
|
782
|
+
|
|
783
|
+
`fts` and `semantic` modes are unaffected. The change is additive: existing vaults need no reindex.
|
|
784
|
+
|
|
785
|
+
```json
|
|
786
|
+
{ "hybridFusion": "linear" }
|
|
787
|
+
```
|
|
788
|
+
|
|
762
789
|
### `vaults`
|
|
763
790
|
|
|
764
791
|
```bash
|
|
@@ -912,6 +939,36 @@ blink pack-backup --vault ./vault --json
|
|
|
912
939
|
Creates an offline backup artifact of encrypted search packs with a second compression pass.
|
|
913
940
|
This is intentionally outside the online retrieval path (`index`, `search`, `context`).
|
|
914
941
|
|
|
942
|
+
### Vault Versioning
|
|
943
|
+
|
|
944
|
+
Version the canonical Markdown with git, or move a vault as a single portable file.
|
|
945
|
+
The derived `.brainlink/` directory is never versioned; any operation that changes
|
|
946
|
+
Markdown reindexes automatically so the vault stays immediately usable.
|
|
947
|
+
|
|
948
|
+
```bash
|
|
949
|
+
# One-shot: create an ENCRYPTED private GitHub repo (via authenticated gh),
|
|
950
|
+
# push, and enable autoVersion so later indexed changes commit and push
|
|
951
|
+
# automatically. The repo holds AES-256-GCM ciphertext; only a Brainlink
|
|
952
|
+
# instance with the local key can decrypt. Use --no-encrypt for plaintext.
|
|
953
|
+
blink vault-provision --name brainlink-vault
|
|
954
|
+
|
|
955
|
+
# Git: history, remotes, restore
|
|
956
|
+
blink vault-init-git --remote git@github.com:you/your-vault.git
|
|
957
|
+
blink vault-commit -m "snapshot notes"
|
|
958
|
+
blink vault-push
|
|
959
|
+
blink vault-pull # pulls and reindexes changed notes
|
|
960
|
+
blink vault-history --limit 20
|
|
961
|
+
blink vault-restore <commit> # restore Markdown from a ref and reindex
|
|
962
|
+
blink vault-clone git@github.com:you/your-vault.git ./vault # clone + build index
|
|
963
|
+
blink vault-status
|
|
964
|
+
|
|
965
|
+
# Portable snapshot (self-contained, index excluded; import rebuilds it)
|
|
966
|
+
blink vault-export -o vault.blvault
|
|
967
|
+
blink vault-import vault.blvault # conflicting local edits preserved as *.conflict-<ts>.md
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
|
|
971
|
+
|
|
915
972
|
### `agents`
|
|
916
973
|
|
|
917
974
|
```bash
|
|
@@ -955,7 +1012,9 @@ blink context-packs --vault ./vault --stale --clear
|
|
|
955
1012
|
|
|
956
1013
|
Builds a compact context package for an agent.
|
|
957
1014
|
Repeated calls with the same vault, agent, query, mode and token/limit settings are served from a short in-memory cache while the index is unchanged.
|
|
958
|
-
The default strategy is configured by `defaultContextStrategy` and starts as `auto`, which uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy rag` forces fresh retrieval assembly from the current index. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata.
|
|
1015
|
+
The default strategy is configured by `defaultContextStrategy` and starts as `auto`, which uses CAG when a fresh pack exists and RAG otherwise, refreshing a pack for future calls. `--strategy cag` enables cache-augmented context generation by reading or refreshing a persisted context pack under `.brainlink/context-packs`; `--strategy rag` forces fresh retrieval assembly from the current index. Context responses include `cache`, `metrics`, `requestedStrategy` and `recommendedStrategy` metadata.
|
|
1016
|
+
|
|
1017
|
+
Packs are derived artifacts invalidated **per cited document**: a pack becomes stale only when one of the documents it was assembled from (or volatile memory) changes, so editing a single note no longer invalidates every pack. `cagPackTtlMs` (default `0`, disabled) sets an optional upper bound in milliseconds on how long a pack survives even when all its cited documents are unchanged — a safety net for a newly added note that would now rank into the result but is not among the pack's existing dependencies.
|
|
959
1018
|
|
|
960
1019
|
### `links`
|
|
961
1020
|
|
|
@@ -1129,6 +1188,8 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
|
|
|
1129
1188
|
`agentProfiles` is optional. When present, CLI and MCP resolve `mode`, `limit`, `tokens` and context `strategy` per agent automatically, then fallback to global defaults.
|
|
1130
1189
|
|
|
1131
1190
|
`autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
|
|
1191
|
+
`autoVersion` is optional and defaults to `false`. When enabled (e.g. by `blink vault-provision` / `brainlink_vault_provision`), a successful index that changed Markdown commits and pushes the vault to its git remote automatically, best-effort — a git or network failure is logged and never breaks indexing. It is a no-op unless the vault is a git repo with an `origin` remote.
|
|
1192
|
+
`vaultEncryption` is optional and defaults to `false`. When enabled, vault versioning commits AES-256-GCM ciphertext (`<note>.md.enc`) and git-ignores the plaintext Markdown, so the repository is opaque to anyone without the key — only a Brainlink instance holding the per-vault key can decrypt it. The key lives at `$BRAINLINK_HOME/keys/vault-versioning-<hash>.key` (outside the vault, never committed) or can be supplied via `BRAINLINK_VAULT_KEY` to restore an encrypted vault on another machine. Pull, restore and clone decrypt back to Markdown before reindexing; encryption is deterministic per note, so unchanged notes add no git churn. `blink vault-provision` enables this by default.
|
|
1132
1193
|
`autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
|
|
1133
1194
|
`autoUpdateCheck` is optional and defaults to `true`. Brainlink checks the npm registry no more often than `updateCheckIntervalMs` and prints an update notice to `stderr`; it never installs updates automatically.
|
|
1134
1195
|
|
|
@@ -50,7 +50,7 @@ export const getExtendedStats = async (vaultPath, agentId) => {
|
|
|
50
50
|
await searchKnowledge(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), agentId, 'hybrid');
|
|
51
51
|
const searchLatency = performance.now() - searchStart;
|
|
52
52
|
const contextStart = performance.now();
|
|
53
|
-
await buildContextPackage(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), defaults.defaultContextTokens, agentId, 'hybrid', undefined, defaults.defaultContextCacheTtlMs);
|
|
53
|
+
await buildContextPackage(absoluteVaultPath, probeQuery, Math.min(defaults.defaultSearchLimit, 8), defaults.defaultContextTokens, agentId, 'hybrid', undefined, defaults.defaultContextCacheTtlMs, defaults.cagPackTtlMs);
|
|
54
54
|
const contextLatency = performance.now() - contextStart;
|
|
55
55
|
return {
|
|
56
56
|
stats,
|
|
@@ -2,7 +2,7 @@ import { stat } from 'node:fs/promises';
|
|
|
2
2
|
import { performance } from 'node:perf_hooks';
|
|
3
3
|
import { formatContextPackage, selectContextSections } from '../domain/context.js';
|
|
4
4
|
import { readContextPack, writeContextPack } from '../infrastructure/context-packs.js';
|
|
5
|
-
import {
|
|
5
|
+
import { indexCommitSignature } from '../infrastructure/index-signature.js';
|
|
6
6
|
import { searchVolatileMemory, volatileMemoryStoragePath } from '../infrastructure/volatile-memory.js';
|
|
7
7
|
import { searchKnowledge } from './search-knowledge.js';
|
|
8
8
|
const contextCacheMaxEntries = 200;
|
|
@@ -16,7 +16,12 @@ const readFileSignature = async (path) => {
|
|
|
16
16
|
return '0:0';
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
|
-
export const readContextDataSignature = async (vaultPath) => `${await
|
|
19
|
+
export const readContextDataSignature = async (vaultPath) => `${await indexCommitSignature(vaultPath)}|${await readFileSignature(volatileMemoryStoragePath(vaultPath))}`;
|
|
20
|
+
// Signature of just the volatile-memory file. Disk context packs no longer gate
|
|
21
|
+
// on the whole-vault index signature (that invalidated every pack on any edit);
|
|
22
|
+
// they gate per cited document plus this volatile signature, so turn memory
|
|
23
|
+
// changes still refresh a pack while unrelated document edits do not.
|
|
24
|
+
export const readVolatileSignature = (vaultPath) => readFileSignature(volatileMemoryStoragePath(vaultPath));
|
|
20
25
|
const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode, strategy) => JSON.stringify({
|
|
21
26
|
vaultPath,
|
|
22
27
|
query: query.trim().toLowerCase(),
|
|
@@ -69,7 +74,7 @@ const emptyMetrics = (context, totalMs, overrides = {}) => ({
|
|
|
69
74
|
estimatedTokens: estimateSectionTokens(context),
|
|
70
75
|
...overrides
|
|
71
76
|
});
|
|
72
|
-
export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy = 'rag', contextCacheTtlMs = 120_000) => {
|
|
77
|
+
export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy = 'rag', contextCacheTtlMs = 120_000, cagPackTtlMs = 0) => {
|
|
73
78
|
const totalStart = performance.now();
|
|
74
79
|
const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode, strategy);
|
|
75
80
|
const dataSignature = await readContextDataSignature(vaultPath);
|
|
@@ -78,10 +83,12 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
78
83
|
return cached;
|
|
79
84
|
}
|
|
80
85
|
const shouldUseContextPack = strategy === 'cag' || strategy === 'auto';
|
|
86
|
+
const volatileSignature = await readVolatileSignature(vaultPath);
|
|
87
|
+
const packFreshness = { volatileSignature, ttlMs: cagPackTtlMs };
|
|
81
88
|
let packReadMs = 0;
|
|
82
89
|
if (shouldUseContextPack) {
|
|
83
90
|
const packReadStart = performance.now();
|
|
84
|
-
const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode },
|
|
91
|
+
const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, packFreshness);
|
|
85
92
|
packReadMs = elapsedMs(packReadStart);
|
|
86
93
|
if (pack.status === 'hit' && pack.context.sections.length > 0) {
|
|
87
94
|
const contextFromPack = {
|
|
@@ -139,7 +146,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
139
146
|
const hasSections = sections.length > 0;
|
|
140
147
|
const packWriteStart = performance.now();
|
|
141
148
|
const packPath = shouldUseContextPack && hasSections
|
|
142
|
-
? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode },
|
|
149
|
+
? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, context, { volatileSignature })
|
|
143
150
|
: undefined;
|
|
144
151
|
const packWriteMs = packPath ? elapsedMs(packWriteStart) : 0;
|
|
145
152
|
const contextWithMetadata = withCacheMetadata(context, {
|
|
@@ -169,7 +176,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
|
|
|
169
176
|
}
|
|
170
177
|
return contextWithMetrics;
|
|
171
178
|
};
|
|
172
|
-
export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000) => {
|
|
173
|
-
const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs);
|
|
179
|
+
export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000, cagPackTtlMs = 0) => {
|
|
180
|
+
const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs, cagPackTtlMs);
|
|
174
181
|
return contextPackage.content;
|
|
175
182
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import {
|
|
2
|
+
import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
|
|
3
3
|
export const getGraphNode = async (vaultPath, id, agentId) => {
|
|
4
4
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
-
const index =
|
|
5
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
6
6
|
try {
|
|
7
7
|
return await index.getGraphNode(id, agentId);
|
|
8
8
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import {
|
|
2
|
+
import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
|
|
3
3
|
export const getGraphSummary = async (vaultPath, agentId) => {
|
|
4
4
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
-
const index =
|
|
5
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
6
6
|
try {
|
|
7
7
|
return await index.getGraphSummary(agentId);
|
|
8
8
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import {
|
|
2
|
+
import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
|
|
3
3
|
export const getGraph = async (vaultPath, agentId) => {
|
|
4
4
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
-
const index =
|
|
5
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
6
6
|
try {
|
|
7
7
|
return await index.getGraph(agentId);
|
|
8
8
|
}
|
|
@@ -72,6 +72,7 @@ export const detectChanges = (params) => {
|
|
|
72
72
|
const settingsChanged = previousState == null ||
|
|
73
73
|
previousState.chunkSize !== config.chunkSize ||
|
|
74
74
|
previousState.embeddingProvider !== embeddingSignature(config) ||
|
|
75
|
+
previousState.storageBackend !== config.storageBackend ||
|
|
75
76
|
graphLinkModelChanged;
|
|
76
77
|
const packSettingsChanged = previousState == null ||
|
|
77
78
|
previousState.searchPackRowChunkSize !== config.searchPack.rowChunkSize ||
|
|
@@ -4,7 +4,8 @@ import { embeddingSignature, loadBrainlinkConfig } from '../infrastructure/confi
|
|
|
4
4
|
import { ensureVault, readMarkdownFileSummaries } from '../infrastructure/file-system-vault.js';
|
|
5
5
|
import { readIndexState, writeIndexState } from '../infrastructure/index-state.js';
|
|
6
6
|
import { ensureSearchPackManifest } from '../infrastructure/search-packs.js';
|
|
7
|
-
import {
|
|
7
|
+
import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
|
|
8
|
+
import { autoVersionVault } from './vault-git-core.js';
|
|
8
9
|
import { assembleIndexedDocuments, createTitleMaps, decidePackRebuild, detectChanges, embedChangedDocuments, rebuildSearchPacksIfNeeded } from './index-vault-phases.js';
|
|
9
10
|
const toIndexResult = (documents) => ({
|
|
10
11
|
documentCount: documents.length,
|
|
@@ -56,7 +57,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
56
57
|
hasPreviousState: previousState != null
|
|
57
58
|
});
|
|
58
59
|
const fullReindex = options.full === true;
|
|
59
|
-
const index =
|
|
60
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
60
61
|
try {
|
|
61
62
|
const existingIndexedDocuments = await index.getIndexedDocuments();
|
|
62
63
|
const existingByPath = new Map(existingIndexedDocuments.map((document) => [document.document.path, document]));
|
|
@@ -162,6 +163,7 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
162
163
|
await writeIndexState(absoluteVaultPath, {
|
|
163
164
|
chunkSize: config.chunkSize,
|
|
164
165
|
embeddingProvider: embeddingSignature(config),
|
|
166
|
+
storageBackend: config.storageBackend,
|
|
165
167
|
graphLinkModelVersion,
|
|
166
168
|
searchPackRowChunkSize: config.searchPack.rowChunkSize,
|
|
167
169
|
searchPackCompressionLevel: config.searchPack.compressionLevel,
|
|
@@ -169,6 +171,18 @@ export const indexVaultWithOptions = async (vaultPath, options) => {
|
|
|
169
171
|
files: currentSnapshot,
|
|
170
172
|
pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
|
|
171
173
|
});
|
|
174
|
+
// Auto-version the vault when enabled and Markdown actually changed. Runs
|
|
175
|
+
// after the index is committed, is best-effort (a git/network failure never
|
|
176
|
+
// breaks indexing), and is a no-op unless the vault is a git repo with an
|
|
177
|
+
// origin remote — commitVault itself skips when nothing is staged.
|
|
178
|
+
if (config.autoVersion && changedDocumentsByPath.size > 0) {
|
|
179
|
+
try {
|
|
180
|
+
await autoVersionVault(absoluteVaultPath, `chore(vault): update ${new Date().toISOString()}`);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
process.stderr.write(`brainlink: auto-version skipped (${error instanceof Error ? error.message : String(error)})\n`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
172
186
|
const result = {
|
|
173
187
|
...toIndexResult(indexedDocuments),
|
|
174
188
|
elapsedMs: elapsedMs(),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import {
|
|
2
|
+
import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
|
|
3
3
|
export const listAgents = async (vaultPath) => {
|
|
4
4
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
-
const index =
|
|
5
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
6
6
|
try {
|
|
7
7
|
return await index.listAgents();
|
|
8
8
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ensureVault } from '../infrastructure/file-system-vault.js';
|
|
2
|
-
import {
|
|
2
|
+
import { openKnowledgeStore } from '../infrastructure/knowledge-store/index.js';
|
|
3
3
|
export const listLinks = async (vaultPath, agentId) => {
|
|
4
4
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
5
|
-
const index =
|
|
5
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
6
6
|
try {
|
|
7
7
|
return await index.listLinks(agentId);
|
|
8
8
|
}
|
|
@@ -12,7 +12,7 @@ export const listLinks = async (vaultPath, agentId) => {
|
|
|
12
12
|
};
|
|
13
13
|
export const listBacklinks = async (vaultPath, title, agentId) => {
|
|
14
14
|
const absoluteVaultPath = await ensureVault(vaultPath);
|
|
15
|
-
const index =
|
|
15
|
+
const index = openKnowledgeStore(absoluteVaultPath);
|
|
16
16
|
try {
|
|
17
17
|
return await index.listBacklinks(title, agentId);
|
|
18
18
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Provision a private GitHub repository for the vault using the already
|
|
2
|
+
// authenticated `gh` CLI, wire it as origin, push the first snapshot, and enable
|
|
3
|
+
// auto-versioning so later changes are committed and pushed automatically. Only
|
|
4
|
+
// the canonical Markdown is versioned; the derived index stays git-ignored.
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { loadRawConfig, writeRawConfig } from '../infrastructure/config.js';
|
|
8
|
+
import { commitVault, hasRemote, initVaultGit, pushVault } from './vault-git-core.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
export class GhUnavailableError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('gh executable not found. Install and authenticate the GitHub CLI (gh auth login) to provision a vault repo.');
|
|
13
|
+
this.name = 'GhUnavailableError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class GhCommandError extends Error {
|
|
17
|
+
constructor(args, stderr) {
|
|
18
|
+
super(`gh ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`);
|
|
19
|
+
this.name = 'GhCommandError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const isMissingBinary = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
23
|
+
const runGh = async (cwd, args) => {
|
|
24
|
+
try {
|
|
25
|
+
const { stdout } = await execFileAsync('gh', [...args], { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
26
|
+
return stdout;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (isMissingBinary(error)) {
|
|
30
|
+
throw new GhUnavailableError();
|
|
31
|
+
}
|
|
32
|
+
const stderr = error.stderr ?? (error instanceof Error ? error.message : String(error));
|
|
33
|
+
throw new GhCommandError(args, stderr);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
// Fail early with an actionable message if gh is not authenticated, rather than
|
|
37
|
+
// letting `repo create` fail deep in the flow.
|
|
38
|
+
const assertGhAuthenticated = async (cwd) => {
|
|
39
|
+
await runGh(cwd, ['auth', 'status']);
|
|
40
|
+
};
|
|
41
|
+
// Persist config toggles to the global config. Global scope matches the common
|
|
42
|
+
// case of a single default vault; a project-local vault can set these in its own
|
|
43
|
+
// config. Written BEFORE the first commit so the initial snapshot is encrypted
|
|
44
|
+
// and the gitignore is correct from the start.
|
|
45
|
+
const mergeGlobalConfig = async (patch) => {
|
|
46
|
+
const current = await loadRawConfig('global');
|
|
47
|
+
await writeRawConfig('global', { ...current, ...patch });
|
|
48
|
+
};
|
|
49
|
+
export const provisionVaultRepo = async (vaultPath, options = {}) => {
|
|
50
|
+
const name = options.name ?? 'brainlink-vault';
|
|
51
|
+
const enableAutoVersion = options.enableAutoVersion !== false;
|
|
52
|
+
const encrypt = options.encrypt !== false;
|
|
53
|
+
// Toggle config first so initVaultGit/commitVault encrypt from the very first
|
|
54
|
+
// snapshot and git-ignore the plaintext Markdown.
|
|
55
|
+
await mergeGlobalConfig({
|
|
56
|
+
...(encrypt ? { vaultEncryption: true } : {}),
|
|
57
|
+
...(enableAutoVersion ? { autoVersion: true } : {})
|
|
58
|
+
});
|
|
59
|
+
const { root } = await initVaultGit(vaultPath);
|
|
60
|
+
const committed = await commitVault(vaultPath, options.message ?? 'chore(vault): initial snapshot');
|
|
61
|
+
// A pre-existing origin means the repo is already provisioned: just push the
|
|
62
|
+
// current state. Only creating a new repository needs gh, so the reuse path
|
|
63
|
+
// works without it.
|
|
64
|
+
const created = !(await hasRemote(root));
|
|
65
|
+
if (created) {
|
|
66
|
+
await assertGhAuthenticated(root);
|
|
67
|
+
const visibility = options.private === false ? '--public' : '--private';
|
|
68
|
+
await runGh(root, ['repo', 'create', name, visibility, '--source', root, '--remote', 'origin', '--push']);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
await pushVault(vaultPath);
|
|
72
|
+
}
|
|
73
|
+
return { repo: name, created, committed, pushed: true, autoVersionEnabled: enableAutoVersion, encrypted: encrypt };
|
|
74
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Backend-agnostic ranking. Given a candidate set (chunks already selected by a
|
|
2
|
+
// storage backend), this module owns the deterministic scoring math: field
|
|
3
|
+
// weighting, BM25 lexical relevance, cosine semantic relevance, hybrid fusion,
|
|
4
|
+
// and stable ordering. Keeping it here means every KnowledgeStore backend
|
|
5
|
+
// (JSON, binary, LMDB) produces an identical ranking for the same candidates.
|
|
6
|
+
import { dotProduct } from '../../domain/embeddings.js';
|
|
7
|
+
import { bm25TermScore, minMaxNormalize } from '../../domain/scoring.js';
|
|
8
|
+
const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
|
|
9
|
+
export const normalizeToken = (value) => value
|
|
10
|
+
.normalize('NFKD')
|
|
11
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
12
|
+
.toLowerCase();
|
|
13
|
+
export const tokenize = (query) => query
|
|
14
|
+
.match(queryTokenPattern)
|
|
15
|
+
?.map(normalizeToken)
|
|
16
|
+
.filter((token) => token.length > 1) ?? [];
|
|
17
|
+
const countOccurrences = (text, token) => {
|
|
18
|
+
let hits = 0;
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
while (cursor < text.length) {
|
|
21
|
+
const index = text.indexOf(token, cursor);
|
|
22
|
+
if (index < 0) {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
hits += 1;
|
|
26
|
+
cursor = index + token.length;
|
|
27
|
+
}
|
|
28
|
+
return hits;
|
|
29
|
+
};
|
|
30
|
+
const titleFieldWeight = 5;
|
|
31
|
+
const tagFieldWeight = 4;
|
|
32
|
+
const pathFieldWeight = 2;
|
|
33
|
+
const contentHitCap = 6;
|
|
34
|
+
const hybridTextWeight = 0.6;
|
|
35
|
+
const hybridSemanticWeight = 0.4;
|
|
36
|
+
// Rank-fusion damping constant (Cormack et al.). 60 is the field-standard value:
|
|
37
|
+
// large enough that the top ranks are not wildly separated, small enough that a
|
|
38
|
+
// document ranked highly by one retriever still clearly outweighs low ranks.
|
|
39
|
+
const rrfConstant = 60;
|
|
40
|
+
const normalizeFields = (row) => ({
|
|
41
|
+
title: normalizeToken(row.title),
|
|
42
|
+
path: normalizeToken(row.path),
|
|
43
|
+
content: normalizeToken(row.content),
|
|
44
|
+
tags: normalizeToken(row.tags.join(' '))
|
|
45
|
+
});
|
|
46
|
+
// Field-weighted term frequency: title/tag/path matches count more than body
|
|
47
|
+
// matches, and body hits saturate at a cap so a term repeated many times in one
|
|
48
|
+
// chunk cannot dominate. This weighted frequency feeds BM25 below.
|
|
49
|
+
const weightedTermFrequency = (fields, token) => {
|
|
50
|
+
const titleHits = countOccurrences(fields.title, token);
|
|
51
|
+
const tagHits = countOccurrences(fields.tags, token);
|
|
52
|
+
const pathHits = countOccurrences(fields.path, token);
|
|
53
|
+
const contentHits = countOccurrences(fields.content, token);
|
|
54
|
+
return (titleHits * titleFieldWeight +
|
|
55
|
+
tagHits * tagFieldWeight +
|
|
56
|
+
pathHits * pathFieldWeight +
|
|
57
|
+
Math.min(contentHits, contentHitCap));
|
|
58
|
+
};
|
|
59
|
+
// Naive additive field score retained for graph-node title/path lookup, where
|
|
60
|
+
// the candidate set is documents (not chunks) and corpus-wide BM25 statistics
|
|
61
|
+
// add no signal over a direct weighted hit count.
|
|
62
|
+
export const lexicalFieldScore = (row, tokens) => {
|
|
63
|
+
if (tokens.length === 0) {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const fields = normalizeFields(row);
|
|
67
|
+
return tokens.reduce((score, token) => score + weightedTermFrequency(fields, token), 0);
|
|
68
|
+
};
|
|
69
|
+
// A single chunk with a non-finite cached tokenCount (legacy index, partial
|
|
70
|
+
// write, manual edit) would otherwise poison the shared averageLength and zero
|
|
71
|
+
// out lexical scoring for the whole corpus, so coerce to a safe positive length.
|
|
72
|
+
const safeDocumentLength = (tokenCount) => Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 1;
|
|
73
|
+
const buildCorpusStatistics = (prepared, tokens) => {
|
|
74
|
+
const documentCount = prepared.length;
|
|
75
|
+
const totalLength = prepared.reduce((sum, entry) => sum + safeDocumentLength(entry.row.tokenCount), 0);
|
|
76
|
+
const documentFrequencyByTerm = new Map();
|
|
77
|
+
for (const term of tokens) {
|
|
78
|
+
let documentFrequency = 0;
|
|
79
|
+
for (const entry of prepared) {
|
|
80
|
+
if ((entry.weightedTermFrequencies.get(term) ?? 0) > 0) {
|
|
81
|
+
documentFrequency += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
documentFrequencyByTerm.set(term, documentFrequency);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
documentCount,
|
|
88
|
+
averageLength: documentCount > 0 ? totalLength / documentCount : 0,
|
|
89
|
+
documentFrequencyByTerm
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const bm25TextScore = (entry, tokens, stats) => tokens.reduce((score, term) => score +
|
|
93
|
+
bm25TermScore({
|
|
94
|
+
termFrequency: entry.weightedTermFrequencies.get(term) ?? 0,
|
|
95
|
+
documentLength: safeDocumentLength(entry.row.tokenCount),
|
|
96
|
+
documentCount: stats.documentCount,
|
|
97
|
+
documentFrequency: stats.documentFrequencyByTerm.get(term) ?? 0,
|
|
98
|
+
averageLength: stats.averageLength
|
|
99
|
+
}), 0);
|
|
100
|
+
const semanticScore = (row, queryEmbedding) => queryEmbedding.length > 0 && row.embedding.length > 0 ? dotProduct(queryEmbedding, row.embedding) : 0;
|
|
101
|
+
const rangeOf = (values) => values.reduce((range, value) => ({ min: Math.min(range.min, value), max: Math.max(range.max, value) }), { min: Infinity, max: -Infinity });
|
|
102
|
+
const toResult = (row, mode, score, text, semantic) => ({
|
|
103
|
+
documentId: row.documentId,
|
|
104
|
+
agentId: row.agentId,
|
|
105
|
+
title: row.title,
|
|
106
|
+
path: row.path,
|
|
107
|
+
chunkId: row.chunkId,
|
|
108
|
+
chunkOrdinal: row.chunkOrdinal,
|
|
109
|
+
content: row.content,
|
|
110
|
+
score,
|
|
111
|
+
textScore: text,
|
|
112
|
+
semanticScore: semantic,
|
|
113
|
+
tokenCount: row.tokenCount,
|
|
114
|
+
searchMode: mode,
|
|
115
|
+
tags: row.tags
|
|
116
|
+
});
|
|
117
|
+
// Reciprocal Rank Fusion: rank the candidates independently by each component
|
|
118
|
+
// (lexical, semantic) and fuse by rank position, `Σ 1/(k + rank)`. Because it
|
|
119
|
+
// consumes ranks rather than raw magnitudes it is immune to the scale mismatch
|
|
120
|
+
// between unbounded BM25 and bounded cosine, so a strong lexical hit and a
|
|
121
|
+
// strong semantic hit contribute comparably without hand-tuned weights. A
|
|
122
|
+
// candidate absent from a component's ranking (zero score there) simply
|
|
123
|
+
// contributes nothing for that component. Ties break by title to stay
|
|
124
|
+
// deterministic, matching the final ordering.
|
|
125
|
+
const reciprocalRankFusion = (entries) => {
|
|
126
|
+
const ranksByField = (component) => {
|
|
127
|
+
const ranked = entries
|
|
128
|
+
.filter((entry) => component(entry) > 0)
|
|
129
|
+
.slice()
|
|
130
|
+
.sort((left, right) => component(right) - component(left) || left.row.title.localeCompare(right.row.title));
|
|
131
|
+
return new Map(ranked.map((entry, index) => [entry, index + 1]));
|
|
132
|
+
};
|
|
133
|
+
const textRanks = ranksByField((entry) => entry.text);
|
|
134
|
+
const semanticRanks = ranksByField((entry) => entry.semantic);
|
|
135
|
+
return new Map(entries.map((entry) => {
|
|
136
|
+
const textRank = textRanks.get(entry);
|
|
137
|
+
const semanticRank = semanticRanks.get(entry);
|
|
138
|
+
const score = (textRank ? 1 / (rrfConstant + textRank) : 0) + (semanticRank ? 1 / (rrfConstant + semanticRank) : 0);
|
|
139
|
+
return [entry, score];
|
|
140
|
+
}));
|
|
141
|
+
};
|
|
142
|
+
// Rank an already-selected candidate set. Backends own candidate selection
|
|
143
|
+
// (full scan, bucket prefilter, ANN); this function owns scoring and ordering so
|
|
144
|
+
// the result is identical regardless of how the candidates were gathered.
|
|
145
|
+
export const runSearch = (candidates, query, limit, mode, queryEmbedding = [], fusion = 'rrf') => {
|
|
146
|
+
const tokens = tokenize(query);
|
|
147
|
+
// Pass 1: field-weighted term frequencies per candidate, then corpus-wide
|
|
148
|
+
// statistics (document frequency, average length) needed for BM25 IDF and
|
|
149
|
+
// length normalization.
|
|
150
|
+
const prepared = candidates.map((row) => {
|
|
151
|
+
const fields = normalizeFields(row);
|
|
152
|
+
const weightedTermFrequencies = new Map();
|
|
153
|
+
for (const term of tokens) {
|
|
154
|
+
const frequency = weightedTermFrequency(fields, term);
|
|
155
|
+
if (frequency > 0) {
|
|
156
|
+
weightedTermFrequencies.set(term, frequency);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { row, weightedTermFrequencies };
|
|
160
|
+
});
|
|
161
|
+
const stats = buildCorpusStatistics(prepared, tokens);
|
|
162
|
+
// Pass 2: raw lexical (BM25) and semantic (cosine) component scores.
|
|
163
|
+
const componentScored = prepared.map((entry) => ({
|
|
164
|
+
row: entry.row,
|
|
165
|
+
text: tokens.length > 0 ? bm25TextScore(entry, tokens, stats) : 0,
|
|
166
|
+
semantic: semanticScore(entry.row, queryEmbedding)
|
|
167
|
+
}));
|
|
168
|
+
// Filter on raw relevance before normalization so a legitimate match that
|
|
169
|
+
// happens to be the weakest in its mode is never min-max scaled to zero
|
|
170
|
+
// and dropped. fts stays lexical-only and semantic stays vector-only.
|
|
171
|
+
const relevant = componentScored.filter((entry) => {
|
|
172
|
+
if (tokens.length === 0) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
if (mode === 'fts') {
|
|
176
|
+
return entry.text > 0;
|
|
177
|
+
}
|
|
178
|
+
if (mode === 'semantic') {
|
|
179
|
+
return entry.semantic > 0;
|
|
180
|
+
}
|
|
181
|
+
return entry.text > 0 || entry.semantic > 0;
|
|
182
|
+
});
|
|
183
|
+
if (relevant.length === 0) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
const textRange = rangeOf(relevant.map((entry) => entry.text));
|
|
187
|
+
const semanticRange = rangeOf(relevant.map((entry) => entry.semantic));
|
|
188
|
+
// Rank-fused hybrid scores are precomputed once over the whole candidate set
|
|
189
|
+
// (RRF needs each component's global ranking); other modes score per entry.
|
|
190
|
+
const rrfScores = mode === 'hybrid' && fusion === 'rrf' ? reciprocalRankFusion(relevant) : null;
|
|
191
|
+
const finalScore = (entry) => {
|
|
192
|
+
if (mode === 'fts') {
|
|
193
|
+
return entry.text;
|
|
194
|
+
}
|
|
195
|
+
if (mode === 'semantic') {
|
|
196
|
+
return entry.semantic;
|
|
197
|
+
}
|
|
198
|
+
if (rrfScores) {
|
|
199
|
+
return rrfScores.get(entry) ?? 0;
|
|
200
|
+
}
|
|
201
|
+
// Linear hybrid mixes the two components on a shared [0, 1] scale so neither
|
|
202
|
+
// the unbounded BM25 magnitude nor the bounded cosine value can swamp the
|
|
203
|
+
// other, replacing the previous fixed `text + semantic * 8` blend.
|
|
204
|
+
const textNorm = minMaxNormalize(entry.text, textRange.min, textRange.max);
|
|
205
|
+
const semanticNorm = minMaxNormalize(entry.semantic, semanticRange.min, semanticRange.max);
|
|
206
|
+
return textNorm * hybridTextWeight + semanticNorm * hybridSemanticWeight;
|
|
207
|
+
};
|
|
208
|
+
return relevant
|
|
209
|
+
.map((entry) => toResult(entry.row, mode, finalScore(entry), entry.text, entry.semantic))
|
|
210
|
+
.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
|
|
211
|
+
.slice(0, Math.max(0, limit));
|
|
212
|
+
};
|