@jcode.labs/ragmir 2.17.2 → 2.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +53 -43
  2. package/dist/access-log.d.ts +10 -0
  3. package/dist/access-log.d.ts.map +1 -1
  4. package/dist/access-log.js +119 -49
  5. package/dist/access-log.js.map +1 -1
  6. package/dist/chunking.d.ts +5 -1
  7. package/dist/chunking.d.ts.map +1 -1
  8. package/dist/chunking.js +102 -16
  9. package/dist/chunking.js.map +1 -1
  10. package/dist/citation.d.ts +18 -0
  11. package/dist/citation.d.ts.map +1 -0
  12. package/dist/citation.js +54 -0
  13. package/dist/citation.js.map +1 -0
  14. package/dist/cli-entry.d.ts +3 -0
  15. package/dist/cli-entry.d.ts.map +1 -0
  16. package/dist/cli-entry.js +16 -0
  17. package/dist/cli-entry.js.map +1 -0
  18. package/dist/cli-fast.d.ts +13 -0
  19. package/dist/cli-fast.d.ts.map +1 -0
  20. package/dist/cli-fast.js +145 -0
  21. package/dist/cli-fast.js.map +1 -0
  22. package/dist/cli-options.d.ts +3 -0
  23. package/dist/cli-options.d.ts.map +1 -1
  24. package/dist/cli-options.js +7 -0
  25. package/dist/cli-options.js.map +1 -1
  26. package/dist/cli-stdin.d.ts +3 -0
  27. package/dist/cli-stdin.d.ts.map +1 -0
  28. package/dist/cli-stdin.js +15 -0
  29. package/dist/cli-stdin.js.map +1 -0
  30. package/dist/cli.js +251 -44
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client.d.ts +13 -2
  33. package/dist/client.d.ts.map +1 -1
  34. package/dist/client.js +155 -10
  35. package/dist/client.js.map +1 -1
  36. package/dist/config.d.ts +6 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +227 -49
  39. package/dist/config.js.map +1 -1
  40. package/dist/context-resources.d.ts +4 -2
  41. package/dist/context-resources.d.ts.map +1 -1
  42. package/dist/context-resources.js +55 -24
  43. package/dist/context-resources.js.map +1 -1
  44. package/dist/defaults.d.ts +27 -0
  45. package/dist/defaults.d.ts.map +1 -1
  46. package/dist/defaults.js +42 -2
  47. package/dist/defaults.js.map +1 -1
  48. package/dist/destroy.d.ts +2 -2
  49. package/dist/destroy.d.ts.map +1 -1
  50. package/dist/destroy.js +34 -20
  51. package/dist/destroy.js.map +1 -1
  52. package/dist/doctor.d.ts +3 -2
  53. package/dist/doctor.d.ts.map +1 -1
  54. package/dist/doctor.js +86 -41
  55. package/dist/doctor.js.map +1 -1
  56. package/dist/durable-file.d.ts +13 -0
  57. package/dist/durable-file.d.ts.map +1 -0
  58. package/dist/durable-file.js +56 -0
  59. package/dist/durable-file.js.map +1 -0
  60. package/dist/embeddings.d.ts +14 -3
  61. package/dist/embeddings.d.ts.map +1 -1
  62. package/dist/embeddings.js +233 -95
  63. package/dist/embeddings.js.map +1 -1
  64. package/dist/errors.d.ts +1 -1
  65. package/dist/errors.d.ts.map +1 -1
  66. package/dist/errors.js.map +1 -1
  67. package/dist/evaluate.d.ts +4 -3
  68. package/dist/evaluate.d.ts.map +1 -1
  69. package/dist/evaluate.js +611 -110
  70. package/dist/evaluate.js.map +1 -1
  71. package/dist/files.d.ts +4 -1
  72. package/dist/files.d.ts.map +1 -1
  73. package/dist/files.js +119 -33
  74. package/dist/files.js.map +1 -1
  75. package/dist/generation-retention.d.ts +52 -0
  76. package/dist/generation-retention.d.ts.map +1 -0
  77. package/dist/generation-retention.js +318 -0
  78. package/dist/generation-retention.js.map +1 -0
  79. package/dist/index-diagnostics.d.ts +3 -2
  80. package/dist/index-diagnostics.d.ts.map +1 -1
  81. package/dist/index-diagnostics.js +32 -3
  82. package/dist/index-diagnostics.js.map +1 -1
  83. package/dist/index-policy.d.ts.map +1 -1
  84. package/dist/index-policy.js +3 -0
  85. package/dist/index-policy.js.map +1 -1
  86. package/dist/index-write-lock.d.ts +16 -1
  87. package/dist/index-write-lock.d.ts.map +1 -1
  88. package/dist/index-write-lock.js +392 -13
  89. package/dist/index-write-lock.js.map +1 -1
  90. package/dist/index.d.ts +12 -4
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +5 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/ingest.d.ts +11 -2
  95. package/dist/ingest.d.ts.map +1 -1
  96. package/dist/ingest.js +680 -165
  97. package/dist/ingest.js.map +1 -1
  98. package/dist/ingestion-metrics.d.ts +58 -0
  99. package/dist/ingestion-metrics.d.ts.map +1 -0
  100. package/dist/ingestion-metrics.js +216 -0
  101. package/dist/ingestion-metrics.js.map +1 -0
  102. package/dist/ingestion-state.d.ts +11 -2
  103. package/dist/ingestion-state.d.ts.map +1 -1
  104. package/dist/ingestion-state.js +480 -49
  105. package/dist/ingestion-state.js.map +1 -1
  106. package/dist/limits.d.ts.map +1 -1
  107. package/dist/limits.js +11 -1
  108. package/dist/limits.js.map +1 -1
  109. package/dist/mcp-output.d.ts +44 -4
  110. package/dist/mcp-output.d.ts.map +1 -1
  111. package/dist/mcp-output.js +132 -159
  112. package/dist/mcp-output.js.map +1 -1
  113. package/dist/mcp-summaries.d.ts +32 -0
  114. package/dist/mcp-summaries.d.ts.map +1 -0
  115. package/dist/mcp-summaries.js +227 -0
  116. package/dist/mcp-summaries.js.map +1 -0
  117. package/dist/mcp.d.ts +1 -1
  118. package/dist/mcp.d.ts.map +1 -1
  119. package/dist/mcp.js +85 -46
  120. package/dist/mcp.js.map +1 -1
  121. package/dist/ocr-cache.d.ts +15 -0
  122. package/dist/ocr-cache.d.ts.map +1 -0
  123. package/dist/ocr-cache.js +90 -0
  124. package/dist/ocr-cache.js.map +1 -0
  125. package/dist/ocr.d.ts +31 -0
  126. package/dist/ocr.d.ts.map +1 -1
  127. package/dist/ocr.js +193 -17
  128. package/dist/ocr.js.map +1 -1
  129. package/dist/parsing.d.ts.map +1 -1
  130. package/dist/parsing.js +482 -85
  131. package/dist/parsing.js.map +1 -1
  132. package/dist/permissions.js +2 -2
  133. package/dist/permissions.js.map +1 -1
  134. package/dist/preview.d.ts.map +1 -1
  135. package/dist/preview.js +10 -11
  136. package/dist/preview.js.map +1 -1
  137. package/dist/quality-report.d.ts +12 -0
  138. package/dist/quality-report.d.ts.map +1 -0
  139. package/dist/quality-report.js +146 -0
  140. package/dist/quality-report.js.map +1 -0
  141. package/dist/query.d.ts +7 -4
  142. package/dist/query.d.ts.map +1 -1
  143. package/dist/query.js +371 -284
  144. package/dist/query.js.map +1 -1
  145. package/dist/ranking.d.ts +38 -0
  146. package/dist/ranking.d.ts.map +1 -0
  147. package/dist/ranking.js +278 -0
  148. package/dist/ranking.js.map +1 -0
  149. package/dist/redaction.d.ts +6 -1
  150. package/dist/redaction.d.ts.map +1 -1
  151. package/dist/redaction.js +118 -4
  152. package/dist/redaction.js.map +1 -1
  153. package/dist/research.d.ts +6 -1
  154. package/dist/research.d.ts.map +1 -1
  155. package/dist/research.js +330 -130
  156. package/dist/research.js.map +1 -1
  157. package/dist/runtime-info.d.ts +3 -0
  158. package/dist/runtime-info.d.ts.map +1 -0
  159. package/dist/runtime-info.js +81 -0
  160. package/dist/runtime-info.js.map +1 -0
  161. package/dist/security.d.ts +3 -2
  162. package/dist/security.d.ts.map +1 -1
  163. package/dist/security.js +242 -45
  164. package/dist/security.js.map +1 -1
  165. package/dist/semantic-config.d.ts +7 -1
  166. package/dist/semantic-config.d.ts.map +1 -1
  167. package/dist/semantic-config.js +30 -4
  168. package/dist/semantic-config.js.map +1 -1
  169. package/dist/setup.js +6 -3
  170. package/dist/setup.js.map +1 -1
  171. package/dist/skill.js +34 -12
  172. package/dist/skill.js.map +1 -1
  173. package/dist/source-fingerprint-cache.d.ts +20 -0
  174. package/dist/source-fingerprint-cache.d.ts.map +1 -0
  175. package/dist/source-fingerprint-cache.js +133 -0
  176. package/dist/source-fingerprint-cache.js.map +1 -0
  177. package/dist/storage-maintenance.d.ts +46 -0
  178. package/dist/storage-maintenance.d.ts.map +1 -0
  179. package/dist/storage-maintenance.js +329 -0
  180. package/dist/storage-maintenance.js.map +1 -0
  181. package/dist/store.d.ts +39 -2
  182. package/dist/store.d.ts.map +1 -1
  183. package/dist/store.js +559 -24
  184. package/dist/store.js.map +1 -1
  185. package/dist/text.d.ts +1 -1
  186. package/dist/text.d.ts.map +1 -1
  187. package/dist/text.js +12 -2
  188. package/dist/text.js.map +1 -1
  189. package/dist/types.d.ts +414 -5
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/vector-index.d.ts +42 -0
  192. package/dist/vector-index.d.ts.map +1 -0
  193. package/dist/vector-index.js +249 -0
  194. package/dist/vector-index.js.map +1 -0
  195. package/dist/version.d.ts +1 -1
  196. package/dist/version.js +1 -1
  197. package/dist/workload.d.ts +22 -0
  198. package/dist/workload.d.ts.map +1 -0
  199. package/dist/workload.js +181 -0
  200. package/dist/workload.js.map +1 -0
  201. package/examples/document-evidence-benchmark/README.md +25 -63
  202. package/examples/document-evidence-benchmark/golden-queries.json +1 -1
  203. package/examples/library-api-demo/README.md +17 -57
  204. package/examples/sovereign-rag-demo/README.md +37 -97
  205. package/package.json +36 -11
