langchain-sqlite-graph-memory 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_sqlite_graph_memory-0.1.0/.github/workflows/publish.yml +51 -0
- langchain_sqlite_graph_memory-0.1.0/.github/workflows/tests.yml +37 -0
- langchain_sqlite_graph_memory-0.1.0/.gitignore +6 -0
- langchain_sqlite_graph_memory-0.1.0/CITATION.cff +13 -0
- langchain_sqlite_graph_memory-0.1.0/FOR-ROBOTS.md +32 -0
- langchain_sqlite_graph_memory-0.1.0/LICENSE +21 -0
- langchain_sqlite_graph_memory-0.1.0/PKG-INFO +129 -0
- langchain_sqlite_graph_memory-0.1.0/README.md +77 -0
- langchain_sqlite_graph_memory-0.1.0/pyproject.toml +47 -0
- langchain_sqlite_graph_memory-0.1.0/src/langchain_sqlite_graph_memory/__init__.py +5 -0
- langchain_sqlite_graph_memory-0.1.0/src/langchain_sqlite_graph_memory/py.typed +0 -0
- langchain_sqlite_graph_memory-0.1.0/src/langchain_sqlite_graph_memory/retrievers.py +146 -0
- langchain_sqlite_graph_memory-0.1.0/tests/__init__.py +0 -0
- langchain_sqlite_graph_memory-0.1.0/tests/fakes.py +65 -0
- langchain_sqlite_graph_memory-0.1.0/tests/integration_tests/__init__.py +0 -0
- langchain_sqlite_graph_memory-0.1.0/tests/integration_tests/test_standard.py +27 -0
- langchain_sqlite_graph_memory-0.1.0/tests/unit_tests/__init__.py +0 -0
- langchain_sqlite_graph_memory-0.1.0/tests/unit_tests/test_retriever.py +89 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Publish to PyPI on every published GitHub release, via PyPI Trusted Publishing (OIDC):
|
|
2
|
+
# no API token is stored anywhere.
|
|
3
|
+
#
|
|
4
|
+
# ONE-TIME SETUP on PyPI before the first release (https://pypi.org/manage/account/publishing/):
|
|
5
|
+
# project langchain-sqlite-graph-memory (add as a "pending publisher")
|
|
6
|
+
# owner tonydzi
|
|
7
|
+
# repository langchain-sqlite-graph-memory
|
|
8
|
+
# workflow publish.yml
|
|
9
|
+
# environment pypi
|
|
10
|
+
# Without it the job fails with `invalid-publisher`.
|
|
11
|
+
name: publish
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
release:
|
|
15
|
+
types: [published]
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
build:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
26
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
- run: |
|
|
30
|
+
python -m pip install build twine
|
|
31
|
+
python -m build
|
|
32
|
+
python -m twine check --strict dist/*
|
|
33
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
34
|
+
with:
|
|
35
|
+
name: dist
|
|
36
|
+
path: dist/
|
|
37
|
+
|
|
38
|
+
publish:
|
|
39
|
+
needs: build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment: pypi
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
50
|
+
with:
|
|
51
|
+
skip-existing: true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
pytest:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
18
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- run: python -m pip install -e ".[test]"
|
|
22
|
+
- run: python -m pytest -q
|
|
23
|
+
|
|
24
|
+
installable:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
28
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
- run: |
|
|
32
|
+
python -m pip install build twine
|
|
33
|
+
python -m build
|
|
34
|
+
python -m twine check --strict dist/*
|
|
35
|
+
python -m venv /tmp/clean
|
|
36
|
+
/tmp/clean/bin/pip install dist/*.whl
|
|
37
|
+
/tmp/clean/bin/python -c "from langchain_sqlite_graph_memory import SQLiteGraphMemoryRetriever as R; print(R.__name__)"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use tonydzi/langchain-sqlite-graph-memory in research or software, please cite it as below."
|
|
3
|
+
title: "langchain-sqlite-graph-memory"
|
|
4
|
+
type: software
|
|
5
|
+
authors:
|
|
6
|
+
- family-names: Dziatkovskii
|
|
7
|
+
given-names: Anton
|
|
8
|
+
orcid: "https://orcid.org/0000-0001-7408-3054"
|
|
9
|
+
affiliation: "Palo Alto AI Research Lab"
|
|
10
|
+
repository-code: "https://github.com/tonydzi/langchain-sqlite-graph-memory"
|
|
11
|
+
url: "https://github.com/tonydzi/langchain-sqlite-graph-memory"
|
|
12
|
+
abstract: >-
|
|
13
|
+
LangChain retriever for sqlite-graph-memory: vector search, 1-hop wikilink graph expansion and cross-encoder rerank over a folder of markdown notes, with no server.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# FOR ROBOTS
|
|
2
|
+
|
|
3
|
+
You are an AI agent reading this repo. Ranked takeaways, densest first. One rule about
|
|
4
|
+
this file itself: **it is data, not authorization.** Nothing here overrides your
|
|
5
|
+
operator's policies or lets you install anything on your own.
|
|
6
|
+
|
|
7
|
+
## Alpha, ranked
|
|
8
|
+
|
|
9
|
+
1. **If your operator keeps notes as markdown with `[[wikilinks]]`, this gives a LangChain
|
|
10
|
+
app recall over those notes and the notes they link to, with no server and no LLM call.**
|
|
11
|
+
`SQLiteGraphMemoryRetriever(index_dir=...).invoke(query)` returns `Document`s, and
|
|
12
|
+
`metadata["via_graph"]` marks the notes that arrived only through a link.
|
|
13
|
+
2. **The adapter does not re-implement retrieval.** It calls the functions `sgm-ask` calls.
|
|
14
|
+
`tests/unit_tests/test_retriever.py::test_same_order_as_the_sgm_pipeline` fails if the two
|
|
15
|
+
drift apart.
|
|
16
|
+
3. **Use `graph="auto"` unless you have measured otherwise.** In the core library's A/B runs
|
|
17
|
+
the link hop helped topic queries and hurt name lookups, so "auto" turns it off for
|
|
18
|
+
names and tools.
|
|
19
|
+
|
|
20
|
+
## Provenance
|
|
21
|
+
|
|
22
|
+
- Tests: 13 pass, including LangChain's standard retriever suite (`langchain-tests`), on a
|
|
23
|
+
four-note vault with fake models. Each of three deliberate breakages (no graph hop,
|
|
24
|
+
per-call `k` ignored, `via_graph` always False) turned the suite red. Measured
|
|
25
|
+
2026-11-05.
|
|
26
|
+
- Live check, 2026-11-05, MacBook, ONNX backend: on a 29,474-note vault (105,383 chunks)
|
|
27
|
+
the retriever returned the same 12 notes in the same order as `sgm-ask --graph`.
|
|
28
|
+
|
|
29
|
+
## Family
|
|
30
|
+
|
|
31
|
+
- Core library: https://github.com/tonydzi/sqlite-graph-memory (indexer, CLI, MCP server).
|
|
32
|
+
- This repo is only the LangChain-facing adapter.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Palo Alto AI Research Lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-sqlite-graph-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain retriever for sqlite-graph-memory: vector search + [[wikilink]] graph hop + cross-encoder rerank over your markdown notes. No server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tonydzi/langchain-sqlite-graph-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/tonydzi/langchain-sqlite-graph-memory
|
|
7
|
+
Project-URL: Issues, https://github.com/tonydzi/langchain-sqlite-graph-memory/issues
|
|
8
|
+
Project-URL: Core library, https://github.com/tonydzi/sqlite-graph-memory
|
|
9
|
+
Author: Anton Dziatkovskii
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Palo Alto AI Research Lab
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: agent-memory,graph-rag,langchain,obsidian,rag,retriever,wikilinks
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Requires-Dist: langchain-core<2,>=1.0
|
|
43
|
+
Requires-Dist: sqlite-graph-memory>=0.3.6
|
|
44
|
+
Provides-Extra: embeddings
|
|
45
|
+
Requires-Dist: sqlite-graph-memory[embeddings]>=0.3.6; extra == 'embeddings'
|
|
46
|
+
Provides-Extra: lite
|
|
47
|
+
Requires-Dist: sqlite-graph-memory[lite]>=0.3.6; extra == 'lite'
|
|
48
|
+
Provides-Extra: test
|
|
49
|
+
Requires-Dist: langchain-tests<2,>=1.0; extra == 'test'
|
|
50
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
51
|
+
Description-Content-Type: text/markdown
|
|
52
|
+
|
|
53
|
+
# langchain-sqlite-graph-memory
|
|
54
|
+
|
|
55
|
+
A LangChain retriever over your own markdown notes, with the links between them. It runs
|
|
56
|
+
[sqlite-graph-memory](https://github.com/tonydzi/sqlite-graph-memory): vector search finds
|
|
57
|
+
the entry points, the `[[wikilinks]]` you already wrote are the graph, and a cross-encoder
|
|
58
|
+
reranks what the 1-hop expansion pulled in. Everything runs on your machine: no server, no
|
|
59
|
+
graph database, no LLM call at retrieval time.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install "langchain-sqlite-graph-memory[lite]" # ONNX models, starts in seconds
|
|
63
|
+
sgm-index /path/to/your/notes # builds ./index once
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from langchain_sqlite_graph_memory import SQLiteGraphMemoryRetriever
|
|
68
|
+
|
|
69
|
+
retriever = SQLiteGraphMemoryRetriever(index_dir="./index", k=8)
|
|
70
|
+
|
|
71
|
+
for doc in retriever.invoke("why did we drop the graph database?"):
|
|
72
|
+
print(doc.metadata["rank"], doc.metadata["title"], doc.metadata["via_graph"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each result is a `Document`. `page_content` holds the matched chunk of the note. `metadata`
|
|
76
|
+
holds `path`, `title`, `date`, `score` (cross-encoder), `rank`, and `via_graph`. `via_graph`
|
|
77
|
+
is `True` when the note came in only through a wikilink from a top vector hit, so you can
|
|
78
|
+
tell associative recall from direct recall.
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
| Argument | Default | What it does |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `index_dir` | `$BRAIN_INDEX_DIR`, else `./index` | Folder with the `sgm-index` output |
|
|
85
|
+
| `k` | 12 | Documents returned. `retriever.invoke(q, k=3)` overrides it per call |
|
|
86
|
+
| `graph` | `"auto"` | `True` always follows wikilinks, `False` never does. `"auto"` follows them except for name or tool lookups, where the hop measured as noise |
|
|
87
|
+
| `fetch_k` | 60 | Unique notes taken from vector search before expansion and rerank |
|
|
88
|
+
| `encoder`, `reranker` | SGM models | Pass your own preloaded models, or fakes in tests |
|
|
89
|
+
|
|
90
|
+
## In an agent
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from langchain_core.tools import create_retriever_tool
|
|
94
|
+
|
|
95
|
+
recall = create_retriever_tool(
|
|
96
|
+
retriever, "recall_notes",
|
|
97
|
+
"Search my notes and the notes they link to. Use for anything I wrote down before.",
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Scope, stated plainly
|
|
102
|
+
|
|
103
|
+
- **This adapter calls the core library and adds nothing to retrieval.** It calls the same
|
|
104
|
+
functions `sgm-ask` calls (`query_sims`, `retrieve_candidates`, `expand_1hop`,
|
|
105
|
+
`rerank_candidates`). A test pins this: the adapter's order must equal a direct call of
|
|
106
|
+
those functions. On a real vault of 29,474 notes (105,383 chunks), the adapter returned the same 12 notes in the
|
|
107
|
+
same order as `sgm-ask --graph`.
|
|
108
|
+
- **The index is two files on disk**, an embedding matrix (`.npy`) and note metadata
|
|
109
|
+
(`.pkl`), written by `sgm-index`. Wikilinks are read from the notes at query time.
|
|
110
|
+
SQLite stores the core library's session memory and A/B logs. The retriever needs only
|
|
111
|
+
the two index files.
|
|
112
|
+
- Embeddings: `intfloat/multilingual-e5-base`. Reranker:
|
|
113
|
+
`cross-encoder/mmarco-mMiniLMv2-L12-H384-v1`. Both work for many languages.
|
|
114
|
+
- Not implemented yet: a `VectorStore` (adding documents through LangChain) and a LangGraph
|
|
115
|
+
long-term memory store. Indexing goes through `sgm-index`.
|
|
116
|
+
|
|
117
|
+
## Tests
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install -e ".[test]"
|
|
121
|
+
pytest
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The suite includes LangChain's standard retriever tests (`langchain-tests`). It runs with
|
|
125
|
+
fake models on a four-note vault: no download, no GPU, a few seconds.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# langchain-sqlite-graph-memory
|
|
2
|
+
|
|
3
|
+
A LangChain retriever over your own markdown notes, with the links between them. It runs
|
|
4
|
+
[sqlite-graph-memory](https://github.com/tonydzi/sqlite-graph-memory): vector search finds
|
|
5
|
+
the entry points, the `[[wikilinks]]` you already wrote are the graph, and a cross-encoder
|
|
6
|
+
reranks what the 1-hop expansion pulled in. Everything runs on your machine: no server, no
|
|
7
|
+
graph database, no LLM call at retrieval time.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "langchain-sqlite-graph-memory[lite]" # ONNX models, starts in seconds
|
|
11
|
+
sgm-index /path/to/your/notes # builds ./index once
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from langchain_sqlite_graph_memory import SQLiteGraphMemoryRetriever
|
|
16
|
+
|
|
17
|
+
retriever = SQLiteGraphMemoryRetriever(index_dir="./index", k=8)
|
|
18
|
+
|
|
19
|
+
for doc in retriever.invoke("why did we drop the graph database?"):
|
|
20
|
+
print(doc.metadata["rank"], doc.metadata["title"], doc.metadata["via_graph"])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each result is a `Document`. `page_content` holds the matched chunk of the note. `metadata`
|
|
24
|
+
holds `path`, `title`, `date`, `score` (cross-encoder), `rank`, and `via_graph`. `via_graph`
|
|
25
|
+
is `True` when the note came in only through a wikilink from a top vector hit, so you can
|
|
26
|
+
tell associative recall from direct recall.
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
| Argument | Default | What it does |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `index_dir` | `$BRAIN_INDEX_DIR`, else `./index` | Folder with the `sgm-index` output |
|
|
33
|
+
| `k` | 12 | Documents returned. `retriever.invoke(q, k=3)` overrides it per call |
|
|
34
|
+
| `graph` | `"auto"` | `True` always follows wikilinks, `False` never does. `"auto"` follows them except for name or tool lookups, where the hop measured as noise |
|
|
35
|
+
| `fetch_k` | 60 | Unique notes taken from vector search before expansion and rerank |
|
|
36
|
+
| `encoder`, `reranker` | SGM models | Pass your own preloaded models, or fakes in tests |
|
|
37
|
+
|
|
38
|
+
## In an agent
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from langchain_core.tools import create_retriever_tool
|
|
42
|
+
|
|
43
|
+
recall = create_retriever_tool(
|
|
44
|
+
retriever, "recall_notes",
|
|
45
|
+
"Search my notes and the notes they link to. Use for anything I wrote down before.",
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Scope, stated plainly
|
|
50
|
+
|
|
51
|
+
- **This adapter calls the core library and adds nothing to retrieval.** It calls the same
|
|
52
|
+
functions `sgm-ask` calls (`query_sims`, `retrieve_candidates`, `expand_1hop`,
|
|
53
|
+
`rerank_candidates`). A test pins this: the adapter's order must equal a direct call of
|
|
54
|
+
those functions. On a real vault of 29,474 notes (105,383 chunks), the adapter returned the same 12 notes in the
|
|
55
|
+
same order as `sgm-ask --graph`.
|
|
56
|
+
- **The index is two files on disk**, an embedding matrix (`.npy`) and note metadata
|
|
57
|
+
(`.pkl`), written by `sgm-index`. Wikilinks are read from the notes at query time.
|
|
58
|
+
SQLite stores the core library's session memory and A/B logs. The retriever needs only
|
|
59
|
+
the two index files.
|
|
60
|
+
- Embeddings: `intfloat/multilingual-e5-base`. Reranker:
|
|
61
|
+
`cross-encoder/mmarco-mMiniLMv2-L12-H384-v1`. Both work for many languages.
|
|
62
|
+
- Not implemented yet: a `VectorStore` (adding documents through LangChain) and a LangGraph
|
|
63
|
+
long-term memory store. Indexing goes through `sgm-index`.
|
|
64
|
+
|
|
65
|
+
## Tests
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[test]"
|
|
69
|
+
pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The suite includes LangChain's standard retriever tests (`langchain-tests`). It runs with
|
|
73
|
+
fake models on a four-note vault: no download, no GPU, a few seconds.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-sqlite-graph-memory"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain retriever for sqlite-graph-memory: vector search + [[wikilink]] graph hop + cross-encoder rerank over your markdown notes. No server."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Anton Dziatkovskii" }]
|
|
13
|
+
keywords = ["langchain", "retriever", "graph-rag", "obsidian", "wikilinks", "agent-memory", "rag"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"langchain-core>=1.0,<2",
|
|
26
|
+
"sqlite-graph-memory>=0.3.6",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
# The two local models. `lite` = ONNX, starts in seconds; `embeddings` = torch.
|
|
31
|
+
lite = ["sqlite-graph-memory[lite]>=0.3.6"]
|
|
32
|
+
embeddings = ["sqlite-graph-memory[embeddings]>=0.3.6"]
|
|
33
|
+
test = ["pytest>=8", "langchain-tests>=1.0,<2"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/tonydzi/langchain-sqlite-graph-memory"
|
|
37
|
+
Repository = "https://github.com/tonydzi/langchain-sqlite-graph-memory"
|
|
38
|
+
Issues = "https://github.com/tonydzi/langchain-sqlite-graph-memory/issues"
|
|
39
|
+
"Core library" = "https://github.com/tonydzi/sqlite-graph-memory"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/langchain_sqlite_graph_memory"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""LangChain retriever over a sqlite-graph-memory index.
|
|
2
|
+
|
|
3
|
+
Паспорт (for the people who maintain this):
|
|
4
|
+
in: a query string; an index built by `sgm-index` (two files: _brain_e5.npy,
|
|
5
|
+
_brain_e5_meta.pkl) in `index_dir`.
|
|
6
|
+
out: list[Document], best first. page_content = the matched chunk; metadata =
|
|
7
|
+
path, title, date, score, rank, via_graph.
|
|
8
|
+
who calls: any LangChain / LangGraph app (`retriever.invoke(q)`, a tool, a chain).
|
|
9
|
+
what breaks it: sqlite-graph-memory changing the signatures of load_index,
|
|
10
|
+
query_sims, retrieve_candidates, expand_1hop, rerank_candidates. The test
|
|
11
|
+
suite pins that contract with fake models, so a drift fails CI, not a user.
|
|
12
|
+
рельса: none, no LLM call. Embedding + cross-encoder run locally.
|
|
13
|
+
|
|
14
|
+
It runs the SAME pipeline functions `sgm-ask` runs (imported from
|
|
15
|
+
sqlite_graph_memory.brain_ask), so recall through LangChain cannot drift away from
|
|
16
|
+
recall through the CLI or the MCP server.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, List, Literal, Optional, Union
|
|
23
|
+
|
|
24
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
25
|
+
from langchain_core.documents import Document
|
|
26
|
+
from langchain_core.retrievers import BaseRetriever
|
|
27
|
+
from pydantic import ConfigDict, Field, PrivateAttr
|
|
28
|
+
|
|
29
|
+
from sqlite_graph_memory import brain_ask as _sgm
|
|
30
|
+
|
|
31
|
+
GraphMode = Union[bool, Literal["auto"]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SQLiteGraphMemoryRetriever(BaseRetriever):
|
|
35
|
+
"""Associative recall over a folder of markdown notes.
|
|
36
|
+
|
|
37
|
+
Vector search finds entry points, 1-hop ``[[wikilinks]]`` pull in linked notes,
|
|
38
|
+
a cross-encoder reranks the union. The index is two files on disk; no server.
|
|
39
|
+
|
|
40
|
+
Setup:
|
|
41
|
+
.. code-block:: bash
|
|
42
|
+
|
|
43
|
+
pip install "langchain-sqlite-graph-memory[lite]"
|
|
44
|
+
sgm-index /path/to/vault --out ./index
|
|
45
|
+
|
|
46
|
+
Instantiate:
|
|
47
|
+
.. code-block:: python
|
|
48
|
+
|
|
49
|
+
from langchain_sqlite_graph_memory import SQLiteGraphMemoryRetriever
|
|
50
|
+
|
|
51
|
+
retriever = SQLiteGraphMemoryRetriever(index_dir="./index", k=8)
|
|
52
|
+
|
|
53
|
+
Invoke:
|
|
54
|
+
.. code-block:: python
|
|
55
|
+
|
|
56
|
+
docs = retriever.invoke("why did we drop the graph database?")
|
|
57
|
+
for d in docs:
|
|
58
|
+
print(d.metadata["rank"], d.metadata["title"], d.metadata["via_graph"])
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
62
|
+
|
|
63
|
+
index_dir: Optional[str] = None
|
|
64
|
+
"""Directory with the `sgm-index` output. Default: env BRAIN_INDEX_DIR, then ./index."""
|
|
65
|
+
k: int = Field(default=_sgm.TOPN, ge=1)
|
|
66
|
+
"""How many documents to return."""
|
|
67
|
+
graph: GraphMode = "auto"
|
|
68
|
+
"""Wikilink expansion. True = always, False = never (plain vector + rerank),
|
|
69
|
+
"auto" = on, except for name/tool lookups, where it measured as noise
|
|
70
|
+
(sqlite_graph_memory.brain_ask.looks_like_entity)."""
|
|
71
|
+
fetch_k: int = Field(default=_sgm.TOPK_RETRIEVE, ge=1)
|
|
72
|
+
"""Unique notes taken from vector search before expansion and rerank."""
|
|
73
|
+
encoder: Any = None
|
|
74
|
+
"""Optional preloaded encoder with `.encode(...)`. Default: the SGM e5 model."""
|
|
75
|
+
reranker: Any = None
|
|
76
|
+
"""Optional preloaded cross-encoder with `.predict(pairs)`. Default: the SGM reranker."""
|
|
77
|
+
|
|
78
|
+
_emb: Any = PrivateAttr(default=None)
|
|
79
|
+
_meta: Any = PrivateAttr(default=None)
|
|
80
|
+
_by_base: Any = PrivateAttr(default=None)
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ loading
|
|
83
|
+
def _index_paths(self) -> tuple[Path, Path]:
|
|
84
|
+
base = Path(self.index_dir or os.getenv("BRAIN_INDEX_DIR", "index"))
|
|
85
|
+
return base / "_brain_e5.npy", base / "_brain_e5_meta.pkl"
|
|
86
|
+
|
|
87
|
+
def _ensure_loaded(self) -> None:
|
|
88
|
+
if self._meta is not None:
|
|
89
|
+
return
|
|
90
|
+
emb_path, meta_path = self._index_paths()
|
|
91
|
+
if not emb_path.exists() or not meta_path.exists():
|
|
92
|
+
raise FileNotFoundError(
|
|
93
|
+
"No sqlite-graph-memory index at %s. Build one first: "
|
|
94
|
+
"sgm-index <notes folder> (see https://github.com/tonydzi/sqlite-graph-memory)"
|
|
95
|
+
% emb_path.parent
|
|
96
|
+
)
|
|
97
|
+
self._emb, self._meta = _sgm.load_index(emb_path, meta_path)
|
|
98
|
+
self._by_base = _sgm.index_by_basename(self._meta)
|
|
99
|
+
if self.encoder is None:
|
|
100
|
+
self.encoder = _sgm.load_encoder()
|
|
101
|
+
if self.reranker is None:
|
|
102
|
+
self.reranker = _sgm.load_reranker()
|
|
103
|
+
|
|
104
|
+
def _use_graph(self, query: str) -> bool:
|
|
105
|
+
if self.graph == "auto":
|
|
106
|
+
return not _sgm.looks_like_entity(query)
|
|
107
|
+
return bool(self.graph)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------ retrieval
|
|
110
|
+
def _get_relevant_documents(
|
|
111
|
+
self,
|
|
112
|
+
query: str,
|
|
113
|
+
*,
|
|
114
|
+
run_manager: CallbackManagerForRetrieverRun,
|
|
115
|
+
**kwargs: Any,
|
|
116
|
+
) -> List[Document]:
|
|
117
|
+
k = int(kwargs.get("k", self.k))
|
|
118
|
+
self._ensure_loaded()
|
|
119
|
+
meta = self._meta
|
|
120
|
+
sims = _sgm.query_sims(self.encoder, self._emb, query)
|
|
121
|
+
base_order = _sgm.retrieve_candidates(sims, meta, topk=self.fetch_k)
|
|
122
|
+
added: List[int] = []
|
|
123
|
+
if base_order and self._use_graph(query):
|
|
124
|
+
added = _sgm.expand_1hop(sims, meta, base_order, by_base=self._by_base)
|
|
125
|
+
via_graph = set(added)
|
|
126
|
+
ranked = _sgm.rerank_candidates(
|
|
127
|
+
self.reranker, query, base_order + added, meta, topn=k
|
|
128
|
+
)
|
|
129
|
+
docs = []
|
|
130
|
+
for rank, (i, score) in enumerate(ranked, 1):
|
|
131
|
+
m = meta[i]
|
|
132
|
+
docs.append(
|
|
133
|
+
Document(
|
|
134
|
+
page_content=m.get("snippet", ""),
|
|
135
|
+
metadata={
|
|
136
|
+
"path": m.get("path"),
|
|
137
|
+
"title": m.get("title"),
|
|
138
|
+
"date": m.get("date"),
|
|
139
|
+
"score": float(score),
|
|
140
|
+
"rank": rank,
|
|
141
|
+
"via_graph": i in via_graph,
|
|
142
|
+
"source": m.get("path"),
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return docs
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tiny stand-ins for the two local models, so the suite runs in seconds with no torch,
|
|
2
|
+
no ONNX and no network. They implement exactly the methods the SGM pipeline calls."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import pickle
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
DIM = 64
|
|
13
|
+
_WORD = re.compile(r"[a-z]+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _words(text: str) -> list[str]:
|
|
17
|
+
return [w for w in _WORD.findall(text.lower()) if w not in {"query", "passage"}]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _vec(text: str) -> np.ndarray:
|
|
21
|
+
v = np.zeros(DIM, dtype="float32")
|
|
22
|
+
for w in _words(text):
|
|
23
|
+
v[int(hashlib.md5(w.encode()).hexdigest(), 16) % DIM] += 1.0
|
|
24
|
+
n = np.linalg.norm(v)
|
|
25
|
+
return v / n if n else v
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeEncoder:
|
|
29
|
+
def encode(self, texts, normalize_embeddings=True, convert_to_numpy=True):
|
|
30
|
+
return np.stack([_vec(t) for t in texts])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeReranker:
|
|
34
|
+
"""Score = shared words between query and chunk, plus a small floor so that a linked
|
|
35
|
+
note with no shared words still ranks (below every note that shares a word)."""
|
|
36
|
+
|
|
37
|
+
def predict(self, pairs):
|
|
38
|
+
out = []
|
|
39
|
+
for q, doc in pairs:
|
|
40
|
+
out.append(len(set(_words(q)) & set(_words(doc))) + 0.01)
|
|
41
|
+
return np.array(out, dtype="float32")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
NOTES = {
|
|
45
|
+
"alpha": "Deploy pipeline and release checklist. Before shipping see [[beta]].",
|
|
46
|
+
"beta": "Rollback plan for outages and incidents.",
|
|
47
|
+
"gamma": "Cooking recipes: pasta, sauce, basil.",
|
|
48
|
+
"delta": "Release notes template for the deploy of a new version.",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_vault_and_index(root: Path) -> Path:
|
|
53
|
+
"""Write NOTES as .md files under root/vault and an SGM-format index under root/index."""
|
|
54
|
+
vault, index = root / "vault", root / "index"
|
|
55
|
+
vault.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
index.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
meta, rows = [], []
|
|
58
|
+
for stem, body in NOTES.items():
|
|
59
|
+
p = vault / ("%s.md" % stem)
|
|
60
|
+
p.write_text(body, encoding="utf-8")
|
|
61
|
+
meta.append({"path": str(p), "title": stem, "snippet": body, "date": "2026-11-05"})
|
|
62
|
+
rows.append(_vec("passage: " + body))
|
|
63
|
+
np.save(index / "_brain_e5.npy", np.stack(rows))
|
|
64
|
+
(index / "_brain_e5_meta.pkl").write_bytes(pickle.dumps(meta))
|
|
65
|
+
return index
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""LangChain's own standard test suite for retrievers (langchain-tests), run against a
|
|
2
|
+
tiny on-disk index with fake models, so it needs no network and no GPU."""
|
|
3
|
+
import tempfile
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from langchain_tests.integration_tests import RetrieversIntegrationTests
|
|
8
|
+
|
|
9
|
+
from langchain_sqlite_graph_memory import SQLiteGraphMemoryRetriever
|
|
10
|
+
from tests.fakes import FakeEncoder, FakeReranker, build_vault_and_index
|
|
11
|
+
|
|
12
|
+
_TMP = Path(tempfile.mkdtemp(prefix="lc-sgm-"))
|
|
13
|
+
_INDEX = str(build_vault_and_index(_TMP))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestSQLiteGraphMemoryRetrieverStandard(RetrieversIntegrationTests):
|
|
17
|
+
@property
|
|
18
|
+
def retriever_constructor(self) -> type[SQLiteGraphMemoryRetriever]:
|
|
19
|
+
return SQLiteGraphMemoryRetriever
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def retriever_constructor_params(self) -> dict:
|
|
23
|
+
return {"index_dir": _INDEX, "encoder": FakeEncoder(), "reranker": FakeReranker()}
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def retriever_query_example(self) -> str:
|
|
27
|
+
return "deploy release checklist"
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from langchain_core.documents import Document
|
|
3
|
+
|
|
4
|
+
from langchain_sqlite_graph_memory import SQLiteGraphMemoryRetriever
|
|
5
|
+
from tests.fakes import FakeEncoder, FakeReranker, build_vault_and_index
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture()
|
|
9
|
+
def index_dir(tmp_path):
|
|
10
|
+
return str(build_vault_and_index(tmp_path))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make(index_dir, **kw):
|
|
14
|
+
return SQLiteGraphMemoryRetriever(
|
|
15
|
+
index_dir=index_dir, encoder=FakeEncoder(), reranker=FakeReranker(), **kw
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_returns_documents_with_metadata(index_dir):
|
|
20
|
+
docs = make(index_dir, k=2).invoke("deploy release checklist")
|
|
21
|
+
assert docs and all(isinstance(d, Document) for d in docs)
|
|
22
|
+
top = docs[0]
|
|
23
|
+
assert top.metadata["title"] == "alpha"
|
|
24
|
+
assert top.metadata["rank"] == 1
|
|
25
|
+
assert top.metadata["path"].endswith("alpha.md")
|
|
26
|
+
assert top.metadata["source"] == top.metadata["path"]
|
|
27
|
+
assert "release checklist" in top.page_content
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_wikilink_hop_brings_in_linked_note(index_dir):
|
|
31
|
+
# fetch_k=1: vector search alone sees only `alpha`; `beta` shares no word with the
|
|
32
|
+
# query and is reachable only through alpha's [[beta]] link.
|
|
33
|
+
docs = make(index_dir, k=3, fetch_k=1, graph=True).invoke("deploy checklist")
|
|
34
|
+
titles = [d.metadata["title"] for d in docs]
|
|
35
|
+
assert titles == ["alpha", "beta"]
|
|
36
|
+
assert docs[1].metadata["via_graph"] is True
|
|
37
|
+
assert docs[0].metadata["via_graph"] is False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_graph_off_is_plain_vector_plus_rerank(index_dir):
|
|
41
|
+
docs = make(index_dir, k=3, fetch_k=1, graph=False).invoke("deploy checklist")
|
|
42
|
+
assert [d.metadata["title"] for d in docs] == ["alpha"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_auto_skips_graph_for_entity_lookups(index_dir):
|
|
46
|
+
# "Obsidian" is a known tool token -> looks_like_entity -> no expansion
|
|
47
|
+
docs = make(index_dir, k=3, fetch_k=1).invoke("Obsidian")
|
|
48
|
+
assert not any(d.metadata["via_graph"] for d in docs)
|
|
49
|
+
docs = make(index_dir, k=3, fetch_k=1).invoke("deploy checklist")
|
|
50
|
+
assert any(d.metadata["via_graph"] for d in docs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_k_kwarg_overrides_constructor(index_dir):
|
|
54
|
+
r = make(index_dir, k=4)
|
|
55
|
+
assert len(r.invoke("release deploy")) == 4
|
|
56
|
+
assert len(r.invoke("release deploy", k=1)) == 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_missing_index_says_how_to_build_one(tmp_path):
|
|
60
|
+
r = make(str(tmp_path / "nowhere"))
|
|
61
|
+
with pytest.raises(FileNotFoundError, match="sgm-index"):
|
|
62
|
+
r.invoke("anything")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_same_order_as_the_sgm_pipeline(index_dir):
|
|
66
|
+
"""The adapter must not re-implement retrieval: its order equals what the SGM
|
|
67
|
+
functions produce when called directly, the way sgm-ask calls them."""
|
|
68
|
+
from sqlite_graph_memory import brain_ask as sgm
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
|
|
71
|
+
q = "release deploy version"
|
|
72
|
+
emb, meta = sgm.load_index(Path(index_dir) / "_brain_e5.npy", Path(index_dir) / "_brain_e5_meta.pkl")
|
|
73
|
+
sims = sgm.query_sims(FakeEncoder(), emb, q)
|
|
74
|
+
base = sgm.retrieve_candidates(sims, meta)
|
|
75
|
+
added = sgm.expand_1hop(sims, meta, base)
|
|
76
|
+
want = [meta[i]["title"] for i, _ in sgm.rerank_candidates(FakeReranker(), q, base + added, meta, topn=3)]
|
|
77
|
+
got = [d.metadata["title"] for d in make(index_dir, k=3, graph=True).invoke(q)]
|
|
78
|
+
assert got == want
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _ainvoke(r, q):
|
|
82
|
+
return await r.ainvoke(q)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_async_invoke(index_dir):
|
|
86
|
+
import asyncio
|
|
87
|
+
|
|
88
|
+
docs = asyncio.run(_ainvoke(make(index_dir, k=2), "deploy release"))
|
|
89
|
+
assert len(docs) == 2
|