Class: Insika::Knowledge::Index::Scan

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/knowledge.rb

Overview

Pure Ruby term-overlap search over one agent's concepts — no SQL, no embeddings. Mirrors ToolCatalog#search's tokenizer/scoring shape (case-insensitive substring match, name weighted over description), extended per RFC §3.7 with a body tier and a confidence × recency multiplier. Correct on every backend; at the scale that matters (a few hundred concepts per agent) this is sub-millisecond.

Constant Summary collapse

NAME_WEIGHT =
3
DESCRIPTION_WEIGHT =
2
BODY_WEIGHT =
1
RECENCY_HALF_LIFE_DAYS =
30.0

Instance Method Summary collapse

Constructor Details

#initialize(store:) ⇒ Scan

Returns a new instance of Scan.



530
531
532
533
534
535
536
537
538
539
540
541
542
543
# File 'lib/insika/knowledge.rb', line 530

def initialize(store:)
  @store = store
  # Read cache: parsing a concept's YAML frontmatter dominates search
  # cost (measured: ~90% of it, not the store I/O) — re-parsing it on
  # every search for a concept nothing wrote to since the last read
  # is pure waste. Keyed by (agent, tenant, name); a cached entry is
  # valid only while `updated_at` (the record's own timestamp, read
  # WITHOUT parsing — KnowledgeStore#meta) still matches, so a write
  # invalidates itself for free. One instance is meant to survive
  # across turns (the context provider holds it), fibers included:
  # a plain Hash is safe here the same way a closure-local counter is
  # elsewhere in the engine — MRI fibers do not preempt mid-statement.
  @cache = {}
end

Instance Method Details

#search(agent_id, query:, tenant: nil, top_k: 5) ⇒ Object

-> [description:, type:, confidence:, provenance:, sources:, occurrences:, body:, ...] sorted by score desc, ties by store enumeration order. Excludes zero-overlap concepts entirely.



548
549
550
551
552
553
554
555
556
557
558
559
560
561
# File 'lib/insika/knowledge.rb', line 548

def search(agent_id, query:, tenant: nil, top_k: 5)
  terms = tokenize(query)
  return [] if terms.empty?

  candidates = @store.names(agent_id, tenant: tenant).filter_map do |name|
    cached_concept(agent_id, name, tenant)
  end
  scored = candidates.each_with_index.filter_map do |concept, idx|
    score = score_of(concept, terms)
    [concept, score, idx] if score.positive?
  end
  scored.sort_by { |_concept, score, idx| [-score, idx] }
        .first(top_k).map(&:first)
end