Skip to content

Commit 2ab4149

Browse files
Merge pull request #29 from HiveForensics-AI/codex/implement-optional-local-semantic-reranking
Add optional Ollama-backed semantic sidecar reranking (lexical-first, additive)
2 parents b1601f9 + 7a89155 commit 2ab4149

14 files changed

Lines changed: 651 additions & 9 deletions

File tree

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,27 @@ const hits = query(kb, 'react native bridge throttling', {
229229
});
230230
```
231231

232+
## Semantic sidecar workflow (Ollama, optional)
233+
234+
Lexical retrieval is still the first-pass and default. Sidecars add optional local reranking over lexical top-N candidates (no vector DB, no `.knolo` format migration).
235+
236+
```bash
237+
# 1) Build deterministic lexical pack
238+
knolo build
239+
240+
# 2) Generate local semantic sidecar (requires Ollama running)
241+
knolo semantic:index --pack ./dist/knowledge.knolo --out ./dist/knowledge.knolo.semantic.json --model qwen3-embedding:4b
242+
243+
# 3) Inspect and validate sidecar before query-time use
244+
knolo semantic:inspect --sidecar ./dist/knowledge.knolo.semantic.json
245+
knolo semantic:validate --pack ./dist/knowledge.knolo --sidecar ./dist/knowledge.knolo.semantic.json --model qwen3-embedding:4b
246+
```
247+
248+
Troubleshooting:
249+
- If Ollama is not running, start it and ensure `http://localhost:11434` is reachable.
250+
- If model is missing, run `ollama pull qwen3-embedding:4b`.
251+
- If validate fails for fingerprint/model mismatch, regenerate sidecar with the current pack and exact model.
252+
232253
---
233254

234255
# 🧠 Optional: Agent Metadata & Routing
@@ -443,4 +464,3 @@ const hits = query(pack, 'knolo determinism', {
443464
# 📄 License
444465

445466
Apache-2.0 — see `LICENSE`
446-

packages/cli/bin/knolo.mjs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const DEFAULT_CONFIG = {
2727
};
2828
const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.json']);
2929
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
30-
const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'dev']);
30+
const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'dev', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
3131

3232
function createError(message) {
3333
return new Error(message);
@@ -87,6 +87,9 @@ function printCommandHelp(command) {
8787
build: 'Usage: knolo build',
8888
query: 'Usage: knolo query <question> [--pack <path>] [--k <number>] [--json]',
8989
dev: 'Usage: knolo dev',
90+
'semantic:index': 'Usage: knolo semantic:index --pack <path> [--out <path>] [--model <id>] [--endpoint <url>]',
91+
'semantic:inspect': 'Usage: knolo semantic:inspect --sidecar <path>',
92+
'semantic:validate': 'Usage: knolo semantic:validate --pack <path> --sidecar <path> --model <id>',
9093
};
9194
console.log(help[command] ?? 'Unknown command.');
9295
}
@@ -313,6 +316,79 @@ async function cmdQuery(core, args) {
313316
});
314317
}
315318

