vocs 2.4.0 → 2.5.2
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/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/internal/retriever.d.ts +21 -2
- package/dist/internal/retriever.d.ts.map +1 -1
- package/dist/internal/retriever.js +152 -43
- package/dist/internal/retriever.js.map +1 -1
- package/dist/internal/search.d.ts.map +1 -1
- package/dist/internal/search.js +2 -1
- package/dist/internal/search.js.map +1 -1
- package/dist/internal/vector-store.d.ts +98 -9
- package/dist/internal/vector-store.d.ts.map +1 -1
- package/dist/internal/vector-store.js +235 -6
- package/dist/internal/vector-store.js.map +1 -1
- package/dist/internal/vite-plugins.d.ts.map +1 -1
- package/dist/internal/vite-plugins.js +12 -5
- package/dist/internal/vite-plugins.js.map +1 -1
- package/dist/waku/internal/middleware/ai-search-warmup.d.ts +16 -0
- package/dist/waku/internal/middleware/ai-search-warmup.d.ts.map +1 -0
- package/dist/waku/internal/middleware/ai-search-warmup.js +54 -0
- package/dist/waku/internal/middleware/ai-search-warmup.js.map +1 -0
- package/dist/waku/internal/middleware/index.d.ts +1 -0
- package/dist/waku/internal/middleware/index.d.ts.map +1 -1
- package/dist/waku/internal/middleware/index.js +1 -0
- package/dist/waku/internal/middleware/index.js.map +1 -1
- package/dist/waku/middleware.d.ts +1 -1
- package/package.json +1 -1
- package/src/cli.ts +20 -1
- package/src/internal/retriever.test.ts +177 -10
- package/src/internal/retriever.ts +177 -46
- package/src/internal/search.test.ts +0 -2
- package/src/internal/search.ts +2 -1
- package/src/internal/vector-store.test.ts +202 -1
- package/src/internal/vector-store.ts +369 -9
- package/src/internal/vite-plugins.ts +13 -5
- package/src/waku/internal/middleware/ai-search-warmup.test.ts +102 -0
- package/src/waku/internal/middleware/ai-search-warmup.ts +52 -0
- package/src/waku/internal/middleware/index.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/waku/internal/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,gBAAgB,CAAA;AACrD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/waku/internal/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACjE,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,gBAAgB,CAAA;AACrD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/waku/internal/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,gBAAgB,CAAA;AACrD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/waku/internal/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACjE,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,gBAAgB,CAAA;AACrD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAA"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as middleware from './internal/middleware/index.js';
|
|
2
2
|
export declare function middlewareModules(overrides?: Record<string, () => Promise<unknown>>): {
|
|
3
3
|
[x: string]: (() => Promise<unknown>) | (() => Promise<{
|
|
4
|
-
default: typeof middleware.mdRouter | (() => import("hono").MiddlewareHandler) | ((options?: middleware.trailingSlash.Options) => import("hono").MiddlewareHandler);
|
|
4
|
+
default: (() => import("hono").MiddlewareHandler) | typeof middleware.mdRouter | (() => import("hono").MiddlewareHandler) | ((options?: middleware.trailingSlash.Options) => import("hono").MiddlewareHandler);
|
|
5
5
|
}>);
|
|
6
6
|
};
|
|
7
7
|
//# sourceMappingURL=middleware.d.ts.map
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -117,12 +117,31 @@ cli
|
|
|
117
117
|
case 'packed':
|
|
118
118
|
console.log(`[vocs] packed vectors (${event.dimensions}d, ${event.format})`)
|
|
119
119
|
break
|
|
120
|
+
case 'sync:start':
|
|
121
|
+
console.log(`[vocs] syncing ${event.chunks} chunks to the remote vector store...`)
|
|
122
|
+
break
|
|
123
|
+
case 'sync:progress': {
|
|
124
|
+
const now = Date.now()
|
|
125
|
+
if (event.done === event.total || now - lastFetchLog > 1000) {
|
|
126
|
+
lastFetchLog = now
|
|
127
|
+
console.log(`[vocs] upserted ${event.done}/${event.total}`)
|
|
128
|
+
}
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
case 'synced':
|
|
132
|
+
console.log(
|
|
133
|
+
`[vocs] synced (${event.upserted} upserted, ${event.skipped} unchanged, ${event.deleted} pruned)`,
|
|
134
|
+
)
|
|
135
|
+
break
|
|
120
136
|
}
|
|
121
137
|
},
|
|
122
138
|
})
|
|
123
139
|
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
140
|
+
const remote = config._localRetriever?.vectorStore.target === 'remote'
|
|
124
141
|
console.log(
|
|
125
|
-
|
|
142
|
+
remote
|
|
143
|
+
? `[vocs] AI search index synced in ${elapsed}s (${manifest.vectorStore.count} chunks, ${manifest.vectorStore.dimensions}d, remote vector store).`
|
|
144
|
+
: `[vocs] AI search index built in ${elapsed}s (${manifest.vectorStore.count} chunks, ${manifest.vectorStore.dimensions}d, ${manifest.vectorStore.format}).`,
|
|
126
145
|
)
|
|
127
146
|
|
|
128
147
|
// Persist to the canonical cache path so `vocs dev` can serve semantic
|
|
@@ -7,6 +7,7 @@ import * as ConfigSerializer from './config-serializer.js'
|
|
|
7
7
|
import * as Embedding from './embedding.js'
|
|
8
8
|
import * as Reranker from './reranker.js'
|
|
9
9
|
import * as Retriever from './retriever.js'
|
|
10
|
+
import * as VectorStore from './vector-store.js'
|
|
10
11
|
|
|
11
12
|
const ctx = { basePath: '/' }
|
|
12
13
|
|
|
@@ -400,6 +401,20 @@ describe('resolveLocal (config split)', () => {
|
|
|
400
401
|
expect(resolved?.private.chunking.maxCharacters).toBe(100)
|
|
401
402
|
expect(resolved?.private.chunking.overlapCharacters).toBe(99)
|
|
402
403
|
})
|
|
404
|
+
|
|
405
|
+
it('accepts a remote vector store (VectorStore.cloudflare)', () => {
|
|
406
|
+
const resolved = Retriever.resolveLocal(
|
|
407
|
+
{
|
|
408
|
+
embedding: Embedding.mock(),
|
|
409
|
+
vectorStore: VectorStore.cloudflare({ accountId: 'acc', apiToken: 'secret-token' }),
|
|
410
|
+
},
|
|
411
|
+
{ basePath: '/' },
|
|
412
|
+
)
|
|
413
|
+
expect(resolved?.private.vectorStore.type).toBe('cloudflare')
|
|
414
|
+
expect(resolved?.private.vectorStore.target).toBe('remote')
|
|
415
|
+
// Adapter (and its credentials) stays in the private, never-serialized half.
|
|
416
|
+
expect(JSON.stringify(resolved?.public)).not.toContain('secret-token')
|
|
417
|
+
})
|
|
403
418
|
})
|
|
404
419
|
|
|
405
420
|
describe('config integration', () => {
|
|
@@ -643,8 +658,9 @@ describe('end-to-end (mock embedder)', () => {
|
|
|
643
658
|
body: JSON.stringify({ query: 'installation' }),
|
|
644
659
|
})
|
|
645
660
|
|
|
646
|
-
// First request returns immediately while the index builds in the background
|
|
647
|
-
|
|
661
|
+
// First request returns immediately while the index builds in the background
|
|
662
|
+
// (`pendingWaitMs: 0` disables the grace wait to observe the 202).
|
|
663
|
+
const first = await Retriever.handleSearchRequest(makeRequest(), config, { pendingWaitMs: 0 })
|
|
648
664
|
expect(first.status).toBe(202)
|
|
649
665
|
const firstJson = (await first.json()) as { results: Retriever.Result[]; indexing: boolean }
|
|
650
666
|
expect(firstJson.indexing).toBe(true)
|
|
@@ -659,6 +675,32 @@ describe('end-to-end (mock embedder)', () => {
|
|
|
659
675
|
expect(Array.isArray(secondJson.results)).toBe(true)
|
|
660
676
|
})
|
|
661
677
|
|
|
678
|
+
it('answers 202 when the build outlasts the grace wait', async () => {
|
|
679
|
+
const embedding = Embedding.from({
|
|
680
|
+
type: 'slow',
|
|
681
|
+
model: 'slow',
|
|
682
|
+
async embed() {
|
|
683
|
+
return new Promise(() => {}) as never // never resolves
|
|
684
|
+
},
|
|
685
|
+
})
|
|
686
|
+
const config = Config.define({
|
|
687
|
+
rootDir: dir,
|
|
688
|
+
ai: { retriever: Retriever.local({ embedding, cache: false }) },
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
const response = await Retriever.handleSearchRequest(
|
|
692
|
+
new Request('http://localhost/api/search', {
|
|
693
|
+
method: 'POST',
|
|
694
|
+
body: JSON.stringify({ query: 'installation' }),
|
|
695
|
+
}),
|
|
696
|
+
config,
|
|
697
|
+
{ pendingWaitMs: 25 },
|
|
698
|
+
)
|
|
699
|
+
expect(response.status).toBe(202)
|
|
700
|
+
const json = (await response.json()) as { indexing: boolean }
|
|
701
|
+
expect(json.indexing).toBe(true)
|
|
702
|
+
})
|
|
703
|
+
|
|
662
704
|
it('dev (NODE_ENV=development) loads the prebuilt index instead of building', async () => {
|
|
663
705
|
const prev = process.env['NODE_ENV']
|
|
664
706
|
process.env['NODE_ENV'] = 'development'
|
|
@@ -746,13 +788,11 @@ describe('end-to-end (mock embedder)', () => {
|
|
|
746
788
|
})
|
|
747
789
|
const options = { loadManifest: async () => manifest }
|
|
748
790
|
|
|
791
|
+
// The manifest loads within the grace wait, so the first request answers
|
|
792
|
+
// 200 directly instead of 202 (`indexing: true`).
|
|
749
793
|
const first = await Retriever.handleSearchRequest(makeRequest(), config, options)
|
|
750
|
-
expect(first.status).toBe(
|
|
751
|
-
await
|
|
752
|
-
|
|
753
|
-
const second = await Retriever.handleSearchRequest(makeRequest(), config, options)
|
|
754
|
-
expect(second.status).toBe(200)
|
|
755
|
-
const json = (await second.json()) as { results: Retriever.Result[] }
|
|
794
|
+
expect(first.status).toBe(200)
|
|
795
|
+
const json = (await first.json()) as { results: Retriever.Result[] }
|
|
756
796
|
expect(json.results.length).toBeGreaterThan(0)
|
|
757
797
|
// Only the query was embedded — the index came from the bundled manifest.
|
|
758
798
|
expect(purposes).toEqual(['query'])
|
|
@@ -803,8 +843,10 @@ describe('end-to-end (mock embedder)', () => {
|
|
|
803
843
|
body: JSON.stringify({ query: 'installation' }),
|
|
804
844
|
})
|
|
805
845
|
|
|
806
|
-
// First request kicks off the build and reports indexing
|
|
807
|
-
|
|
846
|
+
// First request kicks off the build and reports indexing (grace wait
|
|
847
|
+
// disabled — the fast failure would otherwise answer 503 directly).
|
|
848
|
+
const first = await Retriever.handleSearchRequest(makeRequest(), config, { pendingWaitMs: 0 })
|
|
849
|
+
expect(first.status).toBe(202)
|
|
808
850
|
await expect(Retriever.getServerIndex(config)).rejects.toThrow('embedding unavailable')
|
|
809
851
|
|
|
810
852
|
// Subsequent requests surface the failure and don't retrigger the build
|
|
@@ -854,3 +896,128 @@ describe('end-to-end (mock embedder)', () => {
|
|
|
854
896
|
expect((await Retriever.handleSearchRequest(request, config)).status).toBe(400)
|
|
855
897
|
})
|
|
856
898
|
})
|
|
899
|
+
|
|
900
|
+
describe('end-to-end (remote vector store)', () => {
|
|
901
|
+
let dir: string
|
|
902
|
+
|
|
903
|
+
beforeEach(() => {
|
|
904
|
+
Retriever._resetServerIndexCache()
|
|
905
|
+
dir = makeSite()
|
|
906
|
+
})
|
|
907
|
+
afterEach(() => {
|
|
908
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
/** In-memory remote vector store (stands in for e.g. Cloudflare Vectorize). */
|
|
912
|
+
function memoryRemoteStore() {
|
|
913
|
+
const vectors = new Map<string, { metadata: Record<string, unknown>; vector: Float32Array }>()
|
|
914
|
+
const syncs: { count: number; dimensions: number }[] = []
|
|
915
|
+
const store: VectorStore.RemoteAdapter = {
|
|
916
|
+
type: 'memory',
|
|
917
|
+
target: 'remote',
|
|
918
|
+
async sync(entries, { dimensions }) {
|
|
919
|
+
syncs.push({ count: entries.length, dimensions })
|
|
920
|
+
vectors.clear()
|
|
921
|
+
for (const e of entries) vectors.set(e.id, { metadata: e.metadata, vector: e.vector })
|
|
922
|
+
return { deleted: 0, skipped: 0, upserted: entries.length }
|
|
923
|
+
},
|
|
924
|
+
async query(query, { topK }) {
|
|
925
|
+
return [...vectors.entries()]
|
|
926
|
+
.map(([id, v]) => {
|
|
927
|
+
let score = 0
|
|
928
|
+
for (let i = 0; i < query.length; i++) score += (v.vector[i] ?? 0) * (query[i] ?? 0)
|
|
929
|
+
return { id, metadata: v.metadata, score }
|
|
930
|
+
})
|
|
931
|
+
.sort((a, b) => b.score - a.score)
|
|
932
|
+
.slice(0, topK)
|
|
933
|
+
},
|
|
934
|
+
}
|
|
935
|
+
return { store, syncs, vectors }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function remoteConfig(store: VectorStore.RemoteAdapter, reranker?: Reranker.Adapter) {
|
|
939
|
+
return Config.define({
|
|
940
|
+
rootDir: dir,
|
|
941
|
+
ai: {
|
|
942
|
+
retriever: Retriever.local({
|
|
943
|
+
embedding: Embedding.mock({ dimensions: 64 }),
|
|
944
|
+
cache: false,
|
|
945
|
+
vectorStore: store,
|
|
946
|
+
...(reranker ? { reranker } : {}),
|
|
947
|
+
}),
|
|
948
|
+
},
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
it('buildIndex syncs vectors remotely and emits no local artifact', async () => {
|
|
953
|
+
const { store, syncs, vectors } = memoryRemoteStore()
|
|
954
|
+
const config = remoteConfig(store)
|
|
955
|
+
|
|
956
|
+
const events: Retriever.ProgressEvent['type'][] = []
|
|
957
|
+
const manifest = await Retriever.buildIndex(config, {
|
|
958
|
+
onProgress: (event) => events.push(event.type),
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
expect(syncs).toEqual([{ count: vectors.size, dimensions: 64 }])
|
|
962
|
+
expect(vectors.size).toBeGreaterThan(0)
|
|
963
|
+
// Chunk metadata + vectors live remotely — the manifest is metadata-only.
|
|
964
|
+
expect(manifest.chunks).toEqual([])
|
|
965
|
+
expect(manifest.vectors.count).toBe(0)
|
|
966
|
+
expect(manifest.vectorStore.count).toBe(vectors.size)
|
|
967
|
+
expect(events).toContain('sync:start')
|
|
968
|
+
expect(events).toContain('synced')
|
|
969
|
+
expect(events).not.toContain('packed')
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
it('retrieves through the remote store', async () => {
|
|
973
|
+
const { store } = memoryRemoteStore()
|
|
974
|
+
const config = remoteConfig(store)
|
|
975
|
+
await Retriever.buildIndex(config)
|
|
976
|
+
|
|
977
|
+
const results = await Retriever.retrieveLocal(config, { query: 'embeddings cache build time' })
|
|
978
|
+
expect(results.length).toBeGreaterThan(0)
|
|
979
|
+
expect(results.some((r) => r.href.includes('/search'))).toBe(true)
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
it('applies the reranker to remote candidates', async () => {
|
|
983
|
+
const calls: { count: number; query: string }[] = []
|
|
984
|
+
const reranker = Reranker.from({
|
|
985
|
+
type: 'spy',
|
|
986
|
+
model: 'spy',
|
|
987
|
+
async rerank(query, documents, context) {
|
|
988
|
+
calls.push({ count: documents.length, query })
|
|
989
|
+
return documents.map((_, index) => ({ index, score: 0.5 })).slice(0, context.topK)
|
|
990
|
+
},
|
|
991
|
+
})
|
|
992
|
+
const { store } = memoryRemoteStore()
|
|
993
|
+
const config = remoteConfig(store, reranker)
|
|
994
|
+
await Retriever.buildIndex(config)
|
|
995
|
+
|
|
996
|
+
const results = await Retriever.retrieveLocal(config, { query: 'installation' })
|
|
997
|
+
expect(calls[0]?.query).toBe('installation')
|
|
998
|
+
expect(calls[0]?.count).toBeGreaterThan(0)
|
|
999
|
+
// Uniform reranker scores → the lexical title boost decides the winner.
|
|
1000
|
+
expect(results[0]?.title).toBe('Installation')
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
it('serves search requests without an indexing phase', async () => {
|
|
1004
|
+
const { store } = memoryRemoteStore()
|
|
1005
|
+
const config = remoteConfig(store)
|
|
1006
|
+
await Retriever.buildIndex(config)
|
|
1007
|
+
|
|
1008
|
+
// Even the first request answers 200 — remote mode has no in-process
|
|
1009
|
+
// index to warm, so there is never a 202 `indexing` response.
|
|
1010
|
+
const response = await Retriever.handleSearchRequest(
|
|
1011
|
+
new Request('http://localhost/api/search', {
|
|
1012
|
+
method: 'POST',
|
|
1013
|
+
body: JSON.stringify({ query: 'installation' }),
|
|
1014
|
+
}),
|
|
1015
|
+
config,
|
|
1016
|
+
{ pendingWaitMs: 0 },
|
|
1017
|
+
)
|
|
1018
|
+
expect(response.status).toBe(200)
|
|
1019
|
+
const json = (await response.json()) as { indexing: boolean; results: Retriever.Result[] }
|
|
1020
|
+
expect(json.indexing).toBe(false)
|
|
1021
|
+
expect(json.results.length).toBeGreaterThan(0)
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
@@ -469,6 +469,12 @@ export declare namespace handleSearchRequest {
|
|
|
469
469
|
type Options = {
|
|
470
470
|
/** Source of the prebuilt manifest (e.g. baked into the server bundle). */
|
|
471
471
|
loadManifest?: ManifestLoader | undefined
|
|
472
|
+
/**
|
|
473
|
+
* How long to wait for a pending index build before answering `202`.
|
|
474
|
+
*
|
|
475
|
+
* @default 1500
|
|
476
|
+
*/
|
|
477
|
+
pendingWaitMs?: number | undefined
|
|
472
478
|
}
|
|
473
479
|
}
|
|
474
480
|
|
|
@@ -998,7 +1004,8 @@ export type IndexManifest = {
|
|
|
998
1004
|
/**
|
|
999
1005
|
* A build-progress event emitted by {@link buildIndex}, in pipeline order:
|
|
1000
1006
|
* `documents` → (`sources:start` → `sources:progress`* → `sources:done`) →
|
|
1001
|
-
* `chunked` → `embed:start` → `embed:progress`* → `packed
|
|
1007
|
+
* `chunked` → `embed:start` → `embed:progress`* → then `packed` (static
|
|
1008
|
+
* store) or `sync:start` → `sync:progress`* → `synced` (remote store).
|
|
1002
1009
|
*/
|
|
1003
1010
|
export type ProgressEvent =
|
|
1004
1011
|
| { type: 'documents'; local: number }
|
|
@@ -1009,6 +1016,9 @@ export type ProgressEvent =
|
|
|
1009
1016
|
| { type: 'embed:start'; cached: number; toEmbed: number; total: number }
|
|
1010
1017
|
| { type: 'embed:progress'; embedded: number; toEmbed: number }
|
|
1011
1018
|
| { type: 'packed'; dimensions: number; format: VectorStore.Format }
|
|
1019
|
+
| { type: 'sync:start'; chunks: number }
|
|
1020
|
+
| { type: 'sync:progress'; done: number; total: number }
|
|
1021
|
+
| { type: 'synced'; deleted: number; skipped: number; upserted: number }
|
|
1012
1022
|
|
|
1013
1023
|
export declare namespace buildIndex {
|
|
1014
1024
|
type Options = {
|
|
@@ -1083,30 +1093,60 @@ export async function buildIndex(
|
|
|
1083
1093
|
cache.save()
|
|
1084
1094
|
|
|
1085
1095
|
const dimensions = vectors[0]?.length ?? priv.embedding.dimensions ?? 0
|
|
1096
|
+
const embedding: IndexManifest['embedding'] = {
|
|
1097
|
+
type: priv.embedding.type,
|
|
1098
|
+
model: priv.embedding.model,
|
|
1099
|
+
dimensions,
|
|
1100
|
+
normalized: true,
|
|
1101
|
+
}
|
|
1102
|
+
const toMetadata = (c: Chunk): ChunkMetadata => ({
|
|
1103
|
+
id: c.id,
|
|
1104
|
+
href: c.href,
|
|
1105
|
+
title: c.title,
|
|
1106
|
+
titles: c.titles,
|
|
1107
|
+
category: c.category,
|
|
1108
|
+
type: c.type,
|
|
1109
|
+
snippet: c.text.slice(0, 240),
|
|
1110
|
+
text: c.text,
|
|
1111
|
+
...(c.weight !== undefined && c.weight !== 1 ? { weight: c.weight } : {}),
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
// Remote store: push vectors + metadata up instead of packing an artifact.
|
|
1115
|
+
// The returned manifest carries build metadata only — chunks live remotely.
|
|
1116
|
+
if (priv.vectorStore.target === 'remote') {
|
|
1117
|
+
const store = priv.vectorStore
|
|
1118
|
+
let synced: VectorStore.RemoteSyncResult = { deleted: 0, skipped: 0, upserted: 0 }
|
|
1119
|
+
if (chunks.length > 0) {
|
|
1120
|
+
onProgress?.({ type: 'sync:start', chunks: chunks.length })
|
|
1121
|
+
const entries = chunks.map((c, i) => {
|
|
1122
|
+
const vector = vectors[i]
|
|
1123
|
+
if (!vector) throw new Error('[vocs] embedding produced no vector for a chunk')
|
|
1124
|
+
return { id: c.id, metadata: toMetadata(c), vector }
|
|
1125
|
+
})
|
|
1126
|
+
synced = await store.sync(entries, {
|
|
1127
|
+
dimensions,
|
|
1128
|
+
onProgress: (done, total) => onProgress?.({ type: 'sync:progress', done, total }),
|
|
1129
|
+
})
|
|
1130
|
+
}
|
|
1131
|
+
onProgress?.({ type: 'synced', ...synced })
|
|
1132
|
+
return {
|
|
1133
|
+
version: 1,
|
|
1134
|
+
embedding,
|
|
1135
|
+
vectorStore: { format: 'float32', count: chunks.length, dimensions },
|
|
1136
|
+
chunks: [],
|
|
1137
|
+
vectors: VectorStore.pack([], 'float32'),
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1086
1141
|
const format = VectorStore.resolveFormat(priv.vectorStore.format, priv.runtime)
|
|
1087
1142
|
const packed = VectorStore.pack(vectors, format)
|
|
1088
1143
|
onProgress?.({ type: 'packed', format, dimensions })
|
|
1089
1144
|
|
|
1090
1145
|
return {
|
|
1091
1146
|
version: 1,
|
|
1092
|
-
embedding
|
|
1093
|
-
type: priv.embedding.type,
|
|
1094
|
-
model: priv.embedding.model,
|
|
1095
|
-
dimensions,
|
|
1096
|
-
normalized: true,
|
|
1097
|
-
},
|
|
1147
|
+
embedding,
|
|
1098
1148
|
vectorStore: { format, count: chunks.length, dimensions },
|
|
1099
|
-
chunks: chunks.map(
|
|
1100
|
-
id: c.id,
|
|
1101
|
-
href: c.href,
|
|
1102
|
-
title: c.title,
|
|
1103
|
-
titles: c.titles,
|
|
1104
|
-
category: c.category,
|
|
1105
|
-
type: c.type,
|
|
1106
|
-
snippet: c.text.slice(0, 240),
|
|
1107
|
-
text: c.text,
|
|
1108
|
-
...(c.weight !== undefined && c.weight !== 1 ? { weight: c.weight } : {}),
|
|
1109
|
-
})),
|
|
1149
|
+
chunks: chunks.map(toMetadata),
|
|
1110
1150
|
vectors: packed,
|
|
1111
1151
|
}
|
|
1112
1152
|
}
|
|
@@ -1247,6 +1287,13 @@ const serverIndexCache = new Map<string, ServerIndexEntry>()
|
|
|
1247
1287
|
/** Minimum wait before a failed index build may be retried. */
|
|
1248
1288
|
const failedBuildRetryMs = 30_000
|
|
1249
1289
|
|
|
1290
|
+
/**
|
|
1291
|
+
* How long a search request waits for a pending index build before answering
|
|
1292
|
+
* `202`. Matches the client's poll cadence, so a fast manifest load (the
|
|
1293
|
+
* common cold-start case) answers on the first request instead.
|
|
1294
|
+
*/
|
|
1295
|
+
const pendingWaitMsDefault = 1_500
|
|
1296
|
+
|
|
1250
1297
|
function indexCacheKey(config: Config.Config, priv: LocalPrivateConfig): string {
|
|
1251
1298
|
return `${config.rootDir}:${priv.embedding.type}:${priv.embedding.model}`
|
|
1252
1299
|
}
|
|
@@ -1315,6 +1362,12 @@ async function resolveServerIndex(
|
|
|
1315
1362
|
priv: LocalPrivateConfig,
|
|
1316
1363
|
loadManifest?: ManifestLoader | undefined,
|
|
1317
1364
|
): Promise<ServerIndex> {
|
|
1365
|
+
// Remote vector store: vectors live in the hosted database (synced at build
|
|
1366
|
+
// time), so there is no in-process index to load — and never build one here,
|
|
1367
|
+
// which would re-sync the store from a runtime instance.
|
|
1368
|
+
if (priv.vectorStore.target === 'remote')
|
|
1369
|
+
return { store: VectorStore.load(VectorStore.pack([], 'float32')), chunks: [] }
|
|
1370
|
+
|
|
1318
1371
|
// Baked-in manifest first (emitted into the server bundle at build time) —
|
|
1319
1372
|
// serverless filesystems don't carry the cache directory or source pages, so
|
|
1320
1373
|
// this is the only source that works everywhere.
|
|
@@ -1397,33 +1450,92 @@ function titleMatchBoost(queryTokens: readonly string[], title: string): number
|
|
|
1397
1450
|
return queryTokens.length === titleTokens.length ? 0.25 : 0.12
|
|
1398
1451
|
}
|
|
1399
1452
|
|
|
1400
|
-
/** Embeds
|
|
1401
|
-
|
|
1453
|
+
/** Embeds and L2-normalizes a search query. */
|
|
1454
|
+
async function embedQuery(
|
|
1455
|
+
priv: LocalPrivateConfig,
|
|
1456
|
+
query: string,
|
|
1457
|
+
): Promise<Float32Array | undefined> {
|
|
1458
|
+
const embedded = await priv.embedding.embed([query], { purpose: 'query' })
|
|
1459
|
+
const raw = embedded[0]
|
|
1460
|
+
return raw ? VectorStore.normalize(raw) : undefined
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** Maps remote-store metadata back into {@link ChunkMetadata} (skips malformed hits). */
|
|
1464
|
+
function chunkMetadataFromRemote(
|
|
1465
|
+
metadata: Record<string, unknown> | undefined,
|
|
1466
|
+
): ChunkMetadata | undefined {
|
|
1467
|
+
if (!metadata) return undefined
|
|
1468
|
+
const href = str(metadata['href'])
|
|
1469
|
+
const title = str(metadata['title'])
|
|
1470
|
+
if (!href || !title) return undefined
|
|
1471
|
+
const type = metadata['type']
|
|
1472
|
+
return {
|
|
1473
|
+
id: str(metadata['id']) ?? href,
|
|
1474
|
+
href,
|
|
1475
|
+
title,
|
|
1476
|
+
titles: Array.isArray(metadata['titles'])
|
|
1477
|
+
? metadata['titles'].filter((t): t is string => typeof t === 'string')
|
|
1478
|
+
: [],
|
|
1479
|
+
category: typeof metadata['category'] === 'string' ? metadata['category'] : '',
|
|
1480
|
+
type: type === 'page' || type === 'section' || type === 'nav' ? type : 'page',
|
|
1481
|
+
snippet: typeof metadata['snippet'] === 'string' ? metadata['snippet'] : '',
|
|
1482
|
+
...(typeof metadata['text'] === 'string' ? { text: metadata['text'] } : {}),
|
|
1483
|
+
...(typeof metadata['weight'] === 'number' && metadata['weight'] !== 1
|
|
1484
|
+
? { weight: metadata['weight'] }
|
|
1485
|
+
: {}),
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Fetches vector candidates for a query — from the remote store (embed query →
|
|
1491
|
+
* remote nearest-neighbor query) or the in-process static store (embed query →
|
|
1492
|
+
* dot-product scan). `score` is the raw similarity; a reranker may replace it
|
|
1493
|
+
* with a cross-encoder score.
|
|
1494
|
+
*/
|
|
1495
|
+
async function retrieveCandidates(
|
|
1402
1496
|
config: Config.Config,
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1497
|
+
priv: LocalPrivateConfig,
|
|
1498
|
+
queryText: string,
|
|
1499
|
+
): Promise<{ meta: ChunkMetadata; score: number }[]> {
|
|
1500
|
+
if (priv.vectorStore.target === 'remote') {
|
|
1501
|
+
const query = await embedQuery(priv, queryText)
|
|
1502
|
+
if (!query) return []
|
|
1503
|
+
const hits = await priv.vectorStore.query(query, { topK: priv.retrieval.candidateK })
|
|
1504
|
+
return hits
|
|
1505
|
+
.map((hit) => {
|
|
1506
|
+
const meta = chunkMetadataFromRemote(hit.metadata)
|
|
1507
|
+
return meta ? { meta, score: hit.score } : undefined
|
|
1508
|
+
})
|
|
1509
|
+
.filter((hit): hit is { meta: ChunkMetadata; score: number } => hit !== undefined)
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1407
1512
|
const index = await getServerIndex(config)
|
|
1408
1513
|
// Empty index (dev with no prebuilt manifest): skip the query embedding call
|
|
1409
1514
|
// and fall back to keyword-only search on the client.
|
|
1410
1515
|
if (index.chunks.length === 0) return []
|
|
1411
1516
|
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
if (!raw) return []
|
|
1415
|
-
const query = VectorStore.normalize(raw)
|
|
1517
|
+
const query = await embedQuery(priv, queryText)
|
|
1518
|
+
if (!query) return []
|
|
1416
1519
|
|
|
1417
1520
|
const hits = VectorStore.search(index.store, query, priv.retrieval.candidateK)
|
|
1418
|
-
|
|
1419
|
-
// Vector candidates, paired with their chunk metadata. `score` is the raw
|
|
1420
|
-
// similarity; a reranker (below) may replace it with a cross-encoder score.
|
|
1421
|
-
let candidates = hits
|
|
1521
|
+
return hits
|
|
1422
1522
|
.map((hit) => {
|
|
1423
1523
|
const meta = index.chunks[hit.index]
|
|
1424
1524
|
return meta ? { meta, score: hit.score } : undefined
|
|
1425
1525
|
})
|
|
1426
1526
|
.filter((hit): hit is { meta: ChunkMetadata; score: number } => hit !== undefined)
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Embeds the query, searches the vector store, dedupes by href. */
|
|
1530
|
+
export async function retrieveLocal(
|
|
1531
|
+
config: Config.Config,
|
|
1532
|
+
options: retrieveLocal.Options,
|
|
1533
|
+
): Promise<Result[]> {
|
|
1534
|
+
const priv = requireLocal(config)
|
|
1535
|
+
const limit = options.limit ?? priv.retrieval.topK
|
|
1536
|
+
|
|
1537
|
+
let candidates = await retrieveCandidates(config, priv, options.query)
|
|
1538
|
+
if (candidates.length === 0) return []
|
|
1427
1539
|
|
|
1428
1540
|
// Optional rerank: a cross-encoder reads each (query, passage) pair jointly
|
|
1429
1541
|
// and re-scores the candidates for precision. On failure we keep the vector
|
|
@@ -1525,22 +1637,33 @@ async function handleLocalSearchRequest(
|
|
|
1525
1637
|
const limit =
|
|
1526
1638
|
typeof body.limit === 'number' ? Math.max(1, Math.min(20, Math.floor(body.limit))) : undefined
|
|
1527
1639
|
|
|
1528
|
-
//
|
|
1529
|
-
// when present and cold-builds otherwise
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1640
|
+
// Static store only: kick off (or reuse) the in-process index. This loads
|
|
1641
|
+
// the prebuilt manifest when present and cold-builds otherwise. While
|
|
1642
|
+
// pending, wait briefly — a manifest load (the common cold-start case)
|
|
1643
|
+
// usually finishes within the grace window — then respond with
|
|
1644
|
+
// `indexing: true` so the client keeps showing keyword results (and retries
|
|
1645
|
+
// shortly). A failed build answers 503 (instead of 202) so the client stops
|
|
1646
|
+
// polling; the entry is retried after a backoff.
|
|
1647
|
+
//
|
|
1648
|
+
// A remote store has no in-process index (vectors are queried in the
|
|
1649
|
+
// database), so there is never an `indexing` phase.
|
|
1650
|
+
let index: ReturnType<typeof ensureServerIndex> | undefined
|
|
1651
|
+
if (config._localRetriever.vectorStore.target === 'static') {
|
|
1652
|
+
index = ensureServerIndex(config, options)
|
|
1653
|
+
index.promise.catch(() => {}) // avoid unhandled rejection on the background build
|
|
1654
|
+
if (index.status === 'pending') {
|
|
1655
|
+
const waitMs = options.pendingWaitMs ?? pendingWaitMsDefault
|
|
1656
|
+
if (waitMs > 0) await Promise.race([index.promise.catch(() => {}), sleep(waitMs)])
|
|
1657
|
+
}
|
|
1658
|
+
if (index.status === 'error') return json({ error: 'Search failed' }, 503)
|
|
1659
|
+
if (index.status === 'pending')
|
|
1660
|
+
return json({ results: [], indexing: true }, 202, { 'Cache-Control': 'no-store' })
|
|
1661
|
+
}
|
|
1539
1662
|
|
|
1540
1663
|
try {
|
|
1541
1664
|
const t0 = Date.now()
|
|
1542
|
-
const [
|
|
1543
|
-
index.
|
|
1665
|
+
const [store, results] = await Promise.all([
|
|
1666
|
+
index?.promise.then((i) => i.store),
|
|
1544
1667
|
retrieveLocal(config, { query, limit }),
|
|
1545
1668
|
])
|
|
1546
1669
|
const searchMs = Date.now() - t0
|
|
@@ -1551,7 +1674,7 @@ async function handleLocalSearchRequest(
|
|
|
1551
1674
|
{
|
|
1552
1675
|
results,
|
|
1553
1676
|
indexing: false,
|
|
1554
|
-
embedding: { type, model, dimensions: store
|
|
1677
|
+
embedding: { type, model, dimensions: store?.dimensions || dimensions },
|
|
1555
1678
|
...(reranker ? { reranker: { type: reranker.type, model: reranker.model } } : {}),
|
|
1556
1679
|
timings: { searchMs },
|
|
1557
1680
|
},
|
|
@@ -1586,6 +1709,14 @@ function json(data: unknown, status: number, headers?: Record<string, string>):
|
|
|
1586
1709
|
})
|
|
1587
1710
|
}
|
|
1588
1711
|
|
|
1712
|
+
/** Resolves after `ms` without holding the process open (Node-only `unref`). */
|
|
1713
|
+
function sleep(ms: number): Promise<void> {
|
|
1714
|
+
return new Promise((resolve) => {
|
|
1715
|
+
const timer = setTimeout(resolve, ms)
|
|
1716
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
1717
|
+
})
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1589
1720
|
function str(value: unknown): string | undefined {
|
|
1590
1721
|
return typeof value === 'string' && value ? value : undefined
|
|
1591
1722
|
}
|
|
@@ -667,7 +667,6 @@ Then do this.
|
|
|
667
667
|
"title": "Step one",
|
|
668
668
|
"titles": [
|
|
669
669
|
"Getting Started",
|
|
670
|
-
undefined,
|
|
671
670
|
],
|
|
672
671
|
},
|
|
673
672
|
{
|
|
@@ -678,7 +677,6 @@ Then do this.
|
|
|
678
677
|
"title": "Step two",
|
|
679
678
|
"titles": [
|
|
680
679
|
"Getting Started",
|
|
681
|
-
undefined,
|
|
682
680
|
],
|
|
683
681
|
},
|
|
684
682
|
]
|
package/src/internal/search.ts
CHANGED
|
@@ -386,7 +386,8 @@ export function extract(source: string, config: Config.Config): extract.ReturnTy
|
|
|
386
386
|
|
|
387
387
|
// Update title stack based on heading depth
|
|
388
388
|
titleStack.length = depth - 1
|
|
389
|
-
|
|
389
|
+
// Skipped heading levels leave holes; drop them from the breadcrumb.
|
|
390
|
+
const titles = [...titleStack].filter((t) => t !== undefined)
|
|
390
391
|
titleStack[depth - 1] = title
|
|
391
392
|
|
|
392
393
|
currentSection = {
|