Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/lib/embedder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { firstBodyParagraph } from './text.js';

const MODEL = 'Xenova/all-MiniLM-L6-v2';

Expand Down Expand Up @@ -32,7 +33,7 @@ export class Embedder {
tags: string[],
content: string,
): string {
const firstParagraph = content.split(/\n\n+/)[0] ?? '';
const firstParagraph = firstBodyParagraph(content).trim();
const parts = [title];
if (tags.length > 0) {
parts.push(tags.join(', '));
Expand Down
3 changes: 2 additions & 1 deletion src/lib/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3';
import * as sqliteVec from 'sqlite-vec';
import type { ParsedNode, ParsedEdge, SearchResult } from './types.js';
import { firstBodyParagraph } from './text.js';

export class Store {
db: Database.Database;
Expand Down Expand Up @@ -281,7 +282,7 @@ export class Store {
}

function firstParagraph(content: string, maxLen: number): string {
const para = content.split(/\n\n+/).find(p => p.trim().length > 0 && !p.startsWith('#'));
const para = firstBodyParagraph(content);
if (!para) return '';
const trimmed = para.trim();
return trimmed.length > maxLen ? trimmed.slice(0, maxLen) + '...' : trimmed;
Expand Down
14 changes: 14 additions & 0 deletions src/lib/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Returns the first non-empty, non-heading paragraph in `content`.
*
* `gray-matter` leaves a leading newline after stripping the YAML
* frontmatter, so the first paragraph from a naive `split('\n\n')` is often
* `'\n# Title'` — which only looks like a heading once trimmed. Trimming
* before the `#` check ensures the title line is correctly skipped in
* favor of the actual body text.
*/
export function firstBodyParagraph(content: string): string {
return (
content.split(/\n\n+/).find(p => p.trim().length > 0 && !p.trim().startsWith('#')) ?? ''
);
}
12 changes: 12 additions & 0 deletions test/embedder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ describe('Embedder', () => {
expect(text).toContain('theoretical framework');
expect(text).not.toContain('More details here');
});

it('includes the body paragraph, not the title heading, when content has a gray-matter-style leading newline', () => {
// gray-matter leaves a leading newline after stripping YAML frontmatter,
// so the raw content starts with '\n# Widget Theory'.
const text = Embedder.buildEmbeddingText(
'Widget Theory',
[],
'\n# Widget Theory\n\nA theoretical framework for understanding component interactions.\n\n## Section',
);
expect(text).toContain('A theoretical framework for understanding component interactions');
expect(text).not.toContain('# Widget Theory');
});
});

function cosineSimilarity(a: Float32Array, b: Float32Array): number {
Expand Down
17 changes: 17 additions & 0 deletions test/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ describe('Store', () => {
expect(results[0].excerpt).toContain('framework');
});

it('vector search excerpt skips the title heading when content has a gray-matter-style leading newline', () => {
store.upsertNode({
id: 'test.md',
title: 'Widget Theory',
// gray-matter leaves a leading newline after stripping frontmatter,
// so a naive split on '\n\n' yields '\n# Widget Theory' as paragraph 0.
content: '\n# Widget Theory\n\nA framework for understanding component interactions.\n\n## Section',
frontmatter: {},
});
const embedding = new Float32Array(384).fill(0.1);
store.upsertEmbedding('test.md', embedding);
const results = store.searchVector(embedding, 5);
expect(results.length).toBeGreaterThan(0);
expect(results[0].excerpt).toContain('A framework for understanding component interactions');
expect(results[0].excerpt).not.toContain('# Widget Theory');
});

it('counts edges for a node', () => {
store.upsertNode({ id: 'a.md', title: 'A', content: '', frontmatter: {} });
store.upsertNode({ id: 'b.md', title: 'B', content: '', frontmatter: {} });
Expand Down
21 changes: 21 additions & 0 deletions test/text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { firstBodyParagraph } from '../src/lib/text.js';

describe('firstBodyParagraph', () => {
it('returns the first non-empty, non-heading paragraph', () => {
const content = '# Title\n\nFirst body paragraph.\n\nSecond paragraph.';
expect(firstBodyParagraph(content)).toBe('First body paragraph.');
});

it('skips a leading heading even when preceded by a stray newline', () => {
// gray-matter leaves a leading newline after stripping YAML frontmatter,
// so the first split segment is '\n# Title', not '# Title'.
const content = '\n# Title\n\nFirst body paragraph.\n\n## Section';
expect(firstBodyParagraph(content)).toBe('First body paragraph.');
});

it('returns empty string when content has no body paragraph', () => {
expect(firstBodyParagraph('\n# Title Only')).toBe('');
expect(firstBodyParagraph('')).toBe('');
});
});