a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
742 lines
25 KiB
Python
742 lines
25 KiB
Python
# type: ignore
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import itertools
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from langchain_core.embeddings import Embeddings
|
|
from langgraph.store.base import (
|
|
GetOp,
|
|
Item,
|
|
ListNamespacesOp,
|
|
PutOp,
|
|
SearchOp,
|
|
)
|
|
from psycopg import AsyncConnection
|
|
|
|
from langgraph.checkpoint.postgres import _ainternal
|
|
from langgraph.store.postgres import AsyncPostgresStore
|
|
from tests.conftest import (
|
|
DEFAULT_URI,
|
|
VECTOR_TYPES,
|
|
CharacterEmbeddings,
|
|
)
|
|
|
|
TTL_SECONDS = 6
|
|
TTL_MINUTES = TTL_SECONDS / 60
|
|
|
|
|
|
@pytest.fixture(scope="function", params=["default", "pipe", "pool"])
|
|
async def store(request) -> AsyncIterator[AsyncPostgresStore]:
|
|
database = f"test_{uuid.uuid4().hex[:16]}"
|
|
uri_parts = DEFAULT_URI.split("/")
|
|
uri_base = "/".join(uri_parts[:-1])
|
|
query_params = ""
|
|
if "?" in uri_parts[-1]:
|
|
db_name, query_params = uri_parts[-1].split("?", 1)
|
|
query_params = "?" + query_params
|
|
|
|
conn_string = f"{uri_base}/{database}{query_params}"
|
|
admin_conn_string = DEFAULT_URI
|
|
ttl_config = {
|
|
"default_ttl": TTL_MINUTES,
|
|
"refresh_on_read": True,
|
|
"sweep_interval_minutes": TTL_MINUTES / 2,
|
|
}
|
|
async with await AsyncConnection.connect(
|
|
admin_conn_string, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
async with AsyncPostgresStore.from_conn_string(
|
|
conn_string, ttl=ttl_config
|
|
) as store:
|
|
store.MIGRATIONS = [
|
|
(
|
|
mig.replace("ttl_minutes INT;", "ttl_minutes FLOAT;")
|
|
if isinstance(mig, str)
|
|
else mig
|
|
)
|
|
for mig in store.MIGRATIONS
|
|
]
|
|
await store.setup()
|
|
async with store._cursor() as cur:
|
|
# drop the migration index
|
|
await cur.execute("DROP TABLE IF EXISTS store_migrations")
|
|
await store.setup() # Will fail if migrations aren't idempotent
|
|
|
|
if request.param == "pipe":
|
|
async with AsyncPostgresStore.from_conn_string(
|
|
conn_string, pipeline=True, ttl=ttl_config
|
|
) as store:
|
|
await store.start_ttl_sweeper()
|
|
yield store
|
|
await store.stop_ttl_sweeper()
|
|
elif request.param == "pool":
|
|
async with AsyncPostgresStore.from_conn_string(
|
|
conn_string, pool_config={"min_size": 1, "max_size": 10}, ttl=ttl_config
|
|
) as store:
|
|
await store.start_ttl_sweeper()
|
|
yield store
|
|
await store.stop_ttl_sweeper()
|
|
else: # default
|
|
async with AsyncPostgresStore.from_conn_string(
|
|
conn_string, ttl=ttl_config
|
|
) as store:
|
|
await store.start_ttl_sweeper()
|
|
yield store
|
|
await store.stop_ttl_sweeper()
|
|
finally:
|
|
async with await AsyncConnection.connect(
|
|
admin_conn_string, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
async def test_no_running_loop(store: AsyncPostgresStore) -> None:
|
|
with pytest.raises(asyncio.InvalidStateError):
|
|
store.put(("foo", "bar"), "baz", {"val": "baz"})
|
|
with pytest.raises(asyncio.InvalidStateError):
|
|
store.get(("foo", "bar"), "baz")
|
|
with pytest.raises(asyncio.InvalidStateError):
|
|
store.delete(("foo", "bar"), "baz")
|
|
with pytest.raises(asyncio.InvalidStateError):
|
|
store.search(("foo", "bar"))
|
|
with pytest.raises(asyncio.InvalidStateError):
|
|
store.list_namespaces(prefix=("foo",))
|
|
with pytest.raises(asyncio.InvalidStateError):
|
|
store.batch([PutOp(namespace=("foo", "bar"), key="baz", value={"val": "baz"})])
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(store.put, ("foo", "bar"), "baz", {"val": "baz"})
|
|
result = await asyncio.wrap_future(future)
|
|
assert result is None
|
|
future = executor.submit(store.get, ("foo", "bar"), "baz")
|
|
result = await asyncio.wrap_future(future)
|
|
assert result.value == {"val": "baz"}
|
|
result = await asyncio.wrap_future(
|
|
executor.submit(store.list_namespaces, prefix=("foo",))
|
|
)
|
|
|
|
|
|
async def test_large_batches(request: Any, store: AsyncPostgresStore) -> None:
|
|
N = 100 # less important that we are performant here
|
|
M = 10
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = []
|
|
for m in range(M):
|
|
for i in range(N):
|
|
futures += [
|
|
executor.submit(
|
|
store.put,
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
value={"foo": "bar" + str(i)},
|
|
),
|
|
executor.submit(
|
|
store.get,
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
),
|
|
executor.submit(
|
|
store.list_namespaces,
|
|
prefix=None,
|
|
max_depth=m + 1,
|
|
),
|
|
executor.submit(
|
|
store.search,
|
|
("test",),
|
|
),
|
|
executor.submit(
|
|
store.put,
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
value={"foo": "bar" + str(i)},
|
|
),
|
|
executor.submit(
|
|
store.put,
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
None,
|
|
),
|
|
]
|
|
|
|
results = await asyncio.gather(
|
|
*(asyncio.wrap_future(future) for future in futures)
|
|
)
|
|
assert len(results) == M * N * 6
|
|
|
|
|
|
async def test_large_batches_async(store: AsyncPostgresStore) -> None:
|
|
N = 1000
|
|
M = 10
|
|
coros = []
|
|
for m in range(M):
|
|
for i in range(N):
|
|
coros.append(
|
|
store.aput(
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
value={"foo": "bar" + str(i)},
|
|
)
|
|
)
|
|
coros.append(
|
|
store.aget(
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
)
|
|
)
|
|
coros.append(
|
|
store.alist_namespaces(
|
|
prefix=None,
|
|
max_depth=m + 1,
|
|
)
|
|
)
|
|
coros.append(
|
|
store.asearch(
|
|
("test",),
|
|
)
|
|
)
|
|
coros.append(
|
|
store.aput(
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
value={"foo": "bar" + str(i)},
|
|
)
|
|
)
|
|
coros.append(
|
|
store.adelete(
|
|
("test", "foo", "bar", "baz", str(m % 2)),
|
|
f"key{i}",
|
|
)
|
|
)
|
|
|
|
results = await asyncio.gather(*coros)
|
|
assert len(results) == M * N * 6
|
|
|
|
|
|
async def test_abatch_order(store: AsyncPostgresStore) -> None:
|
|
# Setup test data
|
|
await store.aput(("test", "foo"), "key1", {"data": "value1"})
|
|
await store.aput(("test", "bar"), "key2", {"data": "value2"})
|
|
|
|
ops = [
|
|
GetOp(namespace=("test", "foo"), key="key1"),
|
|
PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}),
|
|
SearchOp(
|
|
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
|
|
),
|
|
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
|
|
GetOp(namespace=("test",), key="key3"),
|
|
]
|
|
|
|
results = await store.abatch(ops)
|
|
assert len(results) == 5
|
|
assert isinstance(results[0], Item)
|
|
assert isinstance(results[0].value, dict)
|
|
assert results[0].value == {"data": "value1"}
|
|
assert results[0].key == "key1"
|
|
assert results[1] is None
|
|
assert isinstance(results[2], list)
|
|
assert len(results[2]) == 1
|
|
assert isinstance(results[3], list)
|
|
assert ("test", "foo") in results[3] and ("test", "bar") in results[3]
|
|
assert results[4] is None
|
|
|
|
ops_reordered = [
|
|
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
|
|
GetOp(namespace=("test", "bar"), key="key2"),
|
|
ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
|
|
PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
|
|
GetOp(namespace=("test", "foo"), key="key1"),
|
|
]
|
|
|
|
results_reordered = await store.abatch(ops_reordered)
|
|
assert len(results_reordered) == 5
|
|
assert isinstance(results_reordered[0], list)
|
|
assert len(results_reordered[0]) == 2
|
|
assert isinstance(results_reordered[1], Item)
|
|
assert results_reordered[1].value == {"data": "value2"}
|
|
assert results_reordered[1].key == "key2"
|
|
assert isinstance(results_reordered[2], list)
|
|
assert ("test", "foo") in results_reordered[2] and (
|
|
"test",
|
|
"bar",
|
|
) in results_reordered[2]
|
|
assert results_reordered[3] is None
|
|
assert isinstance(results_reordered[4], Item)
|
|
assert results_reordered[4].value == {"data": "value1"}
|
|
assert results_reordered[4].key == "key1"
|
|
|
|
|
|
async def test_batch_get_ops(store: AsyncPostgresStore) -> None:
|
|
# Setup test data
|
|
await store.aput(("test",), "key1", {"data": "value1"})
|
|
await store.aput(("test",), "key2", {"data": "value2"})
|
|
|
|
ops = [
|
|
GetOp(namespace=("test",), key="key1"),
|
|
GetOp(namespace=("test",), key="key2"),
|
|
GetOp(namespace=("test",), key="key3"),
|
|
]
|
|
|
|
results = await store.abatch(ops)
|
|
|
|
assert len(results) == 3
|
|
assert results[0] is not None
|
|
assert results[1] is not None
|
|
assert results[2] is None
|
|
assert results[0].key == "key1"
|
|
assert results[1].key == "key2"
|
|
|
|
|
|
async def test_batch_put_ops(store: AsyncPostgresStore) -> None:
|
|
ops = [
|
|
PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
|
|
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
|
|
PutOp(namespace=("test",), key="key3", value=None),
|
|
]
|
|
|
|
results = await store.abatch(ops)
|
|
|
|
assert len(results) == 3
|
|
assert all(result is None for result in results)
|
|
|
|
# Verify the puts worked
|
|
items = await store.asearch(["test"], limit=10)
|
|
assert len(items) == 2 # key3 had None value so wasn't stored
|
|
|
|
|
|
async def test_batch_search_ops(store: AsyncPostgresStore) -> None:
|
|
# Setup test data
|
|
await store.aput(("test", "foo"), "key1", {"data": "value1"})
|
|
await store.aput(("test", "bar"), "key2", {"data": "value2"})
|
|
|
|
ops = [
|
|
SearchOp(
|
|
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
|
|
),
|
|
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
|
|
]
|
|
|
|
results = await store.abatch(ops)
|
|
|
|
assert len(results) == 2
|
|
assert len(results[0]) == 1 # Filtered results
|
|
assert len(results[1]) == 2 # All results
|
|
|
|
|
|
async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None:
|
|
# Setup test data
|
|
await store.aput(("test", "namespace1"), "key1", {"data": "value1"})
|
|
await store.aput(("test", "namespace2"), "key2", {"data": "value2"})
|
|
|
|
ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)]
|
|
|
|
results = await store.abatch(ops)
|
|
|
|
assert len(results) == 1
|
|
assert len(results[0]) == 2
|
|
assert ("test", "namespace1") in results[0]
|
|
assert ("test", "namespace2") in results[0]
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _create_pool_store() -> AsyncIterator[AsyncPostgresStore]:
|
|
database = f"test_{uuid.uuid4().hex[:16]}"
|
|
uri_parts = DEFAULT_URI.split("/")
|
|
uri_base = "/".join(uri_parts[:-1])
|
|
query_params = ""
|
|
if "?" in uri_parts[-1]:
|
|
_, query_params = uri_parts[-1].split("?", 1)
|
|
query_params = "?" + query_params
|
|
|
|
conn_string = f"{uri_base}/{database}{query_params}"
|
|
admin_conn_string = DEFAULT_URI
|
|
async with await AsyncConnection.connect(
|
|
admin_conn_string, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
async with AsyncPostgresStore.from_conn_string(
|
|
conn_string, pool_config={"min_size": 1, "max_size": 1}
|
|
) as store:
|
|
await store.setup()
|
|
yield store
|
|
finally:
|
|
async with await AsyncConnection.connect(
|
|
admin_conn_string, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
async def test_abatch_uses_single_pool_checkout(monkeypatch) -> None:
|
|
async with _create_pool_store() as store:
|
|
await store.aput(("test",), "key1", {"data": "value1"})
|
|
|
|
original_get_connection = _ainternal.get_connection
|
|
checkout_count = 0
|
|
|
|
@asynccontextmanager
|
|
async def counting_get_connection(conn):
|
|
nonlocal checkout_count
|
|
checkout_count += 1
|
|
async with original_get_connection(conn) as checked_out_conn:
|
|
yield checked_out_conn
|
|
|
|
monkeypatch.setattr(_ainternal, "get_connection", counting_get_connection)
|
|
|
|
results = await store.abatch([GetOp(namespace=("test",), key="key1")])
|
|
|
|
assert len(results) == 1
|
|
assert results[0] is not None
|
|
assert results[0].value == {"data": "value1"}
|
|
assert checkout_count == 1
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _create_vector_store(
|
|
vector_type: str,
|
|
distance_type: str,
|
|
fake_embeddings: CharacterEmbeddings,
|
|
text_fields: list[str] | None = None,
|
|
) -> AsyncIterator[AsyncPostgresStore]:
|
|
"""Create a store with vector search enabled."""
|
|
|
|
database = f"test_{uuid.uuid4().hex[:16]}"
|
|
uri_parts = DEFAULT_URI.split("/")
|
|
uri_base = "/".join(uri_parts[:-1])
|
|
query_params = ""
|
|
if "?" in uri_parts[-1]:
|
|
db_name, query_params = uri_parts[-1].split("?", 1)
|
|
query_params = "?" + query_params
|
|
|
|
conn_string = f"{uri_base}/{database}{query_params}"
|
|
admin_conn_string = DEFAULT_URI
|
|
|
|
index_config = {
|
|
"dims": fake_embeddings.dims,
|
|
"embed": fake_embeddings,
|
|
"ann_index_config": {
|
|
"vector_type": vector_type,
|
|
},
|
|
"distance_type": distance_type,
|
|
"fields": text_fields,
|
|
}
|
|
|
|
async with await AsyncConnection.connect(
|
|
admin_conn_string, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"CREATE DATABASE {database}")
|
|
try:
|
|
async with AsyncPostgresStore.from_conn_string(
|
|
conn_string,
|
|
index=index_config,
|
|
) as store:
|
|
await store.setup()
|
|
yield store
|
|
finally:
|
|
async with await AsyncConnection.connect(
|
|
admin_conn_string, autocommit=True
|
|
) as conn:
|
|
await conn.execute(f"DROP DATABASE {database}")
|
|
|
|
|
|
@pytest.fixture(
|
|
scope="function",
|
|
params=[
|
|
(vector_type, distance_type)
|
|
for vector_type in VECTOR_TYPES
|
|
for distance_type in (
|
|
["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
|
|
)
|
|
],
|
|
ids=lambda p: f"{p[0]}_{p[1]}",
|
|
)
|
|
async def vector_store(
|
|
request,
|
|
fake_embeddings: CharacterEmbeddings,
|
|
) -> AsyncIterator[AsyncPostgresStore]:
|
|
"""Create a store with vector search enabled."""
|
|
vector_type, distance_type = request.param
|
|
async with _create_vector_store(
|
|
vector_type, distance_type, fake_embeddings
|
|
) as store:
|
|
yield store
|
|
|
|
|
|
async def test_vector_store_initialization(
|
|
vector_store: AsyncPostgresStore, fake_embeddings: CharacterEmbeddings
|
|
) -> None:
|
|
"""Test store initialization with embedding config."""
|
|
assert vector_store.index_config is not None
|
|
assert vector_store.index_config["dims"] == fake_embeddings.dims
|
|
if isinstance(vector_store.index_config["embed"], Embeddings):
|
|
assert vector_store.index_config["embed"] == fake_embeddings
|
|
|
|
|
|
async def test_vector_insert_with_auto_embedding(
|
|
vector_store: AsyncPostgresStore,
|
|
) -> None:
|
|
"""Test inserting items that get auto-embedded."""
|
|
docs = [
|
|
("doc1", {"text": "short text"}),
|
|
("doc2", {"text": "longer text document"}),
|
|
("doc3", {"text": "longest text document here"}),
|
|
("doc4", {"description": "text in description field"}),
|
|
("doc5", {"content": "text in content field"}),
|
|
("doc6", {"body": "text in body field"}),
|
|
]
|
|
|
|
for key, value in docs:
|
|
await vector_store.aput(("test",), key, value)
|
|
|
|
results = await vector_store.asearch(("test",), query="long text")
|
|
assert len(results) > 0
|
|
|
|
doc_order = [r.key for r in results]
|
|
assert "doc2" in doc_order
|
|
assert "doc3" in doc_order
|
|
|
|
|
|
async def test_vector_update_with_embedding(vector_store: AsyncPostgresStore) -> None:
|
|
"""Test that updating items properly updates their embeddings."""
|
|
await vector_store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"})
|
|
await vector_store.aput(("test",), "doc2", {"text": "something about dogs"})
|
|
await vector_store.aput(("test",), "doc3", {"text": "text about birds"})
|
|
|
|
results_initial = await vector_store.asearch(("test",), query="Zany Xerxes")
|
|
assert len(results_initial) > 0
|
|
assert results_initial[0].key == "doc1"
|
|
initial_score = results_initial[0].score
|
|
|
|
await vector_store.aput(("test",), "doc1", {"text": "new text about dogs"})
|
|
|
|
results_after = await vector_store.asearch(("test",), query="Zany Xerxes")
|
|
after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
|
|
assert after_score < initial_score
|
|
|
|
results_new = await vector_store.asearch(("test",), query="new text about dogs")
|
|
for r in results_new:
|
|
if r.key == "doc1":
|
|
assert r.score > after_score
|
|
|
|
# Don't index this one
|
|
await vector_store.aput(
|
|
("test",), "doc4", {"text": "new text about dogs"}, index=False
|
|
)
|
|
results_new = await vector_store.asearch(
|
|
("test",), query="new text about dogs", limit=3
|
|
)
|
|
assert not any(r.key == "doc4" for r in results_new)
|
|
|
|
|
|
async def test_vector_search_with_filters(vector_store: AsyncPostgresStore) -> None:
|
|
"""Test combining vector search with filters."""
|
|
docs = [
|
|
("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
|
|
("doc2", {"text": "red car", "color": "red", "score": 3.0}),
|
|
("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
|
|
("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
|
|
]
|
|
|
|
for key, value in docs:
|
|
await vector_store.aput(("test",), key, value)
|
|
|
|
results = await vector_store.asearch(
|
|
("test",), query="apple", filter={"color": "red"}
|
|
)
|
|
assert len(results) == 2
|
|
assert results[0].key == "doc1"
|
|
|
|
results = await vector_store.asearch(
|
|
("test",), query="car", filter={"color": "red"}
|
|
)
|
|
assert len(results) == 2
|
|
assert results[0].key == "doc2"
|
|
|
|
results = await vector_store.asearch(
|
|
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
|
|
)
|
|
assert len(results) == 3
|
|
assert results[0].key == "doc4"
|
|
|
|
results = await vector_store.asearch(
|
|
("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
|
|
)
|
|
assert len(results) == 1
|
|
assert results[0].key == "doc3"
|
|
|
|
|
|
async def test_vector_search_pagination(vector_store: AsyncPostgresStore) -> None:
|
|
"""Test pagination with vector search."""
|
|
for i in range(5):
|
|
await vector_store.aput(
|
|
("test",), f"doc{i}", {"text": f"test document number {i}"}
|
|
)
|
|
|
|
results_page1 = await vector_store.asearch(("test",), query="test", limit=2)
|
|
results_page2 = await vector_store.asearch(
|
|
("test",), query="test", limit=2, offset=2
|
|
)
|
|
|
|
assert len(results_page1) == 2
|
|
assert len(results_page2) == 2
|
|
assert results_page1[0].key != results_page2[0].key
|
|
|
|
all_results = await vector_store.asearch(("test",), query="test", limit=10)
|
|
assert len(all_results) == 5
|
|
|
|
|
|
async def test_vector_search_edge_cases(vector_store: AsyncPostgresStore) -> None:
|
|
"""Test edge cases in vector search."""
|
|
await vector_store.aput(("test",), "doc1", {"text": "test document"})
|
|
|
|
perfect_match = await vector_store.asearch(("test",), query="text test document")
|
|
perfect_score = perfect_match[0].score
|
|
|
|
results = await vector_store.asearch(("test",), query="")
|
|
assert len(results) == 1
|
|
assert results[0].score is None
|
|
|
|
results = await vector_store.asearch(("test",), query=None)
|
|
assert len(results) == 1
|
|
assert results[0].score is None
|
|
|
|
long_query = "foo " * 100
|
|
results = await vector_store.asearch(("test",), query=long_query)
|
|
assert len(results) == 1
|
|
assert results[0].score < perfect_score
|
|
|
|
special_query = "test!@#$%^&*()"
|
|
results = await vector_store.asearch(("test",), query=special_query)
|
|
assert len(results) == 1
|
|
assert results[0].score < perfect_score
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"vector_type,distance_type",
|
|
[
|
|
*itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]),
|
|
],
|
|
)
|
|
async def test_embed_with_path(
|
|
request: Any,
|
|
fake_embeddings: CharacterEmbeddings,
|
|
vector_type: str,
|
|
distance_type: str,
|
|
) -> None:
|
|
"""Test vector search with specific text fields in Postgres store."""
|
|
async with _create_vector_store(
|
|
vector_type,
|
|
distance_type,
|
|
fake_embeddings,
|
|
text_fields=["key0", "key1", "key3"],
|
|
) as store:
|
|
# This will have 2 vectors representing it
|
|
doc1 = {
|
|
# Omit key0 - check it doesn't raise an error
|
|
"key1": "xxx",
|
|
"key2": "yyy",
|
|
"key3": "zzz",
|
|
}
|
|
# This will have 3 vectors representing it
|
|
doc2 = {
|
|
"key0": "uuu",
|
|
"key1": "vvv",
|
|
"key2": "www",
|
|
"key3": "xxx",
|
|
}
|
|
await store.aput(("test",), "doc1", doc1)
|
|
await store.aput(("test",), "doc2", doc2)
|
|
|
|
# doc2.key3 and doc1.key1 both would have the highest score
|
|
results = await store.asearch(("test",), query="xxx")
|
|
assert len(results) == 2
|
|
assert results[0].key != results[1].key
|
|
ascore = results[0].score
|
|
bscore = results[1].score
|
|
assert ascore == pytest.approx(bscore, abs=1e-3)
|
|
|
|
results = await store.asearch(("test",), query="uuu")
|
|
assert len(results) == 2
|
|
assert results[0].key != results[1].key
|
|
assert results[0].key == "doc2"
|
|
assert results[0].score > results[1].score
|
|
assert ascore == pytest.approx(results[0].score, abs=1e-3)
|
|
|
|
# Un-indexed - will have low results for both. Not zero (because we're projecting)
|
|
# but less than the above.
|
|
results = await store.asearch(("test",), query="www")
|
|
assert len(results) == 2
|
|
assert results[0].score < ascore
|
|
assert results[1].score < ascore
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"vector_type,distance_type",
|
|
[
|
|
*itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]),
|
|
],
|
|
)
|
|
async def test_search_sorting(
|
|
request: Any,
|
|
fake_embeddings: CharacterEmbeddings,
|
|
vector_type: str,
|
|
distance_type: str,
|
|
) -> None:
|
|
"""Test operation-level field configuration for vector search."""
|
|
async with _create_vector_store(
|
|
vector_type,
|
|
distance_type,
|
|
fake_embeddings,
|
|
text_fields=["key1"], # Default fields that won't match our test data
|
|
) as store:
|
|
amatch = {
|
|
"key1": "mmm",
|
|
}
|
|
|
|
await store.aput(("test", "M"), "M", amatch)
|
|
N = 100
|
|
for i in range(N):
|
|
await store.aput(("test", "A"), f"A{i}", {"key1": "no"})
|
|
for i in range(N):
|
|
await store.aput(("test", "Z"), f"Z{i}", {"key1": "no"})
|
|
|
|
results = await store.asearch(("test",), query="mmm", limit=10)
|
|
assert len(results) == 10
|
|
assert len(set(r.key for r in results)) == 10
|
|
assert results[0].key == "M"
|
|
assert results[0].score > results[1].score
|
|
|
|
|
|
async def test_store_ttl(store):
|
|
# Assumes a TTL of 1 minute = 60 seconds
|
|
ns = ("foo",)
|
|
await store.start_ttl_sweeper()
|
|
await store.aput(
|
|
ns,
|
|
key="item1",
|
|
value={"foo": "bar"},
|
|
ttl=TTL_MINUTES, # type: ignore
|
|
)
|
|
await asyncio.sleep(TTL_SECONDS - 2)
|
|
res = await store.aget(ns, key="item1", refresh_ttl=True)
|
|
assert res is not None
|
|
await asyncio.sleep(TTL_SECONDS - 2)
|
|
results = await store.asearch(ns, query="foo", refresh_ttl=True)
|
|
assert len(results) == 1
|
|
await asyncio.sleep(TTL_SECONDS - 2)
|
|
res = await store.aget(ns, key="item1", refresh_ttl=False)
|
|
assert res is not None
|
|
await asyncio.sleep(TTL_SECONDS - 1)
|
|
# Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
|
|
results = await store.asearch(ns, query="bar", refresh_ttl=False)
|
|
assert len(results) == 0
|