319+
function parseKeyValueArgs(args) {
320+
const out = {};
321+
for (let i = 0; i < args.length; i++) {
322+
const key = args[i];
323+
if (!key.startsWith('--')) throw createError(`Unexpected argument: ${key}`);
324+
out[key.slice(2)] = args[++i];
325+
}
326+
return out;
327+
}
328+
329+
async function loadOllamaProvider() {
330+
const mod = await tryImport(path.resolve(__dirname, '../../semantic-ollama/dist/index.js'));
331+
if (mod?.OllamaEmbeddingProvider) return mod.OllamaEmbeddingProvider;
332+
const pkg = await tryImport('@knolo/semantic-ollama');
333+
if (pkg?.OllamaEmbeddingProvider) return pkg.OllamaEmbeddingProvider;
334+
throw createError('Could not load @knolo/semantic-ollama. Build packages/semantic-ollama first.');
335+
}
336+
337+
async function cmdSemanticIndex(core, args) {
338+
const flags = parseKeyValueArgs(args);
339+
const packPath = path.resolve(process.cwd(), flags.pack || 'dist/knowledge.knolo');
340+
const outPath = path.resolve(process.cwd(), flags.out || `${packPath}.semantic.json`);
341+
const modelId = flags.model || 'qwen3-embedding:4b';
342+
const endpoint = flags.endpoint || 'http://localhost:11434';
343+
if (!existsSync(packPath)) throw createError(`Pack file not found at ${path.relative(process.cwd(), packPath)}.`);
344+
345+
const bytes = Uint8Array.from(readFileSync(packPath));
346+
const pack = await mountPackFromBytes(core, bytes);
347+
const OllamaEmbeddingProvider = await loadOllamaProvider();
348+
const provider = new OllamaEmbeddingProvider({ modelId, endpoint });
349+
const vectors = await provider.embedTexts(pack.blocks);
350+
const sidecar = {
351+
version: 1,
352+
packFingerprint: core.createPackFingerprint(pack),
353+
modelId: provider.modelId,
354+
dimension: vectors[0]?.length ?? 0,
355+
metric: 'cosine',
356+
createdAt: new Date().toISOString(),
357+
blocks: vectors.map((vector, blockId) => ({ blockId, vector: Array.from(core.normalizeVector(vector)) })),
358+
};
359+
writeFileSync(outPath, core.serializeSidecar(sidecar));
360+
console.log(`✔ wrote ${path.relative(process.cwd(), outPath)}`);
361+
}
362+
363+
async function cmdSemanticInspect(core, args) {
364+
const flags = parseKeyValueArgs(args);
365+
const sidecarPath = path.resolve(process.cwd(), flags.sidecar);
366+
const sidecar = core.parseSidecar(readFileSync(sidecarPath, 'utf8'));
367+
console.log(JSON.stringify({
368+
version: sidecar.version,
369+
packFingerprint: sidecar.packFingerprint,
370+
modelId: sidecar.modelId,
371+
dimension: sidecar.dimension,
372+
metric: sidecar.metric,
373+
createdAt: sidecar.createdAt,
374+
blocks: sidecar.blocks.length,
375+
}, null, 2));
376+
}
377+
378+
async function cmdSemanticValidate(core, args) {
379+
const flags = parseKeyValueArgs(args);
380+
const packPath = path.resolve(process.cwd(), flags.pack || 'dist/knowledge.knolo');
381+
const sidecarPath = path.resolve(process.cwd(), flags.sidecar);
382+
const modelId = flags.model;
383+
if (!modelId) throw createError('semantic:validate requires --model <id>.');
384+
const pack = await mountPackFromBytes(core, Uint8Array.from(readFileSync(packPath)));
385+
const sidecar = core.parseSidecar(readFileSync(sidecarPath, 'utf8'));
386+
core.validateSidecarForPack({ sidecar, pack, modelId });
387+
if (sidecar.blocks.length !== pack.blocks.length) throw createError(`Semantic block count mismatch: sidecar=${sidecar.blocks.length}, pack=${pack.blocks.length}`);
388+
if (sidecar.dimension <= 0) throw createError('Semantic sidecar dimension must be > 0.');
389+
console.log('✔ semantic sidecar validation passed');
390+
}
391+
316392
async function mountPackFromBytes(core, bytes) {
317393
try {
318394
return await core.mountPack({ bytes });
@@ -598,6 +674,9 @@ async function main() {
598674
if (command === 'build') return await cmdBuild(core);
599675
if (command === 'query') return await cmdQuery(core, commandArgs);
600676
if (command === 'dev') return await cmdDev(core);
677+
if (command === 'semantic:index') return await cmdSemanticIndex(core, commandArgs);
678+
if (command === 'semantic:inspect') return await cmdSemanticInspect(core, commandArgs);
679+
if (command === 'semantic:validate') return await cmdSemanticValidate(core, commandArgs);
601680
}
602681

603682
if (command.startsWith('-')) throw createError(`Unknown option: ${command}`);

packages/cli/test/cli.test.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mkdtempSync, existsSync, mkdirSync, writeFileSync, readFileSync } from
44
import { tmpdir } from 'node:os';
55
import path from 'node:path';
66
import { execFileSync } from 'node:child_process';
7+
import { pathToFileURL } from 'node:url';
78

89
const cliPath = path.resolve(process.cwd(), 'bin/knolo.mjs');
910
const cliPackageJson = JSON.parse(
@@ -91,3 +92,33 @@ test('add updates existing source path', () => {
9192
const config = JSON.parse(readFileSync(path.join(cwd, 'knolo.config.json'), 'utf8'));
9293
assert.equal(config.sources[0].path, './knowledge-base');
9394
});
95+
96+
test('semantic:validate succeeds for matching pack/model and fails on mismatch', async () => {
97+
const cwd = mkdtempSync(path.join(tmpdir(), 'knolo-cli-sem-validate-'));
98+
runCli(['init'], cwd);
99+
runCli(['build'], cwd);
100+
101+
const coreModule = await import(pathToFileURL(path.resolve(process.cwd(), '../core/dist/index.js')).href);
102+
const packPath = path.join(cwd, 'dist/knowledge.knolo');
103+
const packBytes = readFileSync(packPath);
104+
const pack = await coreModule.mountPack({ src: Uint8Array.from(packBytes) });
105+
const sidecarPath = path.join(cwd, 'dist/knowledge.knolo.semantic.json');
106+
const sidecar = {
107+
version: 1,
108+
packFingerprint: coreModule.createPackFingerprint(pack),
109+
modelId: 'qwen3-embedding:4b',
110+
dimension: 3,
111+
metric: 'cosine',
112+
createdAt: new Date().toISOString(),
113+
blocks: pack.blocks.map((_, blockId) => ({ blockId, vector: [1, 0, 0] })),
114+
};
115+
writeFileSync(sidecarPath, coreModule.serializeSidecar(sidecar), 'utf8');
116+
117+
const output = runCli(['semantic:validate', '--pack', './dist/knowledge.knolo', '--sidecar', './dist/knowledge.knolo.semantic.json', '--model', 'qwen3-embedding:4b'], cwd);
118+
assert.match(output, /validation passed/);
119+
120+
assert.throws(
121+
() => runCli(['semantic:validate', '--pack', './dist/knowledge.knolo', '--sidecar', './dist/knowledge.knolo.semantic.json', '--model', 'other-model'], cwd),
122+
/Semantic model mismatch/
123+
);
124+
});

packages/core/scripts/test.mjs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ import {
3737
mergeClaimGraphLogs,
3838
applyClaimGraphLog,
3939
expandQueryWithGraph,
40+
cosineSimilarity,
41+
normalizeVector,
42+
createPackFingerprint,
43+
serializeSidecar,
44+
parseSidecar,
45+
validateSidecarForPack,
4046
} from '../dist/index.js';
4147
import { mountPack as mountPackNode } from '../dist/node.js';
4248

@@ -613,6 +619,132 @@ async function testSemanticRerankErrorAndDefaults() {
613619
);
614620
}
615621

622+
async function testSemanticSidecarRerankAndValidation() {
623+
const docs = [
624+
{ id: 'a', text: 'alpha beta alpha beta alpha beta river stone' },
625+
{ id: 'b', text: 'alpha beta solar wind' },
626+
];
627+
const pack = await mountPack({ src: await buildPack(docs) });
628+
const sidecar = {
629+
version: 1,
630+
packFingerprint: createPackFingerprint(pack),
631+
modelId: 'qwen3-embedding:4b',
632+
dimension: 2,
633+
metric: 'cosine',
634+
createdAt: new Date().toISOString(),
635+
blocks: [
636+
{ blockId: 0, vector: [1, 0] },
637+
{ blockId: 1, vector: [0, 1] },
638+
],
639+
};
640+
641+
const lexical = query(pack, 'alpha beta', { topK: 2, queryExpansion: { enabled: false } });
642+
const reranked = query(pack, 'alpha beta', {
643+
topK: 2,
644+
queryExpansion: { enabled: false },
645+
semantic: {
646+
enabled: true,
647+
sidecarPath: serializeSidecar(sidecar),
648+
provider: { type: 'ollama', modelId: 'qwen3-embedding:4b' },
649+
queryEmbedding: new Float32Array([0, 1]),
650+
force: true,
651+
blend: { enabled: false },
652+
},
653+
});
654+
655+
assert.notEqual(reranked[0]?.source, lexical[0]?.source, 'expected sidecar rerank to update ordering');
656+
assert.equal(reranked[0]?.evidence?.retrieval, 'hybrid');
657+
658+
assert.throws(
659+
() => validateSidecarForPack({ sidecar: { ...sidecar, modelId: 'other' }, pack, modelId: 'qwen3-embedding:4b' }),
660+
/Semantic model mismatch/
661+
);
662+
assert.throws(
663+
() => validateSidecarForPack({ sidecar: { ...sidecar, packFingerprint: 'fnv1a-deadbeef' }, pack, modelId: 'qwen3-embedding:4b' }),
664+
/pack fingerprint mismatch/
665+
);
666+
667+
const loaded = parseSidecar(serializeSidecar(sidecar));
668+
assert.deepEqual(loaded, sidecar, 'expected semantic sidecar round trip to remain stable');
669+
}
670+
671+
async function testSemanticEvidenceScoresRemainCorrectAfterRerank() {
672+
const docs = [
673+
{ id: 'lex-a', text: 'alpha beta alpha beta alpha beta river stone' },
674+
{ id: 'lex-b', text: 'alpha beta solar wind' },
675+
];
676+
const pack = await mountPack({
677+
src: await buildPack(docs, {
678+
semantic: {
679+
enabled: true,
680+
modelId: 'test-model',
681+
embeddings: [new Float32Array([1, 0]), new Float32Array([0, 1])],
682+
quantization: { type: 'int8_l2norm', perVectorScale: true },
683+
},
684+
}),
685+
});
686+
687+
const lexical = query(pack, 'alpha beta', {
688+
topK: 2,
689+
queryExpansion: { enabled: false },
690+
});
691+
const lexicalScores = new Map(lexical.map((h) => [h.blockId, h.evidence?.lexicalScore ?? h.score]));
692+
const reranked = query(pack, 'alpha beta', {
693+
topK: 2,
694+
queryExpansion: { enabled: false },
695+
semantic: {
696+
enabled: true,
697+
queryEmbedding: new Float32Array([0, 1]),
698+
force: true,
699+
blend: { enabled: true, wLex: 0.5, wSem: 0.5 },
700+
},
701+
});
702+
703+
assert.notEqual(
704+
reranked[0]?.source,
705+
lexical[0]?.source,
706+
'expected semantic rerank to change ordering'
707+
);
708+
for (const hit of reranked) {
709+
const before = lexicalScores.get(hit.blockId);
710+
assert.equal(
711+
hit.evidence?.lexicalScore,
712+
before,
713+
'expected evidence.lexicalScore to preserve pre-rerank lexical score'
714+
);
715+
assert.equal(hit.evidence?.retrieval, 'hybrid');
716+
assert.equal(typeof hit.evidence?.semanticScore, 'number');
717+
assert.equal(typeof hit.evidence?.blendedScore, 'number');
718+
}
719+
}
720+
721+
async function testLexicalOnlyEvidenceRemainsUnchanged() {
722+
const docs = [
723+
{ id: 'a', text: 'alpha beta gamma' },
724+
{ id: 'b', text: 'alpha beta delta' },
725+
];
726+
const pack = await mountPack({ src: await buildPack(docs) });
727+
const hits = query(pack, 'alpha beta', {
728+
topK: 2,
729+
queryExpansion: { enabled: false },
730+
});
731+
assert.ok(hits.length > 0, 'expected lexical query to return hits');
732+
for (const hit of hits) {
733+
assert.equal(hit.evidence?.retrieval, 'lexical');
734+
assert.equal(typeof hit.evidence?.lexicalScore, 'number');
735+
assert.equal(hit.evidence?.semanticScore, undefined);
736+
assert.equal(hit.evidence?.blendedScore, undefined);
737+
}
738+
}
739+
740+
async function testCosineHelpers() {
741+
const a = normalizeVector(new Float32Array([3, 4]));
742+
const b = normalizeVector(new Float32Array([3, 4]));
743+
const c = normalizeVector(new Float32Array([4, -3]));
744+
assert.ok(Math.abs(cosineSimilarity(a, b) - 1) < 1e-6, 'expected same vector cosine to be 1');
745+
assert.ok(Math.abs(cosineSimilarity(a, c)) < 1e-6, 'expected orthogonal vector cosine to be ~0');
746+
}
747+
616748
async function testSemanticFixtureAndHelpers() {
617749
const pack = await buildSemanticFixturePack();
618750
assert.ok(
@@ -1638,6 +1770,10 @@ await testLexConfidenceDeterministic();
16381770
await testSemanticRerankLowConfidence();
16391771
await testSemanticRerankRespectsConfidenceAndForce();
16401772
await testSemanticRerankErrorAndDefaults();
1773+
await testSemanticSidecarRerankAndValidation();
1774+
await testSemanticEvidenceScoresRemainCorrectAfterRerank();
1775+
await testLexicalOnlyEvidenceRemainsUnchanged();
1776+
await testCosineHelpers();
16411777
await testSmartQuotePhrase();
16421778
await testFirstBlockRetrieval();
16431779
await testNearDuplicateDedupe();

packages/core/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export {
1313
encodeScaleF16,
1414
decodeScaleF16,
1515
} from './semantic.js';
16+
export { cosineSimilarity, normalizeVector } from './semantic/cosine.js';
17+
export {
18+
createPackFingerprint,
19+
serializeSidecar,
20+
parseSidecar,
21+
validateSidecarForPack,
22+
} from './semantic/sidecar.js';
23+
export { rerankCandidates } from './semantic/rerank.js';
24+
export { assertProviderCompatible, ensureProviderModelId } from './semantic/provider.js';
1625
export {
1726
listAgents,
1827
getAgent,
@@ -39,6 +48,7 @@ export {
3948
export { expandQueryWithGraph } from './graph/query_expand.js';
4049
export type { MountOptions, PackMeta, Pack } from './pack.runtime.js';
4150
export type { QueryOptions, Hit } from './query.js';
51+
export type { EmbeddingProvider, SemanticSidecar, SemanticQueryOptions, RetrievalEvidence } from './semantic/types.js';
4252
export type { ContextPatch } from './patch.js';
4353
export type { BuildInputDoc, BuildPackOptions } from './builder.js';
4454
export type {

0 commit comments

Comments
 (0)