Skip to content

Querying via Vespa ​

Interactive search runs against the Apache Vespa cluster that holds the projected chunk index. Each chunk carries the original text, a 1024-dimensional dense embedding from BAAI/bge-m3, and source provenance.

The query endpoint ​

text
POST https://<your-vespa-host>:8080/search/
Content-Type: application/json

{
  "yql": "select * from chunk where userQuery()",
  "query": "data residency",
  "hits": 10,
  "ranking.profile": "default"
}

Vespa returns:

json
{
  "root": {
    "fields": { "totalCount": 47 },
    "children": [
      {
        "id": "id:moolelo:chunk::a4b2...",
        "relevance": 0.823,
        "source": "chunk",
        "fields": {
          "chunk_id": "a4b2...",
          "parent_entity_id": "confluence|acme|page|45678",
          "text": "...the data residency model in this section...",
          "structural_metadata": {
            "heading_path": "Architecture / Data residency",
            "source_url": "https://acme.atlassian.net/wiki/spaces/ENG/..."
          }
        }
      }
    ]
  }
}

Rank profiles ​

Vespa supports four built-in rank profiles for the chunk doctype:

ProfileInputsUse case
bm25nonePure lexical baseline — keyword matching only
semanticquery(query_embedding)Pure semantic similarity — meaning matching, no keyword bias
hybridquery(query_embedding), query(alpha)Weighted blend; alpha controls lexical-vs-semantic ratio
defaultquery(query_embedding) (alpha hard-coded to 0.3)Production default — light-lexical, heavy-semantic

For interactive search, default is the right starting point. To tune the blend yourself:

json
{
  "yql": "select * from chunk where userQuery()",
  "query": "data residency",
  "hits": 10,
  "ranking.profile": "hybrid",
  "input.query(alpha)": 0.5,
  "input.query(query_embedding)": "embed:bge-m3('data residency')"
}

The embed:bge-m3('...') query syntax is a Vespa server-side embedding shortcut — Vespa runs the bge-m3 model itself to produce the query vector. If you'd rather embed client-side, pass the raw 1024-element float array.

Filtering by tenant, source, or permission ​

YQL supports filter expressions:

text
select * from chunk
where userQuery()
  and tenant_id contains "default"
  and source contains "confluence"

The chunk schema declares filter-friendly attributes for tenant_id, source, and parent_entity_id. Filtering on these is sub-millisecond.

Permission filtering ​

ACL state is not stored on chunk documents. The authoritative source is OpenFGA; the query-coordinator post-filters Vespa candidates through OpenFGA BatchCheck before returning results. If you query Vespa directly (bypassing the coordinator), Vespa will return every chunk that matches the YQL — you are responsible for filtering the result set against your own authorization layer.

For typical deployments you'll go through the Moʻolelo query-coordinator (gRPC) or the MCP search tool, both of which apply the OpenFGA post-filter automatically.

Document API ​

To inspect or delete individual chunks:

sh
# Get one chunk
curl https://<your-vespa-host>:8080/document/v1/moolelo/chunk/docid/<chunk_id>

# Visit chunks for a record
curl 'https://<your-vespa-host>:8080/document/v1/moolelo/chunk/docid?selection=chunk.parent_entity_id="github|acme|issue|42"'

# Delete one chunk (rare — Moʻolelo manages the index automatically)
curl -X DELETE https://<your-vespa-host>:8080/document/v1/moolelo/chunk/docid/<chunk_id>

Manual deletes won't be re-projected from Iceberg automatically. If you delete a chunk and want it back, trigger a backfill on the source connector.

Query examples ​

sh
curl -X POST https://<your-vespa-host>:8080/search/ \
  -H "Content-Type: application/json" \
  -d '{
    "yql": "select chunk_id, text from chunk where userQuery()",
    "query": "how do we handle GDPR right-to-be-forgotten requests",
    "hits": 5,
    "ranking.profile": "semantic",
    "input.query(query_embedding)": "embed:bge-m3(@query)"
  }'

Lexical-heavy search for an exact phrase ​

sh
curl -X POST https://<your-vespa-host>:8080/search/ \
  -H "Content-Type: application/json" \
  -d '{
    "yql": "select * from chunk where userQuery()",
    "query": "moolelo-warehouse",
    "hits": 20,
    "ranking.profile": "bm25"
  }'
sh
curl -X POST https://<your-vespa-host>:8080/search/ \
  -H "Content-Type: application/json" \
  -d '{
    "yql": "select * from chunk where userQuery() and source contains \"github\"",
    "query": "data residency",
    "hits": 10,
    "ranking.profile": "default",
    "input.query(query_embedding)": "embed:bge-m3(@query)"
  }'

Direct Vespa queries do not enforce permissions — run them through the query-coordinator if you need OpenFGA gating.

Latency ​

Typical p95 query latency on a single-node Vespa cluster with a few hundred thousand chunks:

  • bm25 profile: 5–20 ms.
  • semantic profile: 20–80 ms (HNSW lookup + scoring).
  • hybrid / default: 25–100 ms.

Multi-node clusters scale horizontally on the content side; the container side scales with replicas.

Putting a search frontend in front ​

For most deployments, you don't expose Vespa directly to end users. Operators build a small frontend (often a few hundred lines of code) that:

  1. Authenticates the calling user via your IDP.
  2. Looks up their groups and source-system identities.
  3. Constructs the YQL with the right filter clauses.
  4. Issues the query to Vespa.
  5. Renders the results, calling the source system to display the actual record content (since Vespa stores chunks, not full records).

The frontend is your application — Moʻolelo gives you the index and the contract; how you present it is up to you. The chunk schema (deploy/vespa/schemas/chunk.sd in the repo) is the contract.

Moʻolelo — self-hosted federated enterprise knowledge platform.