Class: Pikuri::VectorDb::Tools::Search

Inherits:
Tool
  • Object
show all
Defined in:
lib/pikuri/vector_db/tools/search.rb

Overview

The LLM-facing vectordb_search tool — agentic search, the agent decides when to retrieve. Composes Embedder + Backend + optional Reranker: embed the query, Backend#query for candidates (CANDIDATE_K with a reranker to narrow back to FINAL_K, else FINAL_K directly), optionally rerank (reorder by Reranker::Hit.index, take top FINAL_K), then format as a numbered source + score + snippet list capped at SNIPPET_LENGTH chars.

Which score is shown

The score tracks whatever determined the ordering, so it never disagrees with the rank — but the two modes put different scales behind one score= label:

  • Vector-only (no reranker, or outage fallback) — cosine similarity [0, 1], comparable across results.
  • Reranked — the Reranker::Hit relevance score, a cross-encoder value on a model-dependent scale (can be negative/unbounded for raw logits — see Reranker::Hit), a relative gradient not a 0–1 threshold. pikuri shows the one the order was built from rather than a misleading cosine.

A reranker outage falls back, doesn't fail: its Reranker::LlamaServer RuntimeError is caught, WARN-logged, and the vector-only top FINAL_K returned — reranker is a quality knob, degraded results beat a tool error.

Single-param surface (+query:+ only, no +top_k:+/+reranker:+ toggle): retrieval depth and rerank choice are host policy baked into Extension, not something the LLM should tune mid-conversation.

Sharing: P_shared_locked, and sharing is what you want — an index is expensive to build and is the same corpus for every agent, so ten agents over one Backend is the intended shape. Backend::InMemory locks; the server backends serialize on their own side; the embedder and reranker are stateless HTTP.

Constant Summary collapse

LOGGER =
Pikuri.logger_for('VectorDb::Tools::Search')
FINAL_K =

Returns results returned to the LLM — the RAG sweet spot: enough for related-but-different angles, few enough to fit a turn.

Returns:

  • (Integer)

    results returned to the LLM — the RAG sweet spot: enough for related-but-different angles, few enough to fit a turn.

5
CANDIDATE_K =

Returns over-fetch size with a reranker (retrieve-broad-then- narrow — the cross-encoder sees more candidates than vector search surfaces and reorders by query-conditional relevance).

Returns:

  • (Integer)

    over-fetch size with a reranker (retrieve-broad-then- narrow — the cross-encoder sees more candidates than vector search surfaces and reorders by query-conditional relevance).

50
SNIPPET_LENGTH =

Returns per-result text cap in the observation (~2.5 KB for a five-result page; the agent can re-query if a snippet cuts off).

Returns:

  • (Integer)

    per-result text cap in the observation (~2.5 KB for a five-result page; the agent can re-query if a snippet cuts off).

500
DESCRIPTION =

Returns static description shown to the LLM, opencode-shape (summary + Usage: bullets).

Returns:

  • (String)

    static description shown to the LLM, opencode-shape (summary + Usage: bullets).

<<~DESC
  Search the indexed document corpus for content relevant to a query.

  Usage:
  - Use when the user asks about facts, definitions, or topics that would live in their indexed documents (notes, docs, knowledge base).
  - Phrase the query as a complete question or topic statement — the embedder reads natural language, not keyword bags.
  - Returns up to #{FINAL_K} results with `source` paths and text snippets; cite results back using the source path.
  - If a query returns nothing useful, either the relevant doc isn't in the corpus, or the corpus hasn't been indexed yet — say which you suspect, and offer to run `vectordb_reindex` to build the index.
DESC

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(embedder:, backend:, reranker: nil) ⇒ Search

Parameters:

  • embedder (#embed)

    anything implementing embed(Array<String>) -> Array<Array<Float>>. Typically Embedder.

  • backend (#query, #upsert, #delete_all, #count)

    any Backend implementation.

  • reranker (#rerank, nil) (defaults to: nil)

    optional. nil skips reranking and retrieves FINAL_K from the backend directly.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/pikuri/vector_db/tools/search.rb', line 77

def initialize(embedder:, backend:, reranker: nil)
  super(
    name: 'vectordb_search',
    description: DESCRIPTION,
    parameters: Pikuri::Tool::Parameters.build { |p|
      p.required_string :query,
                        'Natural-language search query, e.g. ' \
                        '"how does the deployment pipeline handle migrations?" or ' \
                        '"recipe with mushrooms and risotto rice".'
    },
    execute: lambda { |query:|
      Search.execute(
        embedder: embedder, backend: backend, reranker: reranker,
        query: query
      )
    },
    # Hard untrusted: a corpus is a pile of documents pikuri did not
    # write, and web-sourced corpora are the common case. No egress —
    # the query never leaves the machine, which is the whole point of
    # local retrieval.
    #
    # No private leg: there is no per-corpus declaration yet, so this
    # stays the quiet default rather than guessing. A private corpus is
    # under-warned; see +private:+ on {Pikuri::Workspace::Filesystem}
    # for the shape it would take.
    trifecta_legs: Pikuri::Tool::TrifectaLegs.new(private: false, untrusted: :hard, egress_payload_review: :no_egress)
  )
end

Class Method Details

.execute(embedder:, backend:, reranker:, query:) ⇒ String

Public so specs can exercise the search pipeline without constructing a Tool wrapper.

Parameters:

  • embedder (#embed)
  • backend (#query)
  • reranker (#rerank, nil)
  • query (String)

Returns:

  • (String)

    formatted observation.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/pikuri/vector_db/tools/search.rb', line 114

def self.execute(embedder:, backend:, reranker:, query:)
  return 'Error: query is empty' if query.nil? || query.strip.empty?

  candidate_k = reranker ? CANDIDATE_K : FINAL_K
  query_vector = embedder.embed([query]).first
  candidates = backend.query(vector: query_vector, top_k: candidate_k)
  return 'No matches found in the indexed corpus.' if candidates.empty?

  final = if reranker
            reranked_or_fallback(reranker: reranker, query: query, candidates: candidates)
          else
            candidates
          end.first(FINAL_K)

  format_observation(final)
end