Verb Sentence Matching System
Overview
The sentence matching system automatically identifies and links verb conjugations and infinitives within sentences, enabling intelligent sentence pair generation and enhanced language learning experiences.
Architecture
Data Flow
┌─────────────────────────────────────────────────────────────┐
│ SENTENCE-DRIVEN MATCHING (sentence → verbs) │
│ │
│ New Sentence Created (≤80 chars) │
│ ↓ │
│ ProcessConjugationMatchesJob (background) │
│ ↓ │
│ ConjugationMatcherService + InfinitiveMatcherService │
│ ↓ │
│ Creates: sentence_conjugations + sentence_infinitives │
│ ↓ │
│ Updates: sentence.verb_matched = true │
│ conjugation.sentences_matched = true │
│ infinitive.sentences_matched = true │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ VERB-DRIVEN MATCHING (verb → sentences) │
│ │
│ FindSentenceMatchesJob triggered (manual/API) │
│ ↓ │
│ Searches all sentences in same language │
│ ↓ │
│ Creates: sentence_conjugations or sentence_infinitives │
│ ↓ │
│ Updates: conjugation/infinitive.sentences_matched = true │
└─────────────────────────────────────────────────────────────┘
Database Schema
Core Tables
conjugations
- Stores finite verb forms (je vais, tu vas, etc.)
- Key fields:
language,lemma,full_form,form - New field:
sentences_matched(boolean) - has this been processed?
infinitives
- Stores infinitive verb forms (aller, sein, hablar, etc.)
- Key fields:
language,lemma,form,variant - New field:
sentences_matched(boolean) - has this been processed?
sentences
- Stores example sentences in all languages
- Key fields:
language,sentence - Existing field:
verb_matched(boolean) - has this been processed? - Only sentences ≤80 characters are matched (performance optimization)
Join Tables
sentence_conjugations
Links sentences to specific conjugations with metadata:
sentence_id,conjugation_idlanguage(denormalized for fast filtering)lemma,mood,tense_name,person_seq,person_label,conj_formapproach- matching method used (‘fts’ for full-text search)matched_text,span_start,span_end- position info (future use)confidence- matching confidence (future use)
sentence_infinitives
Links sentences to infinitives with metadata:
sentence_id,infinitive_idlanguage(denormalized for fast filtering)lemma,infinitive_variantapproach- matching method used (‘fts’ for full-text search)matched_text,span_start,span_end- position info (future use)confidence- matching confidence (future use)
Indexes
Critical for performance:
-- GIN full-text search index on sentences
CREATE INDEX CONCURRENTLY index_sentences_on_sentence_fts
ON sentences USING gin(to_tsvector('simple', sentence));
-- Language filtering indexes
CREATE INDEX index_sentences_on_language_and_verb_matched
ON sentences (language, verb_matched);
CREATE INDEX index_conjugations_on_language_and_sentences_matched
ON conjugations (language, sentences_matched);
CREATE INDEX index_infinitives_on_language_and_sentences_matched
ON infinitives (language, sentences_matched);
-- Join table indexes
CREATE INDEX index_sentence_conjugations_on_language_and_lemma
ON sentence_conjugations (language, lemma);
CREATE INDEX index_sentence_infinitives_on_language_and_lemma
ON sentence_infinitives (language, lemma);
Matching Algorithm
Full-Text Search Approach
The system uses PostgreSQL’s built-in full-text search capabilities:
- Text Vectorization: Sentences are converted to
tsvectorusing the ‘simple’ configuration- ‘simple’ configuration treats all words as-is (no stemming or stop words)
- This allows matching across all supported languages without language-specific dictionaries
- Query Matching: Verb forms are searched using
plainto_tsquery- Special characters are stripped:
j'ai→j ai - Word boundaries are automatically enforced
- Case-insensitive matching
- Special characters are stripped:
- Example Query:
SELECT id FROM sentences WHERE language = 'fr' AND char_length(sentence) <= 80 AND to_tsvector('simple', sentence) @@ plainto_tsquery('simple', 'vous conduisiez')
Language Isolation
CRITICAL: Every query includes a language filter to prevent cross-language matching:
# WRONG - Could match across languages
Conjugation.where.not(full_form: nil)
# CORRECT - Language-isolated
Conjugation.where(language: 'fr').where.not(full_form: nil)
All services, jobs, and rake tasks enforce single-language processing:
- Services receive a sentence/conjugation/infinitive with an embedded language
- Jobs validate language consistency
- Rake tasks process one language at a time
Matching Rules
- Word Boundaries Only: “je vais” matches “Oui, je vais bien” but not “jeune vais”
- Case Insensitive: “Aller” matches “aller”, “Aller”, “ALLER”
- Common Verbs Only: Only matches
verb_lemmas.common = true(performance optimization) - Length Filter: Only sentences ≤80 characters are matched (performance optimization)
- Same Language: Conjugation/infinitive language must match sentence language
Service Layer
ConjugationMatcherService
Purpose: Given a sentence, find all conjugations that appear in it
Usage:
sentence = Sentence.find(123)
service = Resources::Sentences::ConjugationMatcherService.new(sentence: sentence)
match_count = service.call
# => 3 (found 3 conjugations in this sentence)
Process:
- Check if sentence already processed (
verb_matched?) - skip if true - Check sentence length (skip if > 80 chars)
- Execute single SQL query to find all matching conjugations in same language
- Filter out duplicates
- Bulk insert new
sentence_conjugationsrecords - Update
conjugations.sentences_matched = truefor matched conjugations - Mark
sentence.verb_matched = true
InfinitiveMatcherService
Purpose: Given a sentence, find all infinitives that appear in it
Usage:
sentence = Sentence.find(123)
service = Resources::Sentences::InfinitiveMatcherService.new(sentence: sentence)
match_count = service.call
# => 1 (found 1 infinitive in this sentence)
Process: Same as ConjugationMatcherService but for infinitives
Background Jobs
ProcessConjugationMatchesJob
Trigger: Automatically when new sentence created (if ≤80 chars)
Purpose: Process a single sentence to find all verbs within it
Usage:
# Called automatically via Sentence model callback
# Or manually:
Resources::Sentences::ProcessConjugationMatchesJob.perform_later(sentence_id)
Features:
- Runs both ConjugationMatcherService and InfinitiveMatcherService
- Logs match counts
- Idempotent (checks
verb_matchedflag)
FindSentenceMatchesJob (Conjugations)
Trigger: Manual or via API
Purpose: Find all sentences that contain a specific conjugation
Usage:
conjugation = Conjugation.find(456)
Resources::Conjugations::FindSentenceMatchesJob.perform_later(conjugation.id)
Features:
- Skips if
conjugation.sentences_matched = true - Searches all short sentences in same language
- Marks conjugation as processed even if no matches found
FindSentenceMatchesJob (Infinitives)
Trigger: Manual or via API
Purpose: Find all sentences that contain a specific infinitive
Usage:
infinitive = Infinitive.find(789)
Resources::Infinitives::FindSentenceMatchesJob.perform_later(infinitive.id)
Features: Same as conjugation version but for infinitives
Rake Tasks
Main Task: match_sentences
Purpose: Bulk processing of conjugations and infinitives
# Basic usage - all languages, both conjugations and infinitives
bundle exec rake immersive:match_sentences
# Single language, conjugations only
bundle exec rake "immersive:match_sentences[fr,conjugations_only]"
# Single language, infinitives only
bundle exec rake "immersive:match_sentences[de,infinitives_only]"
# Both conjugations and infinitives for Spanish
bundle exec rake "immersive:match_sentences[es,both]"
# Force rerun (clears existing matches)
bundle exec rake "immersive:match_sentences[fr,both,rerun]"
# Test with limited dataset (first 50 items)
bundle exec rake "immersive:match_sentences[fr,conjugations_only,,50]"
Parameters:
language- Language code (fr, de, es, en, pt, it) or blank for allmode-conjugations_only,infinitives_only, orboth(default: both)rerun- Set to ‘rerun’ to clear existing matches and reprocesslimit- Optional limit for testing (e.g., 50 to process only first 50 items)
Features:
- Real-time progress reporting with ETA
- Processes one language at a time (enforces language isolation)
- Skips already-processed items unless
rerunspecified - Reports rate (items/second) and total matches
- Idempotent - safe to run multiple times
Example Output:
================================================================================
SENTENCE MATCHING TASK
================================================================================
Languages: fr
Mode: conjugations_only
Rerun: NO (skip already matched)
Limit: none (process all)
================================================================================
================================================================================
Processing language: FR
================================================================================
--- Processing Conjugations ---
Conjugations to process: 9247
Progress: 9247/9247 (100.0%) | Matches: 15432 | Rate: 45.2/s | ETA: 0m 0s
Conjugations complete: 9247 processed, 15432 total matches in 204.6s
Test Task: test_sentence_matching
Purpose: Test the system with a small dataset to verify everything works
# Test with 10 items in French
bundle exec rake "immersive:test_sentence_matching[fr,10]"
# Test with 5 items in German
bundle exec rake "immersive:test_sentence_matching[de,5]"
# Test with default 20 items in Spanish
bundle exec rake "immersive:test_sentence_matching[es]"
Tests Performed:
- Verify GIN index exists and uses ‘simple’ configuration
- Check data availability (conjugations, infinitives, sentences) for language
- Test sentence → conjugations matching
- Test sentence → infinitives matching
- Test conjugation → sentences matching
- Test infinitive → sentences matching
- Test background jobs can be enqueued
- Show statistics for the language
Example Output:
================================================================================
SENTENCE MATCHING SYSTEM TEST
================================================================================
Language: FR
Sample size: 10 items
================================================================================
TEST 1: Checking GIN full-text search index...
GIN index exists with 'simple' configuration
TEST 2: Checking data availability for fr...
Conjugations (common verbs): 9247
Infinitives (common verbs): 823
Sentences (≤80 chars): 45621
TEST 3: Testing sentence → conjugations matching...
Processed: 10 sentences
With matches: 8 (80.0%)
Total matches: 24
Avg per sentence: 2.40
Example: "Je vais au marché." → je vais (aller)
...
Query Patterns
Find all sentences using a specific conjugation
conjugation = Conjugation.find_by(language: 'fr', full_form: 'je vais')
sentences = conjugation.sentences
# Returns all sentences containing "je vais"
Find all conjugations in a sentence
sentence = Sentence.find(123)
conjugations = sentence.conjugations
# Returns all conjugations found in this sentence
Find all infinitives in sentences for a lemma
infinitives = Infinitive.where(language: 'fr', lemma: 'aller')
sentences = Sentence
.joins(:sentence_infinitives)
.where(sentence_infinitives: {infinitive_id: infinitives.ids})
.distinct
Find unprocessed conjugations for a language
Conjugation
.joins(:verb_lemma)
.where(language: 'fr', sentences_matched: false, verb_lemmas: {common: true})
.where.not(full_form: [nil, ''])
Get statistics for a language
language = 'fr'
stats = {
conjugations_total: Conjugation.where(language: language).count,
conjugations_matched: Conjugation.where(language: language, sentences_matched: true).count,
conjugation_matches_total: SentenceConjugation.where(language: language).count,
infinitives_total: Infinitive.where(language: language).count,
infinitives_matched: Infinitive.where(language: language, sentences_matched: true).count,
infinitive_matches_total: SentenceInfinitive.where(language: language).count,
sentences_total: Sentence.where(language: language).count,
sentences_short: Sentence.where(language: language).where("char_length(sentence) <= 80").count,
sentences_matched: Sentence.where(language: language, verb_matched: true).count
}
Performance Considerations
GIN Index Management
GIN indexes are powerful but have specific performance characteristics:
Write Performance:
- GIN indexes use a “pending list” to batch updates
- Default
gin_pending_list_limitis 4MB - When limit reached, pending list is flushed (can cause slow writes)
- Autovacuum periodically flushes pending list
Strategies for Large Tables:
- Adjust
gin_pending_list_limitbased on workload - Tune autovacuum frequency
- Use
CREATE INDEX CONCURRENTLYto avoid blocking writes - Monitor pending list size with
pgstatginindex('index_name')
Read Performance:
- GIN indexes enable fast full-text search
- Much faster than regex or LIKE queries
- Only supports Bitmap Index Scans (not Index Scan or Index Only Scan)
Batch Processing Strategy
The rake task uses several optimizations:
- Single SQL Queries: One query per conjugation/infinitive instead of N+1
- Bulk Inserts: Use
insert_allinstead of individualcreatecalls - Skip Processed: Check
sentences_matchedflag to avoid re-processing - Progress Reporting: Update every 50 items to balance visibility and performance
- Denormalized Columns: Query on
languagedirectly instead of joining
Expected Performance
On typical hardware:
- Sentence processing: 40-60 sentences/second
- Conjugation processing: 30-50 conjugations/second
- Infinitive processing: 50-80 infinitives/second
For a language with:
- 10,000 conjugations
- 1,000 infinitives
- 50,000 sentences
Full processing time: ~15-20 minutes
Monitoring and Troubleshooting
Check GIN Index Health
-- Check if index exists
SELECT indexname, indexdef
FROM pg_indexes
WHERE indexname = 'index_sentences_on_sentence_fts';
-- Check pending list size (requires pg_stattuple extension)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatginindex('index_sentences_on_sentence_fts');
Common Issues
Issue: Slow sentence matching
Diagnosis:
-- Check if GIN index is being used
EXPLAIN ANALYZE
SELECT id FROM sentences
WHERE language = 'fr'
AND to_tsvector('simple', sentence) @@ plainto_tsquery('simple', 'aller');
Solution: Ensure GIN index exists and is up-to-date. Run VACUUM ANALYZE sentences;
Issue: Cross-language matches appearing
Diagnosis: Check for queries without language filter
# BAD - no language filter
SentenceConjugation.count
# GOOD - language filtered
SentenceConjugation.where(language: 'fr').count
Solution: Always include language filter in queries
Issue: Some conjugations/infinitives not getting matched
Reasons:
- No sentences available containing that form
- Sentence too long (>80 chars)
- Special characters preventing match
Diagnosis:
conjugation = Conjugation.find(id)
escaped_form = conjugation.full_form.gsub(/[^\w\s]/, '')
# Manual search
sentences = Sentence
.where(language: conjugation.language)
.where("sentence ILIKE ?", "%#{conjugation.full_form}%")
# vs FTS search
fts_sentences = Sentence
.where(language: conjugation.language)
.where("to_tsvector('simple', sentence) @@ plainto_tsquery('simple', ?)", escaped_form)
Future Enhancements
Potential improvements to consider:
- Span Detection: Accurately record
span_startandspan_endfor highlighting - Confidence Scoring: Calculate match confidence based on context
- Negative Forms: Handle negative conjugations specially (ne…pas, etc.)
- Compound Tenses: Match auxiliary + participle combinations
- Accent Handling: Add
unaccentextension for accent-insensitive matching - Multi-word Forms: Handle phrasal verbs and verb phrases
- Caching: Cache frequently accessed matches in Redis
- Analytics: Track which verbs have poor sentence coverage
- Auto-generation: Trigger AI sentence generation for under-covered verbs
- Quality Scoring: Rate sentence examples by clarity and usefulness
Related Documentation
- Verb Architecture - Overall verb system design
- PostgreSQL GIN Indexes - GIN index performance details
- Requirements - Original requirements document