Skip to content

Hybrid semantic and temporal search

Tutorial · 3 of 5

Structured filters (where) find records by exact property match. Semantic search finds records by meaning. This tutorial combines both, plus a temporal coordinate, in one query: register an embedding model, ingest some free-text risk notes, then run a query that ranks by similarity while still respecting where and atValidTime.

You will need

  • A running server and an API key (quickstart, steps 2-3)
  • An [embedding] endpoint configured in your telha.toml (see quickstart step 7)
  • An embedding model registered with telha vector register-model (below)
  • curl

Scenario

Your risk notes are free text, not neat structured fields, "supplier missed a shipment window due to a customs hold," not {"category": "logistics_delay"}. You want to find risks that are about supply chain delays, restricted to open risks, without hand-writing a keyword filter for every phrasing.

1. Configure an embedding endpoint

In your telha.toml:

[embedding]
endpoint = "https://api.openai.com/v1" # any OpenAI-compatible embeddings API
model = "text-embedding-3-small"
api_key_env = "TELHA_EMBEDDING_API_KEY"

2. Register the embedding model

vector register-model opens the data directory directly, run it with the server stopped:

telha vector register-model --name text-embedding-3-small --dims 1536 \
  --normalize --provider-ref openai --data-dir ./data

Then set the key env and start (or restart) serve:

export TELHA_EMBEDDING_API_KEY=sk-...
telha serve --data-dir ./data --rest-addr 127.0.0.1:7625 --config telha.toml

Checkpoint

A query with a vector clause naming an unregistered model fails with 400 UNKNOWN_MODEL; naming a registered one, with no [embedding] endpoint configured, fails with 501 UNIMPLEMENTED. If neither error appears and you get scored results in step 4, both pieces are wired correctly.

3. Ingest some free-text risk notes

curl -sS -X POST http://127.0.0.1:7625/v1/ingest \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "format": "json",
    "name": "risk-notes-2026-07.json",
    "content": "[{\"label\": \"RISK\", \"title\": \"Customs hold delays Q3 shipment\", \"note\": \"Supplier missed the shipment window because a customs hold at the port added two weeks to transit.\", \"status\": \"open\", \"severity\": 6}, {\"label\": \"RISK\", \"title\": \"Backup generator maintenance overdue\", \"note\": \"Facilities flagged that the backup generator has not had scheduled maintenance in 14 months.\", \"status\": \"open\", \"severity\": 3}, {\"label\": \"RISK\", \"title\": \"Alternate carrier lead time risk\", \"note\": \"Our secondary freight carrier has been quoting longer lead times, raising exposure if the primary carrier slips again.\", \"status\": \"open\", \"severity\": 5}]"
  }'
Response (201)
{ "deduplicated": false, "sourceId": "e91a...", "sourceVersionTx": 1751600000000000, "nodeIds": ["...", "...", "..."], "edgeIds": [] }

4. Run a hybrid query

Combine vector (semantic scoring), where (structured filter), and atValidTime (temporal coordinate) in one request:

curl -sS -X POST http://127.0.0.1:7625/v1/query \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "find": "RISK",
    "where": {"status": {"$eq": "open"}},
    "vector": {"text": "supply chain shipping delays", "model": "text-embedding-3-small", "k": 10, "minScore": 0.3},
    "atValidTime": "2026-07-01T00:00:00Z",
    "limit": 10
  }'
Response (trimmed)
{
  "records": [
    { "id": "...", "labels": ["RISK"],
      "properties": { "title": "Customs hold delays Q3 shipment", "status": "open", "severity": 6 },
      "score": 0.81, "validTime": {}, "txTime": {}, "tombstone": false },
    { "id": "...", "labels": ["RISK"],
      "properties": { "title": "Alternate carrier lead time risk", "status": "open", "severity": 5 },
      "score": 0.58, "validTime": {}, "txTime": {}, "tombstone": false }
  ],
  "related": [], "nextCursor": null, "traceId": null,
  "stats": { "path": "...", "rowsScanned": 3, "rowsReturned": 2, "durationUs": 480 }
}

Checkpoint

Two of the three risks come back, ranked by score descending, the customs-hold note (directly on-topic) outranks the alternate-carrier note (related but more tangential). The generator-maintenance risk is absent: with minScore: 0.3, it fell below the similarity floor. Note that score only appears on records and related nodes when the query carries a vector clause, see Query language › Vector.

5. Understand what ranked and why

Vector queries do two things at once, per the query language reference:

  • Primary results (the records array) are scored purely on semantic similarity to vector.text (or vector.near).
  • Related nodes reached via expand fuse similarity with decay, the confidence weakening that accumulates over graph hops, so a semantically perfect match three hops away does not automatically outrank a merely-good match one hop away. See Confidence & decay for the concept, and add an expand clause to see decayScore show up on related[].nodes[].
curl -sS -X POST http://127.0.0.1:7625/v1/query \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "find": "RISK",
    "vector": {"text": "supply chain shipping delays", "model": "text-embedding-3-small", "k": 10},
    "expand": [{"type": "OWNED_BY", "direction": "out", "depth": 2}]
  }'

Related nodes ride in the response's related array (never merged into records), each carrying depth, viaEdge, and decayScore alongside whatever similarity contributed.

6. Tune alpha and minScore

Two knobs shape a hybrid query's ranking, both optional and both bounded [0, 1]:

Field Effect
minScore Similarity floor. Raise it to cut marginal matches (as in step 4); lower it (or omit it) to see everything within k.
alpha Per-query override of the similarity/decay fusion balance. Push it toward 1 to weight pure semantic similarity more; toward 0 to let graph decay dominate. See the confidence-decay design in Architecture for what the balance actually composes.
curl -sS -X POST http://127.0.0.1:7625/v1/query \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"find": "RISK", "vector": {"text": "supply chain shipping delays", "model": "text-embedding-3-small", "k": 10, "minScore": 0.5, "alpha": 0.8}}'

Vector queries do not paginate

vector results are k-bounded and similarity-ordered. Supplying a cursor alongside vector is 400 QUERY_INVALID at /cursor, and vector responses never emit nextCursor. If you need more results, raise k and re-run rather than paging.

Combine with time-travel

atValidTime/atTxTime apply to hybrid queries exactly as they do to structured ones, semantic search is partitioned by validity, so a vector query pinned to a past atValidTime only scores against records that were true at that moment. See Time-travel with snapshots and compare for the full temporal-coordinate walkthrough.

What you learned

  • How to configure [embedding] and register a model with telha vector register-model
  • How to combine vector, where, and atValidTime in a single query
  • That primary results rank by similarity alone, while expanded related nodes fuse similarity with graph decay
  • How minScore and alpha shape ranking, and why vector queries never paginate