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 yourtelha.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}]"
}'
{ "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
}'
{
"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
recordsarray) are scored purely on semantic similarity tovector.text(orvector.near). - Related nodes reached via
expandfuse 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 anexpandclause to seedecayScoreshow up onrelated[].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 withtelha vector register-model - How to combine
vector,where, andatValidTimein a single query - That primary results rank by similarity alone, while expanded related nodes fuse similarity with graph decay
- How
minScoreandalphashape ranking, and whyvectorqueries never paginate
Related¶
- Query language › Vector - full field grammar and validation rules
- Concepts › Confidence & decay - the decay and fusion model this tutorial's
expandstep relies on - Ingest and ask a verified question - combine hybrid recall with
/v1/generateviasemanticModel - Time-travel with snapshots and compare - temporal coordinates in depth
- Build an MCP agent - the next tutorial