Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# @antv/context

[![Build](https://github.com/antvis/context/actions/workflows/build.yml/badge.svg)](https://github.com/antvis/context/actions/workflows/build.yml)
[![npm version](https://img.shields.io/npm/v/@antv/context)](https://www.npmjs.com/package/@antv/context)
[![npm downloads](https://img.shields.io/npm/dm/@antv/context)](https://www.npmjs.com/package/@antv/context)
[![License](https://img.shields.io/npm/l/@antv/context)](./LICENSE)

A local context retrieval library that enables semantic search over your documentation. It loads documents (Markdown, JSON, Text), vectorizes them using [Transformers.js](https://huggingface.co/transformers.js), and stores vectors locally in `.zvec` files for fast semantic querying.

> [!TIP]
Expand Down Expand Up @@ -48,7 +53,7 @@ await ctx.close();
| `vectorsDir` | `string` | `.context/vectors` | Directory to store vector files |
| `basePath` | `string` | `process.cwd()` | Base path for resolving document IDs. Set for cross-machine consistent IDs. |
| `onProgress` | `(phase, detail) => void` | — | Progress callback for `load()` phases: `'load'` → `'embed'` → `'insert'`. |
| `queryExpansion` | `QueryExpansionOptions | false` | `false` (no-op) | Query expansion with user-provided synonym map. `false` disables. Without `synonyms`, expansion is a no-op. |
| `queryExpansion` | `QueryExpansionOptions` | `false` | Query expansion with user-provided synonym map. `false` disables. Without `synonyms`, expansion is a no-op. |
Comment thread
hustcc marked this conversation as resolved.
Outdated
| `ftsFields` | `string[]` | `['content']` | Fields to index for Full Text Search in hybrid mode |
| `ftsFieldWeights` | `Record<string, number>` | `{ content: 1 }` | Per-field boost weights for FTS text path. Higher = more influence. |
| `rankConstant` | `number` | `60` | RRF rank constant for hybrid search fusion. Lower = "winner-takes-all", higher = more even. |
Expand Down Expand Up @@ -96,7 +101,7 @@ Document IDs are derived from file paths relative to `basePath` for cross-machin
| Parameter | Type | Description |
|-----------|------|-------------|
| `library` | `string` | Library name for organizing documents |
| `pattern` | `string | string[]` | Glob pattern(s) matching files to load |
| `pattern` | `string \| string[]` | Glob pattern(s) matching files to load |

```typescript
await ctx.load('g2', './docs/**/*.md');
Expand Down
7 changes: 3 additions & 4 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export { pathToId } from './doc';
export { computeContentHash } from './hash';
export { safeJsonParse } from './common';
export { loadSampleText } from './sample';
export { isCJK, detectLanguage, tokenizerForLanguage, detectTokenizer } from './tokenizer';
export type { LanguageHint } from './tokenizer';
export { containsCJK } from './str';
export { loadSampleText, selectSampleFiles } from './sample';
export { isCJK, containsCJK, detectLanguage, tokenizerForLanguage, detectTokenizer } from './tokenizer';
export type { LanguageHint } from './tokenizer';
24 changes: 10 additions & 14 deletions src/utils/sample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as fs from 'fs';
/**
* Select a representative sample of files for tokenizer detection.
*/
function selectSampleFiles(files: string[], maxCount: number): string[] {
export function selectSampleFiles(files: string[], maxCount: number): string[] {
if (files.length <= maxCount) return files;

const result: string[] = [];
Expand All @@ -27,19 +27,15 @@ function selectSampleFiles(files: string[], maxCount: number): string[] {
export async function loadSampleText(files: string[], sampleCount = 5): Promise<string | undefined> {
if (files.length === 0) return undefined;

try {
const sampleFiles = selectSampleFiles(files, sampleCount);
const samples = await Promise.allSettled(
sampleFiles.map((f) => fs.promises.readFile(f, 'utf-8')),
);
const validSamples = samples
.filter((r): r is PromiseFulfilledResult<string> => r.status === 'fulfilled')
.map((r) => r.value);
if (validSamples.length > 0) {
return validSamples.join('\n');
}
} catch {
// Sample failure is non-fatal
const sampleFiles = selectSampleFiles(files, sampleCount);
const samples = await Promise.allSettled(
sampleFiles.map((f) => fs.promises.readFile(f, 'utf-8')),
);
const validSamples = samples
.filter((r): r is PromiseFulfilledResult<string> => r.status === 'fulfilled')
.map((r) => r.value);
if (validSamples.length > 0) {
return validSamples.join('\n');
}
return undefined;
}
7 changes: 0 additions & 7 deletions src/utils/str.ts

This file was deleted.

10 changes: 10 additions & 0 deletions src/utils/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export function isCJK(ch: string): boolean {
);
}

/**
* Check whether a string contains any CJK characters.
*/
export function containsCJK(text: string): boolean {
for (const ch of text) {
if (isCJK(ch)) return true;
}
return false;
}

/**
* Detect the dominant language category of a text sample.
*
Expand Down
18 changes: 18 additions & 0 deletions test/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ describe('Context', () => {
});

describe('load', () => {
it('should call onProgress callback during load', async () => {
const progressCalls: { phase: string; detail: { loaded: number; total: number } }[] = [];

const ctxWithProgress = await Context.create({
vectorsDir: TEST_DIR + '-progress',
onProgress: (phase, detail) => {
progressCalls.push({ phase, detail });
},
});

await ctxWithProgress.load('md', path.join(FIXTURES_DIR, '*.md'));

expect(progressCalls.length).toBeGreaterThan(0);
expect(progressCalls.map(c => c.phase)).toContain('load');

await ctxWithProgress.close();
});
Comment thread
hustcc marked this conversation as resolved.

it('should load markdown files', async () => {
await ctx.load('md', path.join(FIXTURES_DIR, 'getting-started.md'));

Expand Down
18 changes: 18 additions & 0 deletions test/embedder/embedder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { Embedder } from '../../src/embedder/embedder';

describe('Embedder', () => {
it('should have default dimensions of 512', () => {
const embedder = new Embedder();
expect(embedder.dimensions).toBe(512);
});

it('should have static pipeline property', () => {
expect(Embedder.pipeline).toBeNull();
});

it('should be instantiable', () => {
const embedder = new Embedder();
expect(embedder).toBeInstanceOf(Embedder);
});
});
135 changes: 135 additions & 0 deletions test/expander/expand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { expand } from '../../src/expander';

describe('expand', () => {
const queryExpansion = {
synonyms: {
'折线图': ['line chart', '折线'],
'tooltip': ['提示框', '提示', 'hover'],
'animation': ['动效', 'animate', 'transition'],
'config': ['配置', 'configuration', '设置'],
},
};

it('should expand CN chart type to EN equivalent', () => {
const result = expand('折线图', queryExpansion);
expect(result).toContain('line chart');
});

it('should expand EN term to CN equivalent', () => {
const result = expand('tooltip', queryExpansion);
expect(result).toContain('提示框');
});

it('should not duplicate terms already in query', () => {
const result = expand('tooltip 提示框', queryExpansion);
const promptCount = result.split('提示框').length - 1;
expect(promptCount).toBe(1);
});

it('should expand multiple terms in one query', () => {
const result = expand('tooltip config', queryExpansion);
expect(result).toContain('提示框');
expect(result).toContain('配置');
});

it('should preserve original query text', () => {
const result = expand('animation settings', queryExpansion);
expect(result.startsWith('animation settings')).toBe(true);
});

it('should return original query when no synonyms match', () => {
const result = expand('random unrelated terms', queryExpansion);
expect(result).toBe('random unrelated terms');
});

it('should handle empty query', () => {
const result = expand('', queryExpansion);
expect(result).toBe('');
});

it('should return original query with no synonyms', () => {
const result = expand('tooltip configuration');
expect(result).toBe('tooltip configuration');
});

it('should return original query with empty synonyms', () => {
const result = expand('tooltip configuration', {});
expect(result).toBe('tooltip configuration');
});

it('should return query unchanged when queryExpansion is false', () => {
const result = expand('折线图配置', false);
expect(result).toBe('折线图配置');
});

it('should handle CJK terms with substring match', () => {
// Line 14: containsCJK(term) returns true, so substring match is used
const result = expand('折线图', queryExpansion);
expect(result).toContain('折线');
});

it('should handle term at start of query', () => {
// Line 18-28: while loop with word boundary at start
const result = expand('tooltip chart', queryExpansion);
expect(result).toContain('提示框');
});

it('should handle term at end of query', () => {
// Test term matching at end of query
const result = expand('chart tooltip', queryExpansion);
expect(result).toContain('提示框');
});

it('should handle term in middle of query', () => {
const result = expand('show tooltip here', queryExpansion);
expect(result).toContain('提示框');
});

it('should not add synonym already in query', () => {
// Line 45: containsTerm check prevents duplication
const result = expand('tooltip 提示框', queryExpansion);
const parts = result.split(' ');
const promptCount = parts.filter(p => p === '提示框').length;
expect(promptCount).toBe(1);
});

it('should handle synonyms with already added terms', () => {
// Multiple terms matching same synonym
const result = expand('折线图 折线', queryExpansion);
expect(result).toContain('line chart');
});

it('should handle term at word boundary returning true immediately', () => {
// This tests the path where containsTerm finds a match and returns true (line 26)
// Direct term at word boundary
const result = expand('test tooltip config', queryExpansion);
expect(result).toContain('提示');
});

it('should not match partial Latin substring in word', () => {
// This tests the path where term is found but not at word boundaries (lines 27-29)
// Searching for "config" in "configured" - gets past include check but not boundary check
const testExpansion = {
synonyms: { 'config': ['setting'] }
};
const result = expand('configured', testExpansion);
// "config" is part of "configured", not a standalone word - should not match
expect(result).toBe('configured');
});

it('should handle term found but never at word boundary', () => {
// This tests line 29-30: term found but all occurrences fail boundary check
// Using a term that appears only as part of a longer word
const testExpansion = {
synonyms: { 'abc': ['xyz'] }
};
// "abc" does NOT appear in this text at all - so line 11 returns false early
// Need text that contains "abc" but never as a standalone word
const result = expand('xabcy', testExpansion);
// "abc" is in "xabcy" but not at word boundary (surrounded by letters)
// This will go through the loop, never find a boundary match, then return false
// This triggers lines 29-30
expect(result).toBe('xabcy');
});
});
1 change: 1 addition & 0 deletions test/fixtures/docs/string.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"This is a JSON string value"
86 changes: 0 additions & 86 deletions test/language.test.ts

This file was deleted.

Loading
Loading