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
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:
{
"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:
| Profile | Inputs | Use case |
|---|---|---|
bm25 | none | Pure lexical baseline — keyword matching only |
semantic | query(query_embedding) | Pure semantic similarity — meaning matching, no keyword bias |
hybrid | query(query_embedding), query(alpha) | Weighted blend; alpha controls lexical-vs-semantic ratio |
default | query(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:
{
"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:
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:
# 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
Plain semantic search
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
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"
}'Source-filtered search
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:
bm25profile: 5–20 ms.semanticprofile: 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:
- Authenticates the calling user via your IDP.
- Looks up their groups and source-system identities.
- Constructs the YQL with the right filter clauses.
- Issues the query to Vespa.
- 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.

