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.
Files changed (37) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/cli.js.map +1 -1
  3. package/dist/internal/retriever.d.ts +21 -2
  4. package/dist/internal/retriever.d.ts.map +1 -1
  5. package/dist/internal/retriever.js +152 -43
  6. package/dist/internal/retriever.js.map +1 -1
  7. package/dist/internal/search.d.ts.map +1 -1
  8. package/dist/internal/search.js +2 -1
  9. package/dist/internal/search.js.map +1 -1
  10. package/dist/internal/vector-store.d.ts +98 -9
  11. package/dist/internal/vector-store.d.ts.map +1 -1
  12. package/dist/internal/vector-store.js +235 -6
  13. package/dist/internal/vector-store.js.map +1 -1
  14. package/dist/internal/vite-plugins.d.ts.map +1 -1
  15. package/dist/internal/vite-plugins.js +12 -5
  16. package/dist/internal/vite-plugins.js.map +1 -1
  17. package/dist/waku/internal/middleware/ai-search-warmup.d.ts +16 -0
  18. package/dist/waku/internal/middleware/ai-search-warmup.d.ts.map +1 -0
  19. package/dist/waku/internal/middleware/ai-search-warmup.js +54 -0
  20. package/dist/waku/internal/middleware/ai-search-warmup.js.map +1 -0
  21. package/dist/waku/internal/middleware/index.d.ts +1 -0
  22. package/dist/waku/internal/middleware/index.d.ts.map +1 -1
  23. package/dist/waku/internal/middleware/index.js +1 -0
  24. package/dist/waku/internal/middleware/index.js.map +1 -1
  25. package/dist/waku/middleware.d.ts +1 -1
  26. package/package.json +1 -1
  27. package/src/cli.ts +20 -1
  28. package/src/internal/retriever.test.ts +177 -10
  29. package/src/internal/retriever.ts +177 -46
  30. package/src/internal/search.test.ts +0 -2
  31. package/src/internal/search.ts +2 -1
  32. package/src/internal/vector-store.test.ts +202 -1
  33. package/src/internal/vector-store.ts +369 -9
  34. package/src/internal/vite-plugins.ts +13 -5
  35. package/src/waku/internal/middleware/ai-search-warmup.test.ts +102 -0
  36. package/src/waku/internal/middleware/ai-search-warmup.ts +52 -0
  37. 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,3 +1,4 @@
1
+ export { default as aiSearchWarmup } from './ai-search-warmup.js';
1
2
  export { default as mdRouter } from './md-router.js';
2
3
  export { default as redirects } from './redirects.js';
3
4
  export { default as trailingSlash } from './trailing-slash.js';
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vocs",
3
3
  "type": "module",
4
- "version": "2.4.0",
4
+ "version": "2.5.2",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "repository": {
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
- `[vocs] AI search index built in ${elapsed}s (${manifest.vectorStore.count} chunks, ${manifest.vectorStore.dimensions}d, ${manifest.vectorStore.format}).`,
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
- const first = await Retriever.handleSearchRequest(makeRequest(), config)
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(202)
751
- await Retriever.getServerIndex(config)
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
- expect((await Retriever.handleSearchRequest(makeRequest(), config)).status).toBe(202)
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((c) => ({
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 the query, searches the local store, dedupes by href. */
1401
- export async function retrieveLocal(
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
- options: retrieveLocal.Options,
1404
- ): Promise<Result[]> {
1405
- const priv = requireLocal(config)
1406
- const limit = options.limit ?? priv.retrieval.topK
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 embedded = await priv.embedding.embed([options.query], { purpose: 'query' })
1413
- const raw = embedded[0]
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
- // Kick off (or reuse) the in-process index. This loads the prebuilt manifest
1529
- // when present and cold-builds otherwise — either way we never block the
1530
- // request: while pending, respond with `indexing: true` and let the client
1531
- // keep showing keyword results (and retry shortly). A failed build answers
1532
- // 503 (instead of 202) so the client stops polling; the entry is retried
1533
- // after a backoff.
1534
- const index = ensureServerIndex(config, options)
1535
- index.promise.catch(() => {}) // avoid unhandled rejection on the background build
1536
- if (index.status === 'error') return json({ error: 'Search failed' }, 503)
1537
- if (index.status === 'pending')
1538
- return json({ results: [], indexing: true }, 202, { 'Cache-Control': 'no-store' })
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 [{ store }, results] = await Promise.all([
1543
- index.promise,
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.dimensions || dimensions },
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
  ]
@@ -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
- const titles = [...titleStack]
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 = {