Angshul commited on
Commit
181a630
·
verified ·
1 Parent(s): 9890ac7

Upload 11 files

Browse files
API_SPEC.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fuzzy LangChain API
2
+
3
+ ## GET /health
4
+ Returns service status and number of ingested documents.
5
+
6
+ ## POST /ingest
7
+ ```json
8
+ {
9
+ "replace": true,
10
+ "documents": [
11
+ {
12
+ "id": "doc-1",
13
+ "text": "The current access code is BRAVO-9.",
14
+ "title": "Current notice",
15
+ "source": "official",
16
+ "authority": 0.98,
17
+ "freshness": 1.0,
18
+ "reliability": 0.95,
19
+ "metadata": {}
20
+ }
21
+ ]
22
+ }
23
+ ```
24
+
25
+ ## POST /query
26
+ ```json
27
+ {"question":"What is the current access code?","k":4}
28
+ ```
29
+
30
+ The response contains `answer`, `citations`, `confidence`, `margin`, `decision`, `reason`, and ranked `evidence`.
Dockerfile ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY src ./src
6
+ COPY app.py .
7
+ ENV PYTHONPATH=/app/src
8
+ EXPOSE 7860
9
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Angshul Majumdar
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.
README.md CHANGED
@@ -1,3 +1,9 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
1
+ # Fuzzy LangChain — Hugging Face API
2
+
3
+ Minimal deployable API for the Fuzzy LangChain RAG runtime. This package intentionally contains **only the serving surface and the minimum runtime needed by it**. Benchmarks, experiments, tests, and research documentation belong in the GitHub RAG repository.
4
+
5
+ Endpoints: `GET /health`, `POST /ingest`, `POST /query`.
6
+
7
+ The query path runs through the actual LangChain `BaseRetriever` implementation, fuzzy evidence assessment, optional retrieval expansion, grounded answer generation, citation return, and abstention under insufficient or ambiguous evidence.
8
+
9
+ See `API_SPEC.md` for request/response examples.
app.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from fuzzy_langchain.api import app
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn>=0.27
3
+ langchain-core>=1.4.7,<2
4
+ numpy>=1.24
5
+ scikit-learn>=1.3
src/fuzzy_langchain/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .core import (
2
+ DocumentRecord,
3
+ FuzzyEvidence,
4
+ FuzzyEvidenceController,
5
+ FuzzyIndex,
6
+ FuzzyRAGEngine,
7
+ FuzzyRetrievalConfig,
8
+ )
9
+
10
+ __all__ = [
11
+ "DocumentRecord",
12
+ "FuzzyEvidence",
13
+ "FuzzyEvidenceController",
14
+ "FuzzyIndex",
15
+ "FuzzyRAGEngine",
16
+ "FuzzyRetrievalConfig",
17
+ ]
src/fuzzy_langchain/api.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from fastapi import FastAPI, HTTPException
5
+ from pydantic import BaseModel, Field
6
+
7
+ from .core import DocumentRecord, FuzzyIndex, FuzzyRetrievalConfig
8
+ from .rag import LangChainFuzzyRAG
9
+
10
+
11
+ class IngestDocument(BaseModel):
12
+ id: str
13
+ text: str
14
+ title: str = ""
15
+ source: str = ""
16
+ authority: float = Field(0.5, ge=0.0, le=1.0)
17
+ freshness: float = Field(0.5, ge=0.0, le=1.0)
18
+ reliability: float = Field(0.5, ge=0.0, le=1.0)
19
+ metadata: dict[str, Any] = Field(default_factory=dict)
20
+
21
+
22
+ class IngestRequest(BaseModel):
23
+ documents: list[IngestDocument]
24
+ replace: bool = True
25
+
26
+
27
+ class QueryRequest(BaseModel):
28
+ question: str
29
+ k: int = Field(4, ge=1, le=20)
30
+
31
+
32
+ def create_app() -> FastAPI:
33
+ app = FastAPI(title="Fuzzy LangChain RAG API", version="1.0.0")
34
+ holder: dict[str, Any] = {"index": FuzzyIndex(config=FuzzyRetrievalConfig())}
35
+
36
+ @app.get("/health")
37
+ def health():
38
+ return {"status": "ok", "documents": len(holder["index"].documents)}
39
+
40
+ @app.post("/ingest")
41
+ def ingest(req: IngestRequest):
42
+ docs: list[DocumentRecord] = []
43
+ for d in req.documents:
44
+ md = dict(d.metadata)
45
+ md.update({"authority": d.authority, "freshness": d.freshness, "reliability": d.reliability})
46
+ docs.append(DocumentRecord(d.id, d.text, d.title, d.source, md))
47
+ if req.replace:
48
+ holder["index"].fit(docs)
49
+ else:
50
+ holder["index"].add(docs)
51
+ return {"documents": len(holder["index"].documents)}
52
+
53
+ @app.post("/query")
54
+ def query(req: QueryRequest):
55
+ if not holder["index"].documents:
56
+ raise HTTPException(status_code=400, detail="No documents ingested")
57
+ rag = LangChainFuzzyRAG(holder["index"], k=req.k)
58
+ return rag.invoke(req.question)
59
+
60
+ return app
61
+
62
+
63
+ app = create_app()
src/fuzzy_langchain/core.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Iterable, Sequence
5
+ import math
6
+ import re
7
+
8
+ import numpy as np
9
+ from sklearn.feature_extraction.text import TfidfVectorizer
10
+ from sklearn.metrics.pairwise import cosine_similarity
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class DocumentRecord:
15
+ id: str
16
+ text: str
17
+ title: str = ""
18
+ source: str = ""
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FuzzyRetrievalConfig:
24
+ candidate_pool: int = 10
25
+ answer_threshold: float = 0.60
26
+ ambiguity_margin: float = 0.015
27
+ min_raw_relevance: float = 0.14
28
+ expand_threshold: float = 0.48
29
+ extended_pool: int = 16
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class FuzzyEvidence:
34
+ document: DocumentRecord
35
+ raw_relevance: float
36
+ fuzzy_score: float
37
+ authority: float
38
+ freshness: float
39
+ reliability: float
40
+ memberships: dict[str, float]
41
+
42
+
43
+ class FuzzyEvidenceController:
44
+ """Small dependency-free Sugeno fuzzy controller for RAG evidence quality.
45
+
46
+ Inputs are normalized to [0, 1]: lexical/semantic relevance, source authority,
47
+ freshness, and source reliability. The rules intentionally make relevance a
48
+ gate: highly trusted but irrelevant evidence must not become high confidence.
49
+ """
50
+
51
+ @staticmethod
52
+ def _clip(x: float) -> float:
53
+ return max(0.0, min(1.0, float(x)))
54
+
55
+ @classmethod
56
+ def _left(cls, x: float, full_until: float, zero_at: float) -> float:
57
+ x = cls._clip(x)
58
+ if x <= full_until:
59
+ return 1.0
60
+ if x >= zero_at:
61
+ return 0.0
62
+ return (zero_at - x) / (zero_at - full_until)
63
+
64
+ @classmethod
65
+ def _right(cls, x: float, zero_until: float, full_at: float) -> float:
66
+ x = cls._clip(x)
67
+ if x <= zero_until:
68
+ return 0.0
69
+ if x >= full_at:
70
+ return 1.0
71
+ return (x - zero_until) / (full_at - zero_until)
72
+
73
+ @classmethod
74
+ def _triangle(cls, x: float, left: float, peak: float, right: float) -> float:
75
+ x = cls._clip(x)
76
+ if x <= left or x >= right:
77
+ return 0.0
78
+ if x == peak:
79
+ return 1.0
80
+ if x < peak:
81
+ return (x - left) / (peak - left)
82
+ return (right - x) / (right - peak)
83
+
84
+ def memberships(self, relevance: float, authority: float, freshness: float, reliability: float) -> dict[str, float]:
85
+ r = self._clip(relevance)
86
+ a = self._clip(authority)
87
+ f = self._clip(freshness)
88
+ q = self._clip(reliability)
89
+ return {
90
+ "rel_low": self._left(r, 0.05, 0.30),
91
+ "rel_med": self._triangle(r, 0.12, 0.42, 0.70),
92
+ "rel_high": self._right(r, 0.42, 0.72),
93
+ "auth_low": self._left(a, 0.0, 0.55),
94
+ "auth_med": self._triangle(a, 0.35, 0.62, 0.84),
95
+ "auth_high": self._right(a, 0.68, 0.90),
96
+ "fresh_low": self._left(f, 0.0, 0.42),
97
+ "fresh_med": self._triangle(f, 0.28, 0.58, 0.82),
98
+ "fresh_high": self._right(f, 0.68, 0.90),
99
+ "reliability_low": self._left(q, 0.0, 0.50),
100
+ "reliability_high": self._right(q, 0.58, 0.88),
101
+ }
102
+
103
+ def score(self, relevance: float, authority: float, freshness: float, reliability: float) -> tuple[float, dict[str, float]]:
104
+ m = self.memberships(relevance, authority, freshness, reliability)
105
+ # First-order Sugeno rules. Consequents are confidence-like evidence values.
106
+ rules: list[tuple[float, float]] = [
107
+ (min(m["rel_high"], m["auth_high"], m["fresh_high"], m["reliability_high"]), 0.98),
108
+ (min(m["rel_med"], m["auth_high"], m["fresh_high"], m["reliability_high"]), 0.90),
109
+ (min(m["rel_high"], m["auth_high"]), 0.92),
110
+ (min(m["rel_med"], m["auth_high"]), 0.84),
111
+ (min(m["rel_high"], m["auth_med"], m["fresh_high"]), 0.74),
112
+ (min(m["rel_med"], m["auth_med"]), 0.54),
113
+ (min(m["rel_high"], m["fresh_low"]), 0.28),
114
+ (min(m["rel_high"], m["auth_low"]), 0.24),
115
+ (min(m["rel_med"], m["auth_low"]), 0.20),
116
+ (min(m["rel_high"], m["reliability_low"]), 0.26),
117
+ (m["rel_low"], 0.06),
118
+ ]
119
+ denom = sum(w for w, _ in rules)
120
+ if denom <= 1e-12:
121
+ # No fuzzy rule fired strongly; relevance still gates the fallback.
122
+ base = 0.10 + 0.55 * self._clip(relevance)
123
+ else:
124
+ base = sum(w * z for w, z in rules) / denom
125
+ # Relevance is already a fuzzy antecedent and is also enforced by the
126
+ # hard minimum-relevance guard in the final decision.
127
+ value = self._clip(base)
128
+ return value, m
129
+
130
+
131
+ class FuzzyIndex:
132
+ def __init__(self, documents: Sequence[DocumentRecord] | None = None, config: FuzzyRetrievalConfig | None = None):
133
+ self.config = config or FuzzyRetrievalConfig()
134
+ self.controller = FuzzyEvidenceController()
135
+ self.documents: list[DocumentRecord] = []
136
+ self._vectorizer: TfidfVectorizer | None = None
137
+ self._matrix = None
138
+ if documents:
139
+ self.fit(documents)
140
+
141
+ @staticmethod
142
+ def _trust(doc: DocumentRecord) -> tuple[float, float, float]:
143
+ md = doc.metadata or {}
144
+ authority = float(md.get("authority", 0.50))
145
+ freshness = float(md.get("freshness", 0.50))
146
+ if "reliability" in md:
147
+ reliability = float(md["reliability"])
148
+ elif "reliability_alpha" in md or "reliability_beta" in md:
149
+ a = float(md.get("reliability_alpha", 1.0))
150
+ b = float(md.get("reliability_beta", 1.0))
151
+ reliability = a / max(a + b, 1e-12)
152
+ else:
153
+ reliability = 0.50
154
+ return tuple(max(0.0, min(1.0, x)) for x in (authority, freshness, reliability))
155
+
156
+ def fit(self, documents: Sequence[DocumentRecord]) -> "FuzzyIndex":
157
+ self.documents = list(documents)
158
+ corpus = [f"{d.title}. {d.text}".strip() for d in self.documents]
159
+ self._vectorizer = TfidfVectorizer(ngram_range=(1, 2), stop_words="english", sublinear_tf=True)
160
+ self._matrix = self._vectorizer.fit_transform(corpus) if corpus else None
161
+ return self
162
+
163
+ def add(self, documents: Iterable[DocumentRecord]) -> "FuzzyIndex":
164
+ return self.fit([*self.documents, *list(documents)])
165
+
166
+ def search(self, query: str, k: int = 4, candidate_pool: int | None = None) -> list[FuzzyEvidence]:
167
+ if not self.documents or self._vectorizer is None or self._matrix is None:
168
+ return []
169
+ q = self._vectorizer.transform([query])
170
+ sims = cosine_similarity(q, self._matrix)[0]
171
+ pool = min(candidate_pool or self.config.candidate_pool, len(self.documents))
172
+ candidate_ids = np.argsort(-sims, kind="stable")[:pool]
173
+ hits: list[FuzzyEvidence] = []
174
+ for i in candidate_ids:
175
+ doc = self.documents[int(i)]
176
+ relevance = float(sims[int(i)])
177
+ authority, freshness, reliability = self._trust(doc)
178
+ fuzzy, memberships = self.controller.score(relevance, authority, freshness, reliability)
179
+ hits.append(FuzzyEvidence(doc, relevance, fuzzy, authority, freshness, reliability, memberships))
180
+ hits.sort(key=lambda h: (h.fuzzy_score, h.raw_relevance, h.authority, h.freshness), reverse=True)
181
+ return hits[: max(1, k)]
182
+
183
+ def decision(self, hits: Sequence[FuzzyEvidence]) -> dict[str, Any]:
184
+ if not hits:
185
+ return {"route": "abstain", "confidence": 0.0, "margin": 0.0, "reason": "no_evidence"}
186
+ top = hits[0]
187
+ second = hits[1] if len(hits) > 1 else None
188
+ if second is None:
189
+ margin = top.fuzzy_score
190
+ else:
191
+ # Fuzzy confidence can intentionally plateau for equally trustworthy
192
+ # sources. A small relevance component breaks benign ties while exact
193
+ # equal-authority/equal-relevance contradictions remain ambiguous.
194
+ margin = 0.80 * (top.fuzzy_score - second.fuzzy_score) + 0.20 * (top.raw_relevance - second.raw_relevance)
195
+ if top.raw_relevance < self.config.min_raw_relevance:
196
+ return {"route": "abstain", "confidence": top.fuzzy_score, "margin": margin, "reason": "low_relevance"}
197
+ trusted_enough = top.authority >= 0.60 or top.reliability >= 0.75
198
+ if top.fuzzy_score >= self.config.answer_threshold and margin >= self.config.ambiguity_margin and trusted_enough:
199
+ return {"route": "generate", "confidence": top.fuzzy_score, "margin": margin, "reason": "strong_evidence"}
200
+ if top.fuzzy_score >= self.config.expand_threshold:
201
+ return {"route": "expand", "confidence": top.fuzzy_score, "margin": margin, "reason": "borderline_or_ambiguous"}
202
+ return {"route": "abstain", "confidence": top.fuzzy_score, "margin": margin, "reason": "weak_evidence"}
203
+
204
+ def should_abstain(self, hits: Sequence[FuzzyEvidence]) -> bool:
205
+ return self.decision(hits)["route"] != "generate"
206
+
207
+
208
+ class PlainTfidfIndex:
209
+ """Baseline retriever intentionally unaware of source quality or uncertainty."""
210
+
211
+ def __init__(self, documents: Sequence[DocumentRecord]):
212
+ self.documents = list(documents)
213
+ corpus = [f"{d.title}. {d.text}".strip() for d in self.documents]
214
+ self.vectorizer = TfidfVectorizer(ngram_range=(1, 2), stop_words="english", sublinear_tf=True)
215
+ self.matrix = self.vectorizer.fit_transform(corpus)
216
+
217
+ def search(self, query: str, k: int = 4) -> list[FuzzyEvidence]:
218
+ q = self.vectorizer.transform([query])
219
+ sims = cosine_similarity(q, self.matrix)[0]
220
+ ids = np.argsort(-sims, kind="stable")[: min(k, len(self.documents))]
221
+ out = []
222
+ for i in ids:
223
+ d = self.documents[int(i)]
224
+ s = float(sims[int(i)])
225
+ out.append(FuzzyEvidence(d, s, s, 0.5, 0.5, 0.5, {}))
226
+ return out
227
+
228
+
229
+ class ExtractiveGroundedGenerator:
230
+ """Offline answer synthesizer used for reproducible RAG evaluation.
231
+
232
+ It selects the query-most-relevant sentence from the accepted evidence and
233
+ returns it with an explicit citation. Production users can replace this with
234
+ any LangChain Runnable/LLM without changing fuzzy retrieval or routing.
235
+ """
236
+
237
+ abstain_text = "Insufficient reliable evidence to answer."
238
+
239
+ @staticmethod
240
+ def _sentences(text: str) -> list[str]:
241
+ parts = re.split(r"(?<=[.!?])\s+", text.strip())
242
+ return [p.strip() for p in parts if p.strip()]
243
+
244
+ def generate(self, question: str, hits: Sequence[FuzzyEvidence]) -> tuple[str, list[str]]:
245
+ if not hits:
246
+ return self.abstain_text, []
247
+ top = hits[0]
248
+ sentences = self._sentences(top.document.text) or [top.document.text]
249
+ if len(sentences) == 1:
250
+ best = sentences[0]
251
+ else:
252
+ vec = TfidfVectorizer(ngram_range=(1, 2), stop_words="english")
253
+ X = vec.fit_transform([question, *sentences])
254
+ scores = cosine_similarity(X[0:1], X[1:])[0]
255
+ best = sentences[int(np.argmax(scores))]
256
+ return f"{best} [{top.document.id}]", [top.document.id]
257
+
258
+
259
+ class FuzzyRAGEngine:
260
+ def __init__(self, documents: Sequence[DocumentRecord] | None = None, config: FuzzyRetrievalConfig | None = None):
261
+ self.index = FuzzyIndex(documents or [], config)
262
+ self.generator = ExtractiveGroundedGenerator()
263
+
264
+ def ingest(self, documents: Sequence[DocumentRecord], replace: bool = True) -> int:
265
+ if replace:
266
+ self.index.fit(documents)
267
+ else:
268
+ self.index.add(documents)
269
+ return len(self.index.documents)
270
+
271
+ def query(self, question: str, k: int = 4) -> dict[str, Any]:
272
+ hits = self.index.search(question, k=k)
273
+ decision = self.index.decision(hits)
274
+ expanded = False
275
+ if decision["route"] == "expand":
276
+ expanded = True
277
+ hits = self.index.search(question, k=max(k * 2, 6), candidate_pool=self.index.config.extended_pool)
278
+ decision = self.index.decision(hits)
279
+ if decision["route"] == "expand":
280
+ decision = {**decision, "route": "abstain", "reason": "ambiguous_after_expansion"}
281
+ if decision["route"] == "generate":
282
+ answer, citations = self.generator.generate(question, hits)
283
+ else:
284
+ answer, citations = self.generator.abstain_text, []
285
+ return {
286
+ "question": question,
287
+ "answer": answer,
288
+ "citations": citations,
289
+ "confidence": round(float(decision["confidence"]), 6),
290
+ "margin": round(float(decision["margin"]), 6),
291
+ "decision": decision["route"],
292
+ "reason": decision["reason"],
293
+ "expanded": expanded,
294
+ "evidence": [
295
+ {
296
+ "id": h.document.id,
297
+ "title": h.document.title,
298
+ "source": h.document.source,
299
+ "raw_relevance": round(h.raw_relevance, 6),
300
+ "fuzzy_score": round(h.fuzzy_score, 6),
301
+ "authority": round(h.authority, 6),
302
+ "freshness": round(h.freshness, 6),
303
+ "reliability": round(h.reliability, 6),
304
+ }
305
+ for h in hits
306
+ ],
307
+ }
src/fuzzy_langchain/rag.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from .core import ExtractiveGroundedGenerator, FuzzyIndex, FuzzyRetrievalConfig
6
+ from .retriever import FuzzyRetriever
7
+
8
+
9
+ class LangChainFuzzyRAG:
10
+ """End-to-end LangChain RAG using fuzzy evidence control.
11
+
12
+ The optional ``generator`` receives ``(question, documents)`` and may be any
13
+ callable backed by an LLM. Without one, a deterministic grounded sentence
14
+ synthesizer is used for offline evaluation and zero-key deployments.
15
+ """
16
+
17
+ def __init__(self, index: FuzzyIndex, k: int = 4, generator: Callable[[str, list[Any]], str] | None = None):
18
+ self.index = index
19
+ self.retriever = FuzzyRetriever(index=index, k=k)
20
+ self.k = k
21
+ self.generator = generator
22
+ self.extractive = ExtractiveGroundedGenerator()
23
+
24
+ def invoke(self, question: str) -> dict[str, Any]:
25
+ docs = self.retriever.invoke(question)
26
+ # Crucially, decision logic uses the LangChain retriever result rather than
27
+ # silently issuing a second retrieval outside the framework path.
28
+ from .core import DocumentRecord, FuzzyEvidence
29
+
30
+ hits: list[FuzzyEvidence] = []
31
+ for d in docs:
32
+ md = dict(d.metadata or {})
33
+ record = DocumentRecord(
34
+ id=str(md.get("id", "unknown")),
35
+ text=d.page_content,
36
+ title=str(md.get("title", "")),
37
+ source=str(md.get("source", "")),
38
+ metadata=md,
39
+ )
40
+ hits.append(
41
+ FuzzyEvidence(
42
+ document=record,
43
+ raw_relevance=float(md.get("raw_relevance", 0.0)),
44
+ fuzzy_score=float(md.get("fuzzy_score", 0.0)),
45
+ authority=float(md.get("authority", 0.5)),
46
+ freshness=float(md.get("freshness", 0.5)),
47
+ reliability=float(md.get("reliability", 0.5)),
48
+ memberships={},
49
+ )
50
+ )
51
+ decision = self.index.decision(hits)
52
+ if decision["route"] == "expand":
53
+ expanded_retriever = FuzzyRetriever(index=self.index, k=max(self.k * 2, 6), candidate_pool=self.index.config.extended_pool)
54
+ docs = expanded_retriever.invoke(question)
55
+ hits = []
56
+ for d in docs:
57
+ md = dict(d.metadata or {})
58
+ record = DocumentRecord(
59
+ id=str(md.get("id", "unknown")), text=d.page_content,
60
+ title=str(md.get("title", "")), source=str(md.get("source", "")), metadata=md
61
+ )
62
+ hits.append(FuzzyEvidence(
63
+ record, float(md.get("raw_relevance", 0.0)), float(md.get("fuzzy_score", 0.0)),
64
+ float(md.get("authority", 0.5)), float(md.get("freshness", 0.5)),
65
+ float(md.get("reliability", 0.5)), {}
66
+ ))
67
+ decision = self.index.decision(hits)
68
+ if decision["route"] == "expand":
69
+ decision = {**decision, "route": "abstain", "reason": "ambiguous_after_expansion"}
70
+ if decision["route"] != "generate":
71
+ answer, citations = self.extractive.abstain_text, []
72
+ elif self.generator is not None:
73
+ answer = str(self.generator(question, docs))
74
+ citations = [str(d.metadata.get("id")) for d in docs[:1]]
75
+ else:
76
+ answer, citations = self.extractive.generate(question, hits)
77
+ return {
78
+ "question": question,
79
+ "answer": answer,
80
+ "citations": citations,
81
+ "confidence": float(decision["confidence"]),
82
+ "margin": float(decision["margin"]),
83
+ "decision": decision["route"],
84
+ "reason": decision["reason"],
85
+ "evidence": [dict(d.metadata) for d in docs],
86
+ }
87
+
88
+ def as_runnable(self):
89
+ try:
90
+ from langchain_core.runnables import RunnableLambda
91
+ except Exception as exc: # pragma: no cover
92
+ raise RuntimeError("langchain-core is required for as_runnable()") from exc
93
+ return RunnableLambda(lambda x: self.invoke(x["question"] if isinstance(x, dict) else str(x)))
src/fuzzy_langchain/retriever.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .core import DocumentRecord, FuzzyIndex
6
+
7
+ try:
8
+ from langchain_core.documents import Document
9
+ from langchain_core.retrievers import BaseRetriever
10
+ from pydantic import ConfigDict
11
+ except Exception: # pragma: no cover - package remains importable without LangChain
12
+ Document = None
13
+ BaseRetriever = object
14
+ ConfigDict = dict
15
+
16
+
17
+ class FuzzyRetriever(BaseRetriever):
18
+ """LangChain BaseRetriever adapter around :class:`FuzzyIndex`."""
19
+
20
+ if BaseRetriever is not object:
21
+ model_config = ConfigDict(arbitrary_types_allowed=True)
22
+
23
+ index: Any
24
+ k: int = 4
25
+ candidate_pool: int | None = None
26
+
27
+ def _get_relevant_documents(self, query: str, *, run_manager: Any = None):
28
+ if Document is None:
29
+ raise RuntimeError("langchain-core is required for FuzzyRetriever")
30
+ hits = self.index.search(query, k=self.k, candidate_pool=self.candidate_pool)
31
+ docs = []
32
+ for h in hits:
33
+ md = dict(h.document.metadata)
34
+ md.update(
35
+ {
36
+ "id": h.document.id,
37
+ "title": h.document.title,
38
+ "source": h.document.source,
39
+ "raw_relevance": h.raw_relevance,
40
+ "fuzzy_score": h.fuzzy_score,
41
+ "authority": h.authority,
42
+ "freshness": h.freshness,
43
+ "reliability": h.reliability,
44
+ }
45
+ )
46
+ docs.append(Document(page_content=h.document.text, metadata=md))
47
+ return docs
48
+
49
+
50
+ def to_records(documents: list[Any]) -> list[DocumentRecord]:
51
+ records: list[DocumentRecord] = []
52
+ for i, d in enumerate(documents):
53
+ md = dict(getattr(d, "metadata", {}) or {})
54
+ records.append(
55
+ DocumentRecord(
56
+ id=str(md.get("id", f"doc-{i}")),
57
+ text=str(getattr(d, "page_content", "")),
58
+ title=str(md.get("title", "")),
59
+ source=str(md.get("source", "")),
60
+ metadata=md,
61
+ )
62
+ )
63
+ return records