@tryformation/querylight-cli 0.2.3 → 0.2.4
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/README.md +26 -0
- package/dist/cli/main.js +361 -81
- package/dist/index.d.ts +1 -0
- package/dist/index.js +294 -72
- package/dist/query/search-service.d.ts +7 -1
- package/dist/server/search-api.d.ts +15 -0
- package/dist/vector/dense.d.ts +6 -1
- package/package.json +1 -1
- package/scripts/sparse-encode.py +29 -8
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ It is designed for local, inspectable workflows:
|
|
|
16
16
|
- chunk documents for retrieval
|
|
17
17
|
- build a portable local Querylight index
|
|
18
18
|
- search and generate retrieval context for external agents and tools
|
|
19
|
+
- serve an OpenSearch-like `_search` API over one or more local knowledge bases
|
|
19
20
|
- inspect workspace state, diffs, and change reports
|
|
20
21
|
|
|
21
22
|
## Install
|
|
@@ -108,6 +109,9 @@ Search it:
|
|
|
108
109
|
qli search "API authentication"
|
|
109
110
|
qli search --source-type rss --since 2026-05-01 --has-publication-date
|
|
110
111
|
qli search-json '{"query":{"match":{"text":"API authentication"}},"size":5}'
|
|
112
|
+
curl -X POST http://127.0.0.1:3000/_search \
|
|
113
|
+
-H 'content-type: application/json' \
|
|
114
|
+
-d '{"query":{"match":{"text":"API authentication"}},"size":5}'
|
|
111
115
|
```
|
|
112
116
|
|
|
113
117
|
Find related documents for an existing one:
|
|
@@ -122,6 +126,16 @@ Generate retrieval context:
|
|
|
122
126
|
qli context "How do I authenticate API requests?" --top-k 8
|
|
123
127
|
```
|
|
124
128
|
|
|
129
|
+
Serve the lexical index over HTTP:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
qli serve
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`qli serve` loads the current workspace index once at startup and reuses it for each request.
|
|
136
|
+
Use `POST /_search` or `POST /<configured-index-name>/_search` for a single workspace.
|
|
137
|
+
Use `POST /<directory-name>/_search` when `--workspace` points to a directory whose children each contain their own `.kb` workspace.
|
|
138
|
+
|
|
125
139
|
## Example Skill: `qli` with `bunx` and `uv`
|
|
126
140
|
|
|
127
141
|
The repository includes an example skill for running `qli` without a global install and calling it from Python with `uv`:
|
|
@@ -374,6 +388,18 @@ qli context "How do I configure the API?"
|
|
|
374
388
|
qli context "What changed in pricing?" --top-k 12 --max-chars 12000
|
|
375
389
|
```
|
|
376
390
|
|
|
391
|
+
Serve the lexical index over HTTP:
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
qli serve
|
|
395
|
+
qli serve --workspace ./docs/.kb --port 4000
|
|
396
|
+
qli serve --workspace ./kbs --host 0.0.0.0 --port 4000
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
For a single workspace, use `POST /_search` or `POST /<configured-index-name>/_search`.
|
|
400
|
+
For a directory of knowledge bases, use `POST /<directory-name>/_search`.
|
|
401
|
+
The request body must be a Querylight JSON DSL object.
|
|
402
|
+
|
|
377
403
|
### Change Inspection
|
|
378
404
|
|
|
379
405
|
```bash
|
package/dist/cli/main.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/run-cli.ts
|
|
4
4
|
import { Command, Option } from "commander";
|
|
5
|
-
import { readFile as readFile11, stat as
|
|
6
|
-
import
|
|
5
|
+
import { readFile as readFile11, stat as stat5 } from "fs/promises";
|
|
6
|
+
import path22 from "path";
|
|
7
7
|
|
|
8
8
|
// src/chunk/chunker.ts
|
|
9
9
|
import { readFile as readFile3 } from "fs/promises";
|
|
@@ -53,7 +53,7 @@ var defaultConfig = () => ({
|
|
|
53
53
|
defaultMode: "lexical",
|
|
54
54
|
dense: {
|
|
55
55
|
enabled: true,
|
|
56
|
-
modelId: "Xenova/
|
|
56
|
+
modelId: "Xenova/paraphrase-MiniLM-L3-v2",
|
|
57
57
|
cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
|
|
58
58
|
indexHashTables: 8,
|
|
59
59
|
indexRandomSeed: 42,
|
|
@@ -61,7 +61,7 @@ var defaultConfig = () => ({
|
|
|
61
61
|
},
|
|
62
62
|
sparse: {
|
|
63
63
|
enabled: true,
|
|
64
|
-
modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-
|
|
64
|
+
modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini",
|
|
65
65
|
cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
|
|
66
66
|
documentTopTokens: 128,
|
|
67
67
|
queryEncoding: "tokenizer-token-weights",
|
|
@@ -682,15 +682,26 @@ function createSparseChunkText(chunk) {
|
|
|
682
682
|
// src/vector/dense.ts
|
|
683
683
|
var denseEmbedderFactory = null;
|
|
684
684
|
var EXACT_DENSE_RERANK_THRESHOLD = 5e3;
|
|
685
|
+
function normalizeDenseEmbedder(embedder) {
|
|
686
|
+
if (typeof embedder === "function") {
|
|
687
|
+
return { embed: embedder };
|
|
688
|
+
}
|
|
689
|
+
return embedder;
|
|
690
|
+
}
|
|
685
691
|
async function createEmbedder(cacheDir, modelId) {
|
|
686
692
|
if (denseEmbedderFactory) {
|
|
687
|
-
return denseEmbedderFactory(cacheDir, modelId);
|
|
693
|
+
return normalizeDenseEmbedder(await denseEmbedderFactory(cacheDir, modelId));
|
|
688
694
|
}
|
|
689
695
|
const runtime = await getDenseTransformersRuntime(cacheDir);
|
|
690
696
|
const extractor = await runtime.pipeline("feature-extraction", modelId);
|
|
691
|
-
return
|
|
692
|
-
|
|
693
|
-
|
|
697
|
+
return {
|
|
698
|
+
async embed(text) {
|
|
699
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
700
|
+
return output.tolist()[0];
|
|
701
|
+
},
|
|
702
|
+
async dispose() {
|
|
703
|
+
await extractor.dispose();
|
|
704
|
+
}
|
|
694
705
|
};
|
|
695
706
|
}
|
|
696
707
|
function exactDenseQuery(payload, vector, topK) {
|
|
@@ -699,8 +710,12 @@ function exactDenseQuery(payload, vector, topK) {
|
|
|
699
710
|
async function pullDenseModel(workspacePath, config) {
|
|
700
711
|
const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
|
|
701
712
|
await mkdir4(cacheDir, { recursive: true });
|
|
702
|
-
const
|
|
703
|
-
|
|
713
|
+
const embedder = await createEmbedder(cacheDir, config.modelId);
|
|
714
|
+
try {
|
|
715
|
+
await embedder.embed("warm dense model cache");
|
|
716
|
+
} finally {
|
|
717
|
+
await embedder.dispose?.();
|
|
718
|
+
}
|
|
704
719
|
}
|
|
705
720
|
async function buildDenseVectors({
|
|
706
721
|
workspacePath,
|
|
@@ -710,53 +725,57 @@ async function buildDenseVectors({
|
|
|
710
725
|
const chunks = await readJsonl(path8.join(workspacePath, "chunks", "chunks.jsonl"));
|
|
711
726
|
const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
|
|
712
727
|
await mkdir4(cacheDir, { recursive: true });
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
728
|
+
const embedder = await createEmbedder(cacheDir, config.modelId);
|
|
729
|
+
try {
|
|
730
|
+
const records = [];
|
|
731
|
+
let dimensions = 0;
|
|
732
|
+
reportProgress(progress, `Encoding ${chunks.length} chunk${chunks.length === 1 ? "" : "s"} for dense retrieval`);
|
|
733
|
+
for (const chunk of chunks) {
|
|
734
|
+
const embedding = await embedder.embed(createDenseChunkText(chunk));
|
|
735
|
+
dimensions ||= embedding.length;
|
|
736
|
+
records.push({
|
|
737
|
+
chunkId: chunk.id,
|
|
738
|
+
documentId: chunk.documentId,
|
|
739
|
+
sourceId: chunk.sourceId,
|
|
740
|
+
title: chunk.title,
|
|
741
|
+
uri: chunk.uri,
|
|
742
|
+
headingPath: chunk.headingPath,
|
|
743
|
+
text: chunk.text,
|
|
744
|
+
embedding
|
|
745
|
+
});
|
|
746
|
+
if (records.length === 1 || records.length % 100 === 0 || records.length === chunks.length) {
|
|
747
|
+
reportProgressDetail(progress, `Encoded ${records.length}/${chunks.length} chunks for dense retrieval`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
reportProgress(progress, "Building dense vector index");
|
|
751
|
+
const index = new VectorFieldIndex({
|
|
752
|
+
numHashTables: config.indexHashTables,
|
|
753
|
+
dimensions,
|
|
754
|
+
random: createSeededRandom(config.indexRandomSeed)
|
|
729
755
|
});
|
|
730
|
-
|
|
731
|
-
|
|
756
|
+
for (const record of records) {
|
|
757
|
+
index.insert(record.chunkId, [record.embedding]);
|
|
732
758
|
}
|
|
759
|
+
const metadata = {
|
|
760
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
761
|
+
modelId: config.modelId,
|
|
762
|
+
dimensions,
|
|
763
|
+
hashTables: config.indexHashTables,
|
|
764
|
+
randomSeed: config.indexRandomSeed,
|
|
765
|
+
chunkCount: records.length,
|
|
766
|
+
indexHash: sha256(JSON.stringify(index.indexState))
|
|
767
|
+
};
|
|
768
|
+
const payload = {
|
|
769
|
+
metadata,
|
|
770
|
+
indexState: index.indexState,
|
|
771
|
+
chunks: records
|
|
772
|
+
};
|
|
773
|
+
await writeDensePayload(workspacePath, payload);
|
|
774
|
+
reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
|
|
775
|
+
return payload;
|
|
776
|
+
} finally {
|
|
777
|
+
await embedder.dispose?.();
|
|
733
778
|
}
|
|
734
|
-
reportProgress(progress, "Building dense vector index");
|
|
735
|
-
const index = new VectorFieldIndex({
|
|
736
|
-
numHashTables: config.indexHashTables,
|
|
737
|
-
dimensions,
|
|
738
|
-
random: createSeededRandom(config.indexRandomSeed)
|
|
739
|
-
});
|
|
740
|
-
for (const record of records) {
|
|
741
|
-
index.insert(record.chunkId, [record.embedding]);
|
|
742
|
-
}
|
|
743
|
-
const metadata = {
|
|
744
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
745
|
-
modelId: config.modelId,
|
|
746
|
-
dimensions,
|
|
747
|
-
hashTables: config.indexHashTables,
|
|
748
|
-
randomSeed: config.indexRandomSeed,
|
|
749
|
-
chunkCount: records.length,
|
|
750
|
-
indexHash: sha256(JSON.stringify(index.indexState))
|
|
751
|
-
};
|
|
752
|
-
const payload = {
|
|
753
|
-
metadata,
|
|
754
|
-
indexState: index.indexState,
|
|
755
|
-
chunks: records
|
|
756
|
-
};
|
|
757
|
-
await writeDensePayload(workspacePath, payload);
|
|
758
|
-
reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
|
|
759
|
-
return payload;
|
|
760
779
|
}
|
|
761
780
|
async function denseQuery({
|
|
762
781
|
workspacePath,
|
|
@@ -766,21 +785,25 @@ async function denseQuery({
|
|
|
766
785
|
}) {
|
|
767
786
|
const payload = await readDensePayload(workspacePath);
|
|
768
787
|
const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
788
|
+
const embedder = await createEmbedder(cacheDir, config.modelId);
|
|
789
|
+
try {
|
|
790
|
+
const vector = await embedder.embed(query);
|
|
791
|
+
if (payload.chunks.length <= EXACT_DENSE_RERANK_THRESHOLD) {
|
|
792
|
+
return exactDenseQuery(payload, vector, topK);
|
|
793
|
+
}
|
|
794
|
+
const index = new VectorFieldIndex({
|
|
795
|
+
numHashTables: payload.metadata.hashTables,
|
|
796
|
+
dimensions: payload.metadata.dimensions,
|
|
797
|
+
random: createSeededRandom(payload.metadata.randomSeed)
|
|
798
|
+
}).loadState(payload.indexState);
|
|
799
|
+
const approximateHits = index.query(vector, topK);
|
|
800
|
+
if (approximateHits.length >= topK) {
|
|
801
|
+
return approximateHits;
|
|
802
|
+
}
|
|
772
803
|
return exactDenseQuery(payload, vector, topK);
|
|
804
|
+
} finally {
|
|
805
|
+
await embedder.dispose?.();
|
|
773
806
|
}
|
|
774
|
-
const index = new VectorFieldIndex({
|
|
775
|
-
numHashTables: payload.metadata.hashTables,
|
|
776
|
-
dimensions: payload.metadata.dimensions,
|
|
777
|
-
random: createSeededRandom(payload.metadata.randomSeed)
|
|
778
|
-
}).loadState(payload.indexState);
|
|
779
|
-
const approximateHits = index.query(vector, topK);
|
|
780
|
-
if (approximateHits.length >= topK) {
|
|
781
|
-
return approximateHits;
|
|
782
|
-
}
|
|
783
|
-
return exactDenseQuery(payload, vector, topK);
|
|
784
807
|
}
|
|
785
808
|
|
|
786
809
|
// src/vector/sparse.ts
|
|
@@ -2173,13 +2196,17 @@ function isAllowed(url, baseUrl, includePatterns, excludePatterns, disallowRules
|
|
|
2173
2196
|
if (url.search.length > 0) {
|
|
2174
2197
|
return false;
|
|
2175
2198
|
}
|
|
2176
|
-
|
|
2199
|
+
const pathname = url.pathname.toLowerCase();
|
|
2200
|
+
if (pathname.endsWith(".xml")) {
|
|
2177
2201
|
return false;
|
|
2178
2202
|
}
|
|
2179
|
-
if (
|
|
2203
|
+
if (pathname.endsWith(".pdf")) {
|
|
2180
2204
|
return false;
|
|
2181
2205
|
}
|
|
2182
|
-
if (
|
|
2206
|
+
if (pathname.includes("/cdn-cgi/")) {
|
|
2207
|
+
return false;
|
|
2208
|
+
}
|
|
2209
|
+
if (pathname === "/search" || pathname === "/search/" || pathname.endsWith("/search/")) {
|
|
2183
2210
|
return false;
|
|
2184
2211
|
}
|
|
2185
2212
|
if (disallowRules.some((rule) => rule !== "/" && url.pathname.startsWith(rule))) {
|
|
@@ -3203,13 +3230,20 @@ function searchResultsFromResponse(response2, showChunks = false) {
|
|
|
3203
3230
|
metadata: hit._source.metadata
|
|
3204
3231
|
}));
|
|
3205
3232
|
}
|
|
3233
|
+
async function searchJsonRequest({
|
|
3234
|
+
index,
|
|
3235
|
+
request,
|
|
3236
|
+
indexName = "querylight"
|
|
3237
|
+
}) {
|
|
3238
|
+
return searchJsonDsl({ index, request, indexName });
|
|
3239
|
+
}
|
|
3206
3240
|
async function searchJsonIndex({
|
|
3207
3241
|
workspacePath,
|
|
3208
3242
|
request,
|
|
3209
3243
|
indexName = "querylight"
|
|
3210
3244
|
}) {
|
|
3211
3245
|
const index = await loadHydratedIndex(workspacePath);
|
|
3212
|
-
return
|
|
3246
|
+
return searchJsonRequest({ index, request, indexName });
|
|
3213
3247
|
}
|
|
3214
3248
|
function normalizeDisplayTitle(title) {
|
|
3215
3249
|
return title.replace(/\s*\|\s*Querylight TS Demo\s*$/i, "").replace(/\s+/g, " ").trim();
|
|
@@ -3525,8 +3559,197 @@ async function searchIndex({
|
|
|
3525
3559
|
return createSearchResponse(mode, finalHits, Date.now() - startedAt);
|
|
3526
3560
|
}
|
|
3527
3561
|
|
|
3528
|
-
// src/
|
|
3562
|
+
// src/server/search-api.ts
|
|
3563
|
+
import { createServer } from "http";
|
|
3564
|
+
import { readdir, stat as stat4 } from "fs/promises";
|
|
3529
3565
|
import path19 from "path";
|
|
3566
|
+
async function pathIsDirectory(candidatePath) {
|
|
3567
|
+
try {
|
|
3568
|
+
return (await stat4(candidatePath)).isDirectory();
|
|
3569
|
+
} catch {
|
|
3570
|
+
return false;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
async function discoverKnowledgeBases(workspacePath) {
|
|
3574
|
+
try {
|
|
3575
|
+
const singleWorkspace = await assertWorkspaceExists(workspacePath);
|
|
3576
|
+
const config = await loadConfig(singleWorkspace);
|
|
3577
|
+
const index = await loadHydratedIndex(singleWorkspace);
|
|
3578
|
+
return {
|
|
3579
|
+
mode: "single",
|
|
3580
|
+
knowledgeBases: [{
|
|
3581
|
+
name: config.index.name,
|
|
3582
|
+
workspacePath: singleWorkspace,
|
|
3583
|
+
configuredIndexName: config.index.name,
|
|
3584
|
+
index
|
|
3585
|
+
}]
|
|
3586
|
+
};
|
|
3587
|
+
} catch (error) {
|
|
3588
|
+
if (!(error instanceof CliError) || error.code !== "WORKSPACE_ERROR") {
|
|
3589
|
+
throw error;
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
const resolvedRoot = path19.resolve(workspacePath);
|
|
3593
|
+
if (!await pathIsDirectory(resolvedRoot)) {
|
|
3594
|
+
throw new CliError(`workspace path does not exist: ${resolvedRoot}`, "WORKSPACE_ERROR", 3 /* WorkspaceError */);
|
|
3595
|
+
}
|
|
3596
|
+
const entries = await readdir(resolvedRoot, { withFileTypes: true });
|
|
3597
|
+
const knowledgeBases = (await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
3598
|
+
const candidateWorkspace = path19.join(resolvedRoot, entry.name, ".kb");
|
|
3599
|
+
try {
|
|
3600
|
+
const workspace = await assertWorkspaceExists(candidateWorkspace);
|
|
3601
|
+
const config = await loadConfig(workspace);
|
|
3602
|
+
const index = await loadHydratedIndex(workspace);
|
|
3603
|
+
return {
|
|
3604
|
+
name: entry.name,
|
|
3605
|
+
workspacePath: workspace,
|
|
3606
|
+
configuredIndexName: config.index.name,
|
|
3607
|
+
index
|
|
3608
|
+
};
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
if (error instanceof CliError && error.code === "WORKSPACE_ERROR") {
|
|
3611
|
+
return null;
|
|
3612
|
+
}
|
|
3613
|
+
throw error;
|
|
3614
|
+
}
|
|
3615
|
+
}))).filter((knowledgeBase) => knowledgeBase != null);
|
|
3616
|
+
if (knowledgeBases.length === 0) {
|
|
3617
|
+
throw new CliError(
|
|
3618
|
+
`no knowledge bases found at ${resolvedRoot}; use a .kb workspace or a directory of named subdirectories that each contain .kb`,
|
|
3619
|
+
"WORKSPACE_ERROR",
|
|
3620
|
+
3 /* WorkspaceError */
|
|
3621
|
+
);
|
|
3622
|
+
}
|
|
3623
|
+
return { mode: "multi", knowledgeBases };
|
|
3624
|
+
}
|
|
3625
|
+
function sendJson(response2, statusCode, payload) {
|
|
3626
|
+
response2.statusCode = statusCode;
|
|
3627
|
+
response2.setHeader("content-type", "application/json; charset=utf-8");
|
|
3628
|
+
response2.end(JSON.stringify(payload));
|
|
3629
|
+
}
|
|
3630
|
+
function sendError(response2, statusCode, type, reason) {
|
|
3631
|
+
sendJson(response2, statusCode, {
|
|
3632
|
+
error: {
|
|
3633
|
+
type,
|
|
3634
|
+
reason
|
|
3635
|
+
},
|
|
3636
|
+
status: statusCode
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
async function readRequestBody(request) {
|
|
3640
|
+
const chunks = [];
|
|
3641
|
+
for await (const chunk of request) {
|
|
3642
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
3643
|
+
}
|
|
3644
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3645
|
+
}
|
|
3646
|
+
function parseSearchRequest(raw) {
|
|
3647
|
+
const normalized = raw.trim();
|
|
3648
|
+
if (normalized.length === 0) {
|
|
3649
|
+
return {};
|
|
3650
|
+
}
|
|
3651
|
+
try {
|
|
3652
|
+
const parsed = JSON.parse(normalized);
|
|
3653
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3654
|
+
throw new Error("expected a JSON object");
|
|
3655
|
+
}
|
|
3656
|
+
return parsed;
|
|
3657
|
+
} catch (error) {
|
|
3658
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3659
|
+
throw new CliError(`invalid JSON request: ${message}`, "INVALID_ARGUMENT", 2 /* InvalidArguments */);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
function routeForKnowledgeBase(mode, knowledgeBase) {
|
|
3663
|
+
return mode === "single" ? "/_search" : `/${knowledgeBase.name}/_search`;
|
|
3664
|
+
}
|
|
3665
|
+
function resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases) {
|
|
3666
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
3667
|
+
if (mode === "single") {
|
|
3668
|
+
const knowledgeBase = [...knowledgeBases.values()][0];
|
|
3669
|
+
if (!knowledgeBase) {
|
|
3670
|
+
return null;
|
|
3671
|
+
}
|
|
3672
|
+
if (segments.length === 1 && segments[0] === "_search") {
|
|
3673
|
+
return knowledgeBase;
|
|
3674
|
+
}
|
|
3675
|
+
if (segments.length === 2 && segments[1] === "_search" && segments[0] === knowledgeBase.configuredIndexName) {
|
|
3676
|
+
return knowledgeBase;
|
|
3677
|
+
}
|
|
3678
|
+
return null;
|
|
3679
|
+
}
|
|
3680
|
+
if (segments.length === 2 && segments[1] === "_search") {
|
|
3681
|
+
return knowledgeBases.get(segments[0]) ?? null;
|
|
3682
|
+
}
|
|
3683
|
+
return null;
|
|
3684
|
+
}
|
|
3685
|
+
async function handleSearchRequest(request, response2, pathname, mode, knowledgeBases) {
|
|
3686
|
+
if (request.method !== "GET" && request.method !== "POST") {
|
|
3687
|
+
response2.setHeader("allow", "GET, POST");
|
|
3688
|
+
sendError(response2, 405, "method_not_allowed", `unsupported method for ${pathname}`);
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
const knowledgeBase = resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases);
|
|
3692
|
+
if (!knowledgeBase) {
|
|
3693
|
+
sendError(response2, 404, "resource_not_found_exception", `unknown search route: ${pathname}`);
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
try {
|
|
3697
|
+
const requestBody = parseSearchRequest(await readRequestBody(request));
|
|
3698
|
+
const indexName = mode === "multi" ? knowledgeBase.name : knowledgeBase.configuredIndexName;
|
|
3699
|
+
const result = await searchJsonRequest({
|
|
3700
|
+
index: knowledgeBase.index,
|
|
3701
|
+
request: requestBody,
|
|
3702
|
+
indexName
|
|
3703
|
+
});
|
|
3704
|
+
sendJson(response2, 200, result);
|
|
3705
|
+
} catch (error) {
|
|
3706
|
+
if (error instanceof CliError && error.code === "INVALID_ARGUMENT") {
|
|
3707
|
+
sendError(response2, 400, "parse_exception", error.message);
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3711
|
+
sendError(response2, 500, "search_phase_execution_exception", message);
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
async function startSearchApiServer({
|
|
3715
|
+
workspacePath,
|
|
3716
|
+
host = "127.0.0.1",
|
|
3717
|
+
port = 3e3
|
|
3718
|
+
}) {
|
|
3719
|
+
const { mode, knowledgeBases } = await discoverKnowledgeBases(workspacePath);
|
|
3720
|
+
const byName = new Map(knowledgeBases.map((knowledgeBase) => [knowledgeBase.name, knowledgeBase]));
|
|
3721
|
+
const server = createServer(async (request, response2) => {
|
|
3722
|
+
const url2 = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
|
|
3723
|
+
await handleSearchRequest(request, response2, url2.pathname, mode, byName);
|
|
3724
|
+
});
|
|
3725
|
+
await new Promise((resolve2, reject) => {
|
|
3726
|
+
server.once("error", reject);
|
|
3727
|
+
server.listen(port, host, () => {
|
|
3728
|
+
server.off("error", reject);
|
|
3729
|
+
resolve2();
|
|
3730
|
+
});
|
|
3731
|
+
});
|
|
3732
|
+
const address = server.address();
|
|
3733
|
+
if (!address || typeof address === "string") {
|
|
3734
|
+
throw new CliError("server failed to bind to a TCP address", "SERVER_ERROR", 1 /* GeneralError */);
|
|
3735
|
+
}
|
|
3736
|
+
const url = `http://${host}:${address.port}`;
|
|
3737
|
+
return {
|
|
3738
|
+
mode,
|
|
3739
|
+
url,
|
|
3740
|
+
knowledgeBases: knowledgeBases.map((knowledgeBase) => ({
|
|
3741
|
+
name: knowledgeBase.name,
|
|
3742
|
+
workspacePath: knowledgeBase.workspacePath,
|
|
3743
|
+
route: routeForKnowledgeBase(mode, knowledgeBase)
|
|
3744
|
+
})),
|
|
3745
|
+
close: async () => new Promise((resolve2, reject) => {
|
|
3746
|
+
server.close((error) => error ? reject(error) : resolve2());
|
|
3747
|
+
})
|
|
3748
|
+
};
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
// src/query/related-service.ts
|
|
3752
|
+
import path20 from "path";
|
|
3530
3753
|
function cosineSimilarity2(left, right) {
|
|
3531
3754
|
let dot = 0;
|
|
3532
3755
|
let leftNorm = 0;
|
|
@@ -3602,7 +3825,7 @@ async function findRelatedDocuments({
|
|
|
3602
3825
|
if (!await fileExists(denseVectorPath(workspacePath))) {
|
|
3603
3826
|
throw new CliError("dense vector index is not built; run `qli models pull --dense` and `qli rebuild`", "DENSE_INDEX_MISSING", 7 /* QueryError */);
|
|
3604
3827
|
}
|
|
3605
|
-
const documents = await readJsonl(
|
|
3828
|
+
const documents = await readJsonl(path20.join(workspacePath, "documents", "documents.jsonl"));
|
|
3606
3829
|
const selected = resolveDocumentSelector(documents, document);
|
|
3607
3830
|
const densePayload = await readDensePayload(workspacePath);
|
|
3608
3831
|
const vectors = buildDocumentVectors(documents, densePayload.chunks, densePayload.metadata.dimensions);
|
|
@@ -3675,7 +3898,7 @@ async function createContext({
|
|
|
3675
3898
|
}
|
|
3676
3899
|
|
|
3677
3900
|
// src/report/diff-service.ts
|
|
3678
|
-
import
|
|
3901
|
+
import path21 from "path";
|
|
3679
3902
|
function chooseBaselineRun(runs, since) {
|
|
3680
3903
|
if (since === "last-run") {
|
|
3681
3904
|
return runs.at(-1);
|
|
@@ -3691,7 +3914,7 @@ async function diffWorkspace({
|
|
|
3691
3914
|
documentId,
|
|
3692
3915
|
since
|
|
3693
3916
|
}) {
|
|
3694
|
-
const current = await readJsonl(
|
|
3917
|
+
const current = await readJsonl(path21.join(workspacePath, "documents", "documents.jsonl"));
|
|
3695
3918
|
const baseline = chooseBaselineRun(await listRuns(workspacePath), since);
|
|
3696
3919
|
const previous = new Map((baseline?.documentsSnapshot ?? []).map((document) => [document.id, document]));
|
|
3697
3920
|
const changedDocuments = current.filter((document) => (!sourceId || document.sourceId === sourceId) && (!documentId || document.id === documentId)).filter((document) => {
|
|
@@ -4050,7 +4273,7 @@ function parseDateValue(input, optionName) {
|
|
|
4050
4273
|
return parsed.toISOString();
|
|
4051
4274
|
}
|
|
4052
4275
|
async function parseJsonArgument(input) {
|
|
4053
|
-
const raw = input.startsWith("@") ? await readFile11(
|
|
4276
|
+
const raw = input.startsWith("@") ? await readFile11(path22.resolve(input.slice(1)), "utf8") : input;
|
|
4054
4277
|
try {
|
|
4055
4278
|
const parsed = JSON.parse(raw);
|
|
4056
4279
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
@@ -4094,14 +4317,14 @@ function searchDateRanges(options) {
|
|
|
4094
4317
|
return entries;
|
|
4095
4318
|
}
|
|
4096
4319
|
async function resolveWorkspace(options) {
|
|
4097
|
-
return
|
|
4320
|
+
return path22.resolve(options.workspace ?? DEFAULT_WORKSPACE);
|
|
4098
4321
|
}
|
|
4099
4322
|
function workspaceFromArgv(argv) {
|
|
4100
4323
|
const index = argv.findIndex((arg) => arg === "--workspace");
|
|
4101
4324
|
if (index >= 0 && argv[index + 1]) {
|
|
4102
|
-
return
|
|
4325
|
+
return path22.resolve(argv[index + 1]);
|
|
4103
4326
|
}
|
|
4104
|
-
return
|
|
4327
|
+
return path22.resolve(DEFAULT_WORKSPACE);
|
|
4105
4328
|
}
|
|
4106
4329
|
async function runCli(argv, io = {}) {
|
|
4107
4330
|
const capture = { stdout: [], stderr: [], ...io };
|
|
@@ -4195,7 +4418,7 @@ Notes:
|
|
|
4195
4418
|
}
|
|
4196
4419
|
const stored = await addSource(workspace, {
|
|
4197
4420
|
type,
|
|
4198
|
-
uri: ["file", "directory"].includes(type) ?
|
|
4421
|
+
uri: ["file", "directory"].includes(type) ? path22.resolve(uri) : uri,
|
|
4199
4422
|
name: options.name,
|
|
4200
4423
|
enabled: true,
|
|
4201
4424
|
tags: options.tag ?? [],
|
|
@@ -4459,6 +4682,63 @@ Notes:
|
|
|
4459
4682
|
});
|
|
4460
4683
|
emit(global.json, capture, response("search-json", workspace, result), JSON.stringify(result, null, 2));
|
|
4461
4684
|
});
|
|
4685
|
+
program.command("serve").description("Start a small HTTP API that exposes Querylight JSON DSL search through an OpenSearch-like _search endpoint.").option("--host <host>", "Host interface to bind. Defaults to 127.0.0.1.", "127.0.0.1").option("--port <n>", "Port to bind. Use 0 to let the OS choose a free port.", "3000").addHelpText("after", `
|
|
4686
|
+
Examples:
|
|
4687
|
+
qli serve
|
|
4688
|
+
qli serve --workspace ./docs/.kb --port 4000
|
|
4689
|
+
qli serve --workspace ./kbs --host 0.0.0.0 --port 4000
|
|
4690
|
+
|
|
4691
|
+
Routes:
|
|
4692
|
+
Single workspace: POST /_search
|
|
4693
|
+
Single workspace: POST /<configured-index-name>/_search
|
|
4694
|
+
Multi-KB root: POST /<directory-name>/_search
|
|
4695
|
+
|
|
4696
|
+
Notes:
|
|
4697
|
+
The request body must be a Querylight JSON DSL object.
|
|
4698
|
+
serve only exposes lexical _search for now.
|
|
4699
|
+
When --workspace points to a directory of knowledge bases, each child directory must contain its own .kb workspace.
|
|
4700
|
+
Index files are loaded once at startup and reused across requests.`).action(async function command(options) {
|
|
4701
|
+
const global = this.optsWithGlobals();
|
|
4702
|
+
const workspace = await resolveWorkspace({ workspace: global.workspace });
|
|
4703
|
+
const port = Number(options.port);
|
|
4704
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
4705
|
+
throw new CliError(`invalid port: ${options.port}`, "INVALID_ARGUMENT", 2 /* InvalidArguments */);
|
|
4706
|
+
}
|
|
4707
|
+
const server = await startSearchApiServer({
|
|
4708
|
+
workspacePath: workspace,
|
|
4709
|
+
host: options.host,
|
|
4710
|
+
port
|
|
4711
|
+
});
|
|
4712
|
+
const data = {
|
|
4713
|
+
url: server.url,
|
|
4714
|
+
mode: server.mode,
|
|
4715
|
+
knowledgeBases: server.knowledgeBases
|
|
4716
|
+
};
|
|
4717
|
+
const human = [
|
|
4718
|
+
`Listening on ${server.url}`,
|
|
4719
|
+
...server.knowledgeBases.map((knowledgeBase) => `${knowledgeBase.route} -> ${knowledgeBase.workspacePath}`)
|
|
4720
|
+
].join("\n");
|
|
4721
|
+
emit(global.json, capture, response("serve", workspace, data), human);
|
|
4722
|
+
const shutdown = async () => {
|
|
4723
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
4724
|
+
process.off(signal, stop);
|
|
4725
|
+
}
|
|
4726
|
+
await server.close();
|
|
4727
|
+
};
|
|
4728
|
+
const stop = () => {
|
|
4729
|
+
void shutdown().then(() => resolveStop(), rejectStop);
|
|
4730
|
+
};
|
|
4731
|
+
let resolveStop;
|
|
4732
|
+
let rejectStop;
|
|
4733
|
+
const waitForStop = new Promise((resolve2, reject) => {
|
|
4734
|
+
resolveStop = resolve2;
|
|
4735
|
+
rejectStop = reject;
|
|
4736
|
+
});
|
|
4737
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
4738
|
+
process.once(signal, stop);
|
|
4739
|
+
}
|
|
4740
|
+
await waitForStop;
|
|
4741
|
+
});
|
|
4462
4742
|
program.command("related").description("Find documents similar to an existing document by id or URI.").argument("<document>", "Document id, uri, or canonical uri").option("--top-k <n>", "Maximum number of related documents to return.", "12").addHelpText("after", `
|
|
4463
4743
|
Examples:
|
|
4464
4744
|
qli related doc_123
|
|
@@ -4582,7 +4862,7 @@ Examples:
|
|
|
4582
4862
|
try {
|
|
4583
4863
|
const meta = await readLatestIndexMetadata(workspace);
|
|
4584
4864
|
latestIndex = meta.createdAt;
|
|
4585
|
-
indexSize = (await
|
|
4865
|
+
indexSize = (await stat5(await resolveLatestIndexArtifactPath(workspace))).size;
|
|
4586
4866
|
} catch {
|
|
4587
4867
|
latestIndex = void 0;
|
|
4588
4868
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./ingest/ingest-service.js";
|
|
|
6
6
|
export * from "./chunk/chunker.js";
|
|
7
7
|
export * from "./index/querylight-indexer.js";
|
|
8
8
|
export * from "./query/search-service.js";
|
|
9
|
+
export * from "./server/search-api.js";
|
|
9
10
|
export * from "./query/related-service.js";
|
|
10
11
|
export * from "./query/context-builder.js";
|
|
11
12
|
export * from "./report/diff-service.js";
|
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ var defaultConfig = () => ({
|
|
|
57
57
|
defaultMode: "lexical",
|
|
58
58
|
dense: {
|
|
59
59
|
enabled: true,
|
|
60
|
-
modelId: "Xenova/
|
|
60
|
+
modelId: "Xenova/paraphrase-MiniLM-L3-v2",
|
|
61
61
|
cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
|
|
62
62
|
indexHashTables: 8,
|
|
63
63
|
indexRandomSeed: 42,
|
|
@@ -65,7 +65,7 @@ var defaultConfig = () => ({
|
|
|
65
65
|
},
|
|
66
66
|
sparse: {
|
|
67
67
|
enabled: true,
|
|
68
|
-
modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-
|
|
68
|
+
modelId: "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini",
|
|
69
69
|
cacheDir: DEFAULT_SHARED_MODEL_CACHE_DIR,
|
|
70
70
|
documentTopTokens: 128,
|
|
71
71
|
queryEncoding: "tokenizer-token-weights",
|
|
@@ -1213,13 +1213,17 @@ function isAllowed(url, baseUrl, includePatterns, excludePatterns, disallowRules
|
|
|
1213
1213
|
if (url.search.length > 0) {
|
|
1214
1214
|
return false;
|
|
1215
1215
|
}
|
|
1216
|
-
|
|
1216
|
+
const pathname = url.pathname.toLowerCase();
|
|
1217
|
+
if (pathname.endsWith(".xml")) {
|
|
1217
1218
|
return false;
|
|
1218
1219
|
}
|
|
1219
|
-
if (
|
|
1220
|
+
if (pathname.endsWith(".pdf")) {
|
|
1220
1221
|
return false;
|
|
1221
1222
|
}
|
|
1222
|
-
if (
|
|
1223
|
+
if (pathname.includes("/cdn-cgi/")) {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
if (pathname === "/search" || pathname === "/search/" || pathname.endsWith("/search/")) {
|
|
1223
1227
|
return false;
|
|
1224
1228
|
}
|
|
1225
1229
|
if (disallowRules.some((rule) => rule !== "/" && url.pathname.startsWith(rule))) {
|
|
@@ -2058,15 +2062,26 @@ function createSparseChunkText(chunk) {
|
|
|
2058
2062
|
// src/vector/dense.ts
|
|
2059
2063
|
var denseEmbedderFactory = null;
|
|
2060
2064
|
var EXACT_DENSE_RERANK_THRESHOLD = 5e3;
|
|
2065
|
+
function normalizeDenseEmbedder(embedder) {
|
|
2066
|
+
if (typeof embedder === "function") {
|
|
2067
|
+
return { embed: embedder };
|
|
2068
|
+
}
|
|
2069
|
+
return embedder;
|
|
2070
|
+
}
|
|
2061
2071
|
async function createEmbedder(cacheDir, modelId) {
|
|
2062
2072
|
if (denseEmbedderFactory) {
|
|
2063
|
-
return denseEmbedderFactory(cacheDir, modelId);
|
|
2073
|
+
return normalizeDenseEmbedder(await denseEmbedderFactory(cacheDir, modelId));
|
|
2064
2074
|
}
|
|
2065
2075
|
const runtime = await getDenseTransformersRuntime(cacheDir);
|
|
2066
2076
|
const extractor = await runtime.pipeline("feature-extraction", modelId);
|
|
2067
|
-
return
|
|
2068
|
-
|
|
2069
|
-
|
|
2077
|
+
return {
|
|
2078
|
+
async embed(text) {
|
|
2079
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
2080
|
+
return output.tolist()[0];
|
|
2081
|
+
},
|
|
2082
|
+
async dispose() {
|
|
2083
|
+
await extractor.dispose();
|
|
2084
|
+
}
|
|
2070
2085
|
};
|
|
2071
2086
|
}
|
|
2072
2087
|
function exactDenseQuery(payload, vector, topK) {
|
|
@@ -2080,53 +2095,57 @@ async function buildDenseVectors({
|
|
|
2080
2095
|
const chunks = await readJsonl(path14.join(workspacePath, "chunks", "chunks.jsonl"));
|
|
2081
2096
|
const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
|
|
2082
2097
|
await mkdir7(cacheDir, { recursive: true });
|
|
2083
|
-
const
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
const
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2098
|
+
const embedder = await createEmbedder(cacheDir, config.modelId);
|
|
2099
|
+
try {
|
|
2100
|
+
const records = [];
|
|
2101
|
+
let dimensions = 0;
|
|
2102
|
+
reportProgress(progress, `Encoding ${chunks.length} chunk${chunks.length === 1 ? "" : "s"} for dense retrieval`);
|
|
2103
|
+
for (const chunk of chunks) {
|
|
2104
|
+
const embedding = await embedder.embed(createDenseChunkText(chunk));
|
|
2105
|
+
dimensions ||= embedding.length;
|
|
2106
|
+
records.push({
|
|
2107
|
+
chunkId: chunk.id,
|
|
2108
|
+
documentId: chunk.documentId,
|
|
2109
|
+
sourceId: chunk.sourceId,
|
|
2110
|
+
title: chunk.title,
|
|
2111
|
+
uri: chunk.uri,
|
|
2112
|
+
headingPath: chunk.headingPath,
|
|
2113
|
+
text: chunk.text,
|
|
2114
|
+
embedding
|
|
2115
|
+
});
|
|
2116
|
+
if (records.length === 1 || records.length % 100 === 0 || records.length === chunks.length) {
|
|
2117
|
+
reportProgressDetail(progress, `Encoded ${records.length}/${chunks.length} chunks for dense retrieval`);
|
|
2118
|
+
}
|
|
2102
2119
|
}
|
|
2120
|
+
reportProgress(progress, "Building dense vector index");
|
|
2121
|
+
const index = new VectorFieldIndex({
|
|
2122
|
+
numHashTables: config.indexHashTables,
|
|
2123
|
+
dimensions,
|
|
2124
|
+
random: createSeededRandom(config.indexRandomSeed)
|
|
2125
|
+
});
|
|
2126
|
+
for (const record of records) {
|
|
2127
|
+
index.insert(record.chunkId, [record.embedding]);
|
|
2128
|
+
}
|
|
2129
|
+
const metadata = {
|
|
2130
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2131
|
+
modelId: config.modelId,
|
|
2132
|
+
dimensions,
|
|
2133
|
+
hashTables: config.indexHashTables,
|
|
2134
|
+
randomSeed: config.indexRandomSeed,
|
|
2135
|
+
chunkCount: records.length,
|
|
2136
|
+
indexHash: sha256(JSON.stringify(index.indexState))
|
|
2137
|
+
};
|
|
2138
|
+
const payload = {
|
|
2139
|
+
metadata,
|
|
2140
|
+
indexState: index.indexState,
|
|
2141
|
+
chunks: records
|
|
2142
|
+
};
|
|
2143
|
+
await writeDensePayload(workspacePath, payload);
|
|
2144
|
+
reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
|
|
2145
|
+
return payload;
|
|
2146
|
+
} finally {
|
|
2147
|
+
await embedder.dispose?.();
|
|
2103
2148
|
}
|
|
2104
|
-
reportProgress(progress, "Building dense vector index");
|
|
2105
|
-
const index = new VectorFieldIndex({
|
|
2106
|
-
numHashTables: config.indexHashTables,
|
|
2107
|
-
dimensions,
|
|
2108
|
-
random: createSeededRandom(config.indexRandomSeed)
|
|
2109
|
-
});
|
|
2110
|
-
for (const record of records) {
|
|
2111
|
-
index.insert(record.chunkId, [record.embedding]);
|
|
2112
|
-
}
|
|
2113
|
-
const metadata = {
|
|
2114
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2115
|
-
modelId: config.modelId,
|
|
2116
|
-
dimensions,
|
|
2117
|
-
hashTables: config.indexHashTables,
|
|
2118
|
-
randomSeed: config.indexRandomSeed,
|
|
2119
|
-
chunkCount: records.length,
|
|
2120
|
-
indexHash: sha256(JSON.stringify(index.indexState))
|
|
2121
|
-
};
|
|
2122
|
-
const payload = {
|
|
2123
|
-
metadata,
|
|
2124
|
-
indexState: index.indexState,
|
|
2125
|
-
chunks: records
|
|
2126
|
-
};
|
|
2127
|
-
await writeDensePayload(workspacePath, payload);
|
|
2128
|
-
reportProgress(progress, `Dense vectors written for ${records.length} chunk${records.length === 1 ? "" : "s"}`);
|
|
2129
|
-
return payload;
|
|
2130
2149
|
}
|
|
2131
2150
|
async function denseQuery({
|
|
2132
2151
|
workspacePath,
|
|
@@ -2136,21 +2155,25 @@ async function denseQuery({
|
|
|
2136
2155
|
}) {
|
|
2137
2156
|
const payload = await readDensePayload(workspacePath);
|
|
2138
2157
|
const cacheDir = resolveCacheDir(workspacePath, config.cacheDir);
|
|
2139
|
-
const
|
|
2140
|
-
|
|
2141
|
-
|
|
2158
|
+
const embedder = await createEmbedder(cacheDir, config.modelId);
|
|
2159
|
+
try {
|
|
2160
|
+
const vector = await embedder.embed(query);
|
|
2161
|
+
if (payload.chunks.length <= EXACT_DENSE_RERANK_THRESHOLD) {
|
|
2162
|
+
return exactDenseQuery(payload, vector, topK);
|
|
2163
|
+
}
|
|
2164
|
+
const index = new VectorFieldIndex({
|
|
2165
|
+
numHashTables: payload.metadata.hashTables,
|
|
2166
|
+
dimensions: payload.metadata.dimensions,
|
|
2167
|
+
random: createSeededRandom(payload.metadata.randomSeed)
|
|
2168
|
+
}).loadState(payload.indexState);
|
|
2169
|
+
const approximateHits = index.query(vector, topK);
|
|
2170
|
+
if (approximateHits.length >= topK) {
|
|
2171
|
+
return approximateHits;
|
|
2172
|
+
}
|
|
2142
2173
|
return exactDenseQuery(payload, vector, topK);
|
|
2174
|
+
} finally {
|
|
2175
|
+
await embedder.dispose?.();
|
|
2143
2176
|
}
|
|
2144
|
-
const index = new VectorFieldIndex({
|
|
2145
|
-
numHashTables: payload.metadata.hashTables,
|
|
2146
|
-
dimensions: payload.metadata.dimensions,
|
|
2147
|
-
random: createSeededRandom(payload.metadata.randomSeed)
|
|
2148
|
-
}).loadState(payload.indexState);
|
|
2149
|
-
const approximateHits = index.query(vector, topK);
|
|
2150
|
-
if (approximateHits.length >= topK) {
|
|
2151
|
-
return approximateHits;
|
|
2152
|
-
}
|
|
2153
|
-
return exactDenseQuery(payload, vector, topK);
|
|
2154
2177
|
}
|
|
2155
2178
|
|
|
2156
2179
|
// src/vector/sparse.ts
|
|
@@ -2894,13 +2917,20 @@ function searchResultsFromResponse(response, showChunks = false) {
|
|
|
2894
2917
|
metadata: hit._source.metadata
|
|
2895
2918
|
}));
|
|
2896
2919
|
}
|
|
2920
|
+
async function searchJsonRequest({
|
|
2921
|
+
index,
|
|
2922
|
+
request,
|
|
2923
|
+
indexName = "querylight"
|
|
2924
|
+
}) {
|
|
2925
|
+
return searchJsonDsl({ index, request, indexName });
|
|
2926
|
+
}
|
|
2897
2927
|
async function searchJsonIndex({
|
|
2898
2928
|
workspacePath,
|
|
2899
2929
|
request,
|
|
2900
2930
|
indexName = "querylight"
|
|
2901
2931
|
}) {
|
|
2902
2932
|
const index = await loadHydratedIndex(workspacePath);
|
|
2903
|
-
return
|
|
2933
|
+
return searchJsonRequest({ index, request, indexName });
|
|
2904
2934
|
}
|
|
2905
2935
|
function normalizeDisplayTitle(title) {
|
|
2906
2936
|
return title.replace(/\s*\|\s*Querylight TS Demo\s*$/i, "").replace(/\s+/g, " ").trim();
|
|
@@ -3216,8 +3246,197 @@ async function searchIndex({
|
|
|
3216
3246
|
return createSearchResponse(mode, finalHits, Date.now() - startedAt);
|
|
3217
3247
|
}
|
|
3218
3248
|
|
|
3219
|
-
// src/
|
|
3249
|
+
// src/server/search-api.ts
|
|
3250
|
+
import { createServer } from "http";
|
|
3251
|
+
import { readdir, stat as stat4 } from "fs/promises";
|
|
3220
3252
|
import path19 from "path";
|
|
3253
|
+
async function pathIsDirectory(candidatePath) {
|
|
3254
|
+
try {
|
|
3255
|
+
return (await stat4(candidatePath)).isDirectory();
|
|
3256
|
+
} catch {
|
|
3257
|
+
return false;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
async function discoverKnowledgeBases(workspacePath) {
|
|
3261
|
+
try {
|
|
3262
|
+
const singleWorkspace = await assertWorkspaceExists(workspacePath);
|
|
3263
|
+
const config = await loadConfig(singleWorkspace);
|
|
3264
|
+
const index = await loadHydratedIndex(singleWorkspace);
|
|
3265
|
+
return {
|
|
3266
|
+
mode: "single",
|
|
3267
|
+
knowledgeBases: [{
|
|
3268
|
+
name: config.index.name,
|
|
3269
|
+
workspacePath: singleWorkspace,
|
|
3270
|
+
configuredIndexName: config.index.name,
|
|
3271
|
+
index
|
|
3272
|
+
}]
|
|
3273
|
+
};
|
|
3274
|
+
} catch (error) {
|
|
3275
|
+
if (!(error instanceof CliError) || error.code !== "WORKSPACE_ERROR") {
|
|
3276
|
+
throw error;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
const resolvedRoot = path19.resolve(workspacePath);
|
|
3280
|
+
if (!await pathIsDirectory(resolvedRoot)) {
|
|
3281
|
+
throw new CliError(`workspace path does not exist: ${resolvedRoot}`, "WORKSPACE_ERROR", 3 /* WorkspaceError */);
|
|
3282
|
+
}
|
|
3283
|
+
const entries = await readdir(resolvedRoot, { withFileTypes: true });
|
|
3284
|
+
const knowledgeBases = (await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
3285
|
+
const candidateWorkspace = path19.join(resolvedRoot, entry.name, ".kb");
|
|
3286
|
+
try {
|
|
3287
|
+
const workspace = await assertWorkspaceExists(candidateWorkspace);
|
|
3288
|
+
const config = await loadConfig(workspace);
|
|
3289
|
+
const index = await loadHydratedIndex(workspace);
|
|
3290
|
+
return {
|
|
3291
|
+
name: entry.name,
|
|
3292
|
+
workspacePath: workspace,
|
|
3293
|
+
configuredIndexName: config.index.name,
|
|
3294
|
+
index
|
|
3295
|
+
};
|
|
3296
|
+
} catch (error) {
|
|
3297
|
+
if (error instanceof CliError && error.code === "WORKSPACE_ERROR") {
|
|
3298
|
+
return null;
|
|
3299
|
+
}
|
|
3300
|
+
throw error;
|
|
3301
|
+
}
|
|
3302
|
+
}))).filter((knowledgeBase) => knowledgeBase != null);
|
|
3303
|
+
if (knowledgeBases.length === 0) {
|
|
3304
|
+
throw new CliError(
|
|
3305
|
+
`no knowledge bases found at ${resolvedRoot}; use a .kb workspace or a directory of named subdirectories that each contain .kb`,
|
|
3306
|
+
"WORKSPACE_ERROR",
|
|
3307
|
+
3 /* WorkspaceError */
|
|
3308
|
+
);
|
|
3309
|
+
}
|
|
3310
|
+
return { mode: "multi", knowledgeBases };
|
|
3311
|
+
}
|
|
3312
|
+
function sendJson(response, statusCode, payload) {
|
|
3313
|
+
response.statusCode = statusCode;
|
|
3314
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
3315
|
+
response.end(JSON.stringify(payload));
|
|
3316
|
+
}
|
|
3317
|
+
function sendError(response, statusCode, type, reason) {
|
|
3318
|
+
sendJson(response, statusCode, {
|
|
3319
|
+
error: {
|
|
3320
|
+
type,
|
|
3321
|
+
reason
|
|
3322
|
+
},
|
|
3323
|
+
status: statusCode
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
async function readRequestBody(request) {
|
|
3327
|
+
const chunks = [];
|
|
3328
|
+
for await (const chunk of request) {
|
|
3329
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
3330
|
+
}
|
|
3331
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3332
|
+
}
|
|
3333
|
+
function parseSearchRequest(raw) {
|
|
3334
|
+
const normalized = raw.trim();
|
|
3335
|
+
if (normalized.length === 0) {
|
|
3336
|
+
return {};
|
|
3337
|
+
}
|
|
3338
|
+
try {
|
|
3339
|
+
const parsed = JSON.parse(normalized);
|
|
3340
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3341
|
+
throw new Error("expected a JSON object");
|
|
3342
|
+
}
|
|
3343
|
+
return parsed;
|
|
3344
|
+
} catch (error) {
|
|
3345
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3346
|
+
throw new CliError(`invalid JSON request: ${message}`, "INVALID_ARGUMENT", 2 /* InvalidArguments */);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
function routeForKnowledgeBase(mode, knowledgeBase) {
|
|
3350
|
+
return mode === "single" ? "/_search" : `/${knowledgeBase.name}/_search`;
|
|
3351
|
+
}
|
|
3352
|
+
function resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases) {
|
|
3353
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
3354
|
+
if (mode === "single") {
|
|
3355
|
+
const knowledgeBase = [...knowledgeBases.values()][0];
|
|
3356
|
+
if (!knowledgeBase) {
|
|
3357
|
+
return null;
|
|
3358
|
+
}
|
|
3359
|
+
if (segments.length === 1 && segments[0] === "_search") {
|
|
3360
|
+
return knowledgeBase;
|
|
3361
|
+
}
|
|
3362
|
+
if (segments.length === 2 && segments[1] === "_search" && segments[0] === knowledgeBase.configuredIndexName) {
|
|
3363
|
+
return knowledgeBase;
|
|
3364
|
+
}
|
|
3365
|
+
return null;
|
|
3366
|
+
}
|
|
3367
|
+
if (segments.length === 2 && segments[1] === "_search") {
|
|
3368
|
+
return knowledgeBases.get(segments[0]) ?? null;
|
|
3369
|
+
}
|
|
3370
|
+
return null;
|
|
3371
|
+
}
|
|
3372
|
+
async function handleSearchRequest(request, response, pathname, mode, knowledgeBases) {
|
|
3373
|
+
if (request.method !== "GET" && request.method !== "POST") {
|
|
3374
|
+
response.setHeader("allow", "GET, POST");
|
|
3375
|
+
sendError(response, 405, "method_not_allowed", `unsupported method for ${pathname}`);
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
const knowledgeBase = resolveKnowledgeBaseForPath(pathname, mode, knowledgeBases);
|
|
3379
|
+
if (!knowledgeBase) {
|
|
3380
|
+
sendError(response, 404, "resource_not_found_exception", `unknown search route: ${pathname}`);
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
try {
|
|
3384
|
+
const requestBody = parseSearchRequest(await readRequestBody(request));
|
|
3385
|
+
const indexName = mode === "multi" ? knowledgeBase.name : knowledgeBase.configuredIndexName;
|
|
3386
|
+
const result = await searchJsonRequest({
|
|
3387
|
+
index: knowledgeBase.index,
|
|
3388
|
+
request: requestBody,
|
|
3389
|
+
indexName
|
|
3390
|
+
});
|
|
3391
|
+
sendJson(response, 200, result);
|
|
3392
|
+
} catch (error) {
|
|
3393
|
+
if (error instanceof CliError && error.code === "INVALID_ARGUMENT") {
|
|
3394
|
+
sendError(response, 400, "parse_exception", error.message);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3398
|
+
sendError(response, 500, "search_phase_execution_exception", message);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
async function startSearchApiServer({
|
|
3402
|
+
workspacePath,
|
|
3403
|
+
host = "127.0.0.1",
|
|
3404
|
+
port = 3e3
|
|
3405
|
+
}) {
|
|
3406
|
+
const { mode, knowledgeBases } = await discoverKnowledgeBases(workspacePath);
|
|
3407
|
+
const byName = new Map(knowledgeBases.map((knowledgeBase) => [knowledgeBase.name, knowledgeBase]));
|
|
3408
|
+
const server = createServer(async (request, response) => {
|
|
3409
|
+
const url2 = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
|
|
3410
|
+
await handleSearchRequest(request, response, url2.pathname, mode, byName);
|
|
3411
|
+
});
|
|
3412
|
+
await new Promise((resolve2, reject) => {
|
|
3413
|
+
server.once("error", reject);
|
|
3414
|
+
server.listen(port, host, () => {
|
|
3415
|
+
server.off("error", reject);
|
|
3416
|
+
resolve2();
|
|
3417
|
+
});
|
|
3418
|
+
});
|
|
3419
|
+
const address = server.address();
|
|
3420
|
+
if (!address || typeof address === "string") {
|
|
3421
|
+
throw new CliError("server failed to bind to a TCP address", "SERVER_ERROR", 1 /* GeneralError */);
|
|
3422
|
+
}
|
|
3423
|
+
const url = `http://${host}:${address.port}`;
|
|
3424
|
+
return {
|
|
3425
|
+
mode,
|
|
3426
|
+
url,
|
|
3427
|
+
knowledgeBases: knowledgeBases.map((knowledgeBase) => ({
|
|
3428
|
+
name: knowledgeBase.name,
|
|
3429
|
+
workspacePath: knowledgeBase.workspacePath,
|
|
3430
|
+
route: routeForKnowledgeBase(mode, knowledgeBase)
|
|
3431
|
+
})),
|
|
3432
|
+
close: async () => new Promise((resolve2, reject) => {
|
|
3433
|
+
server.close((error) => error ? reject(error) : resolve2());
|
|
3434
|
+
})
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
// src/query/related-service.ts
|
|
3439
|
+
import path20 from "path";
|
|
3221
3440
|
function cosineSimilarity2(left, right) {
|
|
3222
3441
|
let dot = 0;
|
|
3223
3442
|
let leftNorm = 0;
|
|
@@ -3293,7 +3512,7 @@ async function findRelatedDocuments({
|
|
|
3293
3512
|
if (!await fileExists(denseVectorPath(workspacePath))) {
|
|
3294
3513
|
throw new CliError("dense vector index is not built; run `qli models pull --dense` and `qli rebuild`", "DENSE_INDEX_MISSING", 7 /* QueryError */);
|
|
3295
3514
|
}
|
|
3296
|
-
const documents = await readJsonl(
|
|
3515
|
+
const documents = await readJsonl(path20.join(workspacePath, "documents", "documents.jsonl"));
|
|
3297
3516
|
const selected = resolveDocumentSelector(documents, document);
|
|
3298
3517
|
const densePayload = await readDensePayload(workspacePath);
|
|
3299
3518
|
const vectors = buildDocumentVectors(documents, densePayload.chunks, densePayload.metadata.dimensions);
|
|
@@ -3366,7 +3585,7 @@ async function createContext({
|
|
|
3366
3585
|
}
|
|
3367
3586
|
|
|
3368
3587
|
// src/report/diff-service.ts
|
|
3369
|
-
import
|
|
3588
|
+
import path21 from "path";
|
|
3370
3589
|
function chooseBaselineRun(runs, since) {
|
|
3371
3590
|
if (since === "last-run") {
|
|
3372
3591
|
return runs.at(-1);
|
|
@@ -3382,7 +3601,7 @@ async function diffWorkspace({
|
|
|
3382
3601
|
documentId,
|
|
3383
3602
|
since
|
|
3384
3603
|
}) {
|
|
3385
|
-
const current = await readJsonl(
|
|
3604
|
+
const current = await readJsonl(path21.join(workspacePath, "documents", "documents.jsonl"));
|
|
3386
3605
|
const baseline = chooseBaselineRun(await listRuns(workspacePath), since);
|
|
3387
3606
|
const previous = new Map((baseline?.documentsSnapshot ?? []).map((document) => [document.id, document]));
|
|
3388
3607
|
const changedDocuments = current.filter((document) => (!sourceId || document.sourceId === sourceId) && (!documentId || document.id === documentId)).filter((document) => {
|
|
@@ -3438,12 +3657,15 @@ export {
|
|
|
3438
3657
|
ingestSources,
|
|
3439
3658
|
listSources,
|
|
3440
3659
|
loadConfig,
|
|
3660
|
+
loadHydratedIndex,
|
|
3441
3661
|
removeSource,
|
|
3442
3662
|
renderChangeReport,
|
|
3443
3663
|
reprocessDocuments,
|
|
3444
3664
|
searchIndex,
|
|
3445
3665
|
searchJsonIndex,
|
|
3666
|
+
searchJsonRequest,
|
|
3446
3667
|
searchResultsFromResponse,
|
|
3668
|
+
startSearchApiServer,
|
|
3447
3669
|
updateSource,
|
|
3448
3670
|
writeDefaultConfig
|
|
3449
3671
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type JsonDslRequest, type JsonDslResponse } from "@tryformation/querylight-ts";
|
|
1
|
+
import { type DocumentIndex, type JsonDslRequest, type JsonDslResponse } from "@tryformation/querylight-ts";
|
|
2
2
|
import type { RetrievalMode, SearchResponseData, SearchResult } from "../types/models.js";
|
|
3
|
+
export declare function loadHydratedIndex(workspacePath: string): Promise<DocumentIndex>;
|
|
3
4
|
type SearchDateField = "publicationDate" | "firstSeenAt" | "lastSeenAt" | "lastChangedAt" | "crawledAt";
|
|
4
5
|
type SearchDateRange = {
|
|
5
6
|
field: SearchDateField;
|
|
@@ -7,6 +8,11 @@ type SearchDateRange = {
|
|
|
7
8
|
to?: string;
|
|
8
9
|
};
|
|
9
10
|
export declare function searchResultsFromResponse(response: SearchResponseData, showChunks?: boolean): SearchResult[];
|
|
11
|
+
export declare function searchJsonRequest({ index, request, indexName }: {
|
|
12
|
+
index: DocumentIndex;
|
|
13
|
+
request: JsonDslRequest;
|
|
14
|
+
indexName?: string;
|
|
15
|
+
}): Promise<JsonDslResponse>;
|
|
10
16
|
export declare function searchJsonIndex({ workspacePath, request, indexName }: {
|
|
11
17
|
workspacePath: string;
|
|
12
18
|
request: JsonDslRequest;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SearchApiServerInfo = {
|
|
2
|
+
mode: "single" | "multi";
|
|
3
|
+
url: string;
|
|
4
|
+
knowledgeBases: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
workspacePath: string;
|
|
7
|
+
route: string;
|
|
8
|
+
}>;
|
|
9
|
+
close: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function startSearchApiServer({ workspacePath, host, port }: {
|
|
12
|
+
workspacePath: string;
|
|
13
|
+
host?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
}): Promise<SearchApiServerInfo>;
|
package/dist/vector/dense.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type ProgressHandler } from "../core/progress.js";
|
|
2
2
|
import type { DenseVectorPayload, WorkspaceConfig } from "../types/models.js";
|
|
3
|
-
|
|
3
|
+
type DenseEmbedder = {
|
|
4
|
+
embed(text: string): Promise<number[]>;
|
|
5
|
+
dispose?: () => Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare function setDenseEmbedderFactoryForTests(factory: ((cacheDir: string, modelId: string) => Promise<DenseEmbedder | ((text: string) => Promise<number[]>)>) | null): void;
|
|
4
8
|
export declare function pullDenseModel(workspacePath: string, config: WorkspaceConfig["retrieval"]["dense"]): Promise<void>;
|
|
5
9
|
export declare function buildDenseVectors({ workspacePath, config, progress }: {
|
|
6
10
|
workspacePath: string;
|
|
@@ -13,3 +17,4 @@ export declare function denseQuery({ workspacePath, config, query, topK }: {
|
|
|
13
17
|
query: string;
|
|
14
18
|
topK: number;
|
|
15
19
|
}): Promise<Array<[string, number]>>;
|
|
20
|
+
export {};
|
package/package.json
CHANGED
package/scripts/sparse-encode.py
CHANGED
|
@@ -7,19 +7,40 @@ from huggingface_hub import hf_hub_download
|
|
|
7
7
|
from transformers import AutoModelForMaskedLM, AutoTokenizer
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def _load_query_weights_file(model_id: str, filename: str):
|
|
11
|
+
try:
|
|
12
|
+
return hf_hub_download(repo_id=model_id, filename=filename)
|
|
13
|
+
except Exception:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
10
17
|
def build_query_token_weight_vector(tokenizer, model_id: str):
|
|
11
|
-
local_cached_path = hf_hub_download(repo_id=model_id, filename="query_token_weights.txt")
|
|
12
18
|
vector = [0.0] * tokenizer.vocab_size
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
local_cached_path = _load_query_weights_file(model_id, "query_token_weights.txt")
|
|
20
|
+
|
|
21
|
+
if local_cached_path is not None:
|
|
22
|
+
with open(local_cached_path, encoding="utf-8") as handle:
|
|
23
|
+
for line in handle:
|
|
24
|
+
line = line.rstrip("\n")
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
token, weight = line.split("\t", 1)
|
|
28
|
+
token_id = tokenizer._convert_token_to_id_with_added_voc(token)
|
|
29
|
+
if token_id is not None and token_id >= 0:
|
|
30
|
+
vector[token_id] = float(weight)
|
|
31
|
+
return vector
|
|
32
|
+
|
|
33
|
+
local_cached_path = _load_query_weights_file(model_id, "idf.json")
|
|
34
|
+
if local_cached_path is not None:
|
|
35
|
+
with open(local_cached_path, encoding="utf-8") as handle:
|
|
36
|
+
idf = json.load(handle)
|
|
37
|
+
for token, weight in idf.items():
|
|
20
38
|
token_id = tokenizer._convert_token_to_id_with_added_voc(token)
|
|
21
39
|
if token_id is not None and token_id >= 0:
|
|
22
40
|
vector[token_id] = float(weight)
|
|
41
|
+
return vector
|
|
42
|
+
|
|
43
|
+
raise FileNotFoundError(f"missing query token weights for {model_id}: expected query_token_weights.txt or idf.json")
|
|
23
44
|
|
|
24
45
|
return vector
|
|
25
46
|
|