Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 13 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 All @@ -20,6 +25,12 @@ A local context retrieval library that enables semantic search over your documen
npm install @antv/context
```

> [!TIP]
> If you encounter model download timeout when first creating a Context, set the environment variable:
> ```bash
> HF_ENDPOINT=https://hf-mirror.com node your-script.js
> ```

```typescript
import { Context } from '@antv/context';

Expand Down Expand Up @@ -48,7 +59,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 | false | Query expansion with user-provided synonym map. false disables. Without synonyms, expansion is a no-op. |
| `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 +107,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
25 changes: 18 additions & 7 deletions src/embedder/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,32 @@ type TransformersPipeline = (texts: string[], options: Record<string, unknown>)

export class Embedder {
private static pipeline: TransformersPipeline | null = null;
private static pipelinePromise: Promise<TransformersPipeline> | null = null;

readonly dimensions = DEFAULT_DIMENSIONS;

private static async getPipeline(): Promise<TransformersPipeline> {
if (Embedder.pipeline) return Embedder.pipeline;

const mod = await import('@huggingface/transformers');
const hfEndpoint = process.env.HF_ENDPOINT;
if (hfEndpoint && (mod as any).env) {
(mod as any).env.remoteHost = hfEndpoint;
if (!Embedder.pipelinePromise) {
Embedder.pipelinePromise = (async () => {
const mod = await import('@huggingface/transformers');
const hfEndpoint = process.env.HF_ENDPOINT;
if (hfEndpoint && (mod as any).env) {
(mod as any).env.remoteHost = hfEndpoint;
}

const pipe = await mod.pipeline('feature-extraction', DEFAULT_MODEL_ID) as TransformersPipeline;
Embedder.pipeline = pipe;
return Embedder.pipeline;
})().catch((err) => {
Embedder.pipeline = null;
Embedder.pipelinePromise = null;
throw err;
});
}

const pipe = await mod.pipeline('feature-extraction', DEFAULT_MODEL_ID) as TransformersPipeline;
Embedder.pipeline = pipe;
return Embedder.pipeline;
return Embedder.pipelinePromise;
}

async embed(text: string): Promise<number[]> {
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
24 changes: 24 additions & 0 deletions test/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ describe('Context', () => {
});

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

const ctxWithProgress = await Context.create({
vectorsDir: progressDir,
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();

// Cleanup
if (fs.existsSync(progressDir)) {
fs.rmSync(progressDir, { recursive: true, force: true });
}
});
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"
Loading
Loading