@customize-agent/knowledge 4.0.33 → 4.0.34

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.
@@ -130,11 +130,21 @@ export declare class IndexStateStore {
130
130
  format: string;
131
131
  collectionName: string;
132
132
  }): void;
133
+ private chunkQueryParts;
133
134
  listChunks(options?: {
134
135
  collectionName?: string;
135
136
  relativePath?: string;
136
137
  limit?: number;
137
138
  }): StoredChunk[];
139
+ countChunks(options?: {
140
+ collectionName?: string;
141
+ relativePath?: string;
142
+ }): number;
143
+ listChunksByContentBudget(options?: {
144
+ collectionName?: string;
145
+ relativePath?: string;
146
+ maxContentChars?: number;
147
+ }): StoredChunk[];
138
148
  getChunkByRowid(rowid: number): StoredChunk | undefined;
139
149
  getChunksByRowids(rowids: number[]): StoredChunk[];
140
150
  getContextChunks(relativePath: string, chunkIndex: number, window?: number): StoredChunk[];
@@ -152,6 +162,7 @@ export declare class IndexStateStore {
152
162
  filePaths?: string[];
153
163
  }): ChunkSearchResult[];
154
164
  private filePathFilterClause;
165
+ private resolveChunkSearchLimit;
155
166
  private searchChunksFts;
156
167
  private searchChunksLike;
157
168
  findExactDuplicate(contentHash: string, excludePath?: string): FileHashRecord | undefined;
@@ -182,7 +182,7 @@ export class IndexStateStore {
182
182
  });
183
183
  transaction();
184
184
  }
