Files
qdrant/tests/openapi/test_fts.py
Andrey Vasnetsov 8a7325ad5d Remove deprecated search endpoints from OpenAPI, deprecate them in gRPC (#9982)
* Remove deprecated search/recommend/discover endpoints from OpenAPI

Remove deprecated REST API endpoint definitions from the OpenAPI
generator. These endpoints were deprecated in v1.13.3 (`f4ced2567`,
#5907, 2025-01-30) in favor of the universal `/points/query` endpoint:

- POST /points/search
- POST /points/search/batch
- POST /points/search/groups
- POST /points/recommend
- POST /points/recommend/batch
- POST /points/recommend/groups
- POST /points/discover
- POST /points/discover/batch

Also removes the corresponding request types from the schema generator
and updates the expected API count in the consistency check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate OpenAPI integration tests to /points/query

The deprecated /points/search, /points/recommend and /points/discover
endpoints (along with their /batch and /groups variants) were removed
from the OpenAPI spec, which caused validation failures in the Python
integration test harness.

This commit migrates the affected tests to the universal /points/query
endpoint:

- Delete tests dedicated to the deprecated endpoints:
  test_recommend.py, test_discover.py, test_multicollection_reco.py,
  test_recommendation_multivector.py
- Refactor remaining tests to call /points/query (and /query/batch,
  /query/groups), translating request bodies (vector -> query / using,
  positive/negative -> query.recommend, target/context -> query.discover)
  and unwrapping the new result.points response shape.
- Drop equivalence assertions against the now-removed legacy endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Relax non-empty assertions in migrated recommend/discover tests

The previous migration added `len(...) > 0` assertions to tests that
previously only checked equivalence between the deprecated and new
API. These assertions are too strict because the parametrized
`query_filter` cases legitimately produce empty result sets.

Drop the `> 0` assertion and rely on `request_with_validation` to
verify the response is well-formed and HTTP OK.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate remaining OpenAPI tests off deprecated search endpoints

Tests added to dev after the original migration was written still call
/points/search and /points/recommend/groups through
`request_with_validation`, which resolves the endpoint against the
OpenAPI spec and therefore breaks once the endpoint is not in the spec:

- test_turbo4_storage.py, test_sparse_idf_corpus.py, test_validation.py:
  translate /points/search to /points/query (vector{name,vector} ->
  query + using, result -> result.points).
- test_group.py: drop the /points/recommend/groups half of the
  lookup_from validation test in favour of the query equivalent.

test_sparse_idf_corpus.py's test_query_api_supports_idf_corpus goes
away: with the helper on /points/query every test in the file now
exercises what it asserted.

Also record why test_recommend_group cannot assert on its groups: it
uses every point in the collection as a recommend example, so all of
them are excluded and the result is legitimately empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Regenerate openapi.json without the deprecated search endpoints

Drops the 8 deprecated paths and the request schemas that only they
referenced: Search/Recommend/Discover request (+Batch, +Groups) types
and their exclusive dependencies (NamedVector, NamedSparseVector,
NamedVectorStruct, UsingVector, RecommendExample, ContextExamplePair).

Regenerated output is a strict subset of the previous spec, and every
remaining $ref still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Deprecate the search/recommend/discover RPCs in gRPC

The REST counterparts have carried `deprecated: true` since v1.13.3 and
are now gone from the OpenAPI spec, while the gRPC RPCs never got any
deprecation annotation at all. Mark all 8 with `option deprecated = true`
so generated clients warn, and point each doc comment at its `Query`
replacement.

tonic puts `#[deprecated]` on the generated client methods only; the
server trait gets the doc comment alone, so our own `impl` is unaffected.
The RPCs keep serving traffic — this is annotation only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Restore the deleted recommend/discover suites on /points/query

The earlier migration deleted these four files outright, but the
query-side tests it left behind are all shallow smoke tests
(`len(result) > 0`, `"points" in result[0]`). The deleted ones carried
invariants with no query-API equivalent anywhere, so deleting them was a
real loss of coverage rather than de-duplication:

- test_recommend.py: default strategy equals average_vector; batch
  results identical to sequential singles across six request shapes;
  best_score with only negatives yields all-negative scores; best_score
  with a single positive orders identically to a nearest query; raw
  vectors as examples equal ids as examples.
- test_discover.py: context-only scores are all <= 0; target-only orders
  identically to a nearest query but scores differently; with a fixed
  context the integer part of the score is stable while the decimal part
  moves, and vice versa with a fixed target; batch equals singles;
  lookup_from by id equals by vector.
- test_multicollection_reco.py: cross-collection lookup_from, plus
  wrong-vector-size, unknown-collection and unknown-vector rejections.
- test_recommendation_multivector.py: the same recommend invariants over
  a max_sim multivector collection, which the query suite never covered.

Only test_recommend_missing_lookup_from_collection_with_raw_vector is
dropped as genuinely redundant — test_query.py's
test_query_missing_lookup_from_collection covers query, query/batch and
prefetch.

Two request-shape differences the translation had to absorb:

- Giving no examples at all is 422 (a RecommendInput validation rule),
  where the legacy API reported 400 from the query itself. A malformed
  example, such as an empty vector, is still 400.
- DiscoverInput requires the `context` key and accepts only an explicit
  null to mean "no context", so target-only discover must spell it out.
  The legacy API let it be omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:09:43 +02:00

356 lines
9.0 KiB
Python

import random
import pytest
from .helpers.collection_setup import drop_collection
from .helpers.helpers import request_with_validation
texts = [
"2430 A.D.",
"The Acquisitive Chuckle",
"Author! Author!",
"The Bicentennial Man",
"Big Game",
"The Billiard Ball",
"Birth of a Notion",
"Black Friar of the Flame",
"Blank!",
"Blind Alley",
"Breeds There a Man...?",
"Button, Button",
"Buy Jupiter",
"C-Chute",
"Cal",
"The Callistan Menace",
"Catch That Rabbit",
"Christmas on Ganymede",
"Darwinian Pool Room",
"Day of the Hunters",
"Death Sentence",
"Does a Bee Care?",
"Dreaming Is a Private Thing",
"The Dust of Death",
"The Dying Night",
"Each an Explorer",
"Escape!",
"Everest",
"Evidence",
"The Evitable Conflict",
"Exile to Hell",
"Eyes Do More Than See",
"The Feeling of Power",
"Feminine Intuition",
"First Law",
"Flies",
"For the Birds",
"Founding Father",
"The Fun They Had",
"Galley Slave",
"The Gentle Vultures",
"Getting Even",
"Gimmicks Three",
"Gold",
"Good Taste",
"The Greatest Asset",
"Green Patches",
"Half-Breed",
"Half-Breeds on Venus",
"Hallucination",
"The Hazing",
"Hell-Fire",
"Heredity",
"History",
"Homo Sol",
"Hostess",
"I Just Make Them Up, See!",
"I'm in Marsport Without Hilda",
"The Imaginary",
"The Immortal Bard",
"In a Good Cause—",
"Insert Knob A in Hole B",
"The Instability",
"It's Such a Beautiful Day",
"The Key",
"Kid Stuff",
"The Last Answer",
"The Last Question",
"The Last Trump",
"Left to Right",
"Legal Rites",
"Lenny",
"Lest We Remember",
"Let's Not",
"Liar!",
"Light Verse",
"Little Lost Robot",
"The Little Man on the Subway",
"Living Space",
"A Loint of Paw",
"The Magnificent Possession",
"Marching In",
"Marooned off Vesta",
"The Message",
"Mirror Image",
"Mother Earth",
"My Son, the Physicist",
"No Connection",
"No Refuge Could Save",
"Nobody Here But—",
"Not Final!",
"Obituary",
"Old-fashioned",
"Pâté de Foie Gras",
"The Pause",
"Ph as in Phony",
"The Portable Star",
"The Proper Study",
"Rain, Rain, Go Away",
"Reason",
"The Red Queen's Race",
"Rejection Slips",
"Ring Around the Sun",
"Risk",
"Robot AL-76 Goes Astray",
"Robot Dreams",
"Runaround",
"Sally",
"Satisfaction Guaranteed",
"The Secret Sense",
"Shah Guido G.",
"Silly Asses",
"The Singing Bell",
"Sixty Million Trillion Combinations",
"Spell My Name with an S",
"Star Light",
"A Statue for Father",
"Strikebreaker",
"Super-Neutron",
"Take a Match",
"The Talking Stone",
". . . That Thou Art Mindful of Him",
"Thiotimoline",
"Time Pussy",
"Trends",
"Truth to Tell",
"The Ugly Little Boy",
"The Ultimate Crime",
"Unto the Fourth Generation",
"The Up-to-Date Sorcerer",
"Waterclap",
"The Watery Place",
"The Weapon",
"The Weapon Too Dreadful to Use",
"What If—",
"What Is This Thing Called Love?",
"What's in a Name?",
"The Winnowing",
]
def basic_collection_setup(
collection_name='test_collection',
on_disk_vectors=False,
on_disk_payload=False,
):
response = request_with_validation(
api='/collections/{collection_name}',
method="DELETE",
path_params={'collection_name': collection_name},
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': collection_name},
body={
"vectors": {
"size": 4,
"distance": "Dot",
"on_disk": on_disk_vectors,
},
"on_disk_payload": on_disk_payload,
}
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': collection_name},
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={
"points": [
{
"id": idx,
"vector": [random.random() for _ in range(4)],
"payload": {"title": title}
} for idx, title in enumerate(texts)
]
}
)
assert response.ok
@pytest.fixture(autouse=True, scope='module')
def setup(on_disk_vectors, on_disk_payload, collection_name):
basic_collection_setup(collection_name=collection_name, on_disk_vectors=on_disk_vectors, on_disk_payload=on_disk_payload)
yield
drop_collection(collection_name=collection_name)
def test_match_any(collection_name):
# Create index
response = request_with_validation(
api='/collections/{collection_name}/index',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={
"field_name": "title",
"field_schema": {
"type": "text",
"tokenizer": "word",
"lowercase": True,
"stopwords": "english"
}
}
)
assert response.ok
response_any = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': collection_name},
body={
"offset": None,
"limit": 10,
"with_payload": True,
"with_vector": False,
"filter": {
"must": {
"key": "title",
"match": {
"text_any": "robot of the star",
}
}
}
}
)
assert response_any.ok
response_any_ids = {point['id'] for point in response_any.json()['result']['points']}
# Same request, but manually split into words
response_split = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': collection_name},
body={
"offset": None,
"limit": 10,
"with_payload": True,
"with_vector": False,
"filter": {
"should": [
{
"key": "title",
"match": {
"text": "robot",
}
},
{
"key": "title",
"match": {
"text": "star",
}
}
]
}
}
)
assert response_split.ok
response_split_ids = {point['id'] for point in response_split.json()['result']['points']}
assert response_any_ids == response_split_ids
def test_scroll_with_prefix(collection_name):
# Create index
response = request_with_validation(
api='/collections/{collection_name}/index',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={
"field_name": "title",
"field_schema": {
"type": "text",
"tokenizer": "prefix",
}
}
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': collection_name},
body={
"offset": None,
"limit": 10,
"with_payload": True,
"with_vector": False,
"filter": {
"should": [
{
"key": "title",
"match": {
"text": "ROBO",
}
}
]
}
}
)
assert response.ok
assert len(response.json()['result']['points']) == 3
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': collection_name},
body={
"query": [1., 1., 1., 1.],
"limit": 10,
"with_payload": True,
"with_vector": False,
"filter": {
"should": [
{
"key": "title",
"match": {
"text": "ROBO",
}
}
]
}
}
)
assert response.ok
assert len(response.json()['result']['points']) == 3