karajan-code 2.28.0 → 2.29.0
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 +9 -1
- package/package.json +1 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/rag.html +21 -0
- package/packages/hu-board/public/rag.js +39 -0
- package/packages/hu-board/public/styles.css +14 -0
- package/packages/hu-board/src/routes/rag.js +36 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/esbuild-sea.config.mjs +8 -1
- package/src/cli/register-meta.js +3 -0
- package/src/commands/rag.js +3 -1
- package/src/rag/embedders/cohere.js +28 -0
- package/src/rag/embedders/factory.js +15 -5
- package/src/rag/embedders/mistral.js +26 -0
- package/src/rag/embedders/onnx.js +63 -0
- package/src/rag/rerank.js +74 -0
- package/src/rag/retriever.js +17 -3
- package/src/rag/vec-store.js +6 -4
- package/src/rag/where-parser.js +54 -0
package/README.md
CHANGED
|
@@ -345,6 +345,14 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
+
> **v2.29.0 released** — Minor. **RAG quality lift** — dashboard + three new providers + metadata filter + rerank.
|
|
349
|
+
>
|
|
350
|
+
> - **Retrieval dashboard on HU Board** (PR #843): nuevo `/rag.html` con embedder activo, tamaño de DB, last-index, chunks por kind, chunks por proyecto. Primera pieza del config UI que llega completo en v2.30.
|
|
351
|
+
> - **Cohere + Mistral embedders** (PR #848): `embed-multilingual-v3.0` (multi-idioma) y `mistral-embed` (EU-hosted, útil para GDPR). `KJ_COHERE_KEY` / `KJ_MISTRAL_KEY` Karajan-scoped.
|
|
352
|
+
> - **ONNX local embedder** (PR #??): `@huggingface/transformers` corriendo en Node — sin Docker, sin API key, sin Ollama. Default `Xenova/all-MiniLM-L6-v2` (384 dim). Es la base para el zero-config init de v2.31.
|
|
353
|
+
> - **Metadata `--where` filter** (PR #??): `kj rag query 'auth' --where 'symbol=loadConfig AND kind=plan'`. Gramática mínima KEY=VALUE AND KEY=VALUE; cualquier metadata que emite el chunker es queryable sin schema changes.
|
|
354
|
+
> - **Cross-encoder rerank** (PR #??): opt-in `--rerank` re-ordena los topK con un cross-encoder (`Xenova/ms-marco-MiniLM-L-6-v2`). Latencia acotada, calidad final notablemente mejor.
|
|
355
|
+
>
|
|
348
356
|
> **v2.28.0 released** — Minor. **RAG advanced** — live re-index + cloud embedders + hybrid scoring + AST chunker, plus a real fix for the v2.27.0 chapuza.
|
|
349
357
|
>
|
|
350
358
|
> - **`kj watch`** (PR #836): chokidar daemon que re-indexa los archivos del proyecto tras cada edit (1s debounce). Fin del `kj rag index` manual.
|
|
@@ -360,7 +368,7 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
360
368
|
> - **Asymmetric source-vs-test ranking** (PR #833): NL queries like `how does X work` no longer rank `tests/X.test.js` above `src/X.js`; test-flavoured queries still surface tests.
|
|
361
369
|
> - Plus **KJC-BUG-0063** (PR #834): skipped a TZ-dependent test that was blocking every CI run, and a `shrink-budget` workflow exclude fix for `docs/*.md` at the root.
|
|
362
370
|
>
|
|
363
|
-
> **Coming in v2.
|
|
371
|
+
> **Coming in v2.30.0+**: writable config UI on the HU Board (toggle roles, switch coder/reviewer, adjust alpha/mode/rerank without re-editing the YAML), then v2.31 zero-config init (reduce the wizard to one critical question, smart defaults for everything).
|
|
364
372
|
>
|
|
365
373
|
> **v2.26.0 released** — Minor. **RAG Auto-Bootstrap** — Ollama runs in Docker out of the box. `kj init` now provisions the embedder automatically (or skips with a clear reason on modest hardware / `--no-ollama`); `kj doctor` surfaces health; `kj ollama [start|stop|status|pull]` manages lifecycle without docker compose. See [docs/RAG.md](docs/RAG.md) for the full RAG guide ([español](docs/es/RAG.md)).
|
|
366
374
|
>
|
package/package.json
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
<button class="nav-btn" data-view="dashboard" title="Per-project landing: stats grid + project list. Click a project card to focus the kanban / graph on that project.">Dashboard</button>
|
|
26
26
|
<button class="nav-btn" data-view="sessions" title="Every kj run that has touched the board, newest first. Filter by project; click a session to inspect its iterations, commits and checkpoints.">Sessions</button>
|
|
27
27
|
<a class="nav-btn" href="/pipeline.html" title="Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs.">Pipeline</a>
|
|
28
|
+
<a class="nav-btn" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
|
|
28
29
|
<select class="project-select" id="project-select">
|
|
29
30
|
<option value="">All Projects</option>
|
|
30
31
|
</select>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Karajan RAG · Retrieval Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="header">
|
|
11
|
+
<div class="header__logo"><div><div class="header__title">Karajan RAG</div><div class="header__subtitle">retrieval-quality dashboard</div></div></div>
|
|
12
|
+
<nav class="header__nav">
|
|
13
|
+
<a class="nav-btn" href="/">← Board</a>
|
|
14
|
+
<button class="nav-btn active">RAG</button>
|
|
15
|
+
<div class="header__controls"><button class="control-btn" id="rag-refresh" title="Reload stats">🔄</button></div>
|
|
16
|
+
</nav>
|
|
17
|
+
</header>
|
|
18
|
+
<main class="main" id="rag-app"><div class="loading"><div class="loading__spinner"></div><p>Loading…</p></div></main>
|
|
19
|
+
<script src="/rag.js"></script>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// KJC-TSK-0445 — Retrieval dashboard frontend. Fetches /api/rag/stats and
|
|
2
|
+
// renders chunk counts per project/kind, embedder, DB size and last-index.
|
|
3
|
+
const TOKEN = new URLSearchParams(location.search).get("token") || localStorage.getItem("kj_board_token") || "";
|
|
4
|
+
const HEADERS = TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {};
|
|
5
|
+
const ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
6
|
+
|
|
7
|
+
function bytes(n) { for (const u of ["B", "KB", "MB", "GB"]) { if (n < 1024) return `${u === "B" ? n : n.toFixed(1)} ${u}`; n /= 1024; } return `${n.toFixed(2)} TB`; }
|
|
8
|
+
|
|
9
|
+
function bars(rows, lab, val, max) {
|
|
10
|
+
if (!rows.length) return '<p class="rag-empty">no data</p>';
|
|
11
|
+
return rows.map((r) => `<div class="rag-bar"><span class="rag-bar__label">${ESC(lab(r))}</span><span class="rag-bar__fill" style="width:${Math.round((val(r) / max) * 100)}%"></span><span class="rag-bar__value">${val(r).toLocaleString()}</span></div>`).join("");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function embedderCard(e) {
|
|
15
|
+
return e ? `<div class="rag-card"><h3>Embedder</h3><p class="rag-stat-big">${ESC(e.provider)}</p><p class="rag-stat-sub">${ESC(e.model || "default model")} · dim ${e.dim ?? "?"}</p></div>` : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function render(root, data) {
|
|
19
|
+
if (!data.initialized) { root.innerHTML = `<section class="rag-grid"><div class="rag-card"><h3>RAG DB</h3><p class="rag-empty">${ESC(data.message || "not initialized")}</p></div>${embedderCard(data.embedder)}</section>`; return; }
|
|
20
|
+
const lastIdx = data.last_indexed ? new Date(data.last_indexed).toLocaleString() : "never";
|
|
21
|
+
const mk = Math.max(1, ...data.by_kind.map((r) => r.n));
|
|
22
|
+
const mp = Math.max(1, ...data.by_project.map((r) => r.n));
|
|
23
|
+
root.innerHTML = `<section class="rag-grid">
|
|
24
|
+
<div class="rag-card"><h3>Totals</h3><p class="rag-stat-big">${data.total_chunks.toLocaleString()}</p><p class="rag-stat-sub">chunks · ${bytes(data.db_size_bytes)} · last indexed ${ESC(lastIdx)}</p></div>
|
|
25
|
+
${embedderCard(data.embedder)}
|
|
26
|
+
<div class="rag-card rag-card--wide"><h3>By kind</h3>${bars(data.by_kind, (r) => r.kind, (r) => r.n, mk)}</div>
|
|
27
|
+
<div class="rag-card rag-card--wide"><h3>By project (top 20)</h3>${bars(data.by_project, (r) => r.project, (r) => r.n, mp)}</div>
|
|
28
|
+
</section>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function load() {
|
|
32
|
+
const app = document.getElementById("rag-app");
|
|
33
|
+
app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading…</p></div>';
|
|
34
|
+
try { const r = await fetch("/api/rag/stats", { headers: HEADERS }); if (!r.ok) throw new Error(`HTTP ${r.status}`); render(app, await r.json()); }
|
|
35
|
+
catch (e) { app.innerHTML = `<div class="rag-card rag-card--error"><strong>Error:</strong> ${ESC(e.message)}</div>`; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
document.getElementById("rag-refresh").addEventListener("click", load);
|
|
39
|
+
load();
|
|
@@ -916,3 +916,17 @@ body.scoped-mode .nav-btn[data-view="dashboard"],
|
|
|
916
916
|
body.scoped-mode .header__nav a[href="/pipeline.html"] {
|
|
917
917
|
display: none;
|
|
918
918
|
}
|
|
919
|
+
|
|
920
|
+
/* KJC-TSK-0445 — RAG dashboard */
|
|
921
|
+
.rag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; padding: 1rem; }
|
|
922
|
+
.rag-card { background: var(--bg-secondary, #1e1e2e); border-radius: 8px; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
923
|
+
.rag-card--wide { grid-column: 1 / -1; }
|
|
924
|
+
.rag-card--error { color: #f87171; background: #2a1414; }
|
|
925
|
+
.rag-card h3 { margin: 0 0 .75rem; font-size: .9rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary, #94a3b8); }
|
|
926
|
+
.rag-stat-big { font-size: 2rem; font-weight: 700; margin: 0; color: var(--text-primary, #f8fafc); }
|
|
927
|
+
.rag-stat-sub { margin: .25rem 0 0; font-size: .85rem; color: var(--text-secondary, #94a3b8); }
|
|
928
|
+
.rag-empty { color: var(--text-secondary, #94a3b8); font-style: italic; }
|
|
929
|
+
.rag-bar { display: grid; grid-template-columns: 140px 1fr 60px; align-items: center; gap: .5rem; margin: .35rem 0; font-size: .85rem; }
|
|
930
|
+
.rag-bar__label { color: var(--text-secondary, #cbd5e1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
931
|
+
.rag-bar__fill { display: block; height: 14px; background: linear-gradient(90deg, #7c3aed, #a78bfa); border-radius: 3px; }
|
|
932
|
+
.rag-bar__value { text-align: right; color: var(--text-primary, #f8fafc); font-variant-numeric: tabular-nums; }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// KJC-TSK-0445 — RAG retrieval-quality dashboard route.
|
|
2
|
+
// GET /api/rag/stats → snapshot of the local rag.db (counts per kind/project,
|
|
3
|
+
// active embedder, db size, last-index timestamp). Missing DB returns an
|
|
4
|
+
// "uninitialized" payload so the dashboard renders an empty state cleanly.
|
|
5
|
+
import { Router } from "express";
|
|
6
|
+
import { existsSync, statSync } from "node:fs";
|
|
7
|
+
import Database from "better-sqlite3";
|
|
8
|
+
import { dbPath } from "../../../../src/rag/vec-store.js";
|
|
9
|
+
import { readConfig } from "../config-yaml.js";
|
|
10
|
+
|
|
11
|
+
const router = Router();
|
|
12
|
+
const DIMS = { ollama: 768, openai: 1536, voyage: 1024, cohere: 1024, mistral: 1024, onnx: 384 };
|
|
13
|
+
|
|
14
|
+
function embedder() {
|
|
15
|
+
try {
|
|
16
|
+
const e = (readConfig()?.rag?.embedder) || {};
|
|
17
|
+
const provider = e.provider || "ollama";
|
|
18
|
+
return { provider, model: e.model || null, dim: e.dim || DIMS[provider] || null };
|
|
19
|
+
} catch { return { provider: "ollama", model: null, dim: 768 }; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
router.get("/stats", (_req, res) => {
|
|
23
|
+
const path = dbPath();
|
|
24
|
+
if (!existsSync(path)) return res.json({ ok: true, initialized: false, message: "rag.db not initialized — run `kj rag index <dir>` to start indexing.", embedder: embedder() });
|
|
25
|
+
try {
|
|
26
|
+
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
27
|
+
const total_chunks = db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
|
|
28
|
+
const by_kind = db.prepare("SELECT kind, COUNT(*) AS n FROM chunks GROUP BY kind").all();
|
|
29
|
+
const by_project = db.prepare("SELECT COALESCE(project_slug, '(no slug)') AS project, COUNT(*) AS n FROM chunks GROUP BY project_slug ORDER BY n DESC LIMIT 20").all();
|
|
30
|
+
const last_indexed = db.prepare("SELECT MAX(created_at) AS ts FROM chunks").get().ts;
|
|
31
|
+
db.close();
|
|
32
|
+
res.json({ ok: true, initialized: true, db_path: path, db_size_bytes: statSync(path).size, total_chunks, by_kind, by_project, last_indexed, embedder: embedder() });
|
|
33
|
+
} catch (err) { res.status(500).json({ ok: false, error: String(err.message || err) }); }
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export default router;
|
|
@@ -9,6 +9,7 @@ import { initDb, closeDb } from './db.js';
|
|
|
9
9
|
import { fullScan, startWatcher } from './sync.js';
|
|
10
10
|
import apiRoutes from './routes/api.js';
|
|
11
11
|
import pipelineRoutes from './routes/pipeline.js';
|
|
12
|
+
import ragRoutes from './routes/rag.js';
|
|
12
13
|
import { authMiddleware } from './auth.js';
|
|
13
14
|
import { getOrCreateToken, getTokenPath } from './token-store.js';
|
|
14
15
|
import { reapZombieSessions } from './zombie-reaper.js';
|
|
@@ -298,6 +299,7 @@ async function main() {
|
|
|
298
299
|
app.use(express.static(PUBLIC_DIR, { setHeaders: noStoreHeaders, etag: false, lastModified: false }));
|
|
299
300
|
app.use('/api', buildRateLimiter(), authMiddleware(), apiRoutes);
|
|
300
301
|
app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
|
|
302
|
+
app.use('/api/rag', authMiddleware(), ragRoutes);
|
|
301
303
|
|
|
302
304
|
// SPA fallback: serve index.html for non-API, non-static routes
|
|
303
305
|
app.get('/{*splat}', (_req, res) => {
|
|
@@ -173,9 +173,16 @@ const ragStubPlugin = {
|
|
|
173
173
|
// KJC-TSK-0441 — RAG chokidar watcher.
|
|
174
174
|
startWatcher: notAvailable, readPidFile: notAvailable, clearPidFile: notAvailable,
|
|
175
175
|
isPidAlive: notAvailable, writePidFile: notAvailable,
|
|
176
|
-
// KJC-TSK-0442 — RAG embedders cloud + factory.
|
|
176
|
+
// KJC-TSK-0442 / KJC-TSK-0446 — RAG embedders cloud + factory.
|
|
177
177
|
makeEmbedder: notAvailable, OpenAIEmbedder: notAvailable, VoyageEmbedder: notAvailable,
|
|
178
178
|
OpenAIEmbedderError: notAvailable, VoyageEmbedderError: notAvailable, PROVIDERS: notAvailable,
|
|
179
|
+
CohereEmbedder: notAvailable, CohereEmbedderError: notAvailable,
|
|
180
|
+
MistralEmbedder: notAvailable, MistralEmbedderError: notAvailable,
|
|
181
|
+
ONNXEmbedder: notAvailable, ONNXEmbedderError: notAvailable,
|
|
182
|
+
// KJC-TSK-0448 — RAG --where metadata filter.
|
|
183
|
+
parseWhere: notAvailable, buildWhereSql: notAvailable,
|
|
184
|
+
// KJC-TSK-0449 — RAG cross-encoder rerank.
|
|
185
|
+
rerank: notAvailable, RerankError: notAvailable, _resetPipeline: notAvailable,
|
|
179
186
|
default: notAvailable,
|
|
180
187
|
};
|
|
181
188
|
`,
|
package/src/cli/register-meta.js
CHANGED
|
@@ -117,6 +117,9 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
117
117
|
.option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
|
|
118
118
|
.option("--mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
|
|
119
119
|
.option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
|
|
120
|
+
.option("--where <clause>", "Metadata filter, e.g. 'symbol=loadConfig' or 'hu_id=HU-003 AND kind=plan'")
|
|
121
|
+
.option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
|
|
122
|
+
.option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
|
|
120
123
|
.option("--json", "Output the hits as JSON")
|
|
121
124
|
.action(async (text, flags) => {
|
|
122
125
|
await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
|
package/src/commands/rag.js
CHANGED
|
@@ -57,7 +57,9 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
57
57
|
}
|
|
58
58
|
const mode = flags.mode || "hybrid";
|
|
59
59
|
const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
|
|
60
|
-
const
|
|
60
|
+
const where = flags.where || null;
|
|
61
|
+
const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
|
|
62
|
+
const hits = await query(db, makeEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
|
|
61
63
|
if (flags.json) {
|
|
62
64
|
process.stdout.write(`${JSON.stringify(hits)}\n`);
|
|
63
65
|
} else {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// KJC-TSK-0446 — Cohere embed-v3 adapter. Cohere returns
|
|
2
|
+
// { embeddings: { float: [[...]] } } when `embedding_types: ["float"]`
|
|
3
|
+
// is requested, or { embeddings: [[...]] } in legacy v1. We extract the
|
|
4
|
+
// modern shape with a v1 fallback.
|
|
5
|
+
import { cloudEmbed } from "./_cloud-base.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = { url: "https://api.cohere.com/v2/embed", model: "embed-multilingual-v3.0", dim: 1024, timeoutMs: 30000 };
|
|
8
|
+
|
|
9
|
+
export class CohereEmbedderError extends Error {
|
|
10
|
+
constructor(message, { cause, status } = {}) { super(message); this.name = "CohereEmbedderError"; if (cause) this.cause = cause; if (status != null) this.status = status; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class CohereEmbedder {
|
|
14
|
+
constructor({ url = DEFAULTS.url, apiKey = process.env.KJ_COHERE_KEY, model = process.env.KJ_COHERE_EMBED_MODEL || DEFAULTS.model, dim = DEFAULTS.dim, timeoutMs = DEFAULTS.timeoutMs, fetchFn = globalThis.fetch } = {}) {
|
|
15
|
+
// KJC architecture invariant: scoped env var (KJ_COHERE_KEY), not the
|
|
16
|
+
// generic COHERE_API_KEY — same rule as OpenAI/Voyage adapters.
|
|
17
|
+
if (!apiKey) throw new CohereEmbedderError("Cohere embedder requires an api_key (config.rag.embedder.api_key or KJ_COHERE_KEY env)");
|
|
18
|
+
Object.assign(this, { url, apiKey, model, dim, timeoutMs, fetch: fetchFn });
|
|
19
|
+
}
|
|
20
|
+
async embed(text) {
|
|
21
|
+
return cloudEmbed(this, text, CohereEmbedderError, {
|
|
22
|
+
provider: "Cohere",
|
|
23
|
+
body: { model: this.model, texts: [text], input_type: "search_document", embedding_types: ["float"] },
|
|
24
|
+
extract: (b) => b?.embeddings?.float?.[0] || b?.embeddings?.[0],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async embedBatch(texts) { if (!Array.isArray(texts)) throw new CohereEmbedderError("embedBatch: texts must be an array"); const out = []; for (const t of texts) out.push(await this.embed(t)); return out; }
|
|
28
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
// KJC-TSK-0442 — Embedder factory. Selects provider from
|
|
2
|
-
// config.rag.embedder.provider (ollama | openai | voyage
|
|
3
|
-
// Each provider has its own dim default; the factory wires
|
|
4
|
-
// caller does not need to know.
|
|
1
|
+
// KJC-TSK-0442 / KJC-TSK-0446 — Embedder factory. Selects provider from
|
|
2
|
+
// config.rag.embedder.provider (ollama | openai | voyage | cohere | mistral;
|
|
3
|
+
// default ollama). Each provider has its own dim default; the factory wires
|
|
4
|
+
// it so the caller does not need to know.
|
|
5
5
|
import { OllamaEmbedder } from "../embedder.js";
|
|
6
6
|
import { OpenAIEmbedder } from "./openai.js";
|
|
7
7
|
import { VoyageEmbedder } from "./voyage.js";
|
|
8
|
+
import { CohereEmbedder } from "./cohere.js";
|
|
9
|
+
import { MistralEmbedder } from "./mistral.js";
|
|
10
|
+
import { ONNXEmbedder } from "./onnx.js";
|
|
8
11
|
|
|
9
|
-
const PROVIDERS = {
|
|
12
|
+
const PROVIDERS = {
|
|
13
|
+
ollama: { cls: OllamaEmbedder, dim: 768 },
|
|
14
|
+
openai: { cls: OpenAIEmbedder, dim: 1536 },
|
|
15
|
+
voyage: { cls: VoyageEmbedder, dim: 1024 },
|
|
16
|
+
cohere: { cls: CohereEmbedder, dim: 1024 },
|
|
17
|
+
mistral: { cls: MistralEmbedder, dim: 1024 },
|
|
18
|
+
onnx: { cls: ONNXEmbedder, dim: 384 },
|
|
19
|
+
};
|
|
10
20
|
|
|
11
21
|
export function makeEmbedder(config = {}) {
|
|
12
22
|
const cfg = config?.rag?.embedder || {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// KJC-TSK-0446 — Mistral AI embeddings adapter. EU-hosted, useful for
|
|
2
|
+
// users with GDPR constraints that prefer not to send chunks to US
|
|
3
|
+
// endpoints. Single model today (`mistral-embed`, 1024 dim).
|
|
4
|
+
import { cloudEmbed } from "./_cloud-base.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = { url: "https://api.mistral.ai/v1/embeddings", model: "mistral-embed", dim: 1024, timeoutMs: 30000 };
|
|
7
|
+
|
|
8
|
+
export class MistralEmbedderError extends Error {
|
|
9
|
+
constructor(message, { cause, status } = {}) { super(message); this.name = "MistralEmbedderError"; if (cause) this.cause = cause; if (status != null) this.status = status; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MistralEmbedder {
|
|
13
|
+
constructor({ url = DEFAULTS.url, apiKey = process.env.KJ_MISTRAL_KEY, model = process.env.KJ_MISTRAL_EMBED_MODEL || DEFAULTS.model, dim = DEFAULTS.dim, timeoutMs = DEFAULTS.timeoutMs, fetchFn = globalThis.fetch } = {}) {
|
|
14
|
+
// KJC architecture invariant: scoped env var (KJ_MISTRAL_KEY).
|
|
15
|
+
if (!apiKey) throw new MistralEmbedderError("Mistral embedder requires an api_key (config.rag.embedder.api_key or KJ_MISTRAL_KEY env)");
|
|
16
|
+
Object.assign(this, { url, apiKey, model, dim, timeoutMs, fetch: fetchFn });
|
|
17
|
+
}
|
|
18
|
+
async embed(text) {
|
|
19
|
+
return cloudEmbed(this, text, MistralEmbedderError, {
|
|
20
|
+
provider: "Mistral",
|
|
21
|
+
body: { model: this.model, input: [text] },
|
|
22
|
+
extract: (b) => b?.data?.[0]?.embedding,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async embedBatch(texts) { if (!Array.isArray(texts)) throw new MistralEmbedderError("embedBatch: texts must be an array"); const out = []; for (const t of texts) out.push(await this.embed(t)); return out; }
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// KJC-TSK-0447 — ONNX local embedder via @huggingface/transformers
|
|
2
|
+
// (formerly @xenova/transformers). Runs sentence-transformer models
|
|
3
|
+
// directly in Node — no API key, no Docker, no Ollama.
|
|
4
|
+
//
|
|
5
|
+
// First call downloads the model weights to ~/.cache/huggingface/ (~80 MB
|
|
6
|
+
// for the default MiniLM). Subsequent calls reuse the cache, so the steady
|
|
7
|
+
// state is fully offline.
|
|
8
|
+
//
|
|
9
|
+
// Default model: `Xenova/all-MiniLM-L6-v2` (384 dim, ~80 MB, fast).
|
|
10
|
+
// Higher-quality alternative: `Xenova/jina-embeddings-v2-base-en`
|
|
11
|
+
// (768 dim, ~260 MB, slower but better for retrieval).
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = { model: "Xenova/all-MiniLM-L6-v2", dim: 384, pooling: "mean", normalize: true };
|
|
14
|
+
|
|
15
|
+
export class ONNXEmbedderError extends Error {
|
|
16
|
+
constructor(message, { cause } = {}) { super(message); this.name = "ONNXEmbedderError"; if (cause) this.cause = cause; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function loadTransformers() {
|
|
20
|
+
// Prefer the modern @huggingface/transformers (post-2024 rebrand);
|
|
21
|
+
// fall back to @xenova/transformers for users still on the legacy package.
|
|
22
|
+
// Both are optional peer deps — Karajan does not ship them by default to
|
|
23
|
+
// keep the install size small (~500 MB with WASM + weights). The user
|
|
24
|
+
// installs whichever they prefer when they opt into provider: onnx.
|
|
25
|
+
/* eslint-disable import-x/no-unresolved */
|
|
26
|
+
try { return await import("@huggingface/transformers"); }
|
|
27
|
+
catch (e1) {
|
|
28
|
+
try { return await import("@xenova/transformers"); }
|
|
29
|
+
catch (e2) {
|
|
30
|
+
throw new ONNXEmbedderError(
|
|
31
|
+
"ONNX embedder requires @huggingface/transformers (or legacy @xenova/transformers). Install with `npm install @huggingface/transformers`.",
|
|
32
|
+
{ cause: e2 },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/* eslint-enable import-x/no-unresolved */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ONNXEmbedder {
|
|
40
|
+
constructor({ model = process.env.KJ_ONNX_EMBED_MODEL || DEFAULTS.model, dim = DEFAULTS.dim, pooling = DEFAULTS.pooling, normalize = DEFAULTS.normalize } = {}) {
|
|
41
|
+
Object.assign(this, { model, dim, pooling, normalize, _pipe: null });
|
|
42
|
+
}
|
|
43
|
+
async _ensurePipeline() {
|
|
44
|
+
if (this._pipe) return this._pipe;
|
|
45
|
+
const transformers = await loadTransformers();
|
|
46
|
+
this._pipe = await transformers.pipeline("feature-extraction", this.model);
|
|
47
|
+
return this._pipe;
|
|
48
|
+
}
|
|
49
|
+
async embed(text) {
|
|
50
|
+
if (typeof text !== "string" || text.length === 0) throw new ONNXEmbedderError("embed: text must be a non-empty string");
|
|
51
|
+
const pipe = await this._ensurePipeline();
|
|
52
|
+
const out = await pipe(text, { pooling: this.pooling, normalize: this.normalize });
|
|
53
|
+
const arr = Array.from(out.data || []);
|
|
54
|
+
if (arr.length !== this.dim) throw new ONNXEmbedderError(`ONNX dim mismatch: got ${arr.length}, expected ${this.dim} for model ${this.model}`);
|
|
55
|
+
return Float32Array.from(arr);
|
|
56
|
+
}
|
|
57
|
+
async embedBatch(texts) {
|
|
58
|
+
if (!Array.isArray(texts)) throw new ONNXEmbedderError("embedBatch: texts must be an array");
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const t of texts) out.push(await this.embed(t));
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// KJC-TSK-0449 — Cross-encoder rerank stage. Re-scores the top-N hits
|
|
2
|
+
// from the hybrid retriever using a (query, passage) cross-encoder.
|
|
3
|
+
//
|
|
4
|
+
// Cross-encoders are slower than the bi-encoder embedders (they jointly
|
|
5
|
+
// encode query + passage instead of caching the passage), but they are
|
|
6
|
+
// substantially more precise for the final top-K ranking. Karajan calls
|
|
7
|
+
// the reranker only on the post-fusion candidates (≤topK*2), so the
|
|
8
|
+
// added latency is bounded.
|
|
9
|
+
//
|
|
10
|
+
// Model is loaded dynamically via @huggingface/transformers (same dep as
|
|
11
|
+
// the ONNX embedder, KJC-TSK-0447). Default: `Xenova/ms-marco-MiniLM-L-6-v2`,
|
|
12
|
+
// the de-facto standard sentence-transformers reranker.
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MODEL = "Xenova/ms-marco-MiniLM-L-6-v2";
|
|
15
|
+
|
|
16
|
+
export class RerankError extends Error {
|
|
17
|
+
constructor(message, { cause } = {}) { super(message); this.name = "RerankError"; if (cause) this.cause = cause; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function loadTransformers() {
|
|
21
|
+
/* eslint-disable import-x/no-unresolved */
|
|
22
|
+
try { return await import("@huggingface/transformers"); }
|
|
23
|
+
catch (e1) {
|
|
24
|
+
try { return await import("@xenova/transformers"); }
|
|
25
|
+
catch (e2) {
|
|
26
|
+
throw new RerankError(
|
|
27
|
+
"rerank requires @huggingface/transformers (or legacy @xenova/transformers). Install with `npm install @huggingface/transformers`.",
|
|
28
|
+
{ cause: e2 },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/* eslint-enable import-x/no-unresolved */
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lazy singleton — first call downloads weights (~80 MB), every subsequent
|
|
37
|
+
* call reuses the same pipeline instance.
|
|
38
|
+
*/
|
|
39
|
+
let _pipelinePromise = null;
|
|
40
|
+
function getPipeline(modelName) {
|
|
41
|
+
if (!_pipelinePromise) _pipelinePromise = (async () => {
|
|
42
|
+
const t = await loadTransformers();
|
|
43
|
+
// text-classification pipeline returns the cross-encoder relevance score
|
|
44
|
+
// for a `[query, passage]` text pair when applied to a CE model.
|
|
45
|
+
return t.pipeline("text-classification", modelName);
|
|
46
|
+
})();
|
|
47
|
+
return _pipelinePromise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reset for tests. */
|
|
51
|
+
export function _resetPipeline() { _pipelinePromise = null; }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Rerank `hits` against `queryText` using the cross-encoder. Returns a NEW
|
|
55
|
+
* array sorted by descending rerank score (best first), `score` mutated
|
|
56
|
+
* so the existing downstream code (kindBoost, slicing) keeps working.
|
|
57
|
+
*
|
|
58
|
+
* Each input hit is treated as a [query, hit.text] pair. Truncates the
|
|
59
|
+
* passage to `maxChars` to avoid blowing past the CE model's context
|
|
60
|
+
* window — most CE models handle 512 tokens, ~2000 chars is safe.
|
|
61
|
+
*
|
|
62
|
+
* If `pipelineFn` is injected (tests), it bypasses model loading.
|
|
63
|
+
*/
|
|
64
|
+
export async function rerank(queryText, hits, { model = process.env.KJ_RERANK_MODEL || DEFAULT_MODEL, maxChars = 2000, pipelineFn = null } = {}) {
|
|
65
|
+
if (!queryText || typeof queryText !== "string") throw new RerankError("rerank: queryText must be a non-empty string");
|
|
66
|
+
if (!Array.isArray(hits)) throw new RerankError("rerank: hits must be an array");
|
|
67
|
+
if (hits.length === 0) return hits;
|
|
68
|
+
const pipe = pipelineFn || await getPipeline(model);
|
|
69
|
+
const pairs = hits.map((h) => ({ text: queryText, text_pair: String(h.text || "").slice(0, maxChars) }));
|
|
70
|
+
const results = await pipe(pairs);
|
|
71
|
+
return hits
|
|
72
|
+
.map((h, i) => ({ ...h, _rerank: Number(results[i]?.score) || 0, score: -Number(results[i]?.score) || 0 }))
|
|
73
|
+
.sort((a, b) => a.score - b.score);
|
|
74
|
+
}
|
package/src/rag/retriever.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// KJC-PCS-0049 Step 5 — Retriever for the RAG pipeline.
|
|
2
2
|
import { searchSimilar, searchBM25 } from "./vec-store.js";
|
|
3
|
+
import { parseWhere, buildWhereSql } from "./where-parser.js";
|
|
4
|
+
import { rerank } from "./rerank.js";
|
|
3
5
|
|
|
4
6
|
const DEFAULT_KIND_BOOST = { plan: 0.05, onboarding: 0.03, code: 0 };
|
|
5
7
|
|
|
@@ -39,14 +41,21 @@ function fuseHits(semantic, keyword, alpha, mode) {
|
|
|
39
41
|
return list;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null, mode = "hybrid", alpha = 0.6 } = {}) {
|
|
44
|
+
export async function query(db, embedder, text, { topK = 5, scope = "all", kindBoost = DEFAULT_KIND_BOOST, project = null, mode = "hybrid", alpha = 0.6, where = null, rerankOpts = null } = {}) {
|
|
43
45
|
if (!text || typeof text !== "string") throw new Error("query: text must be a non-empty string");
|
|
44
46
|
const fetchK = Math.min(50, topK * 2);
|
|
45
47
|
const scopeKind = scope === "plans" ? "plan" : scope === "code" ? "code" : scope === "onboarding" ? "onboarding" : null;
|
|
48
|
+
// KJC-TSK-0448 — metadata filter. `where` is a string like "symbol=Foo AND
|
|
49
|
+
// hu_id=HU-003"; we parse once and reuse the SQL fragment across both
|
|
50
|
+
// semantic and keyword searches.
|
|
51
|
+
const parsed = parseWhere(where);
|
|
52
|
+
if (!parsed.ok) throw new Error(`query: ${parsed.error}`);
|
|
53
|
+
const { sql: whereSql, params: whereParams } = buildWhereSql(parsed.clauses);
|
|
54
|
+
const opts = { kind: scopeKind, project, whereSql, whereParams };
|
|
46
55
|
const wantSemantic = mode !== "keyword";
|
|
47
56
|
const wantKeyword = mode !== "semantic";
|
|
48
|
-
const semanticHits = wantSemantic ? searchSimilar(db, await embedder.embed(text), fetchK,
|
|
49
|
-
const keywordHits = wantKeyword ? searchBM25(db, text, fetchK,
|
|
57
|
+
const semanticHits = wantSemantic ? searchSimilar(db, await embedder.embed(text), fetchK, opts) : [];
|
|
58
|
+
const keywordHits = wantKeyword ? searchBM25(db, text, fetchK, opts) : [];
|
|
50
59
|
const raw = fuseHits(semanticHits, keywordHits, alpha, mode);
|
|
51
60
|
if (raw.length === 0) return [];
|
|
52
61
|
const boostSources = shouldBoostSources(text);
|
|
@@ -55,6 +64,11 @@ export async function query(db, embedder, text, { topK = 5, scope = "all", kindB
|
|
|
55
64
|
const sourceB = (boostSources && r.kind === "code" && !isTestPath(r.source)) ? SOURCE_BOOST_NON_TEST : 0;
|
|
56
65
|
return { ...r, score: r.distance - kindB - sourceB };
|
|
57
66
|
}).sort((a, b) => a.score - b.score).slice(0, topK);
|
|
67
|
+
// KJC-TSK-0449 — optional cross-encoder rerank. When `rerankOpts` is set,
|
|
68
|
+
// we re-score the topK survivors with a (query, passage) cross-encoder;
|
|
69
|
+
// the kind+source boost has already been applied, so the rerank acts as
|
|
70
|
+
// a finer-grained quality lever on top.
|
|
71
|
+
if (rerankOpts) return await rerank(text, reranked, rerankOpts);
|
|
58
72
|
return reranked;
|
|
59
73
|
}
|
|
60
74
|
|
package/src/rag/vec-store.js
CHANGED
|
@@ -106,7 +106,7 @@ export function insertChunk(db, { source, kind, text, metadata = null, embedding
|
|
|
106
106
|
* text, metadata, distance }]` for the topK best matches; lower
|
|
107
107
|
* distance = closer. Returns `[]` when the store is empty.
|
|
108
108
|
*/
|
|
109
|
-
export function searchSimilar(db, embedding, topK = 5, { kind = null, project = null } = {}) {
|
|
109
|
+
export function searchSimilar(db, embedding, topK = 5, { kind = null, project = null, whereSql = "", whereParams = [] } = {}) {
|
|
110
110
|
const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
|
|
111
111
|
const kindClause = kind ? "AND c.kind = ?" : "";
|
|
112
112
|
const projectClause = project ? "AND c.project_slug = ?" : "";
|
|
@@ -114,12 +114,13 @@ export function searchSimilar(db, embedding, topK = 5, { kind = null, project =
|
|
|
114
114
|
SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, v.distance
|
|
115
115
|
FROM vec_chunks v
|
|
116
116
|
JOIN chunks c ON c.id = v.rowid
|
|
117
|
-
WHERE v.embedding MATCH ? AND k = ? ${kindClause} ${projectClause}
|
|
117
|
+
WHERE v.embedding MATCH ? AND k = ? ${kindClause} ${projectClause}${whereSql}
|
|
118
118
|
ORDER BY v.distance
|
|
119
119
|
`;
|
|
120
120
|
const params = [Buffer.from(buf.buffer), topK];
|
|
121
121
|
if (kind) params.push(kind);
|
|
122
122
|
if (project) params.push(project);
|
|
123
|
+
if (whereParams.length) params.push(...whereParams);
|
|
123
124
|
const rows = db.prepare(sql).all(...params);
|
|
124
125
|
return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
|
|
125
126
|
}
|
|
@@ -163,7 +164,7 @@ export function countChunks(db, { kind = null } = {}) {
|
|
|
163
164
|
* relevance (lower bm25 = better in SQLite FTS5). `query` is sanitized for
|
|
164
165
|
* the FTS5 grammar: special chars are stripped, words split on whitespace.
|
|
165
166
|
*/
|
|
166
|
-
export function searchBM25(db, queryText, topK = 10, { kind = null, project = null } = {}) {
|
|
167
|
+
export function searchBM25(db, queryText, topK = 10, { kind = null, project = null, whereSql = "", whereParams = [] } = {}) {
|
|
167
168
|
const cleaned = String(queryText || "").replace(/["'()]/g, " ").split(/\s+/).filter(Boolean).join(" OR ");
|
|
168
169
|
if (!cleaned) return [];
|
|
169
170
|
const kindClause = kind ? "AND c.kind = ?" : "";
|
|
@@ -171,12 +172,13 @@ export function searchBM25(db, queryText, topK = 10, { kind = null, project = nu
|
|
|
171
172
|
const sql = `
|
|
172
173
|
SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, bm25(chunks_fts) AS bm25
|
|
173
174
|
FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid
|
|
174
|
-
WHERE chunks_fts MATCH ? ${kindClause} ${projectClause}
|
|
175
|
+
WHERE chunks_fts MATCH ? ${kindClause} ${projectClause}${whereSql}
|
|
175
176
|
ORDER BY bm25 LIMIT ?
|
|
176
177
|
`;
|
|
177
178
|
const params = [cleaned];
|
|
178
179
|
if (kind) params.push(kind);
|
|
179
180
|
if (project) params.push(project);
|
|
181
|
+
if (whereParams.length) params.push(...whereParams);
|
|
180
182
|
params.push(topK);
|
|
181
183
|
try {
|
|
182
184
|
const rows = db.prepare(sql).all(...params);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// KJC-TSK-0448 — minimal --where filter parser for metadata-aware RAG
|
|
2
|
+
// queries. Grammar (intentionally small):
|
|
3
|
+
//
|
|
4
|
+
// clause := pair ( "AND" pair )*
|
|
5
|
+
// pair := IDENT "=" VALUE
|
|
6
|
+
// IDENT := [A-Za-z_][A-Za-z0-9_.-]*
|
|
7
|
+
// VALUE := unquoted token | "quoted string"
|
|
8
|
+
//
|
|
9
|
+
// Examples:
|
|
10
|
+
// symbol=loadConfig
|
|
11
|
+
// hu_id=HU-003 AND kind=plan
|
|
12
|
+
// "headingPath.0"=Architecture
|
|
13
|
+
//
|
|
14
|
+
// Returns { ok: true, clauses: [["symbol","loadConfig"], ...] } or
|
|
15
|
+
// { ok: false, error: "...explanation..." }. The retriever turns each
|
|
16
|
+
// clause into a `json_extract(c.metadata, '$.<key>') = ?` SQL fragment;
|
|
17
|
+
// the `kind` key is special-cased to filter the column directly.
|
|
18
|
+
|
|
19
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
20
|
+
|
|
21
|
+
export function parseWhere(input) {
|
|
22
|
+
if (input == null) return { ok: true, clauses: [] };
|
|
23
|
+
if (typeof input !== "string") return { ok: false, error: "where: input must be a string" };
|
|
24
|
+
const s = input.trim();
|
|
25
|
+
if (!s) return { ok: true, clauses: [] };
|
|
26
|
+
const parts = s.split(/\s+AND\s+/i);
|
|
27
|
+
const clauses = [];
|
|
28
|
+
for (const p of parts) {
|
|
29
|
+
const eq = p.indexOf("=");
|
|
30
|
+
if (eq <= 0) return { ok: false, error: `where: bad pair "${p}" — expected key=value` };
|
|
31
|
+
const key = p.slice(0, eq).trim().replace(/^"|"$/g, "");
|
|
32
|
+
let val = p.slice(eq + 1).trim();
|
|
33
|
+
if (!IDENT_RE.test(key)) return { ok: false, error: `where: invalid key "${key}"` };
|
|
34
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
|
35
|
+
if (val.length === 0) return { ok: false, error: `where: empty value for key "${key}"` };
|
|
36
|
+
clauses.push([key, val]);
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, clauses };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a `(sqlFragment, params)` pair to append to a chunks-table query.
|
|
43
|
+
* `kind` filters the column directly; everything else goes through json_extract.
|
|
44
|
+
*/
|
|
45
|
+
export function buildWhereSql(clauses) {
|
|
46
|
+
if (!clauses?.length) return { sql: "", params: [] };
|
|
47
|
+
const fragments = [];
|
|
48
|
+
const params = [];
|
|
49
|
+
for (const [k, v] of clauses) {
|
|
50
|
+
if (k === "kind") { fragments.push("c.kind = ?"); params.push(v); }
|
|
51
|
+
else { fragments.push(`json_extract(c.metadata, '$.${k}') = ?`); params.push(v); }
|
|
52
|
+
}
|
|
53
|
+
return { sql: ` AND ${fragments.join(" AND ")}`, params };
|
|
54
|
+
}
|