185
- listChunks(options = {}) {
185
+ chunkQueryParts(options = {}) {
186
186
  const conditions = [];
187
187
  const params = [];
188
188
  if (options.collectionName) {
@@ -194,17 +194,47 @@ export class IndexStateStore {
194
194
  params.push(options.relativePath);
195
195
  }
196
196
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
197
+ return { where, params };
198
+ }
199
+ listChunks(options = {}) {
200
+ const { where, params } = this.chunkQueryParts(options);
197
201
  const limit = options.limit ? 'LIMIT ?' : '';
198
- if (options.limit)
199
- params.push(options.limit);
202
+ const queryParams = options.limit ? [...params, options.limit] : params;
200
203
  const rows = this.db.prepare(`
201
204
  SELECT rowid, * FROM kb_chunks
202
205
  ${where}
203
206
  ORDER BY relative_path, chunk_index
204
207
  ${limit}
205
- `).all(...params);
208
+ `).all(...queryParams);
206
209
  return rows.map(row => this.rowToChunk(row, 0));
207
210
  }
211
+ countChunks(options = {}) {
212
+ const { where, params } = this.chunkQueryParts(options);
213
+ const row = this.db.prepare(`SELECT COUNT(*) AS count FROM kb_chunks ${where}`).get(...params);
214
+ return Number(row?.count || 0);
215
+ }
216
+ listChunksByContentBudget(options = {}) {
217
+ const maxContentChars = Number(options.maxContentChars);
218
+ if (!Number.isFinite(maxContentChars) || maxContentChars <= 0)
219
+ return this.listChunks(options);
220
+ const { where, params } = this.chunkQueryParts(options);
221
+ const rows = this.db.prepare(`
222
+ SELECT rowid, * FROM kb_chunks
223
+ ${where}
224
+ ORDER BY relative_path, chunk_index
225
+ `).iterate(...params);
226
+ const chunks = [];
227
+ let usedChars = 0;
228
+ for (const row of rows) {
229
+ const chunk = this.rowToChunk(row, 0);
230
+ const length = chunk.content?.length || 0;
231
+ if (chunks.length > 0 && usedChars + length > maxContentChars)
232
+ break;
233
+ chunks.push(chunk);
234
+ usedChars += length;
235
+ }
236
+ return chunks;
237
+ }
208
238
  getChunkByRowid(rowid) {
209
239
  const row = this.db.prepare('SELECT rowid, * FROM kb_chunks WHERE rowid = ?').get(rowid);
210
240
  return row ? this.rowToChunk(row, 0) : undefined;
@@ -264,20 +294,28 @@ export class IndexStateStore {
264
294
  * @param limit 返回结果数量上限
265
295
  * @returns 搜索结果列表(按相关性得分排序)
266
296
  */
267
- searchChunks(query, limit = 10, filters = {}) {
297
+ searchChunks(query, limit, filters = {}) {
268
298
  const terms = this.expandSearchTerms(query);
269
299
  if (terms.length === 0)
270
300
  return [];
271
301
  const filePaths = [...new Set((filters.filePaths ?? []).filter(Boolean))];
302
+ const effectiveLimit = this.resolveChunkSearchLimit(limit, filePaths);
272
303
  const results = [
273
- ...(this.ftsEnabled ? this.searchChunksFts(terms, limit, filePaths) : []),
274
- ...this.searchChunksLike(terms, limit, filePaths),
304
+ ...(this.ftsEnabled ? this.searchChunksFts(terms, effectiveLimit, filePaths) : []),
305
+ ...this.searchChunksLike(terms, effectiveLimit, filePaths),
275
306
  ];
276
- return this.mergeKeywordResults(results, limit);
307
+ return this.mergeKeywordResults(results, effectiveLimit);
277
308
  }
278
309
  filePathFilterClause(filePaths, column = 'relative_path') {
279
310
  return filePaths.length > 0 ? ` AND ${column} IN (${filePaths.map(() => '?').join(', ')})` : '';
280
311
  }
312
+ resolveChunkSearchLimit(limit, filePaths) {
313
+ if (Number.isFinite(limit) && limit > 0)
314
+ return Math.ceil(limit);
315
+ const row = this.db.prepare(`SELECT COUNT(*) as count FROM kb_chunks WHERE 1 = 1${this.filePathFilterClause(filePaths)}`)
316
+ .get(...filePaths);
317
+ return Math.max(1, Math.ceil(Number(row?.count) || 0));
318
+ }
281
319
  searchChunksFts(terms, limit, filePaths) {
282
320
  try {
283
321
  const matchQuery = this.toFtsQuery(terms);
@@ -66,6 +66,8 @@ export declare class KnowledgeBaseManager {
66
66
  }): Promise<DiffResult>;
67
67
  search(query: string, limit?: number, filters?: SearchFilters): ChunkSearchResult[];
68
68
  keywordSearchItems(query: string, limit?: number, filters?: SearchFilters): FederatedSearchItem[];
69
+ private resolveSearchLimit;
70
+ private searchCorpusSize;
69
71
  expandContext(item: FederatedSearchItem): FederatedSearchItem;
70
72
  hybridSearch(query: string, options?: {
71
73
  limit?: number;
@@ -81,11 +83,14 @@ export declare class KnowledgeBaseManager {
81
83
  }): Promise<FederatedResult>;
82
84
  listRelationships(filePath?: string): FileRelationship[];
83
85
  listFiles(): IndexStateRecord[];
84
- getFileDetail(relativePath: string): {
86
+ getFileDetail(relativePath: string, options?: {
87
+ maxChunkContentChars?: number;
88
+ }): {
85
89
  file: IndexStateRecord;
86
90
  absolutePath: string;
87
91
  directory: string;
88
92
  chunks: import("./index-state-store.js").StoredChunk[];
93
+ totalChunkCount: number;
89
94
  parents: import("./index-state-store.js").StoredParentChunk[];
90
95
  relationships: FileRelationship[];
91
96
  tags: {
@@ -326,11 +326,24 @@ export class KnowledgeBaseManager {
326
326
  this.onProgress = previousOnProgress;
327
327
  }
328
328
  }
329
- search(query, limit = 10, filters) {
330
- return this.store.searchChunks(query, limit, { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) });
329
+ search(query, limit, filters) {
330
+ return this.store.searchChunks(query, this.resolveSearchLimit(limit, filters), { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) });
331
331
  }
332
- keywordSearchItems(query, limit = 10, filters) {
333
- return this.store.searchChunks(query, limit, { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) }).map(result => this.toFederatedItem(result, 'keyword'));
332
+ keywordSearchItems(query, limit, filters) {
333
+ return this.store.searchChunks(query, this.resolveSearchLimit(limit, filters), { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) }).map(result => this.toFederatedItem(result, 'keyword'));
334
+ }
335
+ resolveSearchLimit(limit, filters) {
336
+ if (Number.isFinite(limit) && limit > 0)
337
+ return Math.ceil(limit);
338
+ return this.searchCorpusSize(filters);
339
+ }
340
+ searchCorpusSize(filters) {
341
+ const paths = filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined);
342
+ const pathSet = new Set(paths?.filter(Boolean));
343
+ const total = this.store.listRecords()
344
+ .filter(record => pathSet.size === 0 || pathSet.has(record.relativePath))
345
+ .reduce((sum, record) => sum + Math.max(0, Math.ceil(Number(record.chunkCount) || 0)), 0);
346
+ return Math.max(1, total);
334
347
  }
335
348
  expandContext(item) {
336
349
  const chunkIndex = item.chunkIndex ?? this.parseChunkIndex(item.id);
@@ -361,7 +374,9 @@ export class KnowledgeBaseManager {
361
374
  };
362
375
  }
363
376
  async hybridSearch(query, options = {}) {
364
- const limit = options.limit ?? 10;
377
+ const requestedLimit = Number.isFinite(options.limit) && options.limit > 0 ? Math.ceil(options.limit) : undefined;
378
+ const corpusSize = this.searchCorpusSize(options.filters);
379
+ const effectiveLimit = requestedLimit ?? corpusSize;
365
380
  const start = Date.now();
366
381
  const weights = this.retrievalWeights(options.weights);
367
382
  const rewrittenQueries = options.generationMode ? [query.trim()].filter(Boolean) : await this.rewriteQueries(query);
@@ -370,10 +385,12 @@ export class KnowledgeBaseManager {
370
385
  const vectorMultiplier = options.generationMode ? 3 : 6;
371
386
  const vectorQueryLimit = options.generationMode ? 1 : 3;
372
387
  for (const [queryIndex, rewritten] of rewrittenQueries.entries()) {
373
- rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten, limit * keywordMultiplier, options.filters), queryIndex });
388
+ rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten, effectiveLimit * keywordMultiplier, options.filters), queryIndex });
374
389
  if (queryIndex < vectorQueryLimit) {
375
390
  try {
376
- rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: limit * vectorMultiplier })).results.slice(0, limit * keywordMultiplier), queryIndex });
391
+ const vectorLimit = effectiveLimit * vectorMultiplier;
392
+ const keywordLimit = effectiveLimit * keywordMultiplier;
393
+ rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: vectorLimit })).results.slice(0, keywordLimit), queryIndex });
377
394
  }
378
395
  catch { /* 向量搜索在混合搜索中是可选的 */ }
379
396
  }
@@ -381,12 +398,14 @@ export class KnowledgeBaseManager {
381
398
  const keywordItems = rankedLists.filter(list => list.source === 'keyword').flatMap(list => list.items);
382
399
  const vectorItems = rankedLists.filter(list => list.source === 'vector').flatMap(list => list.items);
383
400
  // 1. 先进行初筛合并,合并相同的子块并计算混合初始分(不获取大片段,保留子块自身用于精确打分)
384
- const mergedChildChunks = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, limit * 4, weights), limit * 4);
401
+ const mergeLimit = effectiveLimit * 4;
402
+ const mergedChildChunks = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, mergeLimit, weights), mergeLimit);
385
403
  // 2. 对这些子块进行交叉编码器重排(Cross-Encoder Rerank)
386
404
  let reranked = mergedChildChunks;
387
405
  let rerankerName = 'local-heuristic-fallback';
388
406
  if (mergedChildChunks.length > 0) {
389
- const candidates = mergedChildChunks.slice(0, Math.min(30, limit * 4));
407
+ const rerankLimit = requestedLimit ? Math.min(30, mergeLimit) : mergedChildChunks.length;
408
+ const candidates = mergedChildChunks.slice(0, rerankLimit);
390
409
  // 这里使用的是子块自身内容,通常在 500 tokens 左右,不仅相关性判断最准,而且不会超出 Reranker 的 max_length
391
410
  const textsToRerank = candidates.map(item => `${item.titlePath ?? item.sectionTitle ?? ''}\n${item.content}`);
392
411
  try {
@@ -409,8 +428,8 @@ export class KnowledgeBaseManager {
409
428
  reranked = this.heuristicRerank(query, mergedChildChunks);
410
429
  }
411
430
  }
412
- // 3. 拿到精确打分后的 Top 结果,此时再进行 expandContext 向上追溯到完整的父块大片段
413
- const finalExpandedResults = this.mergeExpandedContexts(reranked.map(item => this.expandContext(item)), limit);
431
+ // 3. 拿到精确打分后的结果,此时再进行 expandContext 向上追溯到完整的父块大片段
432
+ const finalExpandedResults = this.mergeExpandedContexts(reranked.map(item => this.expandContext(item)), effectiveLimit);
414
433
  return {
415
434
  results: finalExpandedResults,
416
435
  scopesSearched: this.scope === 'global' ? ['global'] : ['project'],
@@ -433,7 +452,7 @@ export class KnowledgeBaseManager {
433
452
  const result = await search.search({
434
453
  query,
435
454
  queryEmbedding,
436
- topK: options.limit ?? 10,
455
+ topK: this.resolveSearchLimit(options.limit, options.filters),
437
456
  scope: this.scope,
438
457
  projectId: this.projectId,
439
458
  collections: options.collections,
@@ -451,17 +470,21 @@ export class KnowledgeBaseManager {
451
470
  listFiles() {
452
471
  return this.store.listRecords();
453
472
  }
454
- getFileDetail(relativePath) {
473
+ getFileDetail(relativePath, options = {}) {
455
474
  const normalized = this.normalizeRelativePath(relativePath);
456
475
  const file = this.store.listRecords().find(record => record.relativePath === normalized);
457
476
  if (!file)
458
477
  return undefined;
459
478
  const absolutePath = this.resolveKbRelativePath(normalized);
479
+ const chunks = options.maxChunkContentChars
480
+ ? this.store.listChunksByContentBudget({ relativePath: normalized, maxContentChars: options.maxChunkContentChars })
481
+ : this.store.listChunks({ relativePath: normalized });
460
482
  return {
461
483
  file,
462
484
  absolutePath,
463
485
  directory: path.dirname(absolutePath),
464
- chunks: this.store.listChunks({ relativePath: normalized, limit: 500 }),
486
+ chunks,
487
+ totalChunkCount: this.store.countChunks({ relativePath: normalized }),
465
488
  parents: this.store.listParentChunks(normalized),
466
489
  relationships: this.store.listRelationships(normalized),
467
490
  tags: this.store.listTags(normalized),
@@ -59,7 +59,7 @@ export class MultiProjectManager {
59
59
  return this.registry.list();
60
60
  }
61
61
  async search(projectRoot, query, options = {}) {
62
- const limit = options.limit ?? 10;
62
+ const limit = Number.isFinite(options.limit) && options.limit > 0 ? Math.ceil(options.limit) : undefined;
63
63
  const scope = options.scope ?? 'project';
64
64
  const project = await this.getProject(projectRoot);
65
65
  if (scope === 'project')
@@ -73,7 +73,8 @@ export class MultiProjectManager {
73
73
  }
74
74
  const global = await this.getGlobalKB();
75
75
  const globalResults = await global.hybridSearch(query, { limit, filters: options.filters, weights: options.weights, generationMode: options.generationMode });
76
- const merged = new FederationSearch().merge([...projectResults.results, ...globalResults.results], limit, 'all');
76
+ const mergeLimit = limit ?? (projectResults.results.length + globalResults.results.length);
77
+ const merged = new FederationSearch().merge([...projectResults.results, ...globalResults.results], mergeLimit, 'all');
77
78
  return {
78
79
  ...merged,
79
80
  debug: this.mergeDebug(projectResults.debug, globalResults.debug),
@@ -94,7 +95,8 @@ export class MultiProjectManager {
94
95
  }
95
96
  const global = await this.getGlobalKB();
96
97
  const globalResults = await global.semanticSearch(query, options);
97
- return new FederationSearch().merge([...projectResults.results, ...globalResults.results], options.limit ?? 10, 'all');
98
+ const mergeLimit = Number.isFinite(options.limit) && options.limit > 0 ? Math.ceil(options.limit) : projectResults.results.length + globalResults.results.length;
99
+ return new FederationSearch().merge([...projectResults.results, ...globalResults.results], mergeLimit, 'all');
98
100
  }
99
101
  async findCrossProjectDuplicates() {
100
102
  const projects = this.registry.list();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@customize-agent/knowledge",
3
- "version": "4.0.33",
3
+ "version": "4.0.34",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",