qmd.ts 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314
  1. #!/usr/bin/env bun
  2. import { Database } from "bun:sqlite";
  3. import { Glob } from "bun";
  4. import { mkdirSync, existsSync } from "node:fs";
  5. import { homedir } from "node:os";
  6. import { resolve } from "node:path";
  7. import * as sqliteVec from "sqlite-vec";
  8. // On macOS, use Homebrew's SQLite which supports extensions
  9. if (process.platform === "darwin") {
  10. const homebrewSqlitePath = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib";
  11. if (existsSync(homebrewSqlitePath)) {
  12. Database.setCustomSQLite(homebrewSqlitePath);
  13. }
  14. }
  15. const DEFAULT_EMBED_MODEL = "embeddinggemma";
  16. const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0";
  17. const DEFAULT_QUERY_MODEL = "qwen3:0.6b";
  18. const DEFAULT_GLOB = "**/*.md";
  19. const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
  20. // Terminal colors (respects NO_COLOR env)
  21. const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
  22. const c = {
  23. reset: useColor ? "\x1b[0m" : "",
  24. dim: useColor ? "\x1b[2m" : "",
  25. bold: useColor ? "\x1b[1m" : "",
  26. cyan: useColor ? "\x1b[36m" : "",
  27. yellow: useColor ? "\x1b[33m" : "",
  28. green: useColor ? "\x1b[32m" : "",
  29. magenta: useColor ? "\x1b[35m" : "",
  30. blue: useColor ? "\x1b[34m" : "",
  31. };
  32. // Global state for --index option
  33. let customIndexName: string | null = null;
  34. // Terminal progress bar using OSC 9;4 escape sequence
  35. const progress = {
  36. set(percent: number) {
  37. process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
  38. },
  39. clear() {
  40. process.stderr.write(`\x1b]9;4;0\x07`);
  41. },
  42. indeterminate() {
  43. process.stderr.write(`\x1b]9;4;3\x07`);
  44. },
  45. error() {
  46. process.stderr.write(`\x1b]9;4;2\x07`);
  47. },
  48. };
  49. // Format seconds into human-readable ETA
  50. function formatETA(seconds: number): string {
  51. if (seconds < 60) return `${Math.round(seconds)}s`;
  52. if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
  53. return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
  54. }
  55. function getDbPath(): string {
  56. const cacheDir = process.env.XDG_CACHE_HOME || resolve(homedir(), ".cache");
  57. const qmdCacheDir = resolve(cacheDir, "qmd");
  58. mkdirSync(qmdCacheDir, { recursive: true });
  59. const dbName = customIndexName || "index";
  60. return resolve(qmdCacheDir, `${dbName}.sqlite`);
  61. }
  62. function getPwd(): string {
  63. return process.env.PWD || process.cwd();
  64. }
  65. /*
  66. Schema:
  67. CREATE TABLE collections (
  68. id INTEGER PRIMARY KEY AUTOINCREMENT,
  69. pwd TEXT NOT NULL,
  70. glob_pattern TEXT NOT NULL,
  71. created_at TEXT NOT NULL,
  72. UNIQUE(pwd, glob_pattern)
  73. );
  74. CREATE TABLE documents (
  75. id INTEGER PRIMARY KEY AUTOINCREMENT,
  76. collection_id INTEGER NOT NULL,
  77. name TEXT NOT NULL,
  78. title TEXT NOT NULL,
  79. hash TEXT NOT NULL,
  80. filepath TEXT NOT NULL,
  81. body TEXT NOT NULL,
  82. created_at TEXT NOT NULL,
  83. modified_at TEXT NOT NULL,
  84. active INTEGER NOT NULL DEFAULT 1,
  85. FOREIGN KEY (collection_id) REFERENCES collections(id)
  86. );
  87. CREATE TABLE content_vectors (
  88. hash TEXT PRIMARY KEY,
  89. embedding BLOB NOT NULL,
  90. model TEXT NOT NULL,
  91. embedded_at TEXT NOT NULL
  92. );
  93. CREATE VIRTUAL TABLE documents_fts USING fts5(...);
  94. */
  95. function getDb(): Database {
  96. const db = new Database(getDbPath());
  97. sqliteVec.load(db);
  98. db.exec("PRAGMA journal_mode = WAL");
  99. // Collections table
  100. db.exec(`
  101. CREATE TABLE IF NOT EXISTS collections (
  102. id INTEGER PRIMARY KEY AUTOINCREMENT,
  103. pwd TEXT NOT NULL,
  104. glob_pattern TEXT NOT NULL,
  105. created_at TEXT NOT NULL,
  106. UNIQUE(pwd, glob_pattern)
  107. )
  108. `);
  109. // Documents table with collection_id and full filepath
  110. db.exec(`
  111. CREATE TABLE IF NOT EXISTS documents (
  112. id INTEGER PRIMARY KEY AUTOINCREMENT,
  113. collection_id INTEGER NOT NULL,
  114. name TEXT NOT NULL,
  115. title TEXT NOT NULL,
  116. hash TEXT NOT NULL,
  117. filepath TEXT NOT NULL,
  118. body TEXT NOT NULL,
  119. created_at TEXT NOT NULL,
  120. modified_at TEXT NOT NULL,
  121. active INTEGER NOT NULL DEFAULT 1,
  122. FOREIGN KEY (collection_id) REFERENCES collections(id)
  123. )
  124. `);
  125. // Content vectors keyed by hash (UNIQUE)
  126. db.exec(`
  127. CREATE TABLE IF NOT EXISTS content_vectors (
  128. hash TEXT PRIMARY KEY,
  129. model TEXT NOT NULL,
  130. embedded_at TEXT NOT NULL
  131. )
  132. `);
  133. // FTS on documents
  134. db.exec(`
  135. CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
  136. name, body,
  137. content='documents',
  138. content_rowid='id',
  139. tokenize='porter unicode61'
  140. )
  141. `);
  142. db.exec(`
  143. CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
  144. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  145. END
  146. `);
  147. db.exec(`
  148. CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
  149. INSERT INTO documents_fts(documents_fts, rowid, name, body) VALUES('delete', old.id, old.name, old.body);
  150. END
  151. `);
  152. db.exec(`
  153. CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
  154. INSERT INTO documents_fts(documents_fts, rowid, name, body) VALUES('delete', old.id, old.name, old.body);
  155. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  156. END
  157. `);
  158. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection_id, active)`);
  159. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
  160. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_filepath ON documents(filepath, active)`);
  161. return db;
  162. }
  163. function ensureVecTable(db: Database, dimensions: number): void {
  164. const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
  165. if (tableInfo) {
  166. const match = tableInfo.sql.match(/float\[(\d+)\]/);
  167. if (match && parseInt(match[1]) === dimensions) return;
  168. db.exec("DROP TABLE IF EXISTS vectors_vec");
  169. }
  170. db.exec(`CREATE VIRTUAL TABLE vectors_vec USING vec0(hash TEXT PRIMARY KEY, embedding float[${dimensions}])`);
  171. }
  172. function getHashesNeedingEmbedding(db: Database): number {
  173. const result = db.prepare(`
  174. SELECT COUNT(DISTINCT d.hash) as count
  175. FROM documents d
  176. LEFT JOIN content_vectors v ON d.hash = v.hash
  177. WHERE d.active = 1 AND v.hash IS NULL
  178. `).get() as { count: number };
  179. return result.count;
  180. }
  181. async function hashContent(content: string): Promise<string> {
  182. const hash = new Bun.CryptoHasher("sha256");
  183. hash.update(content);
  184. return hash.digest("hex");
  185. }
  186. // Extract title from first markdown headline, or use filename as fallback
  187. function extractTitle(content: string, filename: string): string {
  188. const match = content.match(/^##?\s+(.+)$/m);
  189. if (match) return match[1].trim();
  190. return filename.replace(/\.md$/, "").split("/").pop() || filename;
  191. }
  192. // Format text for EmbeddingGemma
  193. function formatQueryForEmbedding(query: string): string {
  194. return `task: search result | query: ${query}`;
  195. }
  196. function formatDocForEmbedding(text: string, title?: string): string {
  197. return `title: ${title || "none"} | text: ${text}`;
  198. }
  199. // Auto-pull model if not found
  200. async function ensureModelAvailable(model: string): Promise<void> {
  201. try {
  202. const response = await fetch(`${OLLAMA_URL}/api/show`, {
  203. method: "POST",
  204. headers: { "Content-Type": "application/json" },
  205. body: JSON.stringify({ name: model }),
  206. });
  207. if (response.ok) return;
  208. } catch {
  209. // Continue to pull attempt
  210. }
  211. console.log(`Model ${model} not found. Pulling...`);
  212. progress.indeterminate();
  213. const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, {
  214. method: "POST",
  215. headers: { "Content-Type": "application/json" },
  216. body: JSON.stringify({ name: model, stream: false }),
  217. });
  218. if (!pullResponse.ok) {
  219. progress.error();
  220. throw new Error(`Failed to pull model ${model}: ${pullResponse.status} - ${await pullResponse.text()}`);
  221. }
  222. progress.clear();
  223. console.log(`Model ${model} pulled successfully.`);
  224. }
  225. async function getEmbedding(text: string, model: string, isQuery: boolean = false, title?: string, retried: boolean = false): Promise<number[]> {
  226. const input = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text, title);
  227. const response = await fetch(`${OLLAMA_URL}/api/embed`, {
  228. method: "POST",
  229. headers: { "Content-Type": "application/json" },
  230. body: JSON.stringify({ model, input }),
  231. });
  232. if (!response.ok) {
  233. const errorText = await response.text();
  234. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  235. await ensureModelAvailable(model);
  236. return getEmbedding(text, model, isQuery, title, true);
  237. }
  238. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  239. }
  240. const data = await response.json() as { embeddings: number[][] };
  241. return data.embeddings[0];
  242. }
  243. // Qwen3-Reranker prompt format (trained for yes/no relevance classification)
  244. const RERANK_SYSTEM = `Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".`;
  245. function formatRerankPrompt(query: string, title: string, doc: string): string {
  246. return `<Instruct>: Determine if this document from a Shopify knowledge base is relevant to the search query. The query may reference specific Shopify programs, competitions, features, or named concepts (e.g., "Build a Business" competition, "Shop Pay", "Polaris"). Match documents that discuss the queried topic, even if phrasing differs.
  247. <Query>: ${query}
  248. <Document Title>: ${title}
  249. <Document>: ${doc}`;
  250. }
  251. type LogProb = { token: string; logprob: number };
  252. type RerankResponse = {
  253. response: string;
  254. logprobs?: LogProb[];
  255. };
  256. async function rerankSingle(prompt: string, model: string, retried: boolean = false): Promise<number> {
  257. // Use generate with raw template for qwen3-reranker format
  258. // Include empty <think> tags as per HuggingFace reference implementation
  259. const fullPrompt = `<|im_start|>system
  260. ${RERANK_SYSTEM}<|im_end|>
  261. <|im_start|>user
  262. ${prompt}<|im_end|>
  263. <|im_start|>assistant
  264. <think>
  265. </think>
  266. `;
  267. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  268. method: "POST",
  269. headers: { "Content-Type": "application/json" },
  270. body: JSON.stringify({
  271. model,
  272. prompt: fullPrompt,
  273. raw: true,
  274. stream: false,
  275. logprobs: true,
  276. options: { num_predict: 1 },
  277. }),
  278. });
  279. if (!response.ok) {
  280. const errorText = await response.text();
  281. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  282. await ensureModelAvailable(model);
  283. return rerankSingle(prompt, model, true);
  284. }
  285. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  286. }
  287. const data = await response.json() as RerankResponse;
  288. // Extract score from logprobs - required for proper reranking
  289. if (!data.logprobs || data.logprobs.length === 0) {
  290. throw new Error("Reranker response missing logprobs - ensure Ollama supports logprobs");
  291. }
  292. const firstToken = data.logprobs[0];
  293. const token = firstToken.token.toLowerCase().trim();
  294. const confidence = Math.exp(firstToken.logprob); // 0-1, higher = more confident
  295. if (token === "yes") {
  296. // Relevant: return confidence (e.g., 0.93 for high confidence yes)
  297. return confidence;
  298. }
  299. if (token === "no") {
  300. // Not relevant: return low score, scaled by inverse confidence
  301. // High confidence "no" → very low score
  302. return (1 - confidence) * 0.3; // Cap at 0.3 for uncertain "no"
  303. }
  304. throw new Error(`Unexpected reranker token: "${token}" (expected "yes" or "no")`);
  305. }
  306. async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL): Promise<{ file: string; score: number }[]> {
  307. const results: { file: string; score: number }[] = [];
  308. const total = documents.length;
  309. const PARALLEL = 5;
  310. process.stderr.write(`Reranking ${total} documents with ${model} (parallel: ${PARALLEL})...\n`);
  311. progress.indeterminate();
  312. // Process in parallel batches
  313. for (let i = 0; i < documents.length; i += PARALLEL) {
  314. const batch = documents.slice(i, i + PARALLEL);
  315. const batchResults = await Promise.all(
  316. batch.map(async (doc) => {
  317. try {
  318. // Extract title from filename for reranker context
  319. const title = doc.file.split('/').pop()?.replace(/\.md$/, '') || doc.file;
  320. const prompt = formatRerankPrompt(query, title, doc.text.slice(0, 4000));
  321. const score = await rerankSingle(prompt, model);
  322. return { file: doc.file, score };
  323. } catch (err) {
  324. return { file: doc.file, score: 0 };
  325. }
  326. })
  327. );
  328. results.push(...batchResults);
  329. const processed = Math.min(i + PARALLEL, total);
  330. progress.set((processed / total) * 100);
  331. process.stderr.write(`\rReranking: ${processed}/${total}`);
  332. }
  333. progress.clear();
  334. process.stderr.write("\n");
  335. return results.sort((a, b) => b.score - a.score);
  336. }
  337. function getOrCreateCollection(db: Database, pwd: string, globPattern: string): number {
  338. const now = new Date().toISOString();
  339. // Use INSERT OR IGNORE to handle race conditions, then SELECT
  340. db.prepare(`INSERT OR IGNORE INTO collections (pwd, glob_pattern, created_at) VALUES (?, ?, ?)`).run(pwd, globPattern, now);
  341. const existing = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number };
  342. return existing.id;
  343. }
  344. function cleanupDuplicateCollections(db: Database): void {
  345. // Remove duplicate collections keeping the oldest one
  346. db.exec(`
  347. DELETE FROM collections WHERE id NOT IN (
  348. SELECT MIN(id) FROM collections GROUP BY pwd, glob_pattern
  349. )
  350. `);
  351. // Remove bogus "." glob pattern entries (from earlier bug)
  352. db.exec(`DELETE FROM collections WHERE glob_pattern = '.'`);
  353. }
  354. function formatTimeAgo(date: Date): string {
  355. const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  356. if (seconds < 60) return `${seconds}s ago`;
  357. const minutes = Math.floor(seconds / 60);
  358. if (minutes < 60) return `${minutes}m ago`;
  359. const hours = Math.floor(minutes / 60);
  360. if (hours < 24) return `${hours}h ago`;
  361. const days = Math.floor(hours / 24);
  362. return `${days}d ago`;
  363. }
  364. function formatBytes(bytes: number): string {
  365. if (bytes < 1024) return `${bytes} B`;
  366. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  367. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  368. return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
  369. }
  370. function showStatus(): void {
  371. const dbPath = getDbPath();
  372. const db = getDb();
  373. // Cleanup any duplicate collections
  374. cleanupDuplicateCollections(db);
  375. // Index size
  376. let indexSize = 0;
  377. try {
  378. const stat = Bun.file(dbPath).size;
  379. indexSize = stat;
  380. } catch {}
  381. // Collections info
  382. const collections = db.prepare(`
  383. SELECT c.id, c.pwd, c.glob_pattern, c.created_at,
  384. COUNT(d.id) as doc_count,
  385. SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active_count,
  386. MAX(d.modified_at) as last_modified
  387. FROM collections c
  388. LEFT JOIN documents d ON d.collection_id = c.id
  389. GROUP BY c.id
  390. ORDER BY c.created_at DESC
  391. `).all() as { id: number; pwd: string; glob_pattern: string; created_at: string; doc_count: number; active_count: number; last_modified: string | null }[];
  392. // Overall stats
  393. const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number };
  394. const vectorCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get() as { count: number };
  395. const needsEmbedding = getHashesNeedingEmbedding(db);
  396. // Most recent update across all collections
  397. const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get() as { latest: string | null };
  398. console.log(`${c.bold}QMD Status${c.reset}\n`);
  399. console.log(`Index: ${dbPath}`);
  400. console.log(`Size: ${formatBytes(indexSize)}\n`);
  401. console.log(`${c.bold}Documents${c.reset}`);
  402. console.log(` Total: ${totalDocs.count} files indexed`);
  403. console.log(` Vectors: ${vectorCount.count} embedded`);
  404. if (needsEmbedding > 0) {
  405. console.log(` ${c.yellow}Pending: ${needsEmbedding} need embedding${c.reset} (run 'qmd embed')`);
  406. }
  407. if (mostRecent.latest) {
  408. const lastUpdate = new Date(mostRecent.latest);
  409. console.log(` Updated: ${formatTimeAgo(lastUpdate)}`);
  410. }
  411. if (collections.length > 0) {
  412. console.log(`\n${c.bold}Collections${c.reset}`);
  413. for (const col of collections) {
  414. const lastMod = col.last_modified ? formatTimeAgo(new Date(col.last_modified)) : "never";
  415. console.log(` ${c.cyan}${col.pwd}${c.reset}`);
  416. console.log(` ${col.glob_pattern} → ${col.active_count} docs (updated ${lastMod})`);
  417. }
  418. } else {
  419. console.log(`\n${c.dim}No collections. Run 'qmd add .' to index markdown files.${c.reset}`);
  420. }
  421. db.close();
  422. }
  423. async function updateAllCollections(): Promise<void> {
  424. const db = getDb();
  425. cleanupDuplicateCollections(db);
  426. const collections = db.prepare(`SELECT id, pwd, glob_pattern FROM collections`).all() as { id: number; pwd: string; glob_pattern: string }[];
  427. if (collections.length === 0) {
  428. console.log(`${c.dim}No collections found. Run 'qmd add .' to index markdown files.${c.reset}`);
  429. db.close();
  430. return;
  431. }
  432. db.close();
  433. console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
  434. for (let i = 0; i < collections.length; i++) {
  435. const col = collections[i];
  436. console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.pwd}${c.reset}`);
  437. console.log(`${c.dim} Pattern: ${col.glob_pattern}${c.reset}`);
  438. // Temporarily set PWD for indexing
  439. const originalPwd = process.env.PWD;
  440. process.env.PWD = col.pwd;
  441. await indexFiles(col.glob_pattern);
  442. process.env.PWD = originalPwd;
  443. console.log("");
  444. }
  445. console.log(`${c.green}✓ All collections updated.${c.reset}`);
  446. }
  447. async function dropCollection(globPattern: string): Promise<void> {
  448. const db = getDb();
  449. const pwd = getPwd();
  450. const collection = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number } | null;
  451. if (!collection) {
  452. console.log(`No collection found for ${pwd} with pattern ${globPattern}`);
  453. db.close();
  454. return;
  455. }
  456. // Delete documents in this collection
  457. const deleted = db.prepare(`DELETE FROM documents WHERE collection_id = ?`).run(collection.id);
  458. // Delete the collection
  459. db.prepare(`DELETE FROM collections WHERE id = ?`).run(collection.id);
  460. console.log(`Dropped collection: ${pwd} (${globPattern})`);
  461. console.log(`Removed ${deleted.changes} documents`);
  462. console.log(`(Vectors kept for potential reuse)`);
  463. db.close();
  464. }
  465. async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
  466. const db = getDb();
  467. const pwd = getPwd();
  468. const now = new Date().toISOString();
  469. const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
  470. // Get or create collection for this (pwd, glob)
  471. const collectionId = getOrCreateCollection(db, pwd, globPattern);
  472. console.log(`Collection: ${pwd} (${globPattern})`);
  473. progress.indeterminate();
  474. const glob = new Glob(globPattern);
  475. const files: string[] = [];
  476. for await (const file of glob.scan({ cwd: pwd, onlyFiles: true, followSymlinks: true })) {
  477. // Skip node_modules, hidden folders (.*), and other common excludes
  478. const parts = file.split("/");
  479. const shouldSkip = parts.some(part =>
  480. part === "node_modules" ||
  481. part.startsWith(".") ||
  482. excludeDirs.includes(part)
  483. );
  484. if (!shouldSkip) {
  485. files.push(file);
  486. }
  487. }
  488. const total = files.length;
  489. if (total === 0) {
  490. progress.clear();
  491. console.log("No files found matching pattern.");
  492. db.close();
  493. return;
  494. }
  495. const insertStmt = db.prepare(`INSERT INTO documents (collection_id, name, title, hash, filepath, body, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`);
  496. const deactivateStmt = db.prepare(`UPDATE documents SET active = 0 WHERE collection_id = ? AND filepath = ? AND active = 1`);
  497. const findActiveStmt = db.prepare(`SELECT id, hash FROM documents WHERE collection_id = ? AND filepath = ? AND active = 1`);
  498. let indexed = 0, updated = 0, unchanged = 0, processed = 0;
  499. const seenFiles = new Set<string>();
  500. const startTime = Date.now();
  501. for (const relativeFile of files) {
  502. const filepath = resolve(pwd, relativeFile);
  503. seenFiles.add(filepath);
  504. const content = await Bun.file(filepath).text();
  505. const hash = await hashContent(content);
  506. const name = relativeFile.replace(/\.md$/, "").split("/").pop() || relativeFile;
  507. const title = extractTitle(content, relativeFile);
  508. const existing = findActiveStmt.get(collectionId, filepath) as { id: number; hash: string } | null;
  509. if (existing) {
  510. if (existing.hash === hash) {
  511. unchanged++;
  512. } else {
  513. deactivateStmt.run(collectionId, filepath);
  514. updated++;
  515. const stat = await Bun.file(filepath).stat();
  516. insertStmt.run(collectionId, name, title, hash, filepath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  517. }
  518. } else {
  519. indexed++;
  520. const stat = await Bun.file(filepath).stat();
  521. insertStmt.run(collectionId, name, title, hash, filepath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  522. }
  523. processed++;
  524. progress.set((processed / total) * 100);
  525. const elapsed = (Date.now() - startTime) / 1000;
  526. const rate = processed / elapsed;
  527. const remaining = (total - processed) / rate;
  528. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  529. process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
  530. }
  531. // Deactivate documents in this collection that no longer exist
  532. const allActive = db.prepare(`SELECT filepath FROM documents WHERE collection_id = ? AND active = 1`).all(collectionId) as { filepath: string }[];
  533. let removed = 0;
  534. for (const row of allActive) {
  535. if (!seenFiles.has(row.filepath)) {
  536. deactivateStmt.run(collectionId, row.filepath);
  537. removed++;
  538. }
  539. }
  540. // Check if vector index needs updating
  541. const needsEmbedding = getHashesNeedingEmbedding(db);
  542. progress.clear();
  543. console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
  544. if (needsEmbedding > 0) {
  545. console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
  546. }
  547. db.close();
  548. }
  549. async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = false): Promise<void> {
  550. const db = getDb();
  551. const now = new Date().toISOString();
  552. // If force, clear all vectors
  553. if (force) {
  554. console.log("Force re-indexing: clearing all vectors...");
  555. db.exec(`DELETE FROM content_vectors`);
  556. db.exec(`DROP TABLE IF EXISTS vectors_vec`);
  557. }
  558. // Find unique hashes that need embedding (from active documents)
  559. const hashesToEmbed = db.prepare(`
  560. SELECT DISTINCT d.hash, d.title, d.body
  561. FROM documents d
  562. LEFT JOIN content_vectors v ON d.hash = v.hash
  563. WHERE d.active = 1 AND v.hash IS NULL
  564. `).all() as { hash: string; title: string; body: string }[];
  565. if (hashesToEmbed.length === 0) {
  566. console.log("All content hashes already have embeddings.");
  567. db.close();
  568. return;
  569. }
  570. const total = hashesToEmbed.length;
  571. console.log(`Embedding ${total} unique content hashes with ${model}...`);
  572. progress.indeterminate();
  573. const firstEmbedding = await getEmbedding(hashesToEmbed[0].body, model, false, hashesToEmbed[0].title);
  574. console.log(`Embedding dimensions: ${firstEmbedding.length}`);
  575. ensureVecTable(db, firstEmbedding.length);
  576. const insertVecStmt = db.prepare(`INSERT INTO vectors_vec (hash, embedding) VALUES (?, ?)`);
  577. const insertContentVectorStmt = db.prepare(`INSERT OR REPLACE INTO content_vectors (hash, model, embedded_at) VALUES (?, ?, ?)`);
  578. let embedded = 0, errors = 0;
  579. const startTime = Date.now();
  580. // Insert first
  581. insertVecStmt.run(hashesToEmbed[0].hash, new Float32Array(firstEmbedding));
  582. insertContentVectorStmt.run(hashesToEmbed[0].hash, model, now);
  583. embedded++;
  584. progress.set((embedded / total) * 100);
  585. process.stderr.write(`\rEmbedding: ${embedded}/${total}`);
  586. for (let i = 1; i < hashesToEmbed.length; i++) {
  587. const item = hashesToEmbed[i];
  588. try {
  589. const embedding = await getEmbedding(item.body, model, false, item.title);
  590. insertVecStmt.run(item.hash, new Float32Array(embedding));
  591. insertContentVectorStmt.run(item.hash, model, now);
  592. embedded++;
  593. } catch (err) {
  594. errors++;
  595. progress.error();
  596. console.error(`\nError embedding hash ${item.hash.slice(0, 8)}...: ${err}`);
  597. }
  598. const processed = embedded + errors;
  599. progress.set((processed / total) * 100);
  600. const elapsed = (Date.now() - startTime) / 1000;
  601. const rate = processed / elapsed;
  602. const remaining = (total - processed) / rate;
  603. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  604. process.stderr.write(`\rEmbedding: ${embedded}/${total}${errors > 0 ? ` (${errors} errors)` : ""}${eta} `);
  605. }
  606. progress.clear();
  607. const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
  608. console.log(`\nDone! Embedded ${embedded} hashes${errors > 0 ? `, ${errors} errors` : ""} in ${totalTime}s.`);
  609. db.close();
  610. }
  611. function escapeCSV(value: string): string {
  612. if (value.includes('"') || value.includes(',') || value.includes('\n')) {
  613. return `"${value.replace(/"/g, '""')}"`;
  614. }
  615. return value;
  616. }
  617. function extractSnippet(body: string, query: string, maxLen = 500): { line: number; snippet: string } {
  618. const lines = body.split('\n');
  619. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  620. let bestLine = 0, bestScore = -1;
  621. for (let i = 0; i < lines.length; i++) {
  622. const lineLower = lines[i].toLowerCase();
  623. let score = 0;
  624. for (const term of queryTerms) {
  625. if (lineLower.includes(term)) score++;
  626. }
  627. if (score > bestScore) {
  628. bestScore = score;
  629. bestLine = i;
  630. }
  631. }
  632. const startLine = Math.max(0, bestLine - 1);
  633. const endLine = Math.min(lines.length, bestLine + 2);
  634. let snippet = lines.slice(startLine, endLine).join('\n');
  635. if (snippet.length > maxLen) snippet = snippet.substring(0, maxLen - 3) + "...";
  636. return { line: bestLine + 1, snippet };
  637. }
  638. type SearchResult = { file: string; body: string; score: number; source: "fts" | "vec" };
  639. // Build FTS5 query: phrase-aware with fallback to individual terms
  640. function buildFTS5Query(query: string): string {
  641. const terms = query
  642. .split(/\s+/)
  643. .filter(term => term.length >= 2); // Skip single chars
  644. if (terms.length === 0) return "";
  645. if (terms.length === 1) return `"${terms[0].replace(/"/g, '""')}"`;
  646. // Strategy: exact phrase OR proximity match OR individual terms
  647. // Exact phrase matches rank highest, then close proximity, then any term
  648. const phrase = `"${query.replace(/"/g, '""')}"`;
  649. const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
  650. // FTS5 NEAR syntax: NEAR(term1 term2, distance)
  651. const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
  652. const orTerms = quotedTerms.join(' OR ');
  653. // Exact phrase > proximity > any term
  654. return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
  655. }
  656. // Normalize BM25 score to 0-1 range using sigmoid
  657. function normalizeBM25(score: number): number {
  658. // BM25 scores are negative in SQLite (lower = better)
  659. // Typical range: -15 (excellent) to -2 (weak match)
  660. // Map to 0-1 where higher is better
  661. const absScore = Math.abs(score);
  662. // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
  663. return 1 / (1 + Math.exp(-(absScore - 5) / 3));
  664. }
  665. function searchFTS(db: Database, query: string, limit: number = 20): SearchResult[] {
  666. const ftsQuery = buildFTS5Query(query);
  667. if (!ftsQuery) return [];
  668. // BM25 weights: name=10, body=1 (title matches ranked higher)
  669. const stmt = db.prepare(`
  670. SELECT d.filepath, d.body, bm25(documents_fts, 10.0, 1.0) as score
  671. FROM documents_fts f
  672. JOIN documents d ON d.id = f.rowid
  673. WHERE documents_fts MATCH ? AND d.active = 1
  674. ORDER BY score
  675. LIMIT ?
  676. `);
  677. const results = stmt.all(ftsQuery, limit) as { filepath: string; body: string; score: number }[];
  678. return results.map(r => ({
  679. file: r.filepath,
  680. body: r.body,
  681. score: normalizeBM25(r.score),
  682. source: "fts" as const,
  683. }));
  684. }
  685. async function searchVec(db: Database, query: string, model: string, limit: number = 20): Promise<SearchResult[]> {
  686. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  687. if (!tableExists) return [];
  688. const queryEmbedding = await getEmbedding(query, model, true);
  689. const queryVec = new Float32Array(queryEmbedding);
  690. // Join: documents -> content_vectors -> vectors_vec
  691. const stmt = db.prepare(`
  692. SELECT d.filepath, d.body, vec.distance
  693. FROM vectors_vec vec
  694. JOIN documents d ON d.hash = vec.hash
  695. WHERE vec.embedding MATCH ? AND k = ? AND d.active = 1
  696. ORDER BY vec.distance
  697. `);
  698. const results = stmt.all(queryVec, limit) as { filepath: string; body: string; distance: number }[];
  699. return results.map(r => ({
  700. file: r.filepath,
  701. body: r.body,
  702. score: 1 / (1 + r.distance),
  703. source: "vec" as const,
  704. }));
  705. }
  706. function normalizeScores(results: SearchResult[]): SearchResult[] {
  707. if (results.length === 0) return results;
  708. const maxScore = Math.max(...results.map(r => r.score));
  709. const minScore = Math.min(...results.map(r => r.score));
  710. const range = maxScore - minScore || 1;
  711. return results.map(r => ({ ...r, score: (r.score - minScore) / range }));
  712. }
  713. // Reciprocal Rank Fusion: combines multiple ranked lists
  714. // RRF score = sum(1 / (k + rank)) across all lists where doc appears
  715. // k=60 is standard, provides good balance between top and lower ranks
  716. type RankedResult = { file: string; body: string; score: number };
  717. function reciprocalRankFusion(
  718. resultLists: RankedResult[][],
  719. weights: number[] = [], // Weight per result list (default 1.0)
  720. k: number = 60
  721. ): RankedResult[] {
  722. const scores = new Map<string, { score: number; body: string; bestRank: number }>();
  723. for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
  724. const results = resultLists[listIdx];
  725. const weight = weights[listIdx] ?? 1.0;
  726. for (let rank = 0; rank < results.length; rank++) {
  727. const doc = results[rank];
  728. const rrfScore = weight / (k + rank + 1);
  729. const existing = scores.get(doc.file);
  730. if (existing) {
  731. existing.score += rrfScore;
  732. existing.bestRank = Math.min(existing.bestRank, rank);
  733. } else {
  734. scores.set(doc.file, { score: rrfScore, body: doc.body, bestRank: rank });
  735. }
  736. }
  737. }
  738. // Add bonus for best rank: documents that ranked #1-3 in any list get a boost
  739. // This prevents dilution of exact matches by expansion queries
  740. return Array.from(scores.entries())
  741. .map(([file, { score, body, bestRank }]) => {
  742. let bonus = 0;
  743. if (bestRank === 0) bonus = 0.05; // Ranked #1 somewhere
  744. else if (bestRank <= 2) bonus = 0.02; // Ranked top-3 somewhere
  745. return { file, body, score: score + bonus };
  746. })
  747. .sort((a, b) => b.score - a.score);
  748. }
  749. type OutputFormat = "cli" | "csv" | "md" | "xml";
  750. type OutputOptions = {
  751. format: OutputFormat;
  752. full: boolean;
  753. limit: number;
  754. minScore: number;
  755. };
  756. // Extract snippet with more context lines for CLI display
  757. function extractSnippetWithContext(body: string, query: string, contextLines = 3): { line: number; snippet: string; hasMatch: boolean } {
  758. const lines = body.split('\n');
  759. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  760. let bestLine = 0, bestScore = -1;
  761. for (let i = 0; i < lines.length; i++) {
  762. const lineLower = lines[i].toLowerCase();
  763. let score = 0;
  764. for (const term of queryTerms) {
  765. if (lineLower.includes(term)) score++;
  766. }
  767. if (score > bestScore) {
  768. bestScore = score;
  769. bestLine = i;
  770. }
  771. }
  772. // No query match found - return beginning of file
  773. if (bestScore <= 0) {
  774. const preview = lines.slice(0, contextLines * 2).join('\n').trim();
  775. return { line: 1, snippet: preview, hasMatch: false };
  776. }
  777. const startLine = Math.max(0, bestLine - contextLines);
  778. const endLine = Math.min(lines.length, bestLine + contextLines + 1);
  779. const snippet = lines.slice(startLine, endLine).join('\n').trim();
  780. return { line: bestLine + 1, snippet, hasMatch: true };
  781. }
  782. // Highlight query terms in text (skip short words < 3 chars)
  783. function highlightTerms(text: string, query: string): string {
  784. if (!useColor) return text;
  785. const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  786. let result = text;
  787. for (const term of terms) {
  788. const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  789. result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
  790. }
  791. return result;
  792. }
  793. // Format score with color based on value
  794. function formatScore(score: number): string {
  795. const pct = (score * 100).toFixed(0).padStart(3);
  796. if (!useColor) return `${pct}%`;
  797. if (score >= 0.7) return `${c.green}${pct}%${c.reset}`;
  798. if (score >= 0.4) return `${c.yellow}${pct}%${c.reset}`;
  799. return `${c.dim}${pct}%${c.reset}`;
  800. }
  801. // Shorten filepath for display
  802. function shortPath(filepath: string): string {
  803. const cwd = getPwd();
  804. if (filepath.startsWith(cwd)) {
  805. return filepath.slice(cwd.length + 1);
  806. }
  807. // Show last 2 path components
  808. const parts = filepath.split('/');
  809. if (parts.length > 2) {
  810. return '.../' + parts.slice(-2).join('/');
  811. }
  812. return filepath;
  813. }
  814. function outputResults(results: { file: string; body: string; score: number }[], query: string, opts: OutputOptions): void {
  815. const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
  816. if (filtered.length === 0) {
  817. console.log("No results found above minimum score threshold.");
  818. return;
  819. }
  820. if (opts.format === "cli") {
  821. for (let i = 0; i < filtered.length; i++) {
  822. const row = filtered[i];
  823. const { line, snippet, hasMatch } = extractSnippetWithContext(row.body, query, 2);
  824. // Header: score and filename
  825. const score = formatScore(row.score);
  826. const path = shortPath(row.file);
  827. const lineInfo = hasMatch ? `:${line}` : "";
  828. console.log(`${c.bold}${score}${c.reset} ${c.cyan}${path}${c.dim}${lineInfo}${c.reset}`);
  829. // Snippet with highlighting
  830. const highlighted = highlightTerms(snippet, query);
  831. const indented = highlighted.split('\n').map(l => ` ${c.dim}│${c.reset} ${l}`).join('\n');
  832. console.log(indented);
  833. if (i < filtered.length - 1) console.log();
  834. }
  835. } else if (opts.format === "md") {
  836. for (const row of filtered) {
  837. if (opts.full) {
  838. console.log(`---\n# ${row.file}\n\n${row.body}\n`);
  839. } else {
  840. const { snippet } = extractSnippet(row.body, query);
  841. console.log(`---\n# ${row.file}\n\n${snippet}\n`);
  842. }
  843. }
  844. } else if (opts.format === "xml") {
  845. for (const row of filtered) {
  846. if (opts.full) {
  847. console.log(`<file name="${row.file}">\n${row.body}\n</file>\n`);
  848. } else {
  849. const { snippet } = extractSnippet(row.body, query);
  850. console.log(`<file name="${row.file}">\n${snippet}\n</file>\n`);
  851. }
  852. }
  853. } else {
  854. // CSV format
  855. console.log("score,file,line,snippet");
  856. for (const row of filtered) {
  857. const { line, snippet } = extractSnippet(row.body, query);
  858. const content = opts.full ? row.body : snippet;
  859. console.log(`${row.score.toFixed(4)},${escapeCSV(row.file)},${line},${escapeCSV(content)}`);
  860. }
  861. }
  862. }
  863. function search(query: string, opts: OutputOptions): void {
  864. const db = getDb();
  865. const results = searchFTS(db, query, 50);
  866. db.close();
  867. if (results.length === 0) {
  868. console.log("No results found.");
  869. return;
  870. }
  871. outputResults(results, query, opts);
  872. }
  873. async function vectorSearch(query: string, opts: OutputOptions, model: string = DEFAULT_EMBED_MODEL): Promise<void> {
  874. const db = getDb();
  875. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  876. if (!tableExists) {
  877. console.error("Vector index not found. Run 'qmd embed' first to create embeddings.");
  878. db.close();
  879. return;
  880. }
  881. // Expand query to multiple variations
  882. const queries = await expandQuery(query);
  883. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  884. // Collect results from all query variations
  885. const allResults = new Map<string, { file: string; body: string; score: number }>();
  886. for (const q of queries) {
  887. const vecResults = await searchVec(db, q, model, 20);
  888. for (const r of vecResults) {
  889. const existing = allResults.get(r.file);
  890. if (!existing || r.score > existing.score) {
  891. allResults.set(r.file, { file: r.file, body: r.body, score: r.score });
  892. }
  893. }
  894. }
  895. db.close();
  896. // Sort by max score and limit to requested count
  897. const results = Array.from(allResults.values())
  898. .sort((a, b) => b.score - a.score)
  899. .slice(0, opts.limit);
  900. if (results.length === 0) {
  901. console.log("No results found.");
  902. return;
  903. }
  904. outputResults(results, query, { ...opts, limit: results.length }); // Already limited
  905. }
  906. async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL): Promise<string[]> {
  907. process.stderr.write("Generating query variations...\n");
  908. const prompt = `Generate 3 search query variations to find documents about this topic.
  909. IMPORTANT: Keep multi-word phrases intact if they look like names (e.g., "Build a Business" should stay as "Build a Business", not "create a company").
  910. Query: "${query}"
  911. Output 3 variations, one per line:`;
  912. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  913. method: "POST",
  914. headers: { "Content-Type": "application/json" },
  915. body: JSON.stringify({
  916. model,
  917. prompt,
  918. stream: false,
  919. think: false, // Disable thinking mode for qwen3 models
  920. options: { num_predict: 150 },
  921. }),
  922. });
  923. if (!response.ok) {
  924. const errorText = await response.text();
  925. if (errorText.includes("not found") || errorText.includes("does not exist")) {
  926. await ensureModelAvailable(model);
  927. return expandQuery(query, model);
  928. }
  929. // Fall back to original query if expansion fails
  930. return [query];
  931. }
  932. const data = await response.json() as { response: string };
  933. const lines = data.response.trim().split('\n')
  934. .map(l => l.replace(/^[\d\.\-\*\"\s]+/, '').replace(/["\s]+$/, '').trim())
  935. .filter(l => l.length > 0 && !l.startsWith('<'))
  936. .slice(0, 1); // Only 1 expanded query to preserve original query signal
  937. // Original query + expansions (original gets 2x weight in RRF)
  938. const allQueries = [query, ...lines];
  939. process.stderr.write(`Queries:\n - ${allQueries.join('\n - ')}\n`);
  940. return allQueries;
  941. }
  942. async function querySearch(query: string, opts: OutputOptions, embedModel: string = DEFAULT_EMBED_MODEL, rerankModel: string = DEFAULT_RERANK_MODEL): Promise<void> {
  943. const db = getDb();
  944. // Expand query to multiple variations
  945. const queries = await expandQuery(query);
  946. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  947. // Collect ranked result lists for RRF fusion
  948. const rankedLists: RankedResult[][] = [];
  949. const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  950. for (const q of queries) {
  951. // FTS search - get ranked results
  952. const ftsResults = searchFTS(db, q, 20);
  953. if (ftsResults.length > 0) {
  954. rankedLists.push(ftsResults.map(r => ({ file: r.file, body: r.body, score: r.score })));
  955. }
  956. // Vector search - get ranked results
  957. if (hasVectors) {
  958. const vecResults = await searchVec(db, q, embedModel, 20);
  959. if (vecResults.length > 0) {
  960. rankedLists.push(vecResults.map(r => ({ file: r.file, body: r.body, score: r.score })));
  961. }
  962. }
  963. }
  964. // Apply Reciprocal Rank Fusion to combine all ranked lists
  965. // Give 2x weight to original query results (first 2 lists: FTS + vector)
  966. const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
  967. const fused = reciprocalRankFusion(rankedLists, weights);
  968. const candidates = fused.slice(0, 30); // Over-retrieve for reranking
  969. if (candidates.length === 0) {
  970. console.log("No results found.");
  971. db.close();
  972. return;
  973. }
  974. // Rerank with the original query
  975. const reranked = await rerank(
  976. query,
  977. candidates.map(c => ({ file: c.file, text: c.body })),
  978. rerankModel
  979. );
  980. db.close();
  981. // Blend RRF position score with reranker score using position-aware weights
  982. // Top retrieval results get more protection from reranker disagreement
  983. const bodyMap = new Map(candidates.map(c => [c.file, c.body]));
  984. const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1])); // 1-indexed rank
  985. const finalResults = reranked.map(r => {
  986. const rrfRank = rrfRankMap.get(r.file) || 30;
  987. // Position-aware blending: top retrieval results preserved more
  988. // Rank 1-3: 75% RRF, 25% reranker (trust retrieval for exact matches)
  989. // Rank 4-10: 60% RRF, 40% reranker
  990. // Rank 11+: 40% RRF, 60% reranker (trust reranker for lower-ranked)
  991. let rrfWeight: number;
  992. if (rrfRank <= 3) {
  993. rrfWeight = 0.75;
  994. } else if (rrfRank <= 10) {
  995. rrfWeight = 0.60;
  996. } else {
  997. rrfWeight = 0.40;
  998. }
  999. const rrfScore = 1 / rrfRank; // Position-based: 1, 0.5, 0.33...
  1000. const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
  1001. return {
  1002. file: r.file,
  1003. body: bodyMap.get(r.file) || "",
  1004. score: blendedScore,
  1005. };
  1006. }).sort((a, b) => b.score - a.score);
  1007. outputResults(finalResults, query, opts);
  1008. }
  1009. // Parse CLI options
  1010. function parseOptions(args: string[], defaultMinScore: number = 0): { opts: OutputOptions; query: string } {
  1011. let format: OutputFormat = "cli";
  1012. let full = false;
  1013. let limit = 5;
  1014. let minScore = defaultMinScore;
  1015. const queryParts: string[] = [];
  1016. for (let i = 0; i < args.length; i++) {
  1017. const arg = args[i];
  1018. if (arg === "-n" && i + 1 < args.length) {
  1019. limit = parseInt(args[++i], 10) || 5;
  1020. } else if (arg === "--min-score" && i + 1 < args.length) {
  1021. minScore = parseFloat(args[++i]) || defaultMinScore;
  1022. } else if (arg === "--full") {
  1023. full = true;
  1024. } else if (arg === "-csv" || arg === "--csv") {
  1025. format = "csv";
  1026. } else if (arg === "-md" || arg === "--md") {
  1027. format = "md";
  1028. } else if (arg === "-xml" || arg === "--xml") {
  1029. format = "xml";
  1030. } else if (!arg.startsWith("-")) {
  1031. queryParts.push(arg);
  1032. }
  1033. }
  1034. return {
  1035. opts: { format, full, limit, minScore },
  1036. query: queryParts.join(" "),
  1037. };
  1038. }
  1039. // Parse global options and extract remaining args
  1040. function parseGlobalOptions(args: string[]): string[] {
  1041. const remaining: string[] = [];
  1042. for (let i = 0; i < args.length; i++) {
  1043. if (args[i] === "--index" && i + 1 < args.length) {
  1044. customIndexName = args[++i];
  1045. } else {
  1046. remaining.push(args[i]);
  1047. }
  1048. }
  1049. return remaining;
  1050. }
  1051. // Main CLI
  1052. const rawArgs = process.argv.slice(2);
  1053. const args = parseGlobalOptions(rawArgs);
  1054. if (args.length === 0) {
  1055. console.log("Usage:");
  1056. console.log(" qmd add [--drop] [glob] - Add/update collection from $PWD (default: **/*.md)");
  1057. console.log(" qmd status - Show index status and collections");
  1058. console.log(" qmd update-all - Re-index all collections");
  1059. console.log(" qmd embed [-f] - Create vector embeddings for all content");
  1060. console.log(" qmd search <query> - Full-text search (BM25)");
  1061. console.log(" qmd vsearch <query> - Vector similarity search");
  1062. console.log(" qmd query <query> - Combined search with query expansion + reranking");
  1063. console.log("");
  1064. console.log("Global options:");
  1065. console.log(" --index <name> - Use custom index name (default: index)");
  1066. console.log("");
  1067. console.log("Search options:");
  1068. console.log(" -n <num> - Number of results (default: 5)");
  1069. console.log(" --min-score <num> - Minimum similarity score");
  1070. console.log(" --full - Output full document instead of snippet");
  1071. console.log(" -csv - CSV output (default is colorized CLI)");
  1072. console.log(" -md - Markdown output");
  1073. console.log(" -xml - XML output");
  1074. console.log("");
  1075. console.log("Environment:");
  1076. console.log(" OLLAMA_URL - Ollama server URL (default: http://localhost:11434)");
  1077. console.log("");
  1078. console.log("Models:");
  1079. console.log(` Embedding: ${DEFAULT_EMBED_MODEL}`);
  1080. console.log(` Reranking: ${DEFAULT_RERANK_MODEL}`);
  1081. console.log("");
  1082. console.log(`Index: ${getDbPath()}`);
  1083. process.exit(1);
  1084. }
  1085. const cmd = args[0];
  1086. if (cmd === "add") {
  1087. const addArgs = args.slice(1);
  1088. const drop = addArgs.includes("--drop");
  1089. const globArg = addArgs.find(a => !a.startsWith("-"));
  1090. // Treat "." as "use default glob in current directory"
  1091. const globPattern = (!globArg || globArg === ".") ? DEFAULT_GLOB : globArg;
  1092. if (drop) {
  1093. await dropCollection(globPattern);
  1094. } else {
  1095. await indexFiles(globPattern);
  1096. }
  1097. } else if (cmd === "status") {
  1098. showStatus();
  1099. } else if (cmd === "update-all") {
  1100. await updateAllCollections();
  1101. } else if (cmd === "embed") {
  1102. const embedArgs = args.slice(1);
  1103. const force = embedArgs.includes("-f") || embedArgs.includes("--force");
  1104. await vectorIndex(DEFAULT_EMBED_MODEL, force);
  1105. } else if (cmd === "search") {
  1106. const { opts, query } = parseOptions(args.slice(1), 0);
  1107. if (!query) {
  1108. console.error("Usage: qmd search [-n num] [--min-score num] [--full] [-csv|-md|-xml] <query>");
  1109. process.exit(1);
  1110. }
  1111. search(query, opts);
  1112. } else if (cmd === "vsearch") {
  1113. const { opts, query } = parseOptions(args.slice(1), 0.3);
  1114. if (!query) {
  1115. console.error("Usage: qmd vsearch [-n num] [--min-score num] [--full] [-csv|-md|-xml] <query>");
  1116. process.exit(1);
  1117. }
  1118. await vectorSearch(query, opts);
  1119. } else if (cmd === "query") {
  1120. const { opts, query } = parseOptions(args.slice(1), 0);
  1121. if (!query) {
  1122. console.error("Usage: qmd query [-n num] [--min-score num] [--full] [-csv|-md|-xml] <query>");
  1123. process.exit(1);
  1124. }
  1125. await querySearch(query, opts);
  1126. } else {
  1127. console.error(`Unknown command: ${cmd}`);
  1128. console.error("Run 'qmd' without arguments for usage.");
  1129. process.exit(1);
  1130. }