package/dist/query.js CHANGED
@@ -1,37 +1,40 @@
1
- import { recordAccess } from "./access-log.js";
1
+ import { channel } from "node:diagnostics_channel";
2
+ import { MatchQuery, Operator, PhraseQuery, } from "@lancedb/lancedb";
3
+ import { flushAccessLog, recordAccess } from "./access-log.js";
4
+ import { citationForCoordinates, stripCitationCoordinates } from "./citation.js";
2
5
  import { loadConfig } from "./config.js";
3
- import { VECTOR_DISTANCE_METRIC } from "./defaults.js";
6
+ import { MAX_SEARCH_TOP_K, VECTOR_DISTANCE_METRIC } from "./defaults.js";
4
7
  import { embedText } from "./embeddings.js";
5
8
  import { RagmirError } from "./errors.js";
6
- import { getIndexFreshnessWarning } from "./index-diagnostics.js";
9
+ import { acquireGenerationReadLease } from "./generation-retention.js";
10
+ import { indexFreshnessWarning } from "./index-diagnostics.js";
7
11
  import { operationSignal, throwIfAborted } from "./operation.js";
8
12
  import { sanitizeRetrievalQuery } from "./query-sanitizer.js";
9
- import { openRowsTable, readIndexManifest } from "./store.js";
13
+ import { candidatePassesAbstention, queryEvidence, rankHybridRows, rankingPolicyFingerprint, rankingPolicyFor, tokensAreLexicallyRelated, } from "./ranking.js";
14
+ import { closeIndexReadSnapshot, loadIndexReadSnapshot } from "./store.js";
10
15
  import { tokenize } from "./text.js";
16
+ import { configureAdaptiveVectorQuery, vectorIndexManifestCompatible } from "./vector-index.js";
17
+ import { runWorkload } from "./workload.js";
11
18
  const VECTOR_CANDIDATE_POLICY = {
12
19
  fast: { minimum: 40, multiplier: 3, maxChunksPerSource: 1 },
13
20
  balanced: { minimum: 80, multiplier: 4, maxChunksPerSource: 2 },
14
21
  quality: { minimum: 200, multiplier: 8, maxChunksPerSource: 4 },
15
22
  custom: { minimum: 80, multiplier: 4, maxChunksPerSource: 2 },
16
23
  };
17
- /**
18
- * Reciprocal Rank Fusion (Cormack et al. 2009). Each candidate scores
19
- * `weight / (RRF_K + rank)` per retriever it appears in, summed across
20
- * retrievers. Rank-only fusion removes the score-calibration problem of
21
- * weighted-sum fusion: the BM25 and vector score distributions never need to
22
- * be normalized against each other.
23
- *
24
- * Equal weights let exact lexical evidence rescue a relevant document that is
25
- * absent from the bounded vector candidate pool.
26
- */
27
- const RRF_K = 60;
28
- const RRF_VECTOR_WEIGHT = 1;
29
- const RRF_LEXICAL_WEIGHT = 1;
30
- const BM25_K1 = 1.2;
31
- const BM25_B = 0.75;
24
+ const LEXICAL_CANDIDATE_POLICY = {
25
+ fast: { minimum: 100, multiplier: 10 },
26
+ balanced: { minimum: 250, multiplier: 20 },
27
+ quality: { minimum: 500, multiplier: 40 },
28
+ custom: { minimum: 250, multiplier: 20 },
29
+ };
32
30
  const MAX_CONTEXT_RADIUS = 3;
