Files
moreminimore-chat/app/services/chatbot/knowledge_retriever.rb
Moreminimore 2495239187 feat(chatbot): OSS self-contained guardrail + knowledge-base chatbot
Built-in AI chatbot (no EE, uses Llm::Resolver) that answers in-scope chats
from a knowledge base and hands off to a human when needed. Selected per
inbox via an Integrations::Hook with app_id 'chatbot'.

- Integrations::Chatbot::ProcessorService (mirrors Dialogflow/Captain)
  wired via HookListener + HookJob + apps.yml(chatbot, inbox)
- Chatbot::DecisionService: 1-call default ({in_scope/refuse/handoff}),
  2-call option; thread-safe prompt threading
- Chatbot::KnowledgeRetriever: keyword top-k over KB (+ embedding reserved)
- KnowledgeBaseFaq + import service (md per-heading + front-matter tags)
- Chatbot::ConfigService + admin chatbot_config endpoint
- refs off-topic (e.g. fortune-telling); handoff = bot_handoff! (pending->open)
2026-08-25 16:09:47 +07:00

87 lines
3.0 KiB
Ruby

# KnowledgeBase retriever for the self-contained OSS chatbot.
#
# Given a user message, returns the top-k most relevant KnowledgeBaseFaq entries for
# that account, ordered best-first. Retrieval is keyword-based (pg_trgm similarity on
# title+content + topic_tag match) so it works without an embedding backend.
#
# A `query_embedding` param is accepted as a future extension point for embedding-fusion
# (hybrid keyword + vector ranking), to be wired when an embedding provider is configured
# (see plan). Returns [{ faq:, score: Float }] — the caller injects these into the LLM prompt.
module Chatbot::KnowledgeRetriever
DEFAULT_LIMIT = 5
module_function
# @param account [Account]
# @param query [String] the user message
# @param query_embedding [Array<Float>, nil] reserved; embedding-fusion is a later step
# @param limit [Integer]
# @return [Array<Hash>] [{ faq:, score: Float }]
def retrieve(account:, query:, query_embedding: nil, limit: DEFAULT_LIMIT)
return [] if query.blank?
scores = score_candidates(account, query)
return [] if scores.empty?
max = scores.values.max
scores.map { |faq, score| { faq: faq, score: (score / max).round(4) } }
.sort_by { |h| -h[:score] }
.first(limit)
end
# Rank candidate FAQ entries by pg_trgm similarity + topic-tag match.
# @return [Hash{KnowledgeBaseFaq => Float}]
def score_candidates(account, query)
scores = {}
candidates(account, query).each do |faq|
s = faq_title_similarity(faq, query)
s = [s, faq_content_similarity(faq, query)].max
s += 0.2 if topic_match?(faq, query)
scores[faq] = s if s.positive?
end
scores
end
# Candidate set: entries whose title or content is likely relevant (pre-filter via
# pg_trgm word_similarity to keep the scoring pass small). Falls back to all account
# FAQs if pre-filter isn't available (plain AR without pg_trgm search string).
def candidates(account, query)
relation = account.knowledge_base_faqs
column = %(GREATEST(word_similarity(title, #{quote(query)}), word_similarity(content, #{quote(query)})))
relation.where("#{column} > 0.1").limit(50).to_a
rescue StandardError
relation.limit(200).to_a
end
def faq_title_similarity(faq, query)
pg_similarity(faq.title, query)
end
def faq_content_similarity(faq, query)
pg_similarity(faq.content, query)
end
# Token-overlap similarity ratio computed in Ruby (downcase → split → overlap / max size).
# Deterministic and DB-free; used to rank the small candidate pool from `candidates`.
def pg_similarity(text_a, text_b)
return 0.0 if text_a.blank? || text_b.blank?
a = text_a.downcase.split(/\s+/).reject(&:blank?)
b = text_b.downcase.split(/\s+/).reject(&:blank?)
return 0.0 if a.empty? || b.empty?
overlap = (a & b).size
overlap.to_f / [a.size, b.size].max.to_f
end
def topic_match?(faq, query)
q = query.downcase
faq.topic_tag_list.any? { |t| q.include?(t.downcase) }
end
def quote(value)
ActiveRecord::Base.sanitize_sql_like(value.to_s).gsub("'", "''")
end
end