Five returned notes do not mean five vector candidates, and their scores are not necessarily unadjusted cosine similarities. Explain missed results and ordering by separating application stages from API fields.
This article follows the ChatCrystal embedding service at 7642768. Constants describe that version’s rules, not universally optimal retrieval parameters.
Two entrances serve different questions
/api/notes?search=... applies SQL LIKE to titles and summaries, useful for checking existing terms. /api/search?q=... embeds a query and retrieves note candidates. LIKE also has wildcard semantics; arbitrary input is not always literal matching.
CLI examples on the default local connection:
crystal --json notes list --search SQLite --limit 5
crystal --json search "SQLite concurrent writes" --limit 5
The first JSON response is a list object containing items; the second is a candidate array. The JSON branch forwards the limit instead of using the TTY search interface’s own fixed count. Eligible results can still be fewer than the upper bound.
From note text to candidates
Notes are embedded from selected fields; queries use the current embedding configuration. Both need matching settings. Follow the embedding configuration guide when changing models. Paragraph grouping produces multiple chunks, so a note can have several vectors.
Vectra v0.15.0 LocalIndex scores candidates and retains top K. This path does not use HNSW or LSH approximate candidate lookup. Evaluate cost and quality against actual library size and models; a local index does not establish zero cost or fixed response time.
Database checks, deduplication, and ranking
The application reads database chunks corresponding to candidates and requires note status done. An index entry alone does not establish an eligible result. Multiple chunks are deduplicated by note, keeping the higher score.
Term processing includes ASCII technical words and Han-character bigrams. A candidate containing every extracted term receives a score floor of 0.82; otherwise candidates below 0.58 are filtered. Boosting occurs after materializing vector candidates. It is not a separate keyword fallback scanning the entire library.
If checks, deduplication, or filtering leave too few notes, the code progressively grows candidate count, bounded by available index count or stopping conditions. This can add candidates without guaranteeing the requested result count or inclusion of the semantically correct note.
Compare relation expansion through the API
Use --data-urlencode instead of manually assembling queries with special characters:
curl --get http://localhost:3721/api/search \
--data-urlencode 'q=SQLite concurrent writes' \
--data-urlencode 'limit=5'
Optionally enable relation expansion:
curl --get http://localhost:3721/api/search \
--data-urlencode 'q=SQLite concurrent writes' \
--data-urlencode 'limit=5' \
--data-urlencode 'expand=true'
Current expansion follows one-hop relations from direct hits, requiring confidence at least 0.5. Linked scores use direct score × 0.7 × confidence. This is not multihop path search. Notes already in the result set are skipped, so it does not choose the strongest path for every linked result. Expansion can produce more notes than the direct-hit limit.
Interpret response fields correctly
The HTTP route wraps the array in data, returning note_id, conversation_id, title, project, score, tags, and via_relation. The last field marks a relation type, not a complete path or proof of correct source content. CLI arrays are unwrapped and do not require .data[].
Read the body for note_id, then check the original conversation and current task. Compare the two entrances and expansion switch using known-answer, paraphrase, exact-term, and no-answer queries. Record false matches instead of showing only successful examples. See debugging-solution recovery for reuse and RAG and knowledge graphs for evidence boundaries.