33
- const MIN_FUZZY_TOKEN_LENGTH = 7;
34
- const MIN_TRIGRAM_DICE_SIMILARITY = 0.5;
31
+ const MAX_VECTOR_CANDIDATES = 1_000;
32
+ const MAX_LEXICAL_CANDIDATES = 4_000;
33
+ const FULL_TEXT_INDEX_NAME = "searchText_idx";
34
+ const LEXICAL_IDENTIFIER_PATTERN = /[\p{L}\p{N}]+(?:[-_.][\p{L}\p{N}]+)+/gu;
35
+ const SOURCE_PATH_QUERY_PATTERN = /^[\p{L}\p{N}_. /\\-]+\.[a-z0-9]{1,12}$/iu;
36
+ export const QUERY_EXPLANATION_DIAGNOSTICS_CHANNEL = "ragmir:query-explanation";
37
+ const queryExplanationDiagnostics = channel(QUERY_EXPLANATION_DIAGNOSTICS_CHANNEL);
35
38
  const SEARCH_COLUMNS = [
36
39
  "source",
37
40
  "relativePath",
@@ -45,50 +48,88 @@ const SEARCH_COLUMNS = [
45
48
  "lineEnd",
46
49
  "pageStart",
47
50
  "pageEnd",
51
+ "locationKind",
52
+ "locationStart",
53
+ "locationEnd",
54
+ "locationLabel",
55
+ "cellStart",
56
+ "cellEnd",
48
57
  ];
49
58
  const VECTOR_SEARCH_COLUMNS = [...SEARCH_COLUMNS, "_distance"];
50
59
  const FTS_SEARCH_COLUMNS = [...SEARCH_COLUMNS, "_score"];
51
60
  export async function search(query, options = {}) {
52
61
  const config = await loadConfig(String(options.cwd ?? process.cwd()));
53
- return searchWithConfig(query, options, config);
62
+ const results = await searchWithConfig(query, options, config);
63
+ await flushAccessLog(config);
64
+ return results;
54
65
  }
55
- export async function searchWithConfig(query, options, config, connection, activeSignal) {
66
+ export async function searchWithConfig(query, options, config, connection, activeSignal, suppliedSnapshot, generationLeaseHeld = false) {
67
+ const signal = activeSignal ?? operationSignal(options);
68
+ return runWorkload(config, "search", signal, async ({ queueTimeMs }) => {
69
+ const ownsSnapshot = suppliedSnapshot === undefined;
70
+ const snapshot = suppliedSnapshot ?? (await loadIndexReadSnapshot(config, connection));
71
+ let lease;
72
+ try {
73
+ if (!generationLeaseHeld && snapshot.table) {
74
+ lease = await acquireGenerationReadLease(snapshot.tableName, config);
75
+ }
76
+ return await searchWithinGeneration(query, options, config, snapshot, signal, queueTimeMs);
77
+ }
78
+ finally {
79
+ await lease?.release().catch(() => undefined);
80
+ if (ownsSnapshot) {
81
+ closeIndexReadSnapshot(snapshot, config);
82
+ }
83
+ }
84
+ });
85
+ }
86
+ async function searchWithinGeneration(query, options, config, snapshot, activeSignal, workloadQueueMs = 0) {
56
87
  const signal = activeSignal ?? operationSignal(options);
57
88
  const topK = normalizeTopK(options.topK ?? config.topK);
58
89
  const defaultContextRadius = config.retrievalProfile === "quality" ? 1 : 0;
59
90
  const contextRadius = normalizeContextRadius(options.contextRadius ?? defaultContextRadius);
60
91
  throwIfAborted(signal);
61
- const table = await openRowsTable(config, connection);
92
+ const table = snapshot.table;
62
93
  throwIfAborted(signal);
63
94
  if (!table) {
64
95
  return [];
65
96
  }
66
- await assertIndexFreshness(config);
97
+ assertIndexFreshness(config, snapshot.manifest);
67
98
  const sanitized = sanitizeRetrievalQuery(query);
68
- const queryTokens = tokenize(sanitized.query);
99
+ const evidence = queryEvidence(sanitized.query);
100
+ const queryTokens = evidence.tokens;
69
101
  if (!sanitized.query || queryTokens.length === 0) {
70
102
  return [];
71
103
  }
72
104
  const retrievalPredicate = searchPredicate(options.includePaths, options.excludePaths, options.contextPaths);
73
- const [vector, textRows] = await Promise.all([
74
- embedText(sanitized.query, config),
75
- lexicalCandidateRows(table, sanitized.query, config.hybridTextScanLimit, retrievalPredicate),
105
+ let embeddingQueueMs = 0;
106
+ const [vector, lexicalCandidates] = await Promise.all([
107
+ embedText(sanitized.query, config, signal, ({ queueTimeMs }) => {
108
+ embeddingQueueMs = queueTimeMs;
109
+ }),
110
+ lexicalCandidateRows(table, sanitized.query, {
111
+ ftsLimit: lexicalCandidateLimit(topK, config.retrievalProfile),
112
+ fallbackLimit: config.hybridTextScanLimit,
113
+ indexedChunkCount: snapshot.manifest?.chunkCount ?? 0,
114
+ minimumResults: topK,
115
+ pathPredicate: retrievalPredicate,
116
+ }),
76
117
  ]);
77
118
  throwIfAborted(signal);
78
- await assertVectorIndexCompatibility(config, vector.length);
79
- const vectorQuery = table.vectorSearch(vector).select(VECTOR_SEARCH_COLUMNS);
119
+ const manifest = assertVectorIndexCompatibility(config, snapshot.manifest, vector.length);
120
+ const vectorQuery = configureAdaptiveVectorQuery(table.vectorSearch(vector).select(VECTOR_SEARCH_COLUMNS), manifest.vectorIndex, options.vectorSearchMode === "exact");
80
121
  const vectorRows = (await (retrievalPredicate
81
122
  ? vectorQuery.where(retrievalPredicate)
82
123
  : vectorQuery)
83
124
  .limit(vectorCandidateLimit(topK, config.retrievalProfile))
84
125
  .toArray());
85
126
  throwIfAborted(signal);
86
- const rankedRows = rankHybridRows(sanitized.query, vectorRows, textRows);
87
- const relevantRows = config.embeddingProvider === "local-hash"
88
- ? rankedRows.filter((ranked) => hasLexicalOverlap(queryTokens, ranked.row.searchText))
89
- : rankedRows;
90
- const rows = diversifyRows(relevantRows, topK, config.retrievalProfile);
91
- const contextByRow = await contextChunksByRow(table, rows, contextRadius);
127
+ const rankingPolicy = rankingPolicyFor(config.embeddingProvider, config.retrievalProfile);
128
+ const rankedRows = rankHybridRows(sanitized.query, vectorRows, lexicalCandidates.rows, rankingPolicy);
129
+ const relevantRows = rankedRows.filter((ranked) => lexicalCandidates.exactPathMatches.has(rowKey(ranked.row)) ||
130
+ candidatePassesAbstention(evidence, ranked.row, rankingPolicy));
131
+ const rows = diversifyRows(relevantRows, topK, config.retrievalProfile, lexicalCandidates.exactPathMatches);
132
+ const contextByRow = await contextChunksByRow(table, rows, contextRadius, retrievalPredicate);
92
133
  throwIfAborted(signal);
93
134
  const results = rows.map((row) => {
94
135
  const vectorDistance = typeof row.row._distance === "number" ? row.row._distance : null;
@@ -102,8 +143,8 @@ export async function searchWithConfig(query, options, config, connection, activ
102
143
  distance: vectorDistance,
103
144
  charStart: nullableNumber(row.row.charStart),
104
145
  charEnd: nullableNumber(row.row.charEnd),
105
- lineStart: nullableNumber(row.row.lineStart),
106
- lineEnd: nullableNumber(row.row.lineEnd),
146
+ lineStart: nullableLineNumber(row.row.lineStart),
147
+ lineEnd: nullableLineNumber(row.row.lineEnd),
107
148
  pageStart: nullablePageNumber(row.row.pageStart),
108
149
  pageEnd: nullablePageNumber(row.row.pageEnd),
109
150
  context: contextByRow.get(rowKey(row.row)) ?? [],
@@ -118,13 +159,25 @@ export async function searchWithConfig(query, options, config, connection, activ
118
159
  lexicalRank: row.lexicalRank,
119
160
  vectorDistance,
120
161
  lexicalBackendScore: row.lexicalBackendScore,
121
- matchedTerms: row.matchedTerms,
162
+ lexicalBackend: lexicalCandidates.backend,
163
+ lexicalFallbackActivated: lexicalCandidates.fallbackActivated,
164
+ lexicalFallbackReason: lexicalCandidates.fallbackReason,
165
+ lexicalExactPathMatch: lexicalCandidates.exactPathMatches.has(rowKey(row.row)),
166
+ lexicalCandidateLimit: lexicalCandidates.candidateLimit,
167
+ lexicalCandidatesMaterialized: lexicalCandidates.candidatesMaterialized,
168
+ lexicalQueryVariants: lexicalCandidates.queryVariants,
169
+ lexicalIndexedRows: lexicalCandidates.indexedRows,
170
+ lexicalUnindexedRows: lexicalCandidates.unindexedRows,
171
+ lexicalCoverage: lexicalCandidates.coverage,
172
+ workloadQueueMs: workloadQueueMs + embeddingQueueMs,
173
+ rankingPolicyFingerprint: rankingPolicyFingerprint(rankingPolicy),
174
+ matchedTerms: matchedQueryTerms(config.projectRoot, queryTokens, row.row.searchText),
122
175
  },
123
176
  }
124
177
  : {}),
125
178
  };
126
179
  });
127
- await recordAccess(config, {
180
+ void recordAccess(config, {
128
181
  action: "search",
129
182
  query: sanitized.query,
130
183
  topK,
@@ -132,11 +185,11 @@ export async function searchWithConfig(query, options, config, connection, activ
132
185
  });
133
186
  return results;
134
187
  }
135
- function diversifyRows(rows, topK, profile) {
188
+ function diversifyRows(rows, topK, profile, exactPathMatches) {
136
189
  const uniqueRows = [];
137
190
  const textIndexes = new Map();
138
191
  for (const row of rows) {
139
- const textKey = `${row.row.contextPath}\0${row.row.text.replace(/\s+/gu, " ").trim().toLowerCase()}`;
192
+ const textKey = row.row.text.replace(/\s+/gu, " ").trim().toLowerCase();
140
193
  const existingIndex = textIndexes.get(textKey);
141
194
  if (existingIndex === undefined) {
142
195
  textIndexes.set(textKey, uniqueRows.length);
@@ -144,23 +197,43 @@ function diversifyRows(rows, topK, profile) {
144
197
  continue;
145
198
  }
146
199
  const existing = uniqueRows[existingIndex];
147
- if (existing && preferCanonicalPath(row.row.relativePath, existing.row.relativePath)) {
200
+ const existingIsExact = existing ? exactPathMatches.has(rowKey(existing.row)) : false;
201
+ const candidateIsExact = exactPathMatches.has(rowKey(row.row));
202
+ if (existing &&
203
+ (candidateIsExact !== existingIsExact
204
+ ? candidateIsExact
205
+ : preferCanonicalPath(row.row.relativePath, existing.row.relativePath))) {
148
206
  uniqueRows[existingIndex] = row;
149
207
  }
150
208
  }
151
209
  const selected = [];
210
+ const selectedKeys = new Set();
152
211
  const perSource = new Map();
153
212
  const maxChunksPerSource = VECTOR_CANDIDATE_POLICY[profile].maxChunksPerSource;
154
- for (const row of uniqueRows) {
213
+ const appendRow = (row, enforceSourceCap) => {
214
+ const key = rowKey(row.row);
215
+ if (selectedKeys.has(key)) {
216
+ return;
217
+ }
155
218
  if (selected.some((candidate) => overlapsSourceSpan(candidate.row, row.row))) {
156
- continue;
219
+ return;
157
220
  }
158
221
  const sourceCount = perSource.get(row.row.relativePath) ?? 0;
159
- if (sourceCount >= maxChunksPerSource) {
160
- continue;
222
+ if (enforceSourceCap && sourceCount >= maxChunksPerSource) {
223
+ return;
161
224
  }
162
225
  selected.push(row);
226
+ selectedKeys.add(key);
163
227
  perSource.set(row.row.relativePath, sourceCount + 1);
228
+ };
229
+ for (const row of uniqueRows) {
230
+ appendRow(row, true);
231
+ if (selected.length >= topK) {
232
+ return selected;
233
+ }
234
+ }
235
+ for (const row of uniqueRows) {
236
+ appendRow(row, false);
164
237
  if (selected.length >= topK) {
165
238
  break;
166
239
  }
@@ -185,63 +258,23 @@ function preferCanonicalPath(candidate, current) {
185
258
  return (candidateDepth < currentDepth ||
186
259
  (candidateDepth === currentDepth && candidate.length < current.length));
187
260
  }
188
- function hasLexicalOverlap(queryTokens, text) {
189
- const textTokens = tokenize(text);
190
- return queryTokens.some((queryToken) => textTokens.some((textToken) => tokensAreLexicallyRelated(queryToken, textToken)));
191
- }
192
- function tokensAreLexicallyRelated(queryToken, textToken) {
193
- if (queryToken === textToken) {
194
- return true;
195
- }
196
- if (queryToken.length >= 4 &&
197
- textToken.length >= 4 &&
198
- sharedPrefixLength(queryToken, textToken) >= 4) {
199
- return true;
200
- }
201
- if (queryToken.length < MIN_FUZZY_TOKEN_LENGTH ||
202
- textToken.length < MIN_FUZZY_TOKEN_LENGTH ||
203
- Math.abs(queryToken.length - textToken.length) > 1 ||
204
- !/^[a-z0-9_-]+$/u.test(queryToken) ||
205
- !/^[a-z0-9_-]+$/u.test(textToken)) {
206
- return false;
207
- }
208
- return trigramDiceSimilarity(queryToken, textToken) >= MIN_TRIGRAM_DICE_SIMILARITY;
209
- }
210
- function trigramDiceSimilarity(left, right) {
211
- const leftTrigrams = tokenTrigrams(left);
212
- const rightTrigrams = tokenTrigrams(right);
213
- let shared = 0;
214
- for (const trigram of leftTrigrams) {
215
- if (rightTrigrams.has(trigram)) {
216
- shared += 1;
217
- }
218
- }
219
- return (2 * shared) / (leftTrigrams.size + rightTrigrams.size);
220
- }
221
- function tokenTrigrams(token) {
222
- return new Set(Array.from({ length: token.length - 2 }, (_value, index) => token.slice(index, index + 3)));
223
- }
224
- function sharedPrefixLength(left, right) {
225
- const limit = Math.min(left.length, right.length);
226
- let index = 0;
227
- while (index < limit && left[index] === right[index]) {
228
- index += 1;
229
- }
230
- return index;
231
- }
232
261
  export function vectorCandidateLimit(topK, profile = "balanced") {
233
262
  const policy = VECTOR_CANDIDATE_POLICY[profile];
234
- return Math.max(policy.minimum, topK * policy.multiplier);
263
+ return Math.min(MAX_VECTOR_CANDIDATES, Math.max(policy.minimum, topK * policy.multiplier));
264
+ }
265
+ export function lexicalCandidateLimit(topK, profile = "balanced") {
266
+ const policy = LEXICAL_CANDIDATE_POLICY[profile];
267
+ return Math.min(MAX_LEXICAL_CANDIDATES, Math.max(policy.minimum, topK * policy.multiplier));
235
268
  }
236
269
  export async function ask(query, options = {}) {
237
270
  const config = await loadConfig(String(options.cwd ?? process.cwd()));
238
271
  return askWithConfig(query, options, config);
239
272
  }
240
- export async function askWithConfig(query, options, config, connection) {
273
+ export async function askWithConfig(query, options, config, connection, snapshot) {
241
274
  const signal = operationSignal(options);
242
275
  throwIfAborted(signal);
243
- const sources = await searchWithConfig(query, options, config, connection, signal);
244
- const staleWarning = await getIndexFreshnessWarning(config);
276
+ const sources = await searchWithConfig(query, options, config, connection, signal, snapshot);
277
+ const staleWarning = null;
245
278
  throwIfAborted(signal);
246
279
  if (sources.length === 0) {
247
280
  return {
@@ -266,13 +299,30 @@ export async function expandCitation(citation, options = {}) {
266
299
  const config = await loadConfig(String(options.cwd ?? process.cwd()));
267
300
  return expandCitationWithConfig(citation, options, config);
268
301
  }
269
- export async function expandCitationWithConfig(citation, options, config, connection) {
302
+ export async function expandCitationWithConfig(citation, options, config, connection, suppliedSnapshot) {
303
+ const ownsSnapshot = suppliedSnapshot === undefined;
304
+ const snapshot = suppliedSnapshot ?? (await loadIndexReadSnapshot(config, connection));
305
+ let lease;
306
+ try {
307
+ if (snapshot.table) {
308
+ lease = await acquireGenerationReadLease(snapshot.tableName, config);
309
+ }
310
+ return await expandCitationWithinGeneration(citation, options, config, snapshot);
311
+ }
312
+ finally {
313
+ await lease?.release().catch(() => undefined);
314
+ if (ownsSnapshot) {
315
+ closeIndexReadSnapshot(snapshot, config);
316
+ }
317
+ }
318
+ }
319
+ async function expandCitationWithinGeneration(citation, options, config, snapshot) {
270
320
  const signal = operationSignal(options);
271
321
  throwIfAborted(signal);
272
322
  const requestedCitation = citation.trim();
273
323
  const target = parseCitationTarget(requestedCitation);
274
324
  const contextRadius = normalizeContextRadius(options.contextRadius);
275
- const table = await openRowsTable(config, connection);
325
+ const table = snapshot.table;
276
326
  throwIfAborted(signal);
277
327
  if (!table) {
278
328
  return {
@@ -284,11 +334,10 @@ export async function expandCitationWithConfig(citation, options, config, connec
284
334
  passages: [],
285
335
  };
286
336
  }
287
- const manifest = await readIndexManifest(config);
288
- if (!manifest) {
337
+ if (!snapshot.manifest) {
289
338
  throw new Error("Index manifest is missing. Rebuild with `rgr ingest --rebuild` before expanding citations.");
290
339
  }
291
- const freshnessWarning = await getIndexFreshnessWarning(config);
340
+ const freshnessWarning = indexFreshnessWarning(config, snapshot.manifest);
292
341
  if (freshnessWarning) {
293
342
  throw new Error(freshnessWarning);
294
343
  }
@@ -336,25 +385,115 @@ function retrievalOnlyAnswer(sources) {
336
385
  snippets,
337
386
  ].join("\n");
338
387
  }
339
- async function lexicalCandidateRows(table, query, limit, pathPredicate) {
340
- const ftsQuery = lexicalQuery(query);
341
- if (ftsQuery) {
388
+ async function lexicalCandidateRows(table, query, options) {
389
+ const ftsQueries = lexicalQuery(query);
390
+ const stats = await table.indexStats(FULL_TEXT_INDEX_NAME).catch(() => undefined);
391
+ let fallbackReason = "fts-index-unavailable";
392
+ if (ftsQueries && stats) {
342
393
  try {
343
- const searchQuery = table.search(ftsQuery, "fts", "searchText").select(FTS_SEARCH_COLUMNS);
344
- return (await (pathPredicate ? searchQuery.where(pathPredicate) : searchQuery)
345
- .limit(limit)
346
- .toArray());
394
+ const sourcePathQuery = sourcePathPredicateForQuery(query);
395
+ const sourcePathRows = sourcePathQuery
396
+ ? await executeSourcePathQuery(table, sourcePathQuery, options.ftsLimit, options.pathPredicate)
397
+ : [];
398
+ const primaryRows = await executeFullTextQuery(table, ftsQueries.primary, options.ftsLimit, options.pathPredicate);
399
+ const rows = [];
400
+ const initialRows = [...sourcePathRows, ...primaryRows];
401
+ const seenRows = new Set();
402
+ for (const row of initialRows) {
403
+ const key = rowKey(row);
404
+ if (!seenRows.has(key)) {
405
+ seenRows.add(key);
406
+ rows.push(row);
407
+ }
408
+ }
409
+ let executedVariants = 1 + Number(sourcePathQuery !== null);
410
+ if (rows.length < options.minimumResults) {
411
+ for (const supplementalQuery of ftsQueries.supplemental) {
412
+ const supplementalRows = await executeFullTextQuery(table, supplementalQuery, options.ftsLimit, options.pathPredicate).catch(() => []);
413
+ executedVariants += 1;
414
+ for (const row of supplementalRows) {
415
+ const key = rowKey(row);
416
+ if (seenRows.has(key)) {
417
+ continue;
418
+ }
419
+ seenRows.add(key);
420
+ rows.push(row);
421
+ if (rows.length >= options.ftsLimit) {
422
+ break;
423
+ }
424
+ }
425
+ if (rows.length >= options.minimumResults || rows.length >= options.ftsLimit) {
426
+ break;
427
+ }
428
+ }
429
+ }
430
+ const indexedRows = stats.numIndexedRows;
431
+ const unindexedRows = stats.numUnindexedRows;
432
+ const coveredRows = indexedRows + unindexedRows;
433
+ const exactPathMatches = new Set(sourcePathQuery?.exactRelativePath
434
+ ? sourcePathRows
435
+ .filter((row) => row.relativePath === sourcePathQuery.exactRelativePath)
436
+ .map(rowKey)
437
+ : []);
438
+ return {
439
+ rows,
440
+ exactPathMatches,
441
+ backend: "fts",
442
+ fallbackActivated: false,
443
+ fallbackReason: null,
444
+ candidateLimit: options.ftsLimit,
445
+ candidatesMaterialized: rows.length,
446
+ queryVariants: executedVariants,
447
+ indexedRows,
448
+ unindexedRows,
449
+ coverage: coveredRows === 0 ? 1 : indexedRows / coveredRows,
450
+ };
347
451
  }
348
452
  catch {
349
- // Older indexes may not have the FTS index yet. Keep retrieval usable and
350
- // let doctor/index freshness tell the operator to rebuild.
453
+ fallbackReason = "fts-query-failed";
454
+ // A complete bounded scan remains safe for small or explicitly bounded corpora.
351
455
  }
352
456
  }
457
+ if (options.fallbackLimit < options.indexedChunkCount) {
458
+ throw new RagmirError("INDEX_UNAVAILABLE", `Full-text search is unavailable and the fallback limit would scan only ${options.fallbackLimit} of ${options.indexedChunkCount} chunks. Run \`rgr storage optimize\` or rebuild the index before searching.`, { retryable: true });
459
+ }
353
460
  const fallbackQuery = table.query().select(SEARCH_COLUMNS);
354
- return (await (pathPredicate ? fallbackQuery.where(pathPredicate) : fallbackQuery)
461
+ const rows = (await (options.pathPredicate
462
+ ? fallbackQuery.where(options.pathPredicate)
463
+ : fallbackQuery)
464
+ .limit(options.fallbackLimit)
465
+ .toArray());
466
+ return {
467
+ rows,
468
+ exactPathMatches: new Set(),
469
+ backend: "fallback",
470
+ fallbackActivated: true,
471
+ fallbackReason,
472
+ candidateLimit: options.fallbackLimit,
473
+ candidatesMaterialized: rows.length,
474
+ queryVariants: 0,
475
+ indexedRows: 0,
476
+ unindexedRows: options.indexedChunkCount,
477
+ coverage: options.indexedChunkCount === 0 ? 1 : Math.min(1, rows.length / options.indexedChunkCount),
478
+ };
479
+ }
480
+ async function executeFullTextQuery(table, query, limit, pathPredicate) {
481
+ const searchQuery = table.search(query, "fts", "searchText").select(FTS_SEARCH_COLUMNS);
482
+ return (await (pathPredicate ? searchQuery.where(pathPredicate) : searchQuery)
355
483
  .limit(limit)
356
484
  .toArray());
357
485
  }
486
+ async function executeSourcePathQuery(table, sourcePathQuery, limit, retrievalPredicate) {
487
+ const predicate = retrievalPredicate
488
+ ? `(${sourcePathQuery.predicate}) AND (${retrievalPredicate})`
489
+ : sourcePathQuery.predicate;
490
+ return (await table.query().select(SEARCH_COLUMNS).where(predicate).limit(limit).toArray()).map((row) => ({
491
+ ...row,
492
+ _score: sourcePathQuery.exactRelativePath === row.relativePath
493
+ ? Number.MAX_SAFE_INTEGER
494
+ : Number.MAX_SAFE_INTEGER - 1,
495
+ }));
496
+ }
358
497
  function searchPredicate(includePaths, excludePaths, contextPaths) {
359
498
  const includes = normalizePathPrefixes(includePaths);
360
499
  const excludes = normalizePathPrefixes(excludePaths);
@@ -393,41 +532,68 @@ function contextPathPredicate(prefix) {
393
532
  `starts_with(contextPath, ${sqlString(`${prefix}[`)})`,
394
533
  ].join(" OR ");
395
534
  }
396
- async function contextChunksByRow(table, rows, requestedRadius) {
535
+ async function contextChunksByRow(table, rows, requestedRadius, retrievalPredicate) {
397
536
  const radius = Math.min(MAX_CONTEXT_RADIUS, Math.max(0, requestedRadius));
398
537
  if (radius === 0 || rows.length === 0) {
399
538
  return new Map();
400
539
  }
401
- const predicates = rows.map(({ row }) => {
402
- const minChunk = Math.max(0, row.chunkIndex - radius);
403
- const maxChunk = row.chunkIndex + radius;
404
- return `(relativePath = ${sqlString(row.relativePath)} AND chunkIndex >= ${minChunk} AND chunkIndex <= ${maxChunk})`;
405
- });
540
+ const ranges = contextRangesByPath(rows, radius);
541
+ const predicates = [...ranges.entries()].flatMap(([relativePath, pathRanges]) => pathRanges.map(({ minimum, maximum }) => `(relativePath = ${sqlString(relativePath)} AND chunkIndex >= ${minimum} AND chunkIndex <= ${maximum})`));
542
+ const rangePredicate = predicates.join(" OR ");
543
+ const hydrationPredicate = retrievalPredicate
544
+ ? `(${rangePredicate}) AND (${retrievalPredicate})`
545
+ : rangePredicate;
406
546
  const allContextRows = (await table
407
547
  .query()
408
548
  .select(SEARCH_COLUMNS)
409
- .where(predicates.join(" OR "))
549
+ .where(hydrationPredicate)
410
550
  .toArray());
551
+ const contextRowsByPath = new Map();
552
+ for (const contextRow of allContextRows) {
553
+ const pathRows = contextRowsByPath.get(contextRow.relativePath) ?? [];
554
+ pathRows.push(contextRow);
555
+ contextRowsByPath.set(contextRow.relativePath, pathRows);
556
+ }
411
557
  const contexts = new Map();
412
558
  for (const { row } of rows) {
413
559
  const minChunk = Math.max(0, row.chunkIndex - radius);
414
560
  const maxChunk = row.chunkIndex + radius;
415
- const contextRows = allContextRows.filter((candidate) => candidate.relativePath === row.relativePath &&
416
- candidate.chunkIndex >= minChunk &&
417
- candidate.chunkIndex <= maxChunk);
561
+ const contextRows = (contextRowsByPath.get(row.relativePath) ?? []).filter((candidate) => candidate.chunkIndex >= minChunk && candidate.chunkIndex <= maxChunk);
418
562
  contexts.set(rowKey(row), contextRows.sort(compareChunkRows).map(contextChunkForRow));
419
563
  }
420
564
  return contexts;
421
565
  }
422
- async function assertVectorIndexCompatibility(config, vectorDimension) {
423
- const manifest = await readIndexManifest(config);
566
+ function contextRangesByPath(rows, radius) {
567
+ const rangesByPath = new Map();
568
+ for (const rankedRow of rows) {
569
+ const pathRows = rangesByPath.get(rankedRow.row.relativePath) ?? [];
570
+ pathRows.push(rankedRow);
571
+ rangesByPath.set(rankedRow.row.relativePath, pathRows);
572
+ }
573
+ return new Map([...rangesByPath.entries()].map(([relativePath, pathRows]) => {
574
+ const ranges = pathRows
575
+ .map(({ row }) => ({
576
+ minimum: Math.max(0, row.chunkIndex - radius),
577
+ maximum: row.chunkIndex + radius,
578
+ }))
579
+ .sort((left, right) => left.minimum - right.minimum);
580
+ const merged = [];
581
+ for (const range of ranges) {
582
+ const previous = merged.at(-1);
583
+ if (!previous || range.minimum > previous.maximum + 1) {
584
+ merged.push({ ...range });
585
+ }
586
+ else {
587
+ previous.maximum = Math.max(previous.maximum, range.maximum);
588
+ }
589
+ }
590
+ return [relativePath, merged];
591
+ }));
592
+ }
593
+ function assertVectorIndexCompatibility(config, manifest, vectorDimension) {
424
594
  if (!manifest) {
425
595
  throw new Error("Index manifest is missing. Rebuild with `rgr ingest --rebuild` before searching.");
426
596
  }
427
- const freshnessWarning = await getIndexFreshnessWarning(config);
428
- if (freshnessWarning) {
429
- throw new Error(freshnessWarning);
430
- }
431
597
  if (manifest.vectorDimension !== undefined && manifest.vectorDimension !== vectorDimension) {
432
598
  throw new Error(`Index vector dimension is ${manifest.vectorDimension} but the active embedding produced ${vectorDimension}. Rebuild with \`rgr ingest --rebuild\`.`);
433
599
  }
@@ -435,6 +601,13 @@ async function assertVectorIndexCompatibility(config, vectorDimension) {
435
601
  manifest.vectorDistanceMetric !== VECTOR_DISTANCE_METRIC) {
436
602
  throw new Error(`Index vector distance metric is ${manifest.vectorDistanceMetric} but Ragmir expects ${VECTOR_DISTANCE_METRIC}. Rebuild with \`rgr ingest --rebuild\`.`);
437
603
  }
604
+ if (!manifest.vectorIndex) {
605
+ throw new Error("Index vector strategy metadata is missing. Rebuild with `rgr ingest --rebuild`.");
606
+ }
607
+ if (!vectorIndexManifestCompatible(manifest.vectorIndex, config, vectorDimension)) {
608
+ throw new Error("Index vector strategy is incompatible or incomplete. Rebuild with `rgr ingest --rebuild` or repair coverage with `rgr storage optimize`.");
609
+ }
610
+ return { ...manifest, vectorIndex: manifest.vectorIndex };
438
611
  }
439
612
  function answerText(source) {
440
613
  if (source.context.length === 0) {
@@ -449,160 +622,25 @@ function contextChunkForRow(row) {
449
622
  text: row.text,
450
623
  charStart: nullableNumber(row.charStart),
451
624
  charEnd: nullableNumber(row.charEnd),
452
- lineStart: nullableNumber(row.lineStart),
453
- lineEnd: nullableNumber(row.lineEnd),
625
+ lineStart: nullableLineNumber(row.lineStart),
626
+ lineEnd: nullableLineNumber(row.lineEnd),
454
627
  pageStart: nullablePageNumber(row.pageStart),
455
628
  pageEnd: nullablePageNumber(row.pageEnd),
456
629
  citation: citationForRow(row),
457
630
  };
458
631
  }
459
- /**
460
- * Reciprocal Rank Fusion of vector and lexical retrievers. Rank-only: each
461
- * candidate scores `1/(RRF_K + rank)` per retriever it appears in, summed.
462
- * This removes the score-calibration problem of weighted-sum fusion (the BM25
463
- * and vector score distributions have nothing in common) and is the standard
464
- * hybrid retrieval approach.
465
- *
466
- * Ranks are 0-based internally; a candidate absent from a retriever contributes
467
- * nothing from that retriever. Tie-breaks keep the result deterministic.
468
- */
469
- function rankHybridRows(query, vectorRows, textRows) {
470
- const queryTokens = tokenize(query);
471
- const rows = mergeRows(vectorRows, textRows);
472
- // Vector ranks: lower _distance is better, so sort ascending then assign rank 0..
473
- const vectorRanked = [...vectorRows]
474
- .filter((row) => Number.isFinite(rowDistance(row)))
475
- .sort((a, b) => rowDistance(a) - rowDistance(b));
476
- const vectorRanks = new Map();
477
- vectorRanked.forEach((row, index) => {
478
- vectorRanks.set(rowKey(row), index);
479
- });
480
- // LanceDB FTS already returns BM25-ranked rows. The fallback scan computes
481
- // BM25 locally when an older index has no text index.
482
- const ftsRows = textRows.filter((row) => typeof row._score === "number");
483
- const lexicalRanked = ftsRows.length > 0
484
- ? ftsRows
485
- .sort((a, b) => (b._score ?? 0) - (a._score ?? 0))
486
- .map((row) => [rowKey(row), row._score ?? 0])
487
- : [...bm25Scores(queryTokens, rows).entries()]
488
- .filter(([, score]) => score > 0)
489
- .sort((a, b) => b[1] - a[1]);
490
- const lexicalRanks = new Map();
491
- lexicalRanked.forEach(([key], index) => {
492
- lexicalRanks.set(key, index);
493
- });
494
- const lexicalScores = new Map(lexicalRanked);
495
- return rows
496
- .map((row) => {
497
- const key = rowKey(row);
498
- const vectorRank = vectorRanks.get(key);
499
- const lexicalRank = lexicalRanks.get(key);
500
- let combinedScore = 0;
501
- let vectorScore = 0;
502
- let lexicalScore = 0;
503
- if (vectorRank !== undefined) {
504
- vectorScore = RRF_VECTOR_WEIGHT / (RRF_K + vectorRank);
505
- combinedScore += vectorScore;
506
- }
507
- if (lexicalRank !== undefined) {
508
- lexicalScore = RRF_LEXICAL_WEIGHT / (RRF_K + lexicalRank);
509
- combinedScore += lexicalScore;
510
- }
511
- return {
512
- row,
513
- vectorScore,
514
- lexicalScore,
515
- combinedScore,
516
- vectorRank: vectorRank === undefined ? null : vectorRank + 1,
517
- lexicalRank: lexicalRank === undefined ? null : lexicalRank + 1,
518
- lexicalBackendScore: lexicalScores.get(key) ?? null,
519
- matchedTerms: matchedQueryTerms(queryTokens, row.searchText),
520
- };
521
- })
522
- .filter((ranked) => ranked.combinedScore > 0)
523
- .sort((a, b) => {
524
- const scoreDelta = b.combinedScore - a.combinedScore;
525
- if (scoreDelta !== 0) {
526
- return scoreDelta;
527
- }
528
- const distanceDelta = rowDistance(a.row) - rowDistance(b.row);
529
- if (Number.isFinite(distanceDelta) && distanceDelta !== 0) {
530
- return distanceDelta;
531
- }
532
- return (a.row.relativePath.localeCompare(b.row.relativePath) || a.row.chunkIndex - b.row.chunkIndex);
533
- });
534
- }
535
- function matchedQueryTerms(queryTokens, text) {
632
+ function matchedQueryTerms(projectRoot, queryTokens, text) {
633
+ if (queryExplanationDiagnostics.hasSubscribers) {
634
+ queryExplanationDiagnostics.publish({ projectRoot });
635
+ }
536
636
  const textTokens = tokenize(text);
537
637
  return [...new Set(queryTokens)].filter((queryToken) => textTokens.some((textToken) => tokensAreLexicallyRelated(queryToken, textToken)));
538
638
  }
539
- function mergeRows(vectorRows, textRows) {
540
- const rows = new Map();
541
- for (const row of textRows) {
542
- rows.set(rowKey(row), row);
543
- }
544
- for (const row of vectorRows) {
545
- const existing = rows.get(rowKey(row));
546
- if (!existing) {
547
- rows.set(rowKey(row), row);
548
- continue;
549
- }
550
- const merged = { ...existing, ...row };
551
- if (existing._score !== undefined) {
552
- merged._score = existing._score;
553
- }
554
- rows.set(rowKey(row), merged);
555
- }
556
- return [...rows.values()];
557
- }
558
- function bm25Scores(queryTokens, rows) {
559
- const scores = new Map();
560
- if (queryTokens.length === 0 || rows.length === 0) {
561
- return scores;
562
- }
563
- const uniqueQueryTokens = [...new Set(queryTokens)];
564
- const documents = rows.map((row) => {
565
- const tokens = tokenize(row.searchText);
566
- const frequencies = new Map();
567
- for (const token of tokens) {
568
- frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
569
- }
570
- return { row, tokens, frequencies };
571
- });
572
- const averageLength = documents.reduce((sum, document) => sum + document.tokens.length, 0) / documents.length || 1;
573
- const documentFrequencies = new Map();
574
- for (const token of uniqueQueryTokens) {
575
- documentFrequencies.set(token, documents.filter((document) => document.frequencies.has(token)).length);
576
- }
577
- for (const document of documents) {
578
- let score = 0;
579
- for (const token of uniqueQueryTokens) {
580
- const frequency = document.frequencies.get(token) ?? 0;
581
- if (frequency === 0) {
582
- continue;
583
- }
584
- const documentFrequency = documentFrequencies.get(token) ?? 0;
585
- const inverseDocumentFrequency = Math.log(1 + (documents.length - documentFrequency + 0.5) / (documentFrequency + 0.5));
586
- const denominator = frequency + BM25_K1 * (1 - BM25_B + BM25_B * (document.tokens.length / averageLength));
587
- score += inverseDocumentFrequency * ((frequency * (BM25_K1 + 1)) / denominator);
588
- }
589
- if (score > 0) {
590
- scores.set(rowKey(document.row), score);
591
- }
592
- }
593
- return scores;
594
- }
595
- function rowDistance(row) {
596
- return typeof row._distance === "number" && row._distance >= 0
597
- ? row._distance
598
- : Number.POSITIVE_INFINITY;
599
- }
600
- async function assertIndexFreshness(config) {
601
- const manifest = await readIndexManifest(config);
639
+ function assertIndexFreshness(config, manifest) {
602
640
  if (!manifest) {
603
641
  throw new Error("Index manifest is missing. Rebuild with `rgr ingest --rebuild` before searching.");
604
642
  }
605
- const freshnessWarning = await getIndexFreshnessWarning(config);
643
+ const freshnessWarning = indexFreshnessWarning(config, manifest);
606
644
  if (freshnessWarning) {
607
645
  throw new Error(freshnessWarning);
608
646
  }
@@ -618,8 +656,7 @@ function parseCitationTarget(citation) {
618
656
  }
619
657
  const chunkIndex = Number.parseInt(chunkIndexText, 10);
620
658
  let relativePath = citation.slice(0, match.index);
621
- relativePath = relativePath.replace(/:L\d+-L\d+$/u, "");
622
- relativePath = relativePath.replace(/:p\d+(?:-p\d+)?$/u, "");
659
+ relativePath = stripCitationCoordinates(relativePath);
623
660
  if (!relativePath) {
624
661
  throw new Error("Citation must include a source path before its chunk suffix.");
625
662
  }
@@ -638,23 +675,70 @@ function normalizeTopK(topK) {
638
675
  if (!Number.isSafeInteger(topK) || topK <= 0) {
639
676
  throw new RagmirError("INVALID_ARGUMENT", "topK must be a positive integer.");
640
677
  }
678
+ if (topK > MAX_SEARCH_TOP_K) {
679
+ throw new RagmirError("INVALID_ARGUMENT", `topK must be at most ${MAX_SEARCH_TOP_K}.`);
680
+ }
641
681
  return topK;
642
682
  }
643
683
  function citationForRow(row) {
644
- const lineStart = nullableNumber(row.lineStart);
645
- const lineEnd = nullableNumber(row.lineEnd);
646
- const pageStart = nullablePageNumber(row.pageStart);
647
- const pageEnd = nullablePageNumber(row.pageEnd);
648
- const pageSegment = pageStart === null
649
- ? ""
650
- : `:p${pageStart}${pageEnd !== null && pageEnd !== pageStart ? `-p${pageEnd}` : ""}`;
651
- if (lineStart === null || lineEnd === null) {
652
- return `${row.relativePath}${pageSegment}#${row.chunkIndex}`;
653
- }
654
- return `${row.relativePath}${pageSegment}:L${lineStart}-L${lineEnd}#${row.chunkIndex}`;
684
+ return citationForCoordinates(row);
655
685
  }
656
686
  function lexicalQuery(query) {
657
- return [...new Set(tokenize(query))].join(" ");
687
+ const tokens = [...new Set(tokenize(query))];
688
+ if (tokens.length === 0) {
689
+ return null;
690
+ }
691
+ const joined = tokens.join(" ");
692
+ const supplemental = [];
693
+ if (tokens.length > 1) {
694
+ supplemental.push(new PhraseQuery(joined, "searchText"));
695
+ }
696
+ const identifierTerms = [...query.matchAll(LEXICAL_IDENTIFIER_PATTERN)]
697
+ .map((match) => match[0])
698
+ .filter(Boolean);
699
+ for (const identifier of [...new Set(identifierTerms)]) {
700
+ supplemental.push(new MatchQuery(identifier, "searchText", {
701
+ boost: 2,
702
+ ...(isFuzzyLexicalTerm(identifier) ? { fuzziness: 1, prefixLength: 3 } : {}),
703
+ }));
704
+ }
705
+ const rareTerms = tokens
706
+ .filter(isFuzzyLexicalTerm)
707
+ .sort((left, right) => right.length - left.length || left.localeCompare(right))
708
+ .slice(0, 3);
709
+ for (const term of rareTerms) {
710
+ supplemental.push(new MatchQuery(term, "searchText", {
711
+ boost: 1.25,
712
+ fuzziness: 1,
713
+ prefixLength: 3,
714
+ }));
715
+ }
716
+ return {
717
+ primary: new MatchQuery(joined, "searchText", { operator: Operator.Or }),
718
+ supplemental,
719
+ };
720
+ }
721
+ function isFuzzyLexicalTerm(term) {
722
+ return term.length >= 7 && /^[a-z0-9_.-]+$/u.test(term);
723
+ }
724
+ function sourcePathPredicateForQuery(query) {
725
+ const normalized = query.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
726
+ if (!SOURCE_PATH_QUERY_PATTERN.test(normalized)) {
727
+ return null;
728
+ }
729
+ const fileName = normalized.split("/").at(-1);
730
+ if (!fileName) {
731
+ return null;
732
+ }
733
+ const clauses = new Set([
734
+ `relativePath = ${sqlString(normalized)}`,
735
+ `relativePath = ${sqlString(fileName)}`,
736
+ `ends_with(relativePath, ${sqlString(`/${fileName}`)})`,
737
+ ]);
738
+ return {
739
+ predicate: `(${[...clauses].join(" OR ")})`,
740
+ exactRelativePath: normalized.includes("/") ? normalized : null,
741
+ };
658
742
  }
659
743
  function compareChunkRows(a, b) {
660
744
  return a.chunkIndex - b.chunkIndex;
@@ -662,6 +746,9 @@ function compareChunkRows(a, b) {
662
746
  function nullableNumber(value) {
663
747
  return typeof value === "number" && Number.isFinite(value) ? value : null;
664
748
  }
749
+ function nullableLineNumber(value) {
750
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
751
+ }
665
752
  function nullablePageNumber(value) {
666
753
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
667
754
  }