Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ knowledge list|ls [options]
```
List compatibility JSON-store items with pagination, search, and tag filtering.
Use `knowledge inventory` or `knowledge search` when an agent needs the
SQLite catalog, source chunks, generated wiki pages, artifacts, runs, and sync
state too.
SQLite catalog, source chunks, generated wiki pages, artifacts, runs, sync
state, or keyword retrieval across active compatibility notes too.

| Flag | Description |
|------|-------------|
Expand Down Expand Up @@ -568,17 +568,24 @@ hash, updates permission/path/delete metadata, and records a local run ledger.
Outbox inputs can be local files or allowed S3 objects, but raw source files
remain owned by `open-files`.

Compatibility notes created by `knowledge add` live in the JSON item store.
They are returned by `knowledge search` and `knowledge search --context` through
keyword matching, but they are not chunked or embedded by `reindex`; reindexing
only refreshes SQLite source/wiki chunks and vector rows.

### search
```bash
knowledge search <query> [--scope project] [--limit <n>] [--json]
knowledge search <query> --semantic [--model openai:text-embedding-3-small] [--scope project] [--json]
knowledge search <query> --context [--semantic] [--scope project] [--json]
```
Run hybrid search over `chunks_fts`, generated wiki chunks, wiki/index catalog
rows, and optional vector results. The default path is local-only keyword and
catalog search. `--semantic` embeds the query and merges vector results from
`vector_index_entries`, preserving source refs, artifact URIs, citations,
revision/hash metadata, and provenance in each structured result.
Run hybrid search over active JSON-store notes, `chunks_fts`, generated wiki
chunks, wiki/index catalog rows, and optional vector results. The default path
is local-only keyword and catalog search. `--semantic` embeds the query and
merges vector results from `vector_index_entries`, preserving source refs,
artifact URIs, citations, revision/hash metadata, and provenance in each
structured result. JSON notes are keyword-only results with `kind:
legacy_item` and `knowledge://item/<id>` source refs.

`--context` returns a reranked context pack for agents: selected excerpts,
assembled citations, freshness and permission notes, graph evidence from
Expand Down
28 changes: 19 additions & 9 deletions docs/architecture/hybrid-semantic-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Local mode starts with SQLite:
- `wiki_pages`, `wiki_backlinks`, and `citations` provide graph and provenance
signals.
- `knowledge_indexes` tracks generated machine-readable shards.
- The compatibility JSON item store remains the source of truth for notes
created by `knowledge add`. Hybrid search reads active notes directly as
keyword-only `legacy_item` results; notes are not copied into `chunks` or
vector tables by default.

The JSON vector representation is intentionally simple for the first local
implementation. The retrieval interface should hide it so a later vector
Expand Down Expand Up @@ -83,17 +87,19 @@ unauthorized content.
1. Normalize the query.
2. Embed the query if a semantic-capable provider is configured.
3. Run keyword FTS over source chunks and generated wiki chunks.
4. Search wiki page and machine-readable index catalog rows.
5. Run vector search over source chunks and wiki pages when semantic mode is
4. Keyword-match active compatibility JSON notes.
5. Search wiki page and machine-readable index catalog rows.
6. Run vector search over source chunks and wiki pages when semantic mode is
requested.
6. Expand candidate pages through backlinks and citations.
7. Drop stale candidates whose source revision/hash no longer matches
7. Expand candidate pages through backlinks and citations.
8. Drop stale candidates whose source revision/hash no longer matches
`open-files`.
8. Apply permission filters.
9. Merge and dedupe by source revision, wiki page, citation, and text hash.
10. Rerank by relevance, exact-match score, semantic score, freshness, citation
9. Apply permission filters.
10. Merge and dedupe by source revision, wiki page, citation, note id, and text
hash.
11. Rerank by relevance, exact-match score, semantic score, freshness, citation
quality, and wiki authority.
11. Return structured results with source refs, citation spans, page refs,
12. Return structured results with source refs, citation spans, page refs,
scores, and reason codes.

## Result Shape
Expand Down Expand Up @@ -168,6 +174,9 @@ Reindexing is driven by source revisions:
vector counts; `reindex enqueue` records missing work in `reindex_queue`.
- `reindex embeddings` performs incremental refreshes, while `--full` clears and
rebuilds `chunk_embeddings` and `vector_index_entries`.
- Compatibility JSON notes are not reindex work items. They remain searchable
through direct keyword matching and appear in context packs as
`legacy_item` excerpts with `knowledge://item/<id>` refs.
- Wiki pages should track the source revisions they cite so lint can flag stale
pages.
- Embedding refresh jobs should be idempotent and checkpointed in `runs` and
Expand All @@ -177,7 +186,8 @@ Reindexing is driven by source revisions:

- Local search works without network access for keyword-only retrieval.
- Semantic search is optional and provider-gated.
- Every returned excerpt can resolve to a source ref or wiki page.
- Every returned excerpt can resolve to a source ref, wiki page, or
compatibility `knowledge://item/<id>` note ref.
- Permission filters run before model context assembly.
- Retrieval internals can swap from JSON vectors to pgvector or managed vector
stores without changing CLI/MCP result contracts.
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ async function run(argv: string[]): Promise<void> {
if (!command || flags.help || command === 'help') { printCommandHelp(positional[1]); return; }

const service = createKnowledgeService({ scope: flags.scope });
const storePathOverridden = Boolean(flags.store);
let storePath = flags.store;
if (!storePath) {
if (flags.scope === 'project' || flags.scope === 'local') {
Expand All @@ -484,6 +485,9 @@ async function run(argv: string[]): Promise<void> {
storePath = defaultStorePath();
}
}
if (!storePathOverridden && (command === 'search' || command === 'ask' || command === 'build')) {
ensureStore(storePath);
}

if (command === 'inventory') {
const inventory = service.inventory({
Expand Down Expand Up @@ -1131,6 +1135,7 @@ async function run(argv: string[]): Promise<void> {
modelRef: flags.model,
dimensions: flags.dimensions,
fake: flags.fake,
legacyStorePath: storePath,
});
output({ ok: true, ...context, message: `${context.excerpts.length} context excerpt(s)` }, flags.json);
return;
Expand All @@ -1142,6 +1147,7 @@ async function run(argv: string[]): Promise<void> {
modelRef: flags.model,
dimensions: flags.dimensions,
fake: flags.fake,
legacyStorePath: storePath,
});
output({ ok: true, ...result, message: `${result.results.length} search result(s)` }, flags.json);
return;
Expand Down Expand Up @@ -1177,6 +1183,7 @@ async function run(argv: string[]): Promise<void> {
fake: flags.fake,
generate: flags.generate,
approveWrite: flags.approveWrite,
legacyStorePath: storePath,
});
output({ ok: true, ...result, message: result.generated ? 'Generated answer with citations' : 'Prepared citation context draft' }, flags.json);
return;
Expand Down
1 change: 1 addition & 0 deletions src/retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ function citationScore(result: HybridSearchEntry): number {
function authorityScore(result: HybridSearchEntry): number {
if (result.kind === 'wiki_chunk') return 0.85;
if (result.kind === 'source_chunk') return 0.8;
if (result.kind === 'legacy_item') return 0.6;
if (result.kind === 'wiki_page') return 0.65;
return 0.55;
}
Expand Down
82 changes: 79 additions & 3 deletions src/search.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type { Database } from 'bun:sqlite';
import { existsSync, readFileSync } from 'node:fs';
import { migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
import { searchVectorIndex, type EmbeddingRuntimeOptions } from './embeddings';
import { sourceProvenance, type GeneratedArtifactProvenance, type KnowledgeProvenance } from './provenance';
import type { KnowledgeItem } from './store';
import type { KnowledgeConfig } from './workspace';

export type SearchResultKind = 'source_chunk' | 'wiki_chunk' | 'wiki_page' | 'knowledge_index';
export type SearchResultKind = 'source_chunk' | 'wiki_chunk' | 'legacy_item' | 'wiki_page' | 'knowledge_index';
export type SearchProvenance = KnowledgeProvenance | GeneratedArtifactProvenance;

export interface HybridSearchOptions extends EmbeddingRuntimeOptions {
dbPath: string;
legacyStorePath?: string;
query: string;
limit?: number;
semantic?: boolean;
Expand Down Expand Up @@ -281,6 +284,50 @@ function selectKnowledgeIndexes(db: Database, terms: string[], limit: number): I
).all(...likeParams(terms, fields.length), limit);
}

function readLegacyItems(path?: string): KnowledgeItem[] {
if (!path || !existsSync(path)) return [];
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { items?: unknown };
if (!parsed || !Array.isArray(parsed.items)) return [];
return parsed.items.filter((item): item is KnowledgeItem => {
return Boolean(
item
&& typeof item === 'object'
&& typeof (item as KnowledgeItem).id === 'string'
&& typeof (item as KnowledgeItem).title === 'string'
&& typeof (item as KnowledgeItem).content === 'string',
);
});
} catch {
return [];
}
}

function legacyItemHaystack(item: KnowledgeItem): string {
return [
item.id,
item.short_id,
item.title,
item.content,
item.url,
...(item.tags ?? []),
].filter((value): value is string => typeof value === 'string' && value.length > 0).join(' ').toLowerCase();
}

function selectLegacyItems(path: string | undefined, terms: string[], limit: number): Array<{
item: KnowledgeItem;
score: number;
}> {
if (terms.length === 0) return [];
return readLegacyItems(path)
.filter((item) => item.archived !== true)
.map((item) => ({ item, haystack: legacyItemHaystack(item) }))
.filter(({ haystack }) => terms.some((term) => haystack.includes(term)))
.map(({ item, haystack }) => ({ item, score: catalogScore(haystack, terms) }))
.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id))
.slice(0, limit);
}

function chunkResult(row: FtsChunkRow, keywordScore: number): HybridSearchEntry {
const metadata = parseJsonObject(row.chunk_metadata_json);
const provenance = provenanceForChunk(row);
Expand Down Expand Up @@ -319,6 +366,31 @@ function chunkResult(row: FtsChunkRow, keywordScore: number): HybridSearchEntry
return result;
}

function legacyItemResult(item: KnowledgeItem, keywordScore: number): HybridSearchEntry {
const uri = `knowledge://item/${encodeURIComponent(item.id)}`;
const result: HybridSearchEntry = {
kind: 'legacy_item',
id: item.id,
title: item.title,
text: item.content,
score: 0,
scores: { keyword: keywordScore },
source: {
uri,
ref: uri,
kind: 'legacy_item',
revision: null,
hash: null,
},
citation: null,
artifact: null,
provenance: null,
reasons: ['legacy_note_match', 'keyword_match'],
};
result.score = combinedScore(result.scores, result.citation);
return result;
}

function wikiPageResult(row: WikiPageRow, terms: string[]): HybridSearchEntry {
const metadata = parseJsonObject(row.metadata_json);
const score = catalogScore(`${row.path} ${row.title} ${row.artifact_uri ?? ''} ${row.metadata_json}`.toLowerCase(), terms);
Expand Down Expand Up @@ -395,8 +467,9 @@ function sortResults(results: HybridSearchEntry[]): HybridSearchEntry[] {
const kindOrder: Record<SearchResultKind, number> = {
source_chunk: 0,
wiki_chunk: 1,
wiki_page: 2,
knowledge_index: 3,
legacy_item: 2,
wiki_page: 3,
knowledge_index: 4,
};
return results.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
Expand Down Expand Up @@ -429,7 +502,10 @@ export async function hybridSearch(options: HybridSearchOptions): Promise<Hybrid

const wikiRows = selectWikiPages(db, terms, Math.max(limit, 10));
const indexRows = selectKnowledgeIndexes(db, terms, Math.max(limit, 10));
const legacyRows = selectLegacyItems(options.legacyStorePath, terms, Math.max(limit, 10));
catalogCount = wikiRows.length + indexRows.length;
keywordCount += legacyRows.length;
legacyRows.forEach(({ item, score }) => mergeResult(merged, legacyItemResult(item, score)));
wikiRows.forEach((row) => mergeResult(merged, wikiPageResult(row, terms)));
indexRows.forEach((row) => mergeResult(merged, indexResult(row, terms)));
} finally {
Expand Down
11 changes: 10 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import {
type StorageContract,
type StorageValidationResult,
} from './storage-contract';
import type { KnowledgeItem } from './store';
import { ensureStore, type KnowledgeItem } from './store';
import { initializeWikiLayout, recordWikiLayoutCatalog } from './wiki-layout';
import {
canonicalHasnaXyzKnowledgeStorage,
Expand Down Expand Up @@ -1700,27 +1700,36 @@ export class KnowledgeService {

async search(options: Omit<HybridSearchOptions, 'dbPath' | 'config'>) {
const workspace = this.ensureWorkspace();
const legacyStorePath = options.legacyStorePath ?? workspace.jsonStorePath;
if (!options.legacyStorePath) ensureStore(legacyStorePath);
return hybridSearch({
...options,
dbPath: workspace.knowledgeDbPath,
legacyStorePath,
config: this.config(),
});
}

async retrieveContext(options: Omit<RetrievalOptions, 'dbPath' | 'config'>) {
const workspace = this.ensureWorkspace();
const legacyStorePath = options.legacyStorePath ?? workspace.jsonStorePath;
if (!options.legacyStorePath) ensureStore(legacyStorePath);
return retrieveKnowledgeContext({
...options,
dbPath: workspace.knowledgeDbPath,
legacyStorePath,
config: this.config(),
});
}

async runPrompt(options: Omit<KnowledgePromptOptions, 'dbPath' | 'config'>) {
const workspace = this.ensureWorkspace();
const legacyStorePath = options.legacyStorePath ?? workspace.jsonStorePath;
if (!options.legacyStorePath) ensureStore(legacyStorePath);
return runKnowledgePrompt({
...options,
dbPath: workspace.knowledgeDbPath,
legacyStorePath,
config: this.config(),
});
}
Expand Down
Loading